Többszálúság – sorozat indító

Többszálú programok készítésére már régóta lehetőség van Windows környezetben. Akár Windows 95-ben is megtehette, aki akarta. Mivel benne van a levegőben, szinte mindenki tudja, hogy ma miért sláger a többszálú programozás. A mai processzorok több magot tartalmaznak, ami több szál párhuzamos végrehajtását teszi lehetővé. A több magot kihasználva gyorsítható egy program futása. De mi volt az előnye a többszálú programoknak az egymagos processzorok idejében?

Időosztás, megéri?

Ismert, hogy a processzorok/operációs rendszerek a szálakat időosztásos módon futtatják. Egy szál kap valamennyi időt, hogy dolgozzon, azaz programutasításokat hajtson végre a processzorral. Ezután az operációs rendszer “befagyasztja” a szálat, elmenti a futási környezetét, mint például a processzor regisztereit. Eztán előveszi egy régebben futtatott szál futási környezetét, és elindítja a szálat, amely most valami másik programrész utasításait kezdi el végrehajtani.

Ha van egy régi, egyszerre egy szál futtatására képes processzor, és tíz, egyenként 1 percig tartó számítás, akkor a számításokat időosztásos technikával “párhuzamosan” elindítva mind a tíz számítás eredménye tíz perc után lesz elérhető. Ez vajon jobb annál, ha egymás után futnának le a számítások? Egymás után futtatva a számításokat az első számítás eredménye már meg lesz egy perc alatt, a másodiké két perc alatt, és így tovább. Ez utóbbi jobbnak tűnik.

Nyilvánvaló, hogy régi processzoroknál számításokat párhuzamosan végeztetni nincs sok értelme, hiszen csak rontja a számítások átlagidejét. Miért lehetett akkor egy programnak régen is szüksége, több, mint egy szál futtatására?

Aki abban az időben fejlesztett GUI-s alkalmazásokat, feltehetőleg a ma már Smart UI anti patternnek csúfolt megoldással, azonnal tud egy választ: ha azon a szálon végeztetett a program egy hosszabb műveletet, amelyik egyébként a képernyőt is kezelte (például egy nyomógomb eseménykezelőjéből), az egész alkalmazás be tudott fagyni a művelet idejére. Az volt a vicces (bár sajnos beszélhetünk jelen időben is) ezekben a befagyásokban, hogy ha az ember elkeseredettségében elindította a taszk kezelőt, sokszor azt látta, hogy a processzor nem is dolgozik semmin.

Igazából a processzorok, legalábbis a kliens gépek processzorai már elég régóta nem nagyon csinálnak semmit, általában csak várnak. Egészen pontosan a processzor által futtatott szálak nem nagyon csinálnak semmit, többnyire csak várnak. Az előbb említett befagyott program is leginkább csak várt. Várt arra, hogy az adatbázis kezelő visszaadja az adatot, vagy várt arra, hogy a merevlemezről vagy a hálózatról megjöjjenek az adatok. Ekkor viszont jogos a kérdés, hogy ha várt, akkor miért nem tudott foglalkozni a képernyővel?

A nem-foglalkozásnak több esete is van. Ha egy programozó ír egy programot, és elindítja, akkor a program jelentős részben nem a programozó által írt kódot fogja végrehajtani. Nagyon gyakran az operációs rendszer által adott kód fut, vagy valami más cégtől vásárolt kód, mint egy adatbázis kezelő kliens könyvtára. Ha a programozó meghívja az adatbázis kezelő könyvtárának egy függvényét, akkor az a függvény elkezd dolgozni, majd a munka végeztével visszaadja a vezérlést. Egyes esetekben elég hosszú idő eltelhet attól a ponttól, hogy egy programozó meghívott egy könyvtári függvényt, attól a pontig, hogy a vezérlést visszakapja. Ha a könyvtári függvény egy lassú műveletet végeztet az adatbázissal, akkor belül ezt ő meg fogja várni. Az ilyen hívásokat “Blocking Call”-nak is nevezik.

Onnan indultunk el, hogy miért nem frissül a képernyő a rosszul felépített Smart UI-s programokban. A fent említett Blocking Call-nál a programozó kódja azért nem tudja frissíteni a képernyőt, mert a vezérlés nem a programozó által írt kódban van. Egy adatbázis kezelő könyvtári függvénye meg azért nem fogja frissíteni a képernyőt, mert egyszerűen nincs hozzá köze, nem is tudhatja, hogy lenne ilyen feladat is.

Hasonló a helyzet akkor is, ha a programozó nem csinált blocking call-t, hanem éppen egy algoritmus mélyén egy ciklusban számolt valamit. Ekkor, elviekben hívhatna például egy Windows Forms-os alkalmazás esetén egy Application.DoEvents() metódust, ami egy kis életet lehel az alkalmazás képernyőibe. De komolyan, egy SzamoldAzAdatokat() metódus belső ciklusába odaillik egy Application.DoEvents()? Hát persze, hogy nem!

A fejlesztőnek sokkal egyszerűbb a dolga, ha a folyamatokat, amit a program végez, logikai egységekbe tudja rendezni, és egymástól elkülöníteni. Külön logikai folyamat egy algoritmus végrehajtása, és egy másik a képernyő kezelése. A fejlesztők ösztönösen így dolgoznak, pont emiatt nem hívogatnak Application.DoEvents()-et számolgatás közben.

Amire a képernyőkezelés kapcsán szükség van, az nem igazán a szálak párhuzamos végrehajtása. Arra van szükség, hogy annak ellenére, hogy folyik egy számítás, mint egy különálló logikai folyamat, a felhasználó szemszögéből fontos másik logikailag különálló folyamat, a képernyő frissítése, nagyon rövid időn belül (emberi mércével azonnal) történjen meg. Ez egy egymagos processzoron úgy történhet meg, hogy a számítást végző szál félbeszakad, és elindul a grafikus képernyőt kezelő szál. Amikor elvégezte a frissítést, folytatódhat tovább a számítás. De honnan tudja az operációs rendszer, hogy mikor kell indítani, és mikor van kész a GUI-t kezelő szál? Nézzük az egyszerűbbnek tűnő problémát, hogy honnan lehet tudni, hogy a GUI szál kész van. Egyik dolog, amiből az operációs rendszer tudhatja, hogy végzett a szál, hogy jelzi azt valahogy, például várakozó állapotba kerül. Ilyenkor az operációs rendszer elveszi a vezérlést a várakozó száltól, és egy másik szálnak adja a processzoridőt. De mi van, ha a GUI szál nem kerül várakozási állapotba?

Régebben láthattunk olyan operációs rendszert, ahol a folyamatok maguk döntötték el, hogy mikor engednek más folyamatoknak processzoridőt. Egy ilyen operációs rendszeren a GUI-t kezelő szál elvégezhetné a frissítést, majd amikor kész, visszaadná a vezérlést az operációs rendszernek, ami elkezdené futtatni a számítást végző folyamatot. A gond az volt ezekkel az operációs rendszerekkel, hogy nem minden programozó tudta helyesen implementálni a folyamatokat, így előfordulhatott, hogy a folyamat soha nem adta át a vezérlést. Ilyen esetekben az egész rendszer lefagyott a hibás program miatt, hiszen többé egyik folyamat sem tudott dolgozni.

A hibás programok miatti rendszerleállás elkerülése miatt a modernebb Windows operációs rendszerek egy bizonyos (igen rövid) időn túl mindenképpen elveszik a vezérlést egy száltól, amit persze később majd visszakapnak, ha a szál nincs várakozási állapotban. Emiatt fordul elő, hogy olyan műveletek is futnak „párhuzamosan” (értsd időosztásos módon), amelyeknél sebesség szempontjából ez nem optimális, mint például a cikk elején a tíz párhuzamos számítás. A grafikus képernyő egy számítás mellett időosztással kétszer annyi idő alatt tudja frissíteni az állapotát. Ez sebességben rosszabb, de az operációs rendszer stabilitása miatt összességében mégis jobb megoldás. Az időosztásos „párhuzamosítás” tehát a stabilitás és a könnyebb szervezés miatt szükséges, nem pedig valamilyen látszólagos vagy valós plusz teljesítmény miatt.

Párhuzamos várakozások

Lehet találni más indokot is a párhuzamos végrehajtásra, a várakozások kapcsán. A várakozásnak a számolással szemben van egy kellemes tulajdonsága: korlátlan mértékben párhuzamosítható, mivel a várakozás nem igényel erőforrásokat. Ha egy büfében van három régi italautomata, amelyek a pénz bedobása után a lassú mechanika miatt csak fél perccel adják ki az italokat, és nekem a saját italom mellett két barátomnak is kell vinnem, akkor két választásom van. Egy automatával végigjátszom az egészet háromszor, ami másfél percig tart. Vagy kihasználom azt, hogy egyszerre több automatára is tudok várni, és bedobom mindegyikbe a pénzt, majd kb fél perc múlva összeszedem az italokat.

A várakozások párhuzamosításával tehát időt lehet nyerni. Egy átlagos kliensgépen százas nagyságrendben léteznek szálak párhuzamosan, csak éppen szinte mindegyik várakozik. Várnak arra, hogy adatok érkezzenek a hálózatról, adatok érkezzenek a háttértárról, a felhasználó megnyomjon egy gombot a billentyűzeten, stb.

A Windows szálkezelése a fenti esetekre optimalizálva van, és a .NET az első verziójától kezdve tartalmaz szálkezelést, némileg absztrahálva a windows mechanizmusait. Bár az újabb és újabb .NET-es szálkezelési technikáknak pont az a lényege, hogy a fejlesztőnek egyre kevesebbet kelljen foglalkozni az alsó szinten működő mechanizmusokkal, a szálkezelés pont egy olyan témakör, aminek a téves használata inkább ront a program teljesítményén, mint használ.

Háttérszámítások

A grafikus felhasználói felületek logikája jellemzően az, hogy a felhasználó a felület elemein keresztül rövidebb-hosszabb ideig tartó feladatokat indíthat el. Láttuk az előzőekben, hogy ezeket a feladatokat egy külön szálon érdemes megvalósítani, hogy a felhasználói felület ne fagyjon be, illetve a feladatot megvalósító programkódban ne kelljen az oda nem illő képernyőkezeléssel foglalkozni. Az is gyakran előfordul, hogy ezek a feladatok több-kevesebb alfeladatra oszthatóak szét, melyek külön, akár egymástól függetlenül végezhetőek el.

Számolás intenzív folyamatoknál a régi, egymagos processzorok esetében ezeket a független feladatokat sorban, egymás után indítva is el lehetett végezni egy szálat használva, sőt, teljesítmény szempontjából úgy volt érdemes. Többmagos processzoroknál (több processzoros gépnél) vagy I/O orientált műveleteknél a várakozások párhuzamosíthatósága miatt azonban már érdemes lehet az alfeladatoknak külön szálat biztosítani.

A szálak, mint erőforrások helyes kezelése azonban nem annyira triviális dolog, mint ahogy az esetleg látszik. Egy új szál indítása az operációs rendszernek rengeteg erőforrásába kerül, mind memória, mind műveletszám tekintetében. Az alábbi ábrán az a folyamat látható, ahol egy program 100 szálat indít, amelyek azonban nem csinálnak semmit, csak várakozási állapotba kerülnek. A zöld szín a szálak számát, a kék a memóriafoglalást mutatja.

Látható, hogy bár a szálak csak várakoznak, tehát az általuk végzett művelet nem igényel memóriát, a program memóriahasználata több, mint másfélszeresére emelkedik. A memóriahasználaton felül a sebességgel is komoly problémák vannak. Egy modern processzor egyesével százezerig néhány százezred másodpercen belül képes elszámolni. Nézzük meg, mennyi ideig tart ez úgy, hogy minden továbbszámoláshoz külön szál indul. Az alábbi program nem korrekt abból a szempontból, hogy a rossz szervezés miatt továbbszámolhat, mint 100000, de a nagyságrendek látszódnak így is:

Stopwatch sw = Stopwatch.StartNew();

int counter = 0;

while (counter < 100000) 
{     
    new Thread(        
        () => Interlocked.Increment(ref counter)
    ).Start();
} // while

Console.WriteLine("{0} - {1}", sw.ElapsedMilliseconds, counter);

A program eredménye egy átlagos, kétmagos számítógépen 14 másodperc, ami nagyon sok ahhoz képest, hogy a feladat milyen egyszerű. Ez az idő szinte kizárólag a szálak indítására, és későbbi kezelésére megy el. Ehhez képest az elvégzendő hasznos művelet egy szál számára nagyon rövid.

Az alapötlet egy jobb megoldáshoz az, hogy ha egy szálat az operációs rendszer elindított, és erre erőforrásokat áldozott, akkor a szálat fel lehetne használni több, másik feladat végrehajtására is. Ekkor a szál létrehozásába fektetett erőforrások ára megoszlik a feladatokon, így átlagosan jobb teljesítmény érhető el. Természetesen egyetlen szál több feladatot csak egymás után tud végrehajtani, de ha a feladatok száma sokkal több, mint a számítógépben található processzor magok száma, akkor az egymás után való végrehajtás nem hogy rontja, de inkább javítja a teljesítményt – legalábbis számítás orientált feladatok esetében.

Erre az ötletre alapoz a Thread Pool. A Thread Pool lényege az, hogy néhány előre elindított szál arra várakozik, hogy valamilyen feladatot végrehajthasson. A Thread Pool-oknak tipikusan van egy feladat queue-ja, amelybe az elvégzendő feladatokat lehet helyezni. A szálak ebből a queue-ból vesznek fel feladatokat, és hajtják őket végre. Ekkor nem adódik hozzá minden feladathoz a szál indításához szükséges erőforrások költsége.

A Thread Pool témaköréhez azonban jóval több dolog tartozik, minthogy néhány szál várakozik feladatokra. Nehéz jó algoritmust találni arra, hogy hány szál várakozzon, mikor van szükség esetleg új szálak hozzárendelésére a Thread Pool-hoz. Ha túl kevés a szálak száma, és a feladatok sok eszközt kezelnek, akkor a Thread Pool szálai jórészt az eszközökre fognak várni, az új feladatokat pedig nem fogja felvenni semmi. Ekkor a processzor nem fog dolgozni, holott feladatok állnak sorban. Ha sok a szál, és a feladatok számítás orientáltak, akkor a processzor a sok szálat időosztásos módban fogja futtatni, jelentősen lerontva ezzel a feladatok végrehajtásának átlagos idejét (lásd a 10 db egy perces számítást a cikk elején)

A Windows operációs rendszernek van Thread Pool szolgáltatása, de a .NET keretrendszer nem ezt használja, hanem saját Thread Pool-t valósít meg. A Thread Pool mögött dolgozó algoritmus verzióról verzióra változik. Kezdetben az a naiv megoldás működött, hogy a Thread Pool csak minimális szállal indult, majd ha túl sokáig vártak a feladatok a Thread Pool queue-jában, akkor újabb és újabb szálak kerültek indításra. Ez, ha a szálak szinkron módon kezeltek eszközöket, akkor jól működött, számítás orientált feladatok esetében viszont csak rontotta a teljesítményt. Később a Thread Pool már a processzor kihasználtságát is figyelembe vette új szálak indításakor, így számításorientált feladatok esetén az indítot szálak száma nem ugrott meg. Ma a .NET 4 Thread Pool-ja egy igen szofisztikált, „hill climbing”-nek keresztelt algoritmust használ. Ennek az algoritmusnak a lényege, hogy a keretrendszer folyamatosan méri a Thread Pool feladat feldolgozási teljesítményét, és egész addig növeli a szálak számát, amíg a szálak indításával a teljesítmény nem kezd visszaesni. Ekkor kicsit visszavesz a szálak számából. Az algoritmus tehát egy teljesítménygörbe alapján dolgozik, és mindig próbál annak a csúcsán maradni.

A külön szálakból folytatott egyesével számlálás Thread Pool-on keresztül is kipróbálható. Az alábbi program – további magyarázat nélkül – minden értéknövelést külön feladatként ad ki a Thread Pool-nak:

 sw = Stopwatch.StartNew();

counter = 0;

while (counter < 100000) 
{     
    ThreadPool.UnsafeQueueUserWorkItem(
        (n) => Interlocked.Increment(ref counter),
        null);
} // while

Console.WriteLine("{0} - {1}", sw.ElapsedMilliseconds, counter);

A futásidő 70 ms körüli, tehát körülbelül kétszázszor gyorsabb, mintha minden művelethez külön szál indul. Ez azonban egy kiélezett helyzet nagyon rövid ideig tartó feladattal, hiba lenne arra következtetni, hogy a Thread Pool ennyivel hatékonyabb. Az azonban elmondható, hogy a külön szálak indításából adódó teljesítményveszteség minimalizálható, illetve a processzor optimálishoz közelebb eső terhelése könnyebben kivitelezhető a Thread Pool alkalmazásával.

A lassú eszközök

Mára a processzorok felfoghatatlanul gyorsan hajtják végre a műveleteket. Mire a fény a monitortól az olvasó szeméig elér, addig a mai processzorok gond nélkül elvégeznek néhány műveletet. Ugyanez a fény, ha nem ütközik akadályba, nem sokkal több, mint egy másodperc múlva már a Hold távolságában jár. Ezek után könnyebb elfogadni azt, hogy a processzor számára egy eltúlzott time-lapse videó, mire a merevlemez az olvasófejét odébb mozdítja fél centivel, majd megvárja, amíg a lemez korongja a fej alá fordul a kívánt adatokkal. Arra pedig nehéz értelmes arányt találni, hogy mit jelent egy processzornak, amire a felhasználó felfogja a képernyőre írt üzenetet, majd megnyomja az any key-t. Valójában a processzor nincs arra kényszerítve, hogy ezekre a lassú eseményekre figyeljen. Nehéz is lenne jó technikát találni. Mennyi időnként ellenőrizzen? Másodpercenként ezerszer? Ez egy egérmozgásra vagy billentyű lenyomásra feleslegesen sok, ugyanakkor egy gigabites hálózati kártya számára már lehet, hogy lassú. Ezen felül az eszközökön általában nem is történik semmi, ezért az eszközökre tekintés az esetek legnagyobb részében csak időpazarlás lenne.

Ezek miatt nem is a processzor ellenőrzi az eszközöket, hanem az eszközök értesítik a processzort akkor, ha valamilyen adattal rendelkezésre állnak. Azt a módszert, amivel egy eszköz jelezni tud, megszakításnak hívják, és a név onnan jön, hogy erre a jelzésre a processzor megszakítja azt a folyamatot, amivel foglalkozik, és azt a programrészt kezdi el futtatni, amit az eszközhöz készítettek, hogy a rendelkezésre álló adatokat átvegye. A megszakítás valamilyen elektronikus jel a processzor lábain, amire egy többlépcsős és bonyolult folyamat veszi a kezdetét, aminek ismeretére szerencsére csak az eszközök gyártóinak van szüksége. Ami a számunkra lényeges, az az, hogy a megszakítás után valamennyi idővel rendelkezésre állnak az adatok, amivel kell kezdeni valamit.

Természetesen az eszközök nem önkényesen adnak adatokat, hanem azért, mert arra valahol valamilyen programkódnak szüksége van. A merevlemez azért ad vissza adatokat, mert azt egy program kérte tőle. A hálózati kártya azért ad adatokat, mert egy program távoli eszközökkel akar kommunikálni. Az egér azért ad adatokat, mert az operációs rendszer egy komponense ezek alapján akarja a kurzort elhelyezni a képernyőn. Bármelyik példát is vesszük, mindig az lesz a helyzet, hogy van valahol egy programrész, ami le kell, hogy fusson, amikor az adatok rendelkezésre állnak.

Mivel ez egy általános helyzet, az operációs rendszer ad megoldásokat a kezelésére, ráadásul többféle módszer közül lehet választani. Az egyik, és a fenti bevezető után legtermészetesebbnek tűnő megoldás az, hogy a programrész, ami igényt tart az adatokra (például egy merevlemezen lévő állomány tartalmára) megadja azt a programrészt, amit le kell futtatni akkor, amikor az adatok – a processzor szemszögéből nézve nagyon hosszú idő múlva – rendelkezésre állnak. Hogy működik ez pontosabban?

A Windows operációs rendszer lehetővé teszi, hogy az eszközökkel kapcsolatos műveleteket úgynevezett aszinkron módon végezze el a program. Az aszinkron működés azt takarja, hogy egy műveletet el lehet indítani úgy, hogy ne kelljen megvárni a művelet végét. Egy aszinkron műveletnél lehetőség van annak megadására, hogy amikor az adatok rendelkezésre állnak, melyik programrész fusson le.

Ezt a programrészt Completion Routine-nak hívják. A programozó kérhet adatot egy eszköztől, például az operációs rendszer ReadFileEx() függvényhívásával. Ez a hívás nem fog blokkolni, hanem visszatér a hívóhoz lényegében azonnal, és a kért adatok nélkül. A programozó paraméterként megadhat a ReadFileEx()-nek egy a programozó által írt függvény címét, amelyről szeretné, hogy meghívódna, ha az adatok rendelkezésre állnak.

A Windows operációs rendszeren az eszközöket egy I/O Managernek nevezett modul kezeli. Amikor az I/O Manager egy eszközzel kommunikál, legyen ez bármilyen eszköz, ő maga ezt file írás/olvasásnak tekinti. Ez az egyszerűsítés nem okoz gondot, legtöbb esetben az eszközkommunikáció adatok írását vagy olvasását jelenti, és ekkor a helyzet valóban nagyon hasonlít arra, amikor a hagyományos értelemben vett file műveletek történnek. Ha nem adat írásról/olvasásról van szó, akkor az I/O Manager tud egyéb parancsokat átadni a user programtól az eszköznek, de ez a rész most nem érdekes.

Completion Routine-ok kezelése

A file-okat (hálózati kapcsolatot, soros portot, konzolt, stb) használat előtt meg kell nyitni, megnyitáskor az I/O Manager létrehoz a memóriában egy úgynevezett File Object-et. Az írás/olvasás/egyéb parancsok ezen a File Object-en keresztül történnek. A File Object hivatkozási számát, vagy referenciáját megkapja a felhasználói program, így tud később az adott file-lal műveleteket végezni. A műveleteket tipikusan a windows API ReadFile/ReadFileEx/WriteFile/WriteFileEx függvényeivel lehet kezdeményezni. Ezek a függvények az I/O Manager kódjába hívnak bele (1). Ekkor az I/O Manager a művelet paramétereiből egy I/O request packet-et készít (IRP), amely tartalmazza a művelet paramétereit, és azt, hogy ez a művelet melyik file-ra vonatkozik (2). Ezt az IRP-t átadja a megfelelő driver-nek. A driver vagy azonnal ki tudja szolgálni a kérést, és ezzel visszatér az I/O Managerhez, vagy elteszi az IRP-t későbbre, és visszatér az I/O Managerhez azzal, hogy a kérés feldolgozás alatt van. Foglalkozzunk azzal az ággal, hogy a driver eltette az IRP-t későbbi feldolgozásra (3). Az I/O Manager visszatér a hívóhoz, így az folytathatja tovább, amit akar (4). Legközelebb a driver, amikor kap processzoridőt, elkezdi feldolgozni a sorban álló IRP-k valamelyikét. Hogy melyiket, az a driveren múlik, az optimalizáció miatt nem az érkezési sorrend számít. Ekkor valószínűleg küld valamilyen parancsokat az általa kezelt eszköznek, és ezzel az IRP feldolgozásának egyik fele meg is történt (5).

Ezután az eszköz hardvere dolgozik, ehhez a számítógép processzorának nem kell erőforrásokat allokálnia. Amikor az eszköz hardvere feldolgozta a kérést, és az adatok rendelkezésre állnak, megszakítást kezdeményez a processzornál, mire az abbahagyja az éppen folytatott munkát, és átadja a vezérlést a drivernek (ennél a valódi folyamat bonyolultabb, de a pontos technikai részletek nem számítanak) (6). A driver átveszi az adatokat, ezeket összerendeli az eredeti IRP-vel, majd jelzi az I/O Managernek, hogy az IRP kiszolgálásra került (7).

Az I/O Manager ekkor megvizsgálja, hogy az IRP-hez tartozik-e Completion Routine. Ha igen, módot kell találni rá, hogy az végrehajtódjon. Az Completion Routine-ok mögött lévő logika szerint annak a szálnak kell végrehajtania a Completion Routine-t, amelyik a kérést indította. Hogyan történik ez, amikor az aszinkron hívás lényege pont az volt, hogy az adatot kérő szál tovább dolgozhasson?

A szálak, bár az ember hajlamos őket úgy elképzelni, mint egy folyam, ami az utasításokat sorban végrehajtja, ennél bonyolultabban működnek. Valójában a szálakat el lehet téríteni más feladatokra, az operációs rendszer ezt rendszeresen meg is teszi, amikor egy megszakítás történik. Ekkor a szálat eltéríti a normál futástól, és egy teljesen más kódot kezd végrehajtani vele.

Van más módja is a szálak eltérítésének, bár nem annyira drasztikus, mint egy megszakítás. A Windows operációs rendszeren belül lehetőség van úgynevezett Asynchronous Procedure Call-ra (APC). Ez úgy működik, hogy minden szálnak van egy queue-ja (9) (igazából kettő, de ez most nem lényeg), amibe APC igényeket lehet tölteni. Amikor aztán a szál elkezd várni valamire, az operációs rendszer végrehajtatja vele az APC-ket (hiszen nyilván azért vár a szál, mert más dolga úgysincs) (10). Amikor az APC-k végrehajtódtak, a szál vissza lesz terelve az eredeti folyamba (11), ahol vagy tovább várakozik, vagy időközben bekövetkezett az esemény, amelyre várakozni kellett (esetleg pont egy APC eredménye miatt) (12). Kicsit olyan ez, mint ha a szálak alvajárásra lennének kényszerítve.

Az I/O Manager az APC mechanizmusát használja a Completion Routine-ok végrehajtására (8). Ha a szál, amelyik az igényt feladta, már véget ért, akkor a Completion Routine egyszerűen nem lesz végrehajtva. Ha a szál, ami az igényt feladta folyamatosan csinál valamit, és nem kezd el várakozni, a Completion Routine-ok megint csak nem lesznek végrehajtva. Egy Completion Routine-t használó aszinkron műveletet végrehajtó kódot tehát úgy kell megtervezni, hogy az egyszer valamikor várakozó állapotba kerüljön. Ez a helyzet ideális például egy GUI-t kezelő szálnak, hiszen ő pont ezt teszi (lényegében csak vár a felhasználóra). Egy nagy teljesítményigénynek kitett szervernél azonban egy szálat beakasztani, csak azért, hogy a Completion Routine-ok le tudjanak futni, elég nagy pazarlás. Akkor nem is akkora nagy pazarlás, ha éppen van várakozó APC. De amikor nincs, akkor adott egy szál, ami nem csinál semmit, csak vár. Ha a szervernek egyébként van mit tennie, akkor szüksége van egy új szálra, ami elvégzi a munkát. Ha az is APC-t kezel, neki is le kell előbb utóbb állni. Ez a szálak elburjánzásához vezethet.

Korábban láttuk, hogy a túl sok szál terheli a rendszert, akkor is, ha a szálak csak várakoznak. A Completion Routine-ok bár intuitív és sok esetben jól használható eszközt adnak, teljesítmény szempontjából nem optimálisak. Persze, gondos tervezéssel lehet optimalizálni, hogy a szálak száma ne szaladjon el, ez viszont a Completion Routine-ok kevéssé intuitív használatát igényli. És ha már úgyis kevéssé intuitív megoldásra van szükség a jó teljesítményhez, érdemes inkább a Windows operációs rendszerek egy másik lehetőségével megismerkedni, amit eleve nagyteljesítményű szerverekhez terveztek – de ettől még használható kevésbé teljesítményigényes műveletekhez is.

Completion Port-ok kezelése

Az említett mechanizmust Completion Port-nak nevezik. A Completion Port egy gyűjtőhely, ahova a korábban aszinkron módon kért, de már rendelkezésre álló adatok kerülnek. Az aszinkron hívás első része az Completion Port-ok esetén megegyezik a Completion Routinok esetében látottakéval. A különbség a 8-as pontnál kezdődik. A Completion Routine-ok esetében az I/O Manager egy APC-t helyezett annak a szálnak az APC queue-ba, amelyik az aszinkron műveletet indította. A Completion Port esetében a korábban említett File Object-et kell előre hozzárendelni egy Completion Port-hoz. Az összerendelés után bármelyik szál is indít aszinkron műveletet az adott file-ra, a művelet eredményét (mint a kért adatok, és a művelet sikeressége) az I/O Manager a File Objecthez rendlelt Completion Port queue-ba teszi (9). Ezeket az eredményeket Completion Packet-nek hívják, és az I/O Manager állítja elő a teljesített IRP alapján (8).

Mi fogja a Completion Packet-et a Completion Port-ból kivenni? A Completion Port esetében dedikált szálakat kell adott Completion Port-ra állítani. Ezeknek a szálaknak az egyetlen feladata az kell legyen, hogy a Completion Port queue-ba helyezett Completion Packet-eket feldolgozzák. Amikor a queue-ba egy Completion Packet érkezik, az egyik szál elindul, és feldolgozza a csomagot (10). Ezzel el lehet kerülni, hogy az aszinkron műveletet indító szálat be kelljen állítani. A helyzet tehát a Thread Pool-okéhoz nagyon hasonló.

Nem mindegy azonban, hogy az eredményekre várakozó szálak hogyan vannak szervezve. Volt róla szó, hogy általában nincs értelme annak, hogy egy processzor magon két szál végezzen párhuzamosan műveleteket. Emiatt a Completion Port-nak megadható, hogy egyszerre maximum hány szálat dolgoztasson. Ezt a maximum értéket érdemes annyira állítani, ahány processzor mag van a számítógépben.

Felmerül a kérdés, hogy miért nem elég annyi szálat a Completion Port-hoz rendelni, ahány mag van a számítógépen, ahelyett, hogy egy értéket kelljen megadni, és esetleg ennél az értéknél több szálat rendelni egy Completion Port-hoz. Ennek az az oka, hogy a szál, ami felvett a Completion Port-ból egy Completion Packet-et, csinálhat bármit, például elkezdhet várni egy eseményre. Ebben az esetben, ha nincs tartalék szál, akkor a Completion Port-ra érkező új Completion Packet-eket nem dolgozná fel semmi. Ehelyett, ha egy Packet érkezik a Completion Port-ra, miközben az előző Packetet feldolgozó szál még nem végzett a Packettel, de várakozó állapotba került, akkor egy második szál indul el feldolgozni az új Packetet (persze csak akkor, ha van még egy második szál a Completion Port-hoz rendelve) Előfordulhat ekkor, hogy végül az előbb említett, addig várakozó szál ismét elindul (mert bekövetkezett az esemény, amire várt), és ekkor két szál foglalkozik párhuzamosan, a Completion Packet-ek feldolgozásával. Ez az állapot azonban csak ideiglenes, amint az egyik szál végez, hiába lenne még várakozó Completion Packet a Completion Port-on, azt a most végzett szál nem tudja felvenni, mivel ekkor lesz egy másik szál, amelyik még dolgozik. Amikor a második szál is befejezi a futását, akkor ő fogja megkapni a várakozó Completion Packet-et, és így a Completion Port visszatér a beállított korlát alá a párhuzamosan futtatott szálak tekintetében.

A Completion Port esetében nincs meg az a kényelmes lehetőség, hogy minden aszinkron kéréshez hozzá lehet rendelni egy függvényt, mint a Completion Routine, ami lefut, amikor a kéréshez tartozó adatok megjelennek. Emiatt a Completion Port-okon várakozó szálak kódjába az ezt kezelő logikát a programozónak kell implementálni. Ráadásul egy Completion Porthoz sok File Object-et lehet rendelni, ekkor különböző források eredményei, mint egy Network Socket vagy USB eszköz is ugyanazokon a szálakon csapódnak le.

A folytatásban

A Completion Port kezelése tehát nehezebb, mint a Completion Routin-ok esetében volt. Ugyanakkor teljesítmény szempontjából olyan hatékony, hogy segítségével egy-két szállal megoldható egy terhelt szerver működtetése. Nem véletlen tehát, hogy a .NET az első verziójától kezdve támogatja a használatukat. A .NET által nyújtott Asynchronous Programming Model (APM) a Completion Port-ra épül. Az APM elég alacsonyszintű model, és erősen kiérződik rajta az operációs rendszer felépítése. A legpiszkosabb munkától azonban megkímél minket: a Port-on figyelő szálak olyan programlogikát tartalmaznak, amely lehetővé teszi, hogy a Completion Routin-okhoz nagyon hasonló módon minden aszinkron kéréshez külön meg lehessen határozni az eredményt feldolgozó függvényt. További szolgáltatása a .NET-nek, hogy szükség esetén további szálakat indít és rendel a Completion Port-okhoz, így nehezebben fordul elő, hogy valamilyen rosszul tervezett programlogika lefoglalja az összes szálat, és így az aszinkron műveletek eredményei feldolgozatlanul maradnak. Az APM részletesebben bemutatásra kerül a sorozat második részében.

Az alacsony szintű APM, bár hatékony eszköz, bizonyos helyzetekben nehezen használható. Egyáltalán nem illik például a .NET-ben készíthető grafikus programokhoz. Egyrészt, ezek a programkódok esemény orientáltak, az APM pedig callback függvényeket használ. Másrészt a grafikus képernyő csak egy speciális szálból kezelhető, az APM esetében pedig kiszámíthatatlan, hogy a művelet végén melyik szál indul el. Ezeknek a problémáknak a kiküszöbölésére dolgozták ki az Event Based Asynchronous Pattern-t (EAP), ami a .NET 2.0-val kezdődően használható. Az EAP-ot mutatja be a sorozat harmadik része.

A többmagos processzorok megjelenésével értelmet nyert a számításigényes feladatok párhuzamosítása is. Ennek támogatására jelent meg a .NET 4.0-től kezdve a Parallel Library és az általa támogatott Task Based Asynchronous Pattern (TAP). Szintén bevezetésre kerültek olyan kollekció osztályok, amelyek támogatják a párhuzamos elérést. Erről a sorozat negyedik részében lesz szó.

A Task Based Asynchronous Pattern már egész magas szintű támogatást ad a párhuzamos programok írásához, a fejlesztőnek még mindig delegate-ekben kell megfogalmaznia a párhuzamos blokkjait. Ezen segíthet új nyelvi elemekkel a C# 5.0, mely a sorozat ötödik részében lesz bemutatva.

  1. #1 by mrg on 2010. December 15. - 14:11

    Csak egy apró elírás: “A Thread Pool-oknak tipikusan vagy egy feladat queue-ja, amelybe az elvégzendő feladatokat lehet helyezni.”
    vagy -: van

  2. #2 by tothviktor on 2010. December 15. - 14:38

    köszi, javítva

  3. #3 by Turóczy Attila on 2010. December 27. - 15:57

    Nagyon jó cikk!

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: