Asynchronous Programming Model – és ami mögötte van

A cikksorozat indító cikkéből megtudhattuk, hogy milyen esetekben érdemes többszálú programokat készíteni. Az indokok között az egyik az eszközök lassúsága, egy másik a háttérszámítások elvégzése volt úgy, hogy közben egyéb funkciók, mint például a felhasználói felület frissítése, működőképesek maradjanak.

Tudjuk, hogy a szálak kezelése az operációs rendszer számára sok erőforrásba kerül, ezért számukat a jó teljesítmény érdekében alacsonyan kell tartani, illetve lehetőség szerint el kell kerülni új szálak folyamatos létrehozását és megszüntetését. E célból az operációs rendszer olyan szolgáltatásokat nyújt, mint a Thread Pool, amelyből szükség esetén kölcsönözni lehet szálakat feladatok elvégzéséhez, illetve a Completion Port, aminek segítségével aszinkron I/O műveleteket lehet kevés szál segítségével feldolgozni.

Szükséges ismeretek

A Thread Pool fogalmának ismerete és Windows operációs rendszerek Completion Port szolgáltatásainak ismerete szükséges a cikk megértéséhez. Ezek az információk megtalálhatóak a sorozatinditó cikkben.

Az APM pár mondatban

A .NET Asynchronous Programming Model használata nagyon egyszerű. A fejlesztőnek nem kell Thread Pool-okban és Completion Portokban gondolkodni a használatakor. Lényegében négy dolgot kell tudni:

  • Az aszinkron műveletet egy BeginXX nevű függvénnyel lehet elkezdeni, ahol az XX függ az APM-et megvalósító osztálytól, és a végrehajtandó művelettől. A FileStream osztály például rendelkezik egy BeginRead függvénnyel aszinkron olvasáshoz, illetve egy BeginWrite függvényhez aszinkron íráshoz. A Dns osztály az aszinkron címlekérdezéshez a BeginGetHostAddresses függvényt nyújtja.
  • Az aszinkron műveletekhez egy callback függvényt lehet megadni, amely meghívásra kerül, ha az aszinkron művelet befejeződött.
  • A művelet eredményét egy EndXX nevű függvénnyel lehet megkapni, mint például EndRead vagy EndGetHostAddresses. Az EndXX hívást akkor is meg kell tenni, ha egyébként nincs szükség az eredményre, mivel a háttérmechanizmusok ezt a hívást igényelhetik.
  • Amíg az aszinkron művelet tart (illetve nem lett lezárva az EndXX hívással) egy IAsyncResult interfészt megvalósító osztályon keresztül lehet kapcsolatban maradni az aszinkron művelettel. Hogy ez pontosan mit jelent, nemsokára kiderül. Az IAsyncResult mögött levő osztályt a BeginXX függvények állítják elő, és adják vissza hívásuk eredményeként.

Egy egyszerű példa a következőképpen néz ki:

stream.BeginRead(
  new byte[1024],  // az I/O-ra használt buffer
  0,               // a buffer 0-ik byte-tól írása
  1024,            // 1024 byte olvasása
  Callback,        // az olvasás eredményét feldolgozó függvény
  stream);         //a callback függvénynek átadott paraméter

static void Callback(IAsyncResult result)
{
  FileStream stream = result.AsyncState as FileStream;

  // ... eredmények feldolgozása

  stream.EndRead(result);
} // Callback

Miből főzünk?

A Windows operációs rendszernek van Thread Pool szolgáltatása, a .NET azonban nem erre épül. Mégis, érdemes áttekinteni a Windows Thread Pool-t, ezáltal könnyebb megérteni a nagyon hasonló .NET által megvalósítottat, illetve látszani fog, mennyire kell komolyan venni a köztudatban forgó elnevezéseket és a thread típusok célját.

A Windows Thread Pool-ja alapvetően a Completion Port-okra épül. Ez első hallásra furcsának tűnhet, mivel a Completion Port-ot eddig az aszinkron I/O műveletek eredményeinek gyűjtőhelyeként ismertük. A Completion Port queue-ba azonban nem csak az I/O Manager tud Completion Packet-eket helyezni. Van rá mód, hogy mi magunk is Completion Packet-eket tegyünk ebbe a queue-ba. A kézzel betett Completion Packet nem fog valós I/O eredményeket tükrözni, de ez nem érdekes. A Windows Thread Pool által létrehozott Completion Port-on figyelő szálak ugyanis alapvetően nem arra vannak felkészítve, hogy a Completion Packet-et I/O eredményeket tartalmaznak. Ezek a szálak a Completion Packet-ben egy megfelelő helyre becsomagolt függvénycímet keresnek, és ezen a címen levő függvényt fogják futtatni.

A Windows Thread Pool Completion Port-ját eredeti funkciójának megfelelően is lehet használni, azaz hozzá lehet rendelni az I/O Manager által létrehozott File Object-ekhez, és ekkor minden teljesített IRP-hez az I/O Manager elkészíti a Completion Packetet, és azt a Thread Pool Completion Port-ba teszi. Mivel a Thread Pool Completion Port szálai ebben az esetben is keresik a szokásos függvénycímet, a File Object/Completion Port összerendeléshez egy speciális WinAPI függvényt kell megadni, de ezt itt kevésbé érdekes. Ami érdekes, hogy a Completion Port szálai meghívják a megadott függvényt, ahol fel lehet dolgozni az I/O eredményeket. Ez kicsit kényelmesebb, mint a mikor saját magunk mezítlábasan használjuk a Completion Port-ot, de nincs annyira kényelmes, mint a Completion Routine-ok, ahol minden egyes I/O kéréshez külön callback függvény tartozott. Most egy adott File Object-en végrehajtott összes művelet ugyanarra a callback függvényre fut be.

A problémák akkor kezdődnek a Windows Thread Pool használata közben, ha olyan feladatot teszünk a feladatsorba, (olyan callback függvényt hívatunk meg a Thread Pool szálakkal), amelyik maga I/O műveletet végez Completion Routine-okkal. A baj nem a Completion Routine-nal van, hanem azzal, hogy ezt az operációs rendszer APC-t használva hívná meg. A sorozatinditóban láthattuk, hogy az APC feltétele, hogy a szál várakozási állapotba kerüljön. Mi itt a baj, hiszen a Thread Pool szálai elég gyakran várakoznak a Completion Port-ra kerülő feladatok hiánya miatt. Csakhogy eddig nem lett megemlítve, hogy az APC lefutásához egy speciális, Alertable Wait State állapotban kell várnia a szálnak. Ez semmi különöset nem jelent, csak annyit, hogy amikor a szálat megakasztjuk, egy kiterjesztett függvényt kell használni. Sleep() helyett SleepEx(), Waitxxx() helyett WaitxxxEx(), és így tovább. Ezzel jelzi a fejlesztő, hogy a programlogika fel van készítve arra, hogy azon a ponton APC-k futnak le a szálon.

A Completion Port-ra váró szálak azonban nincsenek Alertable Wait State-ben. Ez annyit jelent, hogy az APC-k soha nem fognak lefutni. Nagy ügy? Nem kell Completion Routine-nal működő aszinkron I/O-t hívni, és nem lesz baj! Csak ez nem olyan egyszerű követelmény. Ha a programozó egy vásárolt könyvtárat használ, akkor kevéssé tudja megválasztani, hogy belül történjen-e APC-t igénylő művelet, vagy ne. Kompatibilitási okokból tehát szükség van olyan szálakra, amelyek Alertable Wait State-ben várnak. Emiatt a Windows Thread Pool tartalmaz olyan szálakat is, amelyek nem a Thread Pool Completion Port-ján figyelnek, hanem Alertable Wait State-ben állnak. Ha a programozó olyan műveleteket akar a Thread Pool feladatsorába tenni, amelyik APC-t igényel, akkor ezt egy paraméterrel jelzi, és ekkor a Windows Thread Pool nem a Completion Port-ra teszi a feladatot, hanem egy Alertable Wait State-ben álló szálat indít el hozzá. Mivel ezek a szálak a feladatok elvégzése után mindig visszatérnek Alertable Wait State-be, az APC-k előbb utóbb fel lesznek dolgozva.

Érdekes módon az APC-t támogató szálakat I/O Thread-nek nevezték el, bár egyrészt nem csak I/O műveleteket lehet vele végrehajtatni, másrészt a Completion Port szálai is tudnak I/O műveletet, csak éppen nem APC-ben végződőt. Emiatt további furcsaság, hogy a Completion Port-ot figyelő szálak a Non-I/O Thread nevet kapták, pedig pont I/O-ra tervezték őket.

A .NET receptje

Most pedig nézzük a .NET Thread Pool-ját. Amikor a .NET-et tervezték, nem kellett azzal foglalkozni, hogy esetleges régi könyvtárak APC-t használnak, mivel nem voltak régi .NET könyvtárak. A .NET egyszerűen nem támogatja az APC-t, mivel lassú és egyébként is szervezési problémákat vet fel. Emiatt a .NET Thread Pool-jában nincsenek is a Windows Thread Pool-ban az I/O Thread-eknek megfelelő szálak. A Completion Port-ot viszont a .NET is használja. A Completion Port, bár nagyon hatékony, azért nem univerzális eszköz. Ha vegyesen használják számításigényes feladatokra és I/O eredmények feldolgozására, akkor a számításigényes feladatok blokkolhatják az I/O műveletek eredményeinek a feldolgozását, mivel a Completion Port alacsonyan tartja az egyszerre futó szálak számát, pont a hatékonyság érdekében. Nem kell itt különleges esetre gondolni, ha egy programozó magáévá tette a feladat alapú (Task Based) programozás filozófiáját, jószándékúan egy halom apró feladattal áraszthatja el a Thread Pool-t, amik viszont elvehetik a szálakat az I/O műveletek elől.

Ezek miatt a .NET Thread Pool a számításigényes feladatoknak külön szálakat tart fent, a Completion Port-ot viszont lényegében csak az I/O műveletek feldolgozására szánja. Emiatt a Completion Port szálait I/O Thread-eknek nevezi (ugye ezek voltak a Windows Thread Pool-ban a Non-I/O Threadek), a számításokhoz használt szálakat pedig Worker Thread-eknek, aminek nincs párja az eredeti Windows Thread Pool-ban. Más források minden, a Main threadtől különböző szálat Worker Threadnek hívnak, én a továbbiakban csak a számításokra használt szálakat hívom Worker Thread-nek.

A Windows Thread Pool esetében a programozó ugyanazt az API függvényt használta, akár I/O Thread-et, akár Non-I/O Thread-et akart futtatni a feladatához (QueueUserWorkItem()). Amire figyelnie kellett, hogy a paraméterek között megadja a szándékát (WT_EXECUTEINIOTHREAD). Ez hibázásra adott lehetőséget, főleg ha a programozó nem olvasta rendesen végig az API-t, és egyébként fogalma sincs arról, hogy mi az az APC. A .NET más filozófiát követ. Lényegében nem ad kényelmes lehetőséget arra, hogy feladatokat közvetlenül az I/O Thread-eknek (tehát a Completion Port szálainak) adjunk ki. Ezeket a szálakat a nemsokára ismertetett APM-et használó osztályok, mint például a FileStream használják belső megvalósításukból. Ahhoz, hogy a programozó egy Completion Port-ra tudjon tenni egy feladatot, jól kell ismerni a .NET osztályait, így véletlenül egészen biztosan nem fog oda nem való feladat a Completion Port-ra kerülni.

A Thread Pool feladatsorába közvetlenül betehető feladatok (melyhez a ThreadPool.QueueUserWorkItem függvényt lehet használni) mind a Worker Thread-ekhez lesznek kiosztva. Ez persze lehet számításigényes feladaton kívül egy szimpla File.ReadAllBytes(), azaz látszólag I/O feladat, viszont a File.ReadAllBytes() egy szinkron olvasás (blocking call-lal), emiatt ez tényleg nem a Completion Port-ra való. Amint a programozó aszinkron olvasást használ, az APM-et kezdi alkalmazni, és így megint a Completion Port-hoz jut. A .NET Thread Pool tehát alapesetekben természetesebben használható a Windows Thread Pool-nál.

Thread Pool Stratégiák

A Thread Pool különböző stratégiákat alkalmazhat arra, hogy hány szálat tartson készenlétben, hány szálat indítson párhuzamosan, mikor szüntessen meg nem használt szálakat. A Completion Port logikáját a (sorozat indító cikkből) már ismerjük, számára meg lehet adni, hogy hány szálat futtasson egyszerre, és efölé a korlát fölé csak akkor megy, ha a már elindított szál valamiért várakozási állapotba kerül.

Mint láttuk, a .NET tulajdonképpen két Pool-t tart karban azzal, hogy megkülönböztet I/O és Worker Thread-eket. Lehet találni információkat arról, hogy mi a .NET stratégiája a szálak indítására. A legelterjedtebb az, hogy egyrészt meg lehet adni a készenléti szálak számát a ThreadPool.SetMinThreads() függvénnyel, illetve a Thread Pool, ha sorban állás van a feladatsorban, akkor fél másodpercenként új szálat indít, a megengedett maximum értékig, amit a ThreadPool.SetMaxThreads()-szel lehet megadni.

A valóság azonban nem ennyire egyszerű, és az alkalmazott stratégia függ a .NET verziószámától, installált service pack-ektől és hotfix-ektől, illetve a processzor leterheltségétől. Nem hiszem, hogy van értelme az összes lehetőséget kinyomozni, pár mérést azonban érdemes csinálni, hogy megbizonyosodjunk arról, hogy úgy kell a programunkat megtervezni, hogy nem támaszkodunk a Thread Pool stratégiájára, illetve ha mégis, akkor azt adott verzióval ki kell tesztelni.

A Worker Thread-ek tesztelésére a következő programot fogjuk használni:

Int64 lastTick = Clock.Value;            // Az utoljára indított szál ideje
 
for (int i = 1; i <= 20; i++)            // Húsz feladat lesz indítva
{
  int n = i;                             // Az i aktuális értékének mentése delegatebe
  ThreadPool.QueueUserWorkItem(          // Feladat helyezése a feladatsorba
    delegate                             // Ez a feladat:
    {
      Int64 thisTick = Clock.Value;      // Aktuális idő
      Int64 previousTick =               // Az utolsó idő megszerzése és frissítése
        Interlocked.Exchange(ref lastTick, thisTick); // thread safe módon
    
      Console.WriteLine(                 // információ ripotolása
        "{0,2}. feladat: {1,9:###0.00} ms", 
        n, 
        Clock.GetMilliseconds(thisTick, previousTick));
 
      while (true) Thread.Sleep(0);      // Busy spin az oprendszer gyilkolása nélkül
    },
    null);
} // for i

A program egy saját számlálót használ, mivel a Stopwatch-ban van egy nem thread safe rész. A belső működése azonban ugyanaz, mint a Stopwatch-é, a QueryPerformanceCounter függvény segítségével lekérdezi a nagyfelbontású, hardveres számláló értékét, illetve később a GetMilliseconds() függvény a számláló frekvenciájának figyelembevételével kiszámolja, hogy két számláló értéke között mennyi idő telt el.

Minden, a for ciklusban indított feladat lekérdezi az aktuális számlálót, és azt összeveti az előző feladat által frissített értékkel. Így ki tudja számolni, hogy az utolsó feladat indítása óta mennyi idő telt el. Mivel minden feladat beakasztja a szálat egy végtelenciklussal, a Thread Pool várhatólag újabb és újabb szálakat fog indítani, ezáltal betekintést nyerhetünk a működésébe.

A végtelenciklus egy Thread.Sleep(0)-át tartalmaz. Ez annyiban jobb, mintha üres lenne a ciklus, hogy üres ciklus esetén minden szál kitöltené a rendelkezésére álló processzoridőt (processzor architektúrától függően 20-30ms kliensgépek esetén), emiatt például 10 szál esetében már 200-300ms eltelhetne, mire a .NET szervizszálai szóhoz jutnak, és ez jelentősen befolyásolná a mérési eredményt.

A Sleep(0) annyit csinál, hogy lemond a hátralévő processzoridejéről, emiatt szinte azonnal végigpörög az összes Worker Thread, és nem késlekedik a .NET keretrendszer működése. Ugyanakkor, mivel a Sleep()-nek átadott paraméter nulla, a szál nem kerül várakozási állapotba, azaz a következő körben az operációs rendszer újra elindítja, és ekkor újra végrehajtódik az a néhány gépi utasítás a következő Sleep(0)-ig. Ez azt eredményezi, hogy a processzor 100%-on pörög, de a méréseink viszonylag pontosak lesznek. Ha nem pörögne 100%-on a processzor, akkor az a .NET Thread Pool-t más stratégiára sarkalhatja, amit rövidesen látni is fogunk.

Először azonban nézzük meg, milyen adatsort generál processzort terhelő program .NET 3.5 alatt:

  1. feladat:      1.17 ms
  2. feladat:      0.19 ms
  3. feladat:   2055.07 ms
  4. feladat:   3088.78 ms
  5. feladat:   4118.69 ms
  6. feladat:   5147.75 ms
  7. feladat:   6177.58 ms
  8. feladat:   7254.00 ms
  9. feladat:   8236.84 ms
 10. feladat:   9266.52 ms
 11. feladat:  10295.93 ms
 12. feladat:  11325.70 ms
 13. feladat:  12370.86 ms
 14. feladat:  13384.80 ms
 15. feladat:  14414.32 ms
 16. feladat:  15444.00 ms
 17. feladat:  16473.78 ms
 18. feladat:  17007.72 ms
 19. feladat:  18016.45 ms
 20. feladat:  19047.44 ms

Az első két feladat lényegében azonnal feldolgozásra került, ami egy kétmagos rendszer esetén, ahol a tesztprogram futott, ideális megoldás. Onnantól viszont megdőlt az az állítás, hogy kb 500ms-enként indít a Thread Pool új szálat, ha van sorban állás a feladatsorban. Látszik, hogy indít ugyan szálakat a Thread Pool, de minél több szál fut már, egyre nehezebben szánja el magát. Minden szál előtt annyi másodpercet várakozik az új szál indításával, ahány szál már dolgozik a Thread Pool-ban. Az utolsó szál emiatt majdnem 20 másodperccel indult az előző szál után, és majdnem három perccel a program indítása után. Valahol érthető ez a hozzáállás, a magszámon felüli szálszám csak rontja a feladatok elvégzésének átlagidejét, lásd a sorozatinditó 10×1 perces feladatok példáját.

Worker Thread-ek esetén a .NET 3.5 figyelembe veszi a SetMinThreads() beállításait. A minimum számot 10-re állítva a következő táblázatot kapjuk:

  1. feladat:      1.88 ms
  6. feladat:      0.28 ms
  4. feladat:      0.39 ms
  2. feladat:      1.80 ms
  3. feladat:      0.02 ms
  7. feladat:      0.18 ms
  8. feladat:      0.36 ms
  9. feladat:      0.13 ms
  5. feladat:      0.24 ms
 10. feladat:   9280.25 ms
 11. feladat:  10296.26 ms
 12. feladat:  11325.39 ms
 13. feladat:  12355.31 ms
 14. feladat:  13384.85 ms
 15. feladat:  14414.33 ms
 16. feladat:  15444.55 ms
 17. feladat:  16473.17 ms
 18. feladat:  17503.12 ms
 19. feladat:  18018.03 ms
 20. feladat:  19047.67 ms

Látható, hogy az első 9 feladat azonnal kiosztásra került. Ezután a következő szál kivárta a korábbról ismert időt, és csak akkor indult el. Bár az idősorok hirtelen ránézésből jobbnak tűnhetnek, a már többször felemlegetett 10-szer egy perces feladat példáján okulva ez rossz megoldás. Ha egy hosszú ideig tartó háttérszámítás van, azt egyszerűen nem a Thread Pool-lal kell elvégeztetni, hanem saját szálat kell neki indítani, esetleg alacsonyabb prioritással. Ekkor a Thread Pool nem fogja kivárni a szál miatt a plusz másodpercet egy új szál indításánál, illetve az alacsony prioritása miatt a hosszú ideig tartó szál engedi érvényesülni a tényleg a Thread Pool-ba való feladatokat.

Miért nem tíz szál indul el azonnal? Nem sikerült rájönni. Az biztos, hogy a számlálásnál nem érdekes, hogy más szálak, például Thread.Start()-tal el lettek indítva, mert kipróbáltam. Az Immediate ablakba betöltött SOS kiterjesztésével utána nézhetünk, mi van a Thread Pool-ban. Az alábbi táblázatban látszik is, hogy maga a Thread Pool is jól látja a számokat:

.load sos
!Threads
ThreadCount: 14
UnstartedThread: 0
BackgroundThread: 13
PendingThread: 0
DeadThread: 0
Hosted Runtime: no
          
       ID OSID      State        APT Exception
4184    1 1058      84028      0 STA
2460    2  99c       b228      0 MTA (Finalizer)
5584    3 15d0      8b228      0 MTA
1888    4  760      8b228      0 MTA
4884    5 1314      8b228      0 MTA
3376    6  d30    188b228      0 MTA (Threadpool Worker)
3580    7  dfc    188b228      0 MTA (Threadpool Worker)
5940    8 1734    188b228      0 MTA (Threadpool Worker)
3384    9  d38    188b228      0 MTA (Threadpool Worker)
2876    a  b3c    188b228      0 MTA (Threadpool Worker)
3600    b  e10    188b228      0 MTA (Threadpool Worker)
5824    c 16c0    188b228      0 MTA (Threadpool Worker)
3040    d  be0    188b228      0 MTA (Threadpool Worker)
3504    e  db0    188b228      0 MTA (Threadpool Worker)
!ThreadPool
CPU utilization 100%
Worker Thread: Total: 9 Running: 9 Idle: 0 MaxLimit: 500 MinLimit: 10
Work Request in Queue: 11
--------------------------------------
Number of Timers: 0
--------------------------------------
Completion Port Thread:Total: 0 Free: 0 MaxFree: 4 CurrentLimit: 0 MaxLimit: 1000 MinLimit: 10

Ahogy a kiemelt sorokban látszik, a Worker Threadek esetén a minimum limit 10, mégis 9 Worker Thread van, ami azonnal indult. A Thread listában az első (1 ID) a Main thread, a második az úgynevezett Finalizer thread, ami a GC által kidobott objektumokon hívogatja a Finalizer-t, ha van nekik. A következő három szálat én indítottam, hogy lássuk, hogy befolyásolja-e a Thread Pool-t a már futó szálak száma (nem befolyásolja). A limit 10, 11 feladat van a feladatsorban, mégis 9 szál indult. Mindegy, végül is majdnem jól működik.

Nézzük meg mi a stratégia akkor, ha a processzor kihasználtsága a sok párhuzamos feladat ellenére is alacsony. Ekkor van értelme új szál indításának, hiszen még maradt processzoridő, amit az új szál ki tud használni. A teszthez módosítani kell a while ciklusban a Sleep() paraméterét egy magasabb értékre. Ekkor a scheduler nem fog folyamatosan processzoridőt allokálni a szálaknak, emiatt a processzor nem fog 100%-on pörögni.

A programon elvégzett módosítás:

while (true) Thread.Sleep(100);

És a futtatás eredménye:

  1. feladat:      1.20 ms
  2. feladat:      2.13 ms
  3. feladat:   1016.19 ms
  4. feladat:    514.79 ms
  5. feladat:    514.74 ms
  6. feladat:    514.86 ms
  7. feladat:    514.75 ms
  8. feladat:    514.87 ms
  9. feladat:    514.74 ms
 10. feladat:    514.78 ms
 11. feladat:    514.93 ms
 12. feladat:    514.79 ms
 13. feladat:    515.22 ms
 14. feladat:    515.29 ms
 15. feladat:    513.68 ms
 16. feladat:    514.97 ms
 17. feladat:    514.73 ms
 18. feladat:    514.79 ms
 19. feladat:    514.82 ms
 20. feladat:    514.82 ms

Ebben az esetben a legendának megfelelő idősort kapjuk, azaz valóban fél másodpercenként indul egy új szál, leszámítva a magszámnak megfelelő első két szálat, illetve az 1 másodpercet váró harmadikat. Ez utóbbi okát nem sikerült kideríteni, de a jelenség következetes. A Thread Pool tehát számításba veszi a processzor terheltségét az új szálak indításánál, ami helyes.

A 4.0-ás .NET az előző tesztekre pontosan a fenti eredményeket adja. Meg kell azonban jegyezni, hogy a Parallel Library-val bejövő taszkok miatt a 4.0 Thread Poolja jelentős változásokon ment keresztül, hogy a taszkokat jól tudja kezelni. Ezek a cikksorozat egy későbbi részében kerülnek nagyító alá.

Nézzük, hogyan viselkedik a Thread Pool, amikor I/O Thread-eket kell használni. Az ehhez használt tesztkód a következő:

var stream =                              // Az aszinkron olvasáshoz használt stream
  new FileStream(                
        @"c:\temp\data.txt",              // Olvasni kívánt file
        FileMode.Open,                    // Meglévőt nyitja
        FileAccess.Read,                  // Olvasásra
        FileShare.Read,                   // Más olvashatja
        1024,                             // Buffer méret
        FileOptions.Asynchronous);        // !!! Ez kell az aszinkron olvasáshoz

Int64 lastTick = Clock.Value;             // "utolsó" thread indítás

for (int i = 1; i <= 20; i++)             // Húsz feladat lesz indítva
{
  int n = i;                              // Az értéket használjuk a delegate-ben

  stream.BeginRead(                       // Aszinkron olvasás APM-mel
    new byte[1024],                       // Olvasson ebbe a bufferbe
    0,                                    // nulladik offset-től
    1024,                                 // 1024 byte-ot

    delegate(IAsyncResult asyncResult)    // Ha megvannak az adatok, futtassa ezt:
    {
      Int64 thisTick = Clock.Value;       // Aktuális idő
      Int64 previousTick =                // Az utolsó idő megszerzése és frissítése
        Interlocked.Exchange(ref lastTick, thisTick);  // thread safe módon

      Console.WriteLine(                  // információ ripotolása
        "{0,2}. olvasás: {1,9:###0.00} ms", 
        n, 
        Clock.GetMilliseconds(thisTick, previousTick));
 
      while (true) Thread.Sleep(0);       // Busy spin az oprendszer gyilkolása nélkül
    },

    null);
} // for i 

A program az előző teszthez nagyon hasonló. Első lépésben aszinkron olvasásra megnyit egy állományt. Ezután indít 20 darab aszinkron olvasást, viszont az eredményt átvevő kód egy végtelen ciklusba kerül, ahol lemond ugyan a processzoridejéről, de nem kerül várakozás állapotba, ezért a processzor 100%-os kihasználtságú lesz. A program nem korrekt, mivel egy EndRead() hívást kellene neki végrehajtania, de a tesztprogram volta miatt ettől most eltekintünk.

A kapott eredmények .NET 3.5 esetén a következőek:

 1. olvasás:      1.47 ms
 2. olvasás:   1017.27 ms
 3. olvasás:    514.80 ms
 4. olvasás:    514.79 ms
 5. olvasás:    514.76 ms
 6. olvasás:    515.73 ms
 7. olvasás:    514.02 ms
 8. olvasás:    514.80 ms
 9. olvasás:    514.75 ms
10. olvasás:    514.81 ms
11. olvasás:    514.71 ms
12. olvasás:    514.85 ms
13. olvasás:    514.75 ms
14. olvasás:    515.46 ms
15. olvasás:    514.17 ms
16. olvasás:    514.84 ms
17. olvasás:    514.74 ms
18. olvasás:    514.82 ms
19. olvasás:    514.82 ms
20. olvasás:    516.30 ms

Az adatsor hasonló ahhoz, mint amikor a worker threadek esetében a processzor nem volt 100%-ig kihasználva. Ez a stratégia érdekes, egyrészt ellene megy a Completion Port-ok természetével, amelyek csak akkor indítanak új szálat, ha az éppen dolgozó szál várakozási állapotba került, másrészt a worker threadek ilyen esetben legalább egyre óvatosabban indítottak szálakat, ennek most a nyomát sem látjuk. Internetes keresgélések alapján kiderült, hogy a Microsoft azért módosítgatta a stratégiáját az I/O Threadek-nek, beleértve az alapértelmezett maximumértékét az indítható I/O threadek számához, mert sok fejlesztő Dead Lock-okat tapasztalt, ha nem indulhatnak újabb és újabb szálak, amelyek elvégzik azt a munkát, amire a beakadt szálak várnak. Sajnos az nem volt leírva, hogy körülbelül milyen esetekben jönnek elő ezek a Dead Lockok. Az is érdekes, hogy csak egy szál indul késlekedés nélkül egy kétmagos rendszeren.

Ha az I/O Thread-ek várakozik, azaz Sleep(100) van a végtelenciklus magjában, akkor ugyanez az adatsor, illetve a 3.5 .NET I/O Threadek esetén érzéketlen SetMinThreads() beállításaira, legalábbis a nálam levő konfigurációban. Hiába állítom fel mondjuk 10-re, a 10 (vagy 9) szál nem indul el azonnal, hanem kivárják a fél másodpercet. A Thread Pool működését service packek és hotfixek befolyásolják, ezért nem lehet előre kiszámítani, hogy melyik gépen mi történik. A SetMaxThreads() viszont működik, bár egy jól megírt programnál ezeket a beállításokat nem kell finomhangolni.

A .NET 4 az I/O Threadek esetében teljesen más működést mutat, mint a 3.5. Ha az I/O Thread-ek 100%-ban dolgoznak, a következő eredményt kapjuk:

 1. olvasás:      3.92 ms
 2. olvasás:     19.09 ms

Az adatsor nem folytatódik, ugyanis a .NET 4-es nem indít új I/O szálat, ha a processzor terhelt, csak a SetMinThreads() által beállított korlátig, ami ebben az esetben a két magnak megfelelő két szál. Ez megegyezik a Completion Port-ok eredeti filozófiájával és a hatékonysági irányelvekkel.

Ha a processzor nem leterhelt, azaz a tesztprogram ciklusában a Sleep(100) található, akkor az 500ms-os szabály lép életbe, ami érthető és elfogadható működés. Érdekes, hogy a 4-es .NET mennyire különbözően kezeli az I/O threadeket, mint a 3.5. Saját véleményem szerint egyébként a 4.0-ás verzió módszere a helyesebb – bár ez a szigorú működés kevésbé jól felépített programoknál lehet, hogy gondot fog okozni – lásd a Dead Lock problémákat.

A Thread Pool szálak programlogikája

Aszinkron I/O műveletekre már a Windows 95-ben is volt lehetőség, bár jóval egyszerűbb módon. Akkor a programozó elindíthatott egy aszinkron műveletet. Ehhez a művelethez összeállított egy úgynevezett OVERLAPPED struktúrát, amit arra lehetett használni, hogy a program a futása közben a struktúrán keresztül tudja monitorozni az aszinkron művelete eredményét, mint hogy történt-e hiba vagy készen van-e már, illetve ezen a struktúrán kapta meg az aszinkron művelettel átvitt byte-ok számát. Miért érdekes ez? Azért, mert ez a struktúra még ma is használatban van, csak éppen már közel sem elég a lehetőségekhez, ezért sokat kell trükközni vele.

Például az egyik dolog, amit a Completion Port-on figyelő szál megkap a Completion Packet részeként, az az OVERLAPPED struktúra. Mint korábban kiderült, ebben a struktúrában benne van, hogy a művelet sikerült, és hány byte érintett. Hogy melyik műveletről van szó? Ohh, hát az nincs benne! Legalább, hogy melyik file-on (USB eszközön, hálózati socketen, stb) történt a művelet? Az sincs. Az OVERLAPED struktúra ebben a helyzetben önmagában semmire sem elég. Emiatt, amikor egy File Object-et az I/O Manager egy Completion Port-hoz rendel, meg lehet adni egy úgynevezett kulcs értéket, ami egy mutató, tehát 32 bites rendszernél 32 bit, 64-nél 64 bit. Ezt a kulcs értéket bármire használhatja a programozó, és minden adott File Object-re végrehajtott aszinkron eredményhez az I/O Manager a kulcs értéket (mutatót) beleteszi a Completion Packet-be. Ez már egész jó, ekkor legalább azt lehet azonosítani, hogy az aszinkron művelet melyik file-ra (USB eszközre, hálózati socketre, stb) vonatkozik, illetve a nem I/O eredményeként létrejött Completion Packet-ok mindegyikéhez saját kulcsot lehet rendelni, egyértelműen azonosítva azt.

A Windows Thread pool szálai ezt a kulcs értéket veszik callback függvény címnek, és így a Windows Thread Pool threadek egyszerű programlogikája az, hogy meghívják a kulcs által mutatott callback függvényt, majd visszatérnek a Thread Pool-hoz. A Windows Thread Pool működése az alábbi ábrán látható.

1-es ábra

Első lépésként a megnyitott file-t (USB eszközt, socketet, stb) képviselő File Object-et össze kell rendelni a Thread Pool által kezelt Completion Port-tal, és az összerendeléshez megadható egy kulcs érték, ami a Completion Port esetében egy callback függvény címe lesz. Később egy aszinkron művelet indítható ezen a File Object-en (2). Ekkor elindul az a folyamat, amit az (sorozatindítóban) megismertünk, melynek a végén visszatér a vezérlés az I/O Manager-hez. Itt (3) az I/O Manager előállít egy Completion Packet-et, melybe beleteszi azt az OVERLAPPED struktúrát (pontosabban annak a memóriabeli címét) amit a hívó az aszinkron I/O műveletével megadott. Ez a struktúra az I/O művelet eredményének megfelelően módosul (hibakód, átvitt byte-ok száma). A Completion Packet-be szintén belekerült az (1) pontban a File Object-hez rendelt kulcs, ami az esetünkben egy callback függvény címe. Az elkészített Completion Packet a Completion Port queue-ba kerül (4). Ekkor feléled egy szál, amely feldolgozza a csomagot. A Thread Pool Completion Portjához rendelt szálak programlogikája az, hogy veszik a Completion Packet-ben tárolt kulcsot, és meghívják a kulcs által tárolt callback függvényt (5).

A .NET Thread Pool-jai ugyanezt teszik, viszont aki ismeri az APM-et, tudja, hogy .NET esetében minden egyes aszinkron I/O művelet esetén külön megadható a callback függvény, nem pedig csak adott file-ra vonatkozóan egy globális. Ehhez plusz információ szükséges, viszont az I/O műveletenként egyedi OVERLAPPED struktúrának nincs használható mezője.

Ezzel a problémával már a .NET előtt találkoztak a fejlesztők, emiatt egy jól bevált módszer ismert az áthidalására. Ha nem megfelelő az OVERLAPPED struktúra, hát le kell származtatni belőle egy olyat, ami már tartalmazza a szükséges mezőket. Ezzel egy apró probléma van, hogy a WinAPI nem objektum orientált, így nincs származtatás sem. Ez azonban csak látszólag okoz nehézségeket. Az öröklődés alacsony szinten nem jelent túl sokat, ahogy azt a értéktípusokról szóló cikkben láthattuk. Memóriahasználat tekintetében arról van csupán szó, hogy a leszármazott típus megismétli az őstípus összes mezőjét, majd mögé felsorolja a sajátjait. Ha ugyanezt csináljuk az OVERLAPPED struktúrával, akkor az I/O Manager boldogan használja a benne található mezőket, miközben ott lapulnak mögöttük a sajátjaink.

A .NET pontosan ehhez a trükkhöz folyamodik, és egy konkrét példán keresztül nézzük meg, hogy hogyan. A FileStream osztály támogatja az aszinkron műveleteket. Ha a stream-et aszinkron módban nyitjuk meg, akkor egyéb műveletek mellett a FileStream hozzárendelteti az általa kezelt FileObject-et (File Object Handle-t) a Thread Pool Completion Port-jához, az 1-es ábra (1) pontjának megfelelően. Hozzárendelésnél meg lehet adni egy kulcs értéket, ami majd megjelenik minden, a FileStream példányon végrehajtott aszinkron művelet eredményével a Completion Packet-ekben. A .NET esetében az alsóbb réteg függvényei mindig egy BindIOCompletionCallbackNative nevezetű függvény címét adja meg kulcsként, ami a .NET Thread Pool implementációjának egy C nyelven írt függvénye. Bármely (I/O Manager által készített) File Object-et is rendeljük a .NET-ből a Completion Port-hoz, minden a File Object-en végrehajtott műveletre ez a függvény fog lefutni. Ez a függvény ismeri a kiterjesztett OVERLAPPED struktúrát, és tudja, hogy minden aszinkron műveletnél a műveletet indító függvény eltárolt egy IOCompletionCallback típusú delegate-et, ahová az aszinkron művelet végeztével meg szeretné kapni a vezérlést. Az BindIOCompletionCallbackNative előveszi ezt a delegate-t, elvégzi azokat a teendőket, ami kell, hogy visszahívhasson a .NET-es kódba, majd meghívja a delegate-et. Aki jól ismeri a delegate-eket, tudja, hogy a .NET delegate-ek egy MulticastDelegate típus leszármazottai, ami egyben azt is jelenti, hogy itt akár több függvény hívásáról is lehet szó. Ez így van, ha valaki azt szeretné, akkor egy egész sor függvénye meghívódhat ezen a ponton, egymás után. A FileStream osztály azonban nem ennyire mohó, ő minden aszinkron művelethez a FileStream.AsyncFSCallback függvényének a címét adja az OVERLAPPED struktúra mellé, emiatt a vezérlés mindig erre a függvényre adódik át, amikor az aszinkron művelet elkészült. Ekkor, bár már két függvényhíváson túlvagyunk, és kihasználtuk a minden I/O műveletre egyedi, kibővített OVERLAPPED struktúrát, még mindig ott tartunk, hogy ugyanaz a függvény fut minden egyes aszinkron művelethez.

Többször érintettük már, hogy a régi világ maradványaként az OVERLAPPED struktúrát kell hurcolászni az aszinkron műveletekhez, illetve az is említésre került, hogy a .NET-es infrastruktúra ezt a struktúrát kibővíti. Nos, a kibővítés helyett a lecserélés lenne a találóbb kifejezés. A .NET szimpla kényszerből hordozza ugyan az OVERLAPPED struktúrát, de igazából a saját konstrukcióját használja. Ez a konstrukció egy IAsyncResult interfészt megvalósító osztály, a konkrét implementáció az APM-et támogató osztály igényeitől függ. Maga az IAsyncResult nem sokkal okosabb, mint az OVERLAPPED, lényegében ugyanazok az információk olvashatóak ki belőle, csak a .NET-nek megfelelő formában. Amivel többet ad, hogy az IAsyncResult interfész mögött bármilyen osztály lehet, például olyan, ami egy callback delegate-et tud tárolni. Mivel külön IAsyncResult példány tartozik minden I/O művelethez (az OVERLAPPED struktúra is egyedi I/O művelethez tartozott), emiatt külön callback delegate tárolható minden egyes I/O művelethez. A 2-es ábrán követhető, hogy mi történik.

2-es ábra

Amikor egy APM-et támogató osztály példányon, például egy FileStream példányon aszinkron I/O műveletet indítanak (mint a BeginRead), belül elkészül egy IAsyncResult-ot megvalósító példány (1). A BeginRead függvény paraméterei között van egy AsyncCallback típusú delegate, illetve egy szabadon használható object típusú stateObject is. Ezek a paraméterek egyszerűen eltárolásra kerülnek az IAsyncResult-ot megvalósító példányon. Ezután lassan eljön a pillanat, amikor túl közel kerül a vezérlés az operációs rendszerhez, ami viszont az OVERLAPPED struktúrát várja. Emiatt át kell térni erre a struktúrára. Ezt az áttérést a .NET-es Overlapped osztály támogatja (2).

Az Overlapped class konstruktora azokat a paramétereket várja, amely adatok az OVERLAPPED struktúrában is megvannak, illetve vár még egy IAsyncResult típusú referenciát is. Az áttérést az OVERLAPPED struktúrára az Overlapped.Pack() függvénnyel történik. Ez a függvény vár két paramétert. Az egyik egy IOCompletionCallback típusú delegate, aki emlékszik még rá, pont ilyet keres a BindIOCompletionCallbackNative függvény. A másik paraméter az, ahová (vagy ahonnan) az I/O művelet dolgozik. Erre a területre (vagy erről a területről) fognak mozogni az adatok. Mivel managed területen vagyunk, egy esetleges szemétgyűjtés azt okozhatja, hogy a memóriaterület át lesz helyezve, erre azonban a nem managelt kódok (s főleg az operációs rendszer) nincs felkészülve. Emiatt a területet fixálni (kiszögelni, vagy pinnelni) kell az I/O művelet idejére. Az Overlapped.Pack() függvény azt csinálja, hogy lefoglal a memóriában egy területet, azon belül kialakít egy OVERLAPPED struktúrának megfelelő részt, kitölti a megfelelő mezőit, eltárolja a (konstruktorban kapott) IAsyncResult referenciát és a (Pack függvény paramétereként kapott) IOCompletionCallback delegate-et, kiszögeli a buffernek használt területet és a most foglalt területet, majd a területen belül az OVERLAPPED struktúrára mutató pointerrel tér vissza, aminek a típusa egyébként NativeOverlapped a .NET szemszögéből, de a memória layout-ja egy az egyben az OVERLAPPED struktúráé (3).

Miután ez a lépés megtörtént, meg lehet hívni az operációs rendszer (I/O Manager) aszinkron függvényeit az előbb előállított NativeOverlapped pointerrel, és ekkor kezdetét veszi a folyamat, amit a sorozatindító cikkben már áttekintettünk. Ami ez után következik, követhető a 3-as ábrán.

3-es ábra

Miután az aszinkron I/O művelet befejeződött, az I/O Manager előállít egy Completion Packet-et, ami tartalmazza egyebek mellett a NativeOverlapped pointert (amiről az I/O Manager azt hiszi, hogy kommersz OVERLAPPED struktúra) és a File Object-hez rendelt kulcsot, ami nem más, mint a BindIOCompletionCallbackNative függvény címe (1). Amikor a Completion Port-ra figyelő szál felveszi a feladatot, akkor meghívja a kulcs által mutatott függvényt, így a vezérlés a BindIOCompletionCallbackNative-ra adódik (2). Ez előveszi a NativeOverlapped struktúra mellé tett IOCompletionCallback delegate-et, azt meghívja, így visszatérünk a .NET fennhatósága alá, FileStream-et használva a FileStream.AsyncFSCallback függvénybe (3). Ez a függvény megkapja a NativeOverlapped struktúrát is, amit csak arra használ, hogy az Overlapped osztály Unpack() függvényével visszakapja az IAsyncResult implementációját (4), illetve újra mozgathatóvá teszi a nem managelt kódok miatt kiszögelt buffert, könnyítve ezzel a garbage collector dolgán. Az AsyncFSCallback ezután beállítja és lekezeli az IAsyncResult “hivatalos” mezőit, mint például az IsCompleted értékét, majd ha a felhasználó az aszinkron művelet paramétereként adott meg callback függvényt, az AsyncFSCallback ezt meghívja (5).

Ekkor végre sikerült eljutni arra a pontra, amit a régebbi, de kevésbé hatékony Completion Routine-ok tudtak, tehát minden műveletnek saját callback-je van. A trükközések miatt fenntartott infrastruktúra miatt azonban ezen a ponton nem lehet megállni. Ezen a ponton még a memóriában van az Overlapped osztály által kezelt struktúra, a memóriában van a később tárgyalandó Event példány, illetve a hibakezelés sem történt meg.

A hibakezelés modern nyelvek esetében exception-ökkel történik, de ha az eddig leírt körben bárhol exception történt volna, akkor azt a programozónak esélye sem lett volna elkapni és lekezelni. Éppen ezért az APM-et támogató osztályoknak van egy mintája, hogy a művelet végén egy Endxxx, például EndRead() függvényt kell hívni a műveletet indító példányon, mint a FileStream. Ez az EndRead függvény a paraméterben megkapja az IAsyncResult struktúrát, amely interfész mögött az osztály saját megvalósítása áll. Amikor FileStream esetében az AsyncFSCallback megkapta a vezérlést, a paraméterei között megkapott egy hibakódot is, amely jelezte az I/O művelet eredményét. Ez a hibakód az operációs rendszer (az I/O Manager, a driver, stb) hibakódja, tipikusan azok az értékek, amiket a Winerror.h-ban találhatunk. Az AsyncFSCallback azonban nem foglalkozott ennek az értékével, csak eltárolta az IAyncResult megvalósításában. Most azonban, hogy az EndRead() függvény meghívásra került, ez a hibakód ismét előkerül. Az EndRead(), ha a hibakód nem sikeres műveletet ír le, a kódot egy exception-né konvertálja, és azt eldobja. Emiatt az aszinkron műveletek hibakezelése az EndRead() függvény hívásán keresztül történik. Ha a callback függvényünkben keletkezik lekezeletlen kivétel, az viszont a programunk futásának a végét jelenti, de arról mi magunk tehetünk.

Ha nem vagyunk kíváncsiak a visszakapott hibára, hanem az aszinkron műveletet “fire and forget” módon kívánjuk használni, az EndRead() függvényt akkor is meg kell hívni a lefoglalt, Overlapped által kezelt adatok miatt. Ez a terület ki van szegelve (pinnelt), mivel a nem managelt kódok használják, és az AsyncFSCallback sem engedi el. Ez azt jelenti, hogy a példányt a GC nem tudja felszabadítani, és ami még rosszabb, az Overlapped példány referál a bufferre, ahonnan vagy ahova az I/O műveletek adatai mozognak. Ez így már jelentős memóriafolyást eredményezhet, amit könnyű is kipróbálni. Az alábbi két diagram kb 20000 aszinkron 1kb-os olvasás során mutatja a memóriafoglaltságot, és a felhasznált operációs rendszer HANDLE-k számát, ami az aszinkron olvasásokhoz készítetett Event-ek miatt (lásd később) érdekes. Látható, hogy a memóriahasználat a többszörösére növekedett, ha nincs EndRead(). Az EndRead() használatával egy kezdeti memóriafogyasztás után a folyamat megáll, a felhasznált HANDLE-k száma pedig nem is látszik a diagramon, mivel végig az alsó tengelyre tapad az érték.


A memóriafolyásnak nyomkövetéssel is utána tudunk nézni. Azt kell tudni, hogy az Overlapped osztály egy OverlappedData típusú példányban tárolja az adatait.

Miután lefutott 20 aszinkron olvasás EndRead nélkül (a cikk elején bemutatott tesztporgrammal) az Immediate ablakban az SOS kiterjesztést betöltve (.load sos) a következő parancsokkal vizsgálódhatunk:

Elsőnek szedjük össze az Overlapped példányainkat:

!DumpHeap -type OverlappedData
 Address       MT     Size
01b23640 0145c4c8       20     
01b236b0 0145c420       68     
01b236f4 0145c420       68     
01b23738 0145c420       68     
...
total 35 objects
Statistics:
      MT    Count    TotalSize Class Name
0145c294        1           16 System.Threading.OverlappedDataCache
0145c4c8        2           40 System.Threading.OverlappedDataCacheLine
0145c420       32         2176 System.Threading.OverlappedData
Total 35 objects

Listázzuk ki az egyik példányt:

!do 01b236b0
Name: System.Threading.OverlappedData
MethodTable: 0145c420
EEClass: 01445ed4
Size: 68(0x44) bytes
 (C:\Windows\assembly\GAC_32\mscorlib\2.0.0.0__b77a5c561934e089\mscorlib.dll)
Fields:
      MT    Field   Offset                 Type VT     Attr    Value Name
01459ddc  4000616        4  System.IAsyncResult  0 instance 01b235d8 m_asyncResult
0145a828  4000617        8 ...ompletionCallback  0 instance 01b22ef8 m_iocb
0145c67c  4000618        c ...ompletionCallback  0 instance 01b23b18 m_iocbHelper
0145bed0  4000619       10 ...eading.Overlapped  0 instance 01b23634 m_overlapped
002c6d58  400061a       14        System.Object  0 instance 01b231ac m_userObject
0145c4c8  400061b       18 ...ppedDataCacheLine  0 instance 01b23640 m_cacheLine
004c7520  400061c       1c        System.IntPtr  1 instance 002714FC m_pinSelf
004c7520  400061d       20        System.IntPtr  1 instance 00000000 m_userObjectInternal
004ce038  400061e       24         System.Int32  1 instance        1 m_AppDomainId
01492754  400061f       28         System.Int16  1 instance        0 m_slot
004c4fa0  4000620       2a          System.Byte  1 instance        0 m_isArray
004c4fa0  4000621       2b          System.Byte  1 instance        0 m_toBeCleaned
0145b910  4000622       2c ....NativeOverlapped  1 instance 01b236dc m_nativeOverlapped

Az m_nativeOverlapped mező lett átadva az aszinkron művelethez a nem managelt kódoknak. Nézzük meg, ki hivatkozik erre az OverlappedData példányra:

!GCRoot 01b236b0
Scan Thread 5912 OSTHread 1718
Scan Thread 3636 OSTHread e34
Scan Thread 4828 OSTHread 12dc
DOMAIN(00371CE8):HANDLE(AsyncPinned):2714fc:Root:01b236b0(System.Threading.OverlappedData)

Látszik, hogy senki nem hivatkozik rá, viszont ki van szegezve (AsyncPinned), emiatt a GC nem fogja felszabadítani, és nem fogja felszabadítani az összes benne levő, mezők által referált adatot sem. Ezekben az adatokban benne van egyéb más mellett is az 1K buffer, ahova az olvasás történt. Nézzük meg, mennyi helyet foglal mindenestől ez az osztály:

!ObjSize 01b236b0
sizeof(01b236b0) =         2472 (       0x9a8) bytes (System.Threading.OverlappedData)

Tehát a veszteségünk kb 2.4K olvasásonként, ha nem használjuk az EndRead-et, ráadásul a fel nem szabadítható területek miatt a memória fragmentálódik, amit a .NET memóriakezelője nem tud hatékonyan kezelni. Ha valakinek néhány naponta újra kell indítani a szerver alkalmazását, mert folyik a memória, hát tessék, itt egy lehetséges ok.

Hol tartunk most?

Láttuk, mire és hogyan használja a .NET a Thread Pool különböző típusú szálait. Megtudtuk, hogy bár a Completion Port nem olyan kényelmes alapból, mint amilyenek a Completion Routine-ok voltak, a .NET megadja azt az infrastruktúrát, aminek segítségével az aszinkron műveletek mégis kényelmesen használhatóak. Az infrastruktúra három kulcseleme az OVERLAPPED struktúrát kiegészítő IAsyncResult példány, és a BeginMulvelet/EndMuvelet híváspáros. Megtudtuk, hogy bár néha feleslegesnek tűnik, aszinkron művelet End párját mindenképpen meg kell hívni, mivel a háttérrendszer ekkor szabadítja fel a művelethez allokált erőforrásokat.

Mit tudnak még a Completion Port-ok?

Korábban már volt róla szó, hogy Completion Packet-et nem csak az I/O Manager tud a Completion Port queue-ba helyezni, hanem ezt “kézzel” a programozó is megteheti. Ekkor egy “kamu” OVERLAPPED struktúra kerül a Completion Packet-be, ha pedig .NET-ből használjuk, akkor az OVERLAPPED struktúra kibővítését nekünk is meg kell tennünk, hiszen a Thread Pool szálak a megfelelő helyeken keresni fogják a megfelelő adatokat, mint a callback delegate.

A segédosztályoknak hála ez nem olyan bonyolult feladat, érdemes egy próbát tenni, még egy nézőpont arról, hogy mi is történik valójában.

Első lépésnek kell egy IAsyncResult megvalósítás, ami az OVERLAPPED struktúrát cseréli le a .NET alatt. A mi struktúránk semmi tudással nem fog rendelkezni, egyedül egy string típusú adatot hordoz majd magával:

public class CustomAsyncResult : IAsyncResult
{
    private readonly string message;
 
    public CustomAsyncResult(string message) 
  { 
    this.message = message; 
    this.IsCompleted = false;
  }

    public object AsyncState { get { return this.message; } }
    public WaitHandle AsyncWaitHandle { get { return null; } }
    public bool CompletedSynchronously { get { return false; } }
    public bool IsCompleted { get; set; }
} // class CustomAsyncResult

Az saját AsyncResult tehát egy üzenetet hordoz, és az egyetlen támogatott funkciója az, hogy meg tudja mondani, kész van-e már a művelet, vagy nem. Normál körülmények között az IsCompleted property-t nem lehetne kívülről állítgatni, de most nekünk ez így megfelel.

Szükségünk lesz egy CustomAsyncResult példányra, ezek a példányok az APM-es osztályok esetén a Beginxxx függvényekben jönnek létre, és többek között a Beginxxx paramétereit tárolják, mint a használt buffer, buffer index, mozgatni kívánt adatok, callback, stb. Ehhez komplexebb IAsyncResult megvalósítás kell, de ami osztályunknak csak egy paramétere van:

IAsyncResult asyncResult = new CustomAsyncResult("From Completion Port");

Mivel keresztezzük a régi világot, hiszen a Completion Port-ot végsősoron az operációs rendszer működteti, át kell térnünk a régi OVERLAPPED struktúrára. Ehhez a .NET Overlapped osztályát használjuk. Nem igazi file műveletünk van, illetve nem használjuk ki az overlapped műveletek által támogatott Event-et sem (lásd később), emiatt létrehozhatjuk az Overlapped osztályt csupa nulla értékekkel, leszámítva az IAsyncResult példányunkat:

Overlapped overlapped = new Overlapped(0, 0, IntPtr.Zero, asyncResult);

Ismerve a Thread Pool thread-ek logikáját, nekik szükségük lesz egy IOCompletionCallback típusú callback delegate-re. Ez a delegate azokat a paramétereket kapja meg, amelyek a Completion Packet-ben is benne vannak (kivéve a kulcs értéket, ami amúgy mindig ennek a delegate-nek a címe). Az errorCode és a numBytes egyébként kinyerhető az OVERLAPPED struktúrából is.

unsafe static void Callback(uint errorCode, int numBytes, NativeOverlapped* pOverlap)
{
    Overlapped overlapped = Overlapped.Unpack(pOverlap);  // A kibővített OVERLAPPED visszaszerzése
    IAsyncResult asyncResult = overlapped.AsyncResult;    // Minket csak az asyncResult-unk érdekel
 
    string message = asyncResult.AsyncState as string ?? "no message"; // átadott üzenet
    Console.WriteLine(message);                                        // Üzenet kiírása
 
    return;
} // Callback()

A függvény belül visszaalakítja az OVERLAPPED struktúrát a kiterjesztett típusra, amiből pedig megszerzi az AsyncResult-ot. Az ebben található stringet fogja kiírni a képernyőre. A mi IOCompletionCallback típusú delegate-ünk, amit az OVERLAPPED mellé kell csomagolni, tehát a Callback függvényre mutat, ezt fogja elővenni a a BindIOCompletionCallbackNative függvény.

IOCompletionCallback cb = Callback;

Ha nagyon kísérletező kedvünkben vagyunk, kihasználva a MulticastDelegate osztály tudását megadhatunk több függvényt, most a próba kedvéért ugyanazt, mint az előbb, így elvileg kétszer lesz meghívva:

cb += Callback;

Lassan közeledünk ahhoz a ponthoz, ahol áthívunk a natív oldalra, emiatt szükségünk van az OVERLAPPED struktúrára, ami körbe van dekorálva a Thread Pool szálak által keresett információval. Ezt a lépést az Overlapped osztály Pack() függvényével tehetjük meg. Meg kell adni a delegate-et, amit a BindIOCompletionCallbackNative majd megtalál (igazából az ilyen kézihajtányos módszernél egy másik függvény címe kerül kulcsként a Completion Packet-be, ami amúgy pontosan úgy működik, mint a BindIOCompletionCallbackNative, ezért most ettől a részlettől eltekintünk)

NativeOverlapped* pOverlapped = overlapped.Pack(cb, null);

Ezekután nincs más teendő, mint a Completion Port-ra küldeni a csomagot:

ThreadPool.UnsafeQueueNativeOverlapped(pOverlapped);

Elvileg az üzenet kiíródik kétszer, mivel a callback delegate kétszer tartalmazza a függvényünket. Ezzel a módszerrel a Completion Port thread-jeinek lehet nem I/O orientált feladatokat kiadni, aminek amúgy sok értelme nincsen, emiatt a fent ismertetett folyamatot túl gyakran nem hiszem, hogy használni kellene. Az I/O műveleteket működtető osztályok azonban körülbelül a fenti folyamatot járják végig, kivéve, hogy nekik nem kell a ThreadPool.UnsafeQueueNativeOverlapped függvényt hívni, hogy a Completion Packet a Completion Port-ra kerüljön, hanem egy WinAPI ReadFile/WriteFile függvény indítja el azt a kört, aminek a végén az I/O Manager készíti el a Completion Packetet.

Kernel Objektumok használata

A fenti kevéssé hasznos lehetőségen kívül vannak más lehetőségeink is, például egy File Object-től különböző kernel objektumot rendelünk a Completion Port-hoz. Mit jelent ez, és mi az értelme?

A windows operációs rendszer esetében az erőforrásokat úgynevezett Object-ek képviselik, de ennek nincs sok köze az objektum orientált programozáshoz. Ezeknek az Object-eknek több típusa van, az egyik típus a Kernel Object. A Kernel Object-ek egy részének megvan az a jó tulajdonsága, hogy amikor a képviselt erőforrással történik valami, akkor az a hozzá tartozó Object-en keresztül detektálható, és például egy szál tud várakozni egy ilyen eseményre, mondjuk a WinAPI WaitForSingleObject(), vagy a .NET WaitHandle.WaitOne() függvényekkel . Van pár objektum típus, aminél egyértelmű, hogy mi ez a változás. Event, Mutext, Waitable Timer, ezeket talán többen ismerik. De hasonlóan működő kernel objektum a processz is, ami akkor “triggerelődik”, amikor az objektum által képviselt processz futása véget ér. Egy szálat tehát be lehet állítani úgy, hogy a futása akkor folytatódjon, ha egy processz furása véget ért. Ami jó, hogy nem csak a szálak tudnak figyelni egy kernel object-ra a WaitHandle.WaitOne()-nal, hanem a Completion Port is. A Completion Port-hoz bizonyos típusú kernel object-ek rendelhetőek, és ekkor egy Completion Port-on figyelő szál akkor is elindul, ha ez az objektum triggerelődik. A fenti példát folytatva megoldható például, hogy egy szál induljon el, amikor egy programot bezártak.

A Completion Port ezen tulajdonsága a .NET-en keresztül is elérhető. A kérdéses függvény a ThreadPool.RegisterWaitForSingleObject(). Ez a függvény paraméterként, hogy mire várakozzon, egy WaitHandle-t vár, aminek túl sok implementációja (leszármazottja) nincs a .NET-ben, de a leghasznosabb szinkronizációs objektumok megtalálhatóak, mint például AutoResetEvent, Mutex, Semaphore. Azonban mi is készíthetünk, most az egyszerűség kedvéért egy primitív verziót, ami egy tetszőleges Kernel Object Handle-re fog ráülni. Szerencsére a .NET Process oszrtálytól meg lehet szerezni a Kernel Object Handle-t, és ezt be lehet ágyazni egy WaitHandle megvalósításunkba. Legyen a primitív WaitHandle implementációnk a következő:

public class PrimitiveWaitHandle : WaitHandle
{
  public PrimitiveWaitHandle(IntPtr handle)
  {
    this.SafeWaitHandle = new SafeWaitHandle(handle, false);
  } // PrimitiveWaitHandle
} // class PrimitiveWaitHandle 

Most ebbe az osztályba bármilyen natív Handle-t becsomagolhatunk, például egy processzét:

Process process = new Process();
process.StartInfo.FileName = "notepad";
process.Start();
      
PrimitiveWaitHandle handle = new PrimitiveWaitHandle(process.Handle);

A fenti pár sor elindít egy notepad alkalmazást, megszerzi a Handle-jét, és becsomagolja egy WaitHandle osztályba. Emiatt mostmár hozzá lehet rendelni a Thread Pool Completion Port-jához:

ThreadPool.RegisterWaitForSingleObject(
  handle,                           // A handle, amit a Completion Port-ra teszünk
  delegate                          // A callback delegate
  {
    Console.WriteLine("kilepett");
  },
  null,                             // State object, most nem kell
  -1,                               // Timeout, most ne legyen
  true);                            // Csak egy szignált várunk.

A fenti pár sor hatására az operációs rendszer a handle-t hozzárendeli a Completion Port-hoz, így ha a handle triggerelődik, akkor egy Completion Port szál elindul, és végrehajtja az átadott delegate-et.

A Timer mit használ?

A fentihez hasonló módon lehetne használni az operációs rendszer waitable timer objektumát periodikusan végrehajtandó funkciókhoz. A .NET azonban nem ezt a lehetőséget használja. A .NET esetében egy külön szál szolgálja ki a timereket. Ez a külön szál nem indul el addig, amíg nincs szükség rá. Az első System.Threading.Timer használatkor azonban elindul, és a következőként működik:

A timer szálnak van egy listája arról, hogy a programozó milyen timer-eket indított el. Ennek a listának az elemeiben azok az információk vannak benne, amit a Timer class a konstruktorában megkapott. A timer szál ezek alapján az információk alapján ki tudja számolni, hogy mi az a következő pillanat, amikor valamelyik timer miatt meg kell hívni egy callback függvényt. Addig, amíg nincs teendő, a timer szál várakozási állapotba kerül.

Mi van akkor, amikor a programozó egy új Timer osztályt hoz létre? A Timer osztály konstruktora vicces módon a jó öreg, és a .NET-ből amúgy mellőzött APC technikát alkalmazza. A timer szál ugyanis Alertable Wait State-ben várakozik addig, amíg a következő timer esemény bekövetkezik. A Timer osztály konstruktora egy APC requesttel adja át a saját információit. Mivel a timer szál SleepEx() WinAPI hívással került várakozási állapotba, és ennek a függvénynek be lehet állítani, hogy egy esetleges APC hívás után azonnal ébredjen fel. Miután a timer információs lista frissült, a szál felébred, újraszámolja a következő aktivitás idejét, és újra várakozási állapotba kerül.

Amikor eljön egy aktivitás ideje, a timer szál felébred, és a Thread Pool worker szálainak kiad egy feladatot a Timer osztály konstruktorában megadott callback függvénnyel. A .NET Timer tehát nem használja a Completion Port-ot.

A Thread Pool korábban látott működése miatt előfordulhat, hogy a callback függvény egy terhelt processzoron nem fut azonnal, mivel a Thread Pool esetleg csak jóval később tudja feldolgozni a kérést. Terhelt processzornál az is előfordulhat, hogy egy timertől több kérés sorakozik a Thread Pool feladatsorában.

Mi maradt még?

A régi aszinkron műveletek a régebbi Windows verziókon nem használtak callback függvényeket. Két módon lehetett kezelni az aszinkron kéréseket. Az egyik, hogy az aszinkron művelet után ciklusban le lehetett kérdezni az OVERLAPPED struktúrát használva, hogy az aszinkron művelet véget ért-e már. Ha nem, lehetett csinálni valami hasznosat, vagy lehetett várni néhány tized másodpercet, és újra kérdezni. A másik, hogy az OVERLAPPED struktúrába lehetett helyezni egy Event-et, ami az operációs rendszer egy kernel objektuma, és segítségével megoldható, hogy egy szál a processzor igénybevétele nélkül addig vár, amíg az Event nem “triggerelődik”. Az I/O Manager, ha látja, hogy egy OVERLAPPED struktúrában az Event egy érvényes objektum (nem nulla a mező értéke), akkor triggereli azt, és a rá várakozó szálat az operációs rendszer feléleszti.

Bár ezek a módszerek közel sem tűnnek kifinomultnak, egyszerűbb igényeknél talán jobban használhatóak, mint egy callback függvény. Emiatt a .NET is támogatja ezeket. Az IAsyncResult-nél már láthattuk, hogy annak van egy IsCompleted property-je. Ezt a property-t a FileStream esetében például az AsyncFSCallback callback függvény állította be. Ennek a property-nek a vizsgálatával tehát megoldható az a stratégia, hogy periódikusan ellenőrizzük, hogy a művelet végetért-e. Ezt a módszert “polling rendezvous technique”-nek hívják, bár más forrásokban Busy Waiting design antipatternként is fellelhető. Valóban, nem túl elegáns módszerről van szó, és bár biztos vannak esetek, amikor hasznos, most nem tudnék olyat mondani, ahol ezt használnám. Talán egy GUI szálban elvégzett File I/O-nál a ciklus magjában életben lehet tartan a képernyőt. A pszeudokód egyébként így néz ki:

IAsyncResult result = stream.BeginRead(...);
while (result.IsCompleted == false)
{
  .. hasznos műveletek
}
stream.EndRead(result);

Az event alapú szinkronizáció szintén megoldható az IAsyncResult-on keresztül, erre szolgál az AsyncWaitHandle property. Lehetnek APM-et megvalósító osztályok, ahol ez az Event osztály csak igény esetén készül el, de például a FileStream mindenképpen elkészíti, és csak az EndRead/EndWrite függvény hívásakor szünteti meg, mint ahogy a teljesítmény mérés diagramján korábban láthattuk a megszaladt handle számot Endxxx() hiányában. A lényeg, hogy az AsyncWaitHandle property hívásával egy olyan objektumot kapunk, amelyre a szál tud várakozni, és akkor indul el újra a futása, ha az I/O művelet befejeződött. A FileStream esetében például az AsyncFSCallback függvény triggereli az Event-et. Ezt a technikát “Wait until done rendezvous technique”-nek hívják, és kicsit talán értelmesebb, mint a polling. Akkor használható, amikor egy-két műveletet lehet párhuzamosítani az I/O művelettel. Az aszinkron I/O indítása után el lehet végezni az egy-két teendőt, utána pedig várakozni az event-re. A pszeudokód így néz ki:

IAsyncResult result = stream.BeginRead(...);
 
  .. hasznos műveletek
 
result.AsyncWaitHandle().WaitOne();
stream.EndRead(result);

Az APM egyébként egy egyszerűbb megoldást is kínál erre az esetre. Ha idő előtt hívja a program az Endxxx() függvényt, akkor az megvárja, amíg a művelet befejeződik, és csak ezután tér vissza. A FileStream osztály EndRead/EndWrite megoldása például pontosan ugyanazt az event-et használja várakozásra, mint amit az AsyncWaitHandle property-vel visszakapnánk. Ekkor a pszeudokód a következő:

IAsyncResult result = stream.BeginRead(...);
  .. hasznos műveletek
stream.EndRead(result);

Mit kell még tudnunk?

Azt, hogy egy File Object aszinkron vagy szinkron módon lesz üzemeltetve, meg kell mondani a file megnyitásánál. Ekkor az I/O Manager a File Object-ben eltárolja ez a tényt, és a későbbiekben enneg megfelelően kezeli a file-t. Ez azt jelenti, hogy az üzemmódokat nem lehet keverni. A .NET osztályok figyelnek erre, így amikor a programozó nem a megnyitásnak megfelelően használja például a FileStream-et, akkor az szimulálja a kívánt működést.

Egy szinkron módon megnyitott file-nál például, ha valaki BeginRead()-et hív, akkor az I/O Manager nem tudná aszinkron módon intézni a hívást. Emiatt a BeginRead() implementációja ellenőrzi, hogy aszinkron módon van-e megnyitva a stream. Ha nem, akkor a szinkron Read() függvényt hívja meg, de hogy megtartsa az aszinkron hívás illúzióját, a híváshoz a MulticastDelegate azt a képességét használja ki, hogy a MulticastDelegate az APM patternjét használva bármelyik függvényt meg tudja hívni aszinkron módon.

MulticastDelegate és az APM

Akkor, amikor leírunk egy „delegate void Alma(int a);” sort, akkor a C# fordító egy új osztályt generál, amely a MulticastDelegate osztályból származik. Ennek az új osztálynak lesz egy BeginInvoke()/EndInvoke() függvénypárja. A BeginInvoke() függvény az utolsó két paraméterét leszámítva ugyanazokat a paramétereket várja, mint amit a delegate definíciójában megadunk. Az utolsó két paraméter a BeginXX függvényeknél már megszokott callback és userState object.

Ami különös ezekben a függvényekben, hogy nem tartozik hozzájuk implementáció. A Reflector-ban megnyitva nem tudjuk megnézni, hogy hogyan működnek. Ezekhez a függvényekhez a .NET runtime generál kódot az első használatkor.

A generált kód a .NET Remoting osztályait használja a függvényhívások lebonyolításához. A hívás adatait (callback függvény és paraméterei) egy remoting-os MessageData struktúrává alakítja, ezután pedig egy AgileAsyncWorkerItem.ThreadPoolCallBack delegate kerül a Thread Pool feladatsorába, amely paraméterként megkapja a fent említett MessageData-t. Amikor az AgileAsyncWorkerItem.ThreadPoolCallBack meghívódik a Thread Pool egyik worker thread-jén, a MessageData alapján továbbítja az AgileAsyncWorkerItem.ThreadPoolCallBack a hívást az eredetileg hívni kívánt delegate-re. Mint más APM hívásoknál, a BeginInvoke-nak is adható meg egy callback delegate, amit meg kell hívni, ha az APM-es művelet befejeződött. Ennek aszinkron delegate hívásoknál kevesebb haszna van, de az egységes minta miatt mégis megadható. Az esetlegesen megadott callback delegate-et szintén az AgileAsyncWorkerItem.ThreadPoolCallBack fogja meghívni, nem sokkal az eredeti delegate után, és ugyanabból a szálból.

A Remoting-os infrastrultúra, illetve a Thread Pool elég nagy overhead-et tesz a BeginInvoke-ra, emiatt apró kis műveleteknél nem éri meg alkalmazni – egy szinkron delegate hívás kb század annyi időbe telik, mint az aszinkron.

Mindig jó az APM?

Az APM-et és a mögötte levő mechanizmusokat alapvetően arra találták ki, hogy a várakozások párhuzamosításának módszerével (lásd indító cikk) időt lehessen nyerni. Az APM-nek tehát akkor van értelme, ha több aszinkron műveletet lehet egyszerre végrehajtani, vagy pedig egy aszinkron művelet alatt van mit számolni. Mivel az APM mögötti infrastruktúra elég súlyos, “csak úgy” nem éri meg alkalmazni. Ezt egy egyszerű kis programmal ki is lehet próbálni.

var stream =
  new FileStream(
        @"c:\temp\data.txt",
         FileMode.Open,
         FileAccess.Read,
         FileShare.Read,
         1024,
         FileOptions.None);

Int64 firstRead = Clock.Value;

for (int i = 1; i <= 200000; i++)
{
  stream.Seek(0, SeekOrigin.Begin);
  stream.Read(new byte[1024], 0, 1024);
} // for i  

Int64 thisTick = Clock.Value;

Console.WriteLine(
  "finished {0} ms", 
  Clock.GetMilliseconds(thisTick, firstRead));

A 200000 szinkron olvasás eredménye 936ms. Ezzen szemben az aszinkron olvasás tesztje:

var stream =
  new FileStream(
        @"c:\temp\data.txt",
         FileMode.Open,
         FileAccess.Read,
         FileShare.Read,
         1024,
         FileOptions.Asynchronous);

Int64 firstRead = Clock.Value;

for (int i = 1; i <= 200000; i++)
{
  int n = i;
  stream.BeginRead(
    new byte[1024],
    0,
    1024,
    delegate(IAsyncResult asyncResult)
    {
      stream.EndRead(asyncResult);

      if (n == 200000)
      {
        Console.WriteLine(
          "finished {0} ms", 
          Clock.GetMilliseconds(Clock.Value, firstRead));
      } // if
    }, // delegate
    null);
} // for i  

Ennek a programnak a futásideje .NET 4 esetében (ez a Thread Pool stratégia miatt lehet érdekes) 12 másodperc. Ugyanennyi a futásidő akkor is, ha 0 byte-ot olvastatunk, tehát nem a merevlemez zavarodik meg a rendeteg olvasástól (ilyen kicsi adat egyébként is cache-ből jön). Amíg a processzor dolgozik, a .NET 4 nem engedi megszaladni a szálak számát, tehát nem is a sok szál kezelése adja az overhead-et. Aszinkron hívás esetén nagyon sok drága művelet történik. Maga az I/O Manager is bonyolultabban kezeli az aszinkron olvasást, főleg itt történik a teljesítményvesztés, de a sok feladat áthajtása a Completion Port-on szintén időbe telik. Ezen a teszten látszik, önmagában az aszinkron művelet nagyon sokba kerül. Akkor érné meg használni, ha sok várakozás lenne egy műveleten, mert akkor a várakozásokat lehetne párhuzamosítani, és ezzel időt nyerhetünk. Más esetekben a szinkron olvasás sokkal gyorsabb.

Konklúzió

Az APM-et az őt működtető mechanizmusokon keresztül ismertük meg. Ezáltal tudjuk, hogy mikor milyen folyamatok játszódnak le. Láthattuk a Beginxxx/Endxxx hívások összjátékát, illetve azt, hogy hogyan egészíti ki az IAsyncResult a régi OVERLAPPED struktúrát. Megtudtuk azt is, hogy az I/O műveleteken kívül milyen egyéb dolgokat lehet az APM-nek megfelelően használni.

A belső mechanizmusok alapján látszik, hogy az APM akkor használható hatékonyan, ha több párhuzamos folyamatot kell egyszerre kezelni. Egyszerűbb esetekben viszont csak felesleges műveleti időket és komplexitást visz az alkalmazásunkba.

  1. #1 by Sammal on 2010. December 27. - 14:32

    Gratula a cikhez, nem egy mindennap olvasott téma. várom a foltytást

  2. #2 by Sammal on 2010. December 27. - 14:33

    Gratula a cikhez, nem mindennap téma jó bőven🙂

  3. #3 by Csaba on 2011. January 16. - 20:16

    A legjobbkor, pont egy ilyen cikkre van most nagy szükségem, köszönet érte!🙂 Várom a további érdekfeszítő írásokat…

  4. #4 by flata on 2011. December 7. - 19:52

    Egy olyan kérdésem lenne, hogy mi történik akkor, mikor egy sima delegaten hívunk Begin és EndInvoke-t? IOCP-hez nem nagyon tudom kötni🙂.

    • #5 by Tóth Viktor on 2011. December 8. - 10:34

      A Thread Pool-nak két féle szála áll készenlétben, az egyik féle van az IOCP-hez kötve, ezek fogják felvenni pl az aszinkron file műveletek vagy hálózati műveletek eredményeit. A másik féle szál pedig azokkal a munkákkal foglalkozik, amit te a QueueUserWorkItem() metódust használva teszel oda a Thread Pool-nak, ezeknek nincs köze az I/O-hoz. Amikor delegate-et hívsz BeginInvoke-kal, akkor ő a háttérben összeállít egy csomagot, amiben benne vannak a delegate-ed paraméterei, illetve egy spéci delegate-et tesz a Thread Poolra a QueueUserWorkItem()-mel, ami az előbb említett csomagból kimazsolázza a paramétereket, és meghívja az eredeti delegate-edet. Szóval nem az IOCP-hez rendelt szálak fognak dolgozni, hanem a másik csoport. Így nem is kell az IOCP-hez kötni🙂

      • #6 by flata on 2011. December 8. - 14:38

        köszi, most látom, hogy ez is le van írva itt (valamiért elkerülte a figyelmem):
        [MulticastDelegate és az APM]

  5. #7 by flata on 2012. January 27. - 00:07

    Na ez pont a témához kapcsolódik és talán Téged is érdekelhet, az én esetem az OVERLAPPED IO-val:
    http://stackoverflow.com/questions/9009664/usb-hid-communication-with-p-invoke-win32-api-and-concurrent-threads/9025899#9025899

    Bizony ilyen dolgokra is kellene figyelni. Kis utánajárással kiderült, hogy az Overlapped.Pack hajtja végre a FileStream osztályban a pinnelést. Szívás a köbön🙂

    • #8 by Tóth Viktor on 2012. January 27. - 13:16

      Hát igen, kemény az élet🙂 Ha megnézed, fentebb én is leírtam ezt:

      “Az Overlapped.Pack() függvény … kialakít egy OVERLAPPED struktúrának megfelelő részt … kiszögeli a … a most foglalt területet, majd a területen belül az OVERLAPPED struktúrára mutató pointerrel tér vissza”😀

      Csak más kontextusban mint ami a te problémád volt. De amúgy tisztelet, hogy rájöttél, az ilyen “néha kifagy” hibákat a legreménytelenebb kijavítani, ez ráadásul egyáltalán nem is volt triviális helyzet.

      • #9 by flata on 2012. January 27. - 16:00

        Téged kellett volna kérdezni, valsz rávágtad volna egyből🙂

        Arra egyébként nincs ötleted, hogy abban az esetben, ha nem írok az USB device felé (WriteFile, gyakorlatban pull üzenetek), hanem csak az olvasó szál fut (ReadFile, így a push üzeneteket tudja fogadni), akkor miért nem crashel el az app? Ez jól félre is vezetett engem egyébként.

      • #10 by Tóth Viktor on 2012. January 27. - 16:17

        Nincs igazán ötletem arra, hogy az olvasó szál miért jó. Max annyi, hogy a kód olyan, hogy amikor a ReadFile-t nézted, nem okozott annyi gc-t, vagy úgy alakult a memória, hogy az érzékeny pontokat pont nem mozgatta át, és így nagyobb szerencséje volt. Tűzdeld tele a kódot körülötte kamu memóriafoglalásokkal, (esetleg másik szálon) hátha kifagy a read is:)

  1. A WCF ára - pro C# tology - devPortal
  2. Aszinkron programozás – Áttekintés – Újdonságok a C# 5-ben « DotNetForAll
  3. Aszinkron programozás – Áttekintés – Újdonságok a C# 5-ben | .NET apps

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: