Akadémia alcsoport

Keresés

Névtelen metódusok és az “elkapott” helyi változók Nyíl

(Oké, hogy programozói magyarsággal mondjuk: anonymous methods and captured local variables. :) )

Nemrég egyik ismerősöm egy furcsa hibával keresett meg. A programot, amit írt, többszálúvá kellett tennie; 4-5 szál is futhatott egyszerre, és egy ciklusban indította el őket. Minden teljesen jól működött – habár én próbáltam rábeszélni, hogy készüljön már most a Taskokra való átállásra :) –, egészen addig, amíg rá nem jött, hogy C# 2.0 óta van egy gyönyörűséges lehetőség a nyelvben a kód egyszerűsítésére, ez pedig a névtelen metódusok intézménye. Ha egy rövid kódot csak egy helyen kell felhasználnia a programban, miért szemetelje tele az osztályait kis metódusokkal?

Ha az embernek kalapácsa van, akkor

1. hajlamos minden problémát szögnek nézni,

2. felteheti a kérdést, hogy minek a kalapot ácsolni.

Hogy kicsit megfoghatóbb legyen nekünk is, leegyszerűsítve, kódban mondom el a történetet:

Adott tehát egy metódus, amit több szálon is futtatni szeretnénk,

private static void WriteNumber(object o)

{

    Console.WriteLine("Something: {0} on Thread no {1}", o, Thread.CurrentThread.ManagedThreadId);

}

 

(bocs az angol elnevezésekért) és adott a Main metódus, ami majd futtatja:

static void Main()

{

    for (int i = 0; i < 25; i++)

    {

        Thread t = new Thread(WriteNumber);

        t.Start(i);

    }

 

    Console.Read();

}

 

Eddig minden szép és jó tehát; ha futtatjuk, valami ilyesmit kellene látnunk:

AMVC01

Valószínűleg nem sorban lesznek az eredmények, de alapvetően 0-tól 24-ig más-más szálak kiírnak 1-1 számot. Ez az elvárt működés, jegyezzük meg. :)

Akkor ugye jön az igény, hogy kicsit változtassunk a metóduson: ne fixen írja ki a stringet, ami bele van égetve (“Something: … on thread no …”), hanem azt is lehessen szabályozni a hívó metódus egy belső változójával. De lesznek olyan helyzetek is, amikor megelégszünk az alapfunkcionalitással, nem kell a testreszabott string.

Mit lehet tenni? Írhatunk még egy metódust, ami két paramétert fogad… Esetleg hívhatja egyik a másikat, és akkor legalább a logika valamivel szebb lesz. De akkor is néhány sornyi valódi kód kedvéért írunk húsz sort. Vaaaaaagy… Hatékonyan oldjuk meg! Kalapács, szög, névtelen metódus.

A probléma szempontjából ez a plusz string nem fontos, ezért kihagyhatjuk. A lényeg, hogy normál metódusról áttérünk névtelenre. Most lambda kifejezésként deklaráljuk, de az majdnem ugyanaz. Tehát:

Thread t = new Thread(() => Console.WriteLine("Something: {0} on Thread no {1}", i, Thread.CurrentThread.ManagedThreadId));

 

Vegyük észre, hogy innentől már a Thread indításánál megússzuk a paraméter átadását is. Elvégre az i-t adtuk át, de az is belső változó, tehát a “belső” függvény már eléri. Minden szép és jó! Leegyszerűsítettük a metódust, sőt, eltüntettük. Kevesebb, átláthatóbb kód. (Utóbbival azért szoktak vitatkozni.)

Akkor mi a baj? Futtassuk csak meg!

AMVC02

A végeredmény persze nem determinisztikus, de ahogy a kép is mutatja, lehet baj. Ennél a futtatásnál éppen a 2, 8, 12… számok fordultak elő több alkalommal, pedig ugyanúgy 25 szálat indítunk el. Tehát vannak számok, amelyek nem jelennek meg egyik szálon sem, vannak, amelyek többször is. WTF??? (Why the face. :) )

Ismerősöm ekkor átállt közvetlen ThreadPool használatra – ebben az esetben jó eséllyel már minden szál 25-öt ír ki. :D

A megoldás egyszerűen bonyolult. Mielőtt megmutatnám, még nézzünk bele a generált IL-kódba! (Az egyszerűség kedvéért a release fordítás utánit mutatom.)

AMVC03

Na, ez mi? Ott a Program osztály, eddig sima ügy. De ott van egy beágyazott osztály egy elég fura névvel… Ráadásul van benne egy i nevű mező. Nagyszerű, a compiler szabadidejében random osztályokat generál… :) Természetesen nem ez a helyzet. Gondoljuk csak meg, mi is a névtelen metódus! Ez is csak egy normál függvény lesz, tehát meg kell jelennie egy osztályban. (Tehát igen, a névtelen metódusnak is van neve, csak PR szempontból szebb névtelennek nevezni, mint azt mondani, hogy method with a name that is ugly beyond expression.) Mit gondoltok, mi lenne a logikus, hol tároljuk ezt a metódust?

Szerintem kivétel nélkül arra gondoltatok, hogy rakjuk bele abba az osztályba, mely a névtelen metódust tartalmazó “igazi” metódust tartalmazza. És alapesetben pontosan ezt teszi a fordító!

Próbáljátok ki: írjátok át a lambdában lévő WriteLine-t, hogy csak írja ki: alma. Nézzétek meg a generált IL-kódot, nem lesz plusz osztály.

A mi esetünkben azonban van egy fontos dolog a lambda/névtelen metódus belsejében. Felhasználjuk az i-t. Az i a tartalmazó metódus saját változója, vagyis a névtelen metódusunkon kívülről jön! Azt tudjuk, hogy a névtelen metódus is valódi metódusként jelenik meg a háttérben, vagyis nincs ténylegesen beágyazva a tartalmazó metódusba. Ebből következik, hogy az i változót sem láthatja. Tehát ha használni akarjuk, valahogy át kell adni ennek a metódusnak!

Na, erre való ez a beágyazott, ronda nevű osztály, és ezért tartalmaz egy i nevű publikus mezőt!

Nézzünk bele a beágyazott osztályba! Van benne egy konstruktor, sima ügy, viszont van egy furcsa nevű metódusa is. Lessük meg az ő kódját neki jól, egyre csak!

.method public hidebysig instance void  '<Main>b__0'() cil managed
{
  // Code size       37 (0x25)
  .maxstack  8
  IL_0000:  ldstr      "Something: {0} on Thread no {1}"
  IL_0005:  ldarg.0
  IL_0006:  ldfld      int32 Test.Program/'<>c__DisplayClass2'::i
  IL_000b:  box        [mscorlib]System.Int32
  IL_0010:  call       class [mscorlib]System.Threading.Thread [mscorlib]System.Threading.Thread::get_CurrentThread()
  IL_0015:  callvirt   instance int32 [mscorlib]System.Threading.Thread::get_ManagedThreadId()
  IL_001a:  box        [mscorlib]System.Int32
  IL_001f:  call       void [mscorlib]System.Console::WriteLine(string,
                                                                object,
                                                                object)
  IL_0024:  ret
} // end of method '<>c__DisplayClass2'::'<Main>b__0'

Elég átlátható, gyakorlatilag néhány betöltögetés, dobozolás, és a végén a WriteLine meghívása. (Azért kicsit durva, hogy egy ilyen egyszerű kódban is két dobozolást talál az ember, ha a függöny mögé néz.) Most akkor nézzük meg a másik fontos metódust, a Program.Maint!

.method private hidebysig static void  Main() cil managed
{
  .entrypoint
  // Code size       77 (0x4d)
  .maxstack  3
  .locals init ([0] class [mscorlib]System.Threading.Thread t,
           [1] class [mscorlib]System.Threading.ThreadStart 'CS$<>9__CachedAnonymousMethodDelegate1',
           [2] class Test.Program/'<>c__DisplayClass2' 'CS$<>8__locals3')
  IL_0000:  ldnull
  IL_0001:  stloc.1
  IL_0002:  newobj     instance void Test.Program/'<>c__DisplayClass2'::.ctor()
  IL_0007:  stloc.2
  IL_0008:  ldloc.2
  IL_0009:  ldc.i4.0
  IL_000a:  stfld      int32 Test.Program/'<>c__DisplayClass2'::i
  IL_000f:  br.s       IL_003c
  IL_0011:  ldloc.1
  IL_0012:  brtrue.s   IL_0021
  IL_0014:  ldloc.2
  IL_0015:  ldftn      instance void Test.Program/'<>c__DisplayClass2'::'<Main>b__0'()
  IL_001b:  newobj     instance void [mscorlib]System.Threading.ThreadStart::.ctor(object,
                                                                                   native int)
  IL_0020:  stloc.1
  IL_0021:  ldloc.1
  IL_0022:  newobj     instance void [mscorlib]System.Threading.Thread::.ctor(class [mscorlib]System.Threading.ThreadStart)
  IL_0027:  stloc.0
  IL_0028:  ldloc.0
  IL_0029:  callvirt   instance void [mscorlib]System.Threading.Thread::Start()
  IL_002e:  ldloc.2
  IL_002f:  dup
  IL_0030:  ldfld      int32 Test.Program/'<>c__DisplayClass2'::i
  IL_0035:  ldc.i4.1
  IL_0036:  add
  IL_0037:  stfld      int32 Test.Program/'<>c__DisplayClass2'::i
  IL_003c:  ldloc.2
  IL_003d:  ldfld      int32 Test.Program/'<>c__DisplayClass2'::i
  IL_0042:  ldc.i4.s   25
  IL_0044:  blt.s      IL_0011
  IL_0046:  call       int32 [mscorlib]System.Console::Read()
  IL_004b:  pop
  IL_004c:  ret
} // end of method Program::Main

Ahhoz már elég kemény tudás kell, hogy ezt végigelemezzük, de néhány nagyon fontos helyet megjelölnék:

- a 0002-es sorban jön létre a háttérben lévő rejtett objektum (ami eltárolja az i-t, illetve amin hívható lesz a névtelen metódus),

- a 000a sorban kap értéket ennek i tagja, illetve

- a 0011-es sorban kezdődik a ciklus.

Most akkor nézzük, hogyan tudjuk megoldani a problémát! Egy apró változtatást rakjunk bele a ciklusba, illetve a névtelen metódusba…

static void Main()

{

    for (int i = 0; i < 25; i++)

    {

        int n = i;

        Thread t = new Thread(() => Console.WriteLine("Something: {0} on Thread no {1}", n, Thread.CurrentThread.ManagedThreadId));

        t.Start();

    }

    Console.Read();

}

 

Ugye, hogy szinte észrevehetetlen? Futtassuk meg, lássuk, mit csinál!

AMVC04

Ésmegyésmegyésmegy! De miért???

Az egyszerű válasz az, hogy feketemágia!!! :) De ne elégedjünk meg azzal, hogy a programozóból hidat építünk – mert azt kőből is lehet –, hanem nézzük meg, mi is történik itt.

Elég egyértelmű, hogy a választ nem találjuk meg a C# szintjén, mélyebbre kell ásnunk. Ha most vetünk egy pillantást a disassemblyre, láthatjuk, hogy a beágyazott osztály továbbra ott van, viszont nem i, hanem n nevű mezővel. A névtelen (randa nevű) metódus kódja ugyanaz, mint eddig, ott is csak annyi változott, hogy n változót néz i helyett. Viszont ha rányitunk a Mainre, érdekes dolgot láthatunk!

.method private hidebysig static void  Main() cil managed
{
  .entrypoint
  // Code size       57 (0x39)
  .maxstack  3
  .locals init ([0] int32 i,
           [1] class [mscorlib]System.Threading.Thread t,
           [2] class Test.Program/'<>c__DisplayClass1' 'CS$<>8__locals2')
  IL_0000:  ldc.i4.0
  IL_0001:  stloc.0
  IL_0002:  br.s       IL_002d
  IL_0004:  newobj     instance void Test.Program/'<>c__DisplayClass1'::.ctor()
  IL_0009:  stloc.2
  IL_000a:  ldloc.2
  IL_000b:  ldloc.0
  IL_000c:  stfld      int32 Test.Program/'<>c__DisplayClass1'::n
  IL_0011:  ldloc.2
  IL_0012:  ldftn      instance void Test.Program/'<>c__DisplayClass1'::'<Main>b__0'()
  IL_0018:  newobj     instance void [mscorlib]System.Threading.ThreadStart::.ctor(object,
                                                                                   native int)
  IL_001d:  newobj     instance void [mscorlib]System.Threading.Thread::.ctor(class [mscorlib]System.Threading.ThreadStart)
  IL_0022:  stloc.1
  IL_0023:  ldloc.1
  IL_0024:  callvirt   instance void [mscorlib]System.Threading.Thread::Start()
  IL_0029:  ldloc.0
  IL_002a:  ldc.i4.1
  IL_002b:  add
  IL_002c:  stloc.0
  IL_002d:  ldloc.0
  IL_002e:  ldc.i4.s   25
  IL_0030:  blt.s      IL_0004
  IL_0032:  call       int32 [mscorlib]System.Console::Read()
  IL_0037:  pop
  IL_0038:  ret
} // end of method Program::Main

Huhh, elég nagy különbség, már akkor is ha a kódsorok számát nézzük. De a lényeg néhány művelet sorrendjében van. Ugyanis a ciklus első sora a 0004-es, ahol létrehozza a program a rejtett objektumot, aminek majd meghívhatja a “névtelen” metódusát. Mit jelent ez? Hogy itt 25 ilyen objektumunk lesz, míg az előző esetben, az n = i sor nélkül csak egyetlen egy volt! Tehát nem egy integer lesz, amin osztozik az összes metódus, hanem mindegyiknek lesz egy sajátja – és mivel ezen saját integerek mindig az iteráción belül kerülnek beállításra, mindegyik a helyes értéket fogja tartalmazni, szemben a korábbi példával, ahol ha egy szál nem futott le elég gyorsan, az i értékét felülírta a ciklus következő iterációja.

Mi a jobb megoldás?

A esetben sok (igazi) metódust pakolunk a osztályba, szanaszét szemetelve azt olyan kóddal, amit lehet, hogy csak egyszer-egyszer használunk fel.

B esetben megoldjuk névtelen metódusokkal: kevesebb kód, szebb kód*, cserébe a háttérben plusz osztályok, objektumok jönnek létre, tehát esetenként erősen ráterhelhetünk a GC-re. Plusz jönnek az olyan finomságok, mint amit fentebb is bemutattam.

Úgyhogy csak óvatosan azokkal a névtelen metódusokkal!

Na, folytassuk a kódolást!

 

*: ízlés kérdése persze. Mindenesetre gondoljuk meg, hogy nézne ki egy LINQ lekérdezés, ha nem lennének névtelen metódusaink…

Slussz poén: ha beraktok egy nagyon minimális 10-20 msec-os sleepet a Start után, akkor is jól fog működni, mivel a szál előbb lefut, mint ahogy az i értéke megváltozna; nincs szükség a plusz változóra. Tanulság: minél lassabb a program, annál jobban működik! De ezt azért a megrendelőnek ne hangoztassuk… :)))

A teljes bejegyzést itt olvashatod: http://davidfulop.spaces.live.com/Blog/cns!BC8EF43294ECB87!600.entry


Elküldve 2010. 02. 08. 16:54 by [assembly: AssemblyTitle(&quot;Chev&quot;)] Megtekintve: 238 alkalommal