Event-based Asynchronous Pattern – és ami mögötte van

Az ExecutionContext-ekről szóló rész kapcsán láthattuk, hogyan kell többszálú programokat írni GUI-t használó programok esetén. A SynchronizationContext-nek köszönhetően van egy eszköz ahhoz, hogy amelyik műveletnél fontos, az a megfelelő szálon tudjon lefutni.

A GUI-s fejlesztéseknek azonban van egy másik oldala, mégpedig az, hogy annak egy számottevő része a GUI designer képernyő körül forog, ahol komponensekben és a komponensek eseményeiben gondolkodunk. Ha nem is GUI szerkesztőt használunk, a fejlesztők gondolkodásmódja a GUI fejlesztése környékén hajlamos átkapcsolni eseményvezérelt gondolkodásmódba – ami egyébként teljesen helyes. A Thread Pool és az APM callback delegate-eket használ, emiatt kevésbé illik az eseményvezérelt képbe.

Az EAP célja a könnyebb kezelhetőség, callback helyett események

Az EAP kívülről

Az Event-based Asynchronous Pattern a GUI programok fejlesztési és futási igényeihez lett kidolgozva. A pattern maga néhány egyszerű konvenció betartásából áll:

  • Az aszinkron műveletek egy xxxAsync névvel rendelkeznek, ahol xxx jellemzően egy szinkron művelet neve, például Load()/LoadAsync()
  • Amikor az xxxAsync() által indított aszinkron művelet végetért, akkor xxxCompleted esemény keletkezik, ráadásul azon a szálon, amelyik az xxxAsync() eseményt indította. Emiatt a GUI programok megfelelően működhetnek, az eseménykezelőből például közvetlenül elérhetőek a képernyő kontroljai.

GUI alkalmazásoknál gyakran előfordulhat, hogy egy elindított aszinkron művelet folyamatáról vizuális visszajelzést kell adni a felhasználónak, illetve bizonyos esetekben alkalmat kell adni a művelet megszakítására. Emiatt az EAP két, az APM-ben nem ismert lehetőséget kínál:

  • Bizonyos EAP-ot megvalósító osztályok rendelkeznek egy CancelAsync függvénnyel, amely megszakítja (nem feltétlenül azonnal) a háttérben folyó aszinkron műveletet. Ha egy osztály több aszinkron művelettel rendelkezik (mint AlmaAsync() és KorteAsync()), akkor a CancelAsync nem lenne egyértelmű, emiatt ekkor AlmaAsyncCancel és KorteAsyncCancel lesz a függvénynév (nem tudom, hogy a CancelAsync() analógiájára miért nem CancelAlmaAsync() vagy AlmaCancelAsync nevet kell adni a minta szerint.)
  • Az EAP-ot megvalósító osztály rendelkezhet egy xxxProgressChanged vagy csak símán ProgressChanged eseménnyel, amely egy ProgressChangedEventArgs argumentumot szállít. Ennek a lényeges property-je a ProgressPercentage, amely százalékos formában adja meg, hogy a folyamat feldolgozása hol tart.

Egy tipikus EAP komponens működése látható a következő ábrán:

Az (1) pontban a GUI szálon a programlogika meghívja az EAP komponens xxxAsync() függvényét. Ennek a hívásnak az implementációja a szükséges feladatot egy másik szálra irányítja (2), például a Thread Pool egyik szálára. A Thread Pool szál elkezdi feldolgozni a feladatot (3), például kiszámol valamit, vagy szinkron file műveletet végez. Ha a munkafolyamat támogatja, akkor a munka menetéről lehet visszajelzést küldeni, amit például meg lehet jeleníteni egy progress bar segítségével, vagy szöveggel kiírni százalékos formában. Emiatt a Thread Pool-on futó folyamat xxxProgressChanged eseményeket aktivál, azonban az eseményre feliratkozott eseménykezelőknek a GUI szálon kell lefutniuk. Emiatt egy később ismertetett mechanizmussal a Thread Pool szál eseménykezelőket hív, de a GUI szálon (4). Amikor a munka készen van, akkor a progresz eseményekhez hasonló módon a Thread Pool szál egy eseményt küld a munkafolyamat végéről (5)

Az EAP belülről

A korábbi cikkek ismeretében az ember azonnal sejti, hogy mi van az EAP mögött. Az első lépés, hogy az xxxAsync függvény átterelje a végrehajtást egy Thread Pool vagy dedikált szálra (2. pont a fenti ábrán). Ha I/O műveletre van szükség, akkor az átterelés minden bizonnyal egy APM-es BeginXXX hívás lesz, számításigényes feladatoknál pedig lehet használni a ThreadPool.QueueUserWorkItem-et, illetve sok esetben itt kényelmesebb a Delegate osztály BeginInvoke-ja, mivel a paraméterátadást az kényelmesebb módon támogatja. Ekkor elindul a háttérművelet, és az xxxAsync hívója visszakapja a vezérlést.

A ProgressChanged jellegű eseményt úgy kell aktiválni, hogy az eseménykezelők a GUI (vagy az xxxAsync-t hívó) szálon fussanak le (4). Erre a célra már ismerjük a SynchronizationContext osztályt, amelyet pont az ilyen helyzetekre találtak ki. Ha rendesen működne az ExecutionContext ezen része, akkor minden további nélkül használhatnánk is. Mivel azonban az előző cikkben megtudtuk, hogy a SynchronizationContext nem utazik a logikai szálakkal, ezért ezt nekünk magunknak kell utaztatni.

AsyncOperation – a felesleges absztrakció

Az EAP számára azonban rendelkezésre áll egy másik módszer a SynchronizationContext körüli probléma megoldására, méghozzá az AsyncOperationManager és az AsyncOperation osztályok használatával. Kezdhetnénk a két osztály bemutatását az MSDN stílusában, azt érzékeltetve, hogy a két osztály tud valami újat. Gyorsabban haladunk azonban a valóság feltárásával: AsyncOperationManager osztály egyetlen lényeges CreateOperation() függvénye annyit csinál, hogy elkéri a hívó szálon a SynchronizationContext példányt (ha nincs, akkor csinál egy újat), ezt becsomagolja egy AsyncOperation osztálypéldányba, és visszaadja. Az AsyncOperation osztály sem sokkal okosabb, a két lényeges függvénye, a Post() és a PostOperationCompleted() meghívja az eltárolt SynchronizationContext példány Post() függvényét. Ami keveset bevezet az AsyncOperation osztály, az egy Completed értelmű flag, amelyet a PostOperationCompleted() hívás állít be, és a hatása mindössze annyi, hogy többet nem engedi hívni a Post() és PostOperationCompleted() függvényeket. Maga a PostOperationCompleted() függvény sem csinál különlegeset, hívja a SynchronizationContext.Post()-ot, majd beállítja az említett Completed flag-et, illetve a SynchronizationContext példánynak van egy OperationCompleted() függvénye, amely sem az SynchronizationContext példányban, sem a leszármazottaiban nem csinál semmit (üres függvények), de meghívásra kerülnek az AsyncOperation.PostOperationCompleted() függvényből.

Van még egy apróság, amit az AsyncOperationManager/AsyncOperation páros tud. Az AsyncOperation el tud tárolni egy objektum referenciát userSuppliedState néven. Ezzel az objektummal aztán nem csinál semmit, de el lehet tőle kérni. Az AsyncOperationManager.CreateOperation() paramétere tehát csak akkor érdekes, ha az EAP-ot támogató komponens használja. A jelenlegi tudásunk alapján tehát a következő ábrát rajzolhatjuk meg, példának egy WinForms alkalmazást véve:

Amikor a GUI szálon az első kontrol létrejön (WinForms esetén tipikusan az alkalmazás főablaka, egy new MainForm() hívás következtében a program Main() függvényében), akkor létrejön a szálhoz tartozó SynchronizationContext példány (1). WPF esetében nem is kell kontrol, ott az Application object a konstruktorában a Dispatcher-en keresztül elkészíti a SynchronizationContext példányt. Később az EAP-ot támogató komponensnek meghívják az xxxAsync függvényét (2). Ez a függvény két fő dolgot csinál. Egyrészt létrehoz egy AsyncOperation példányt az AsyncOperationManager osztály segítségével. Az AsyncOperation egy egyszerű wrapper osztály a SynchronizationContext körül. A második lépése az xxxAsync metódusnak, hogy valamilyen módszerrel, például a Delegate.BeginInvoke() függvényt használva egy új szálon kezdi el futtatni a feladatát (3). Ezek után az xxxAsync() hívás visszatér a GUI szálhoz (lásd kicsi kék nyílak a 2-es pont mellett). Közben a háttér munkát végző szálról eseményeket kell indítani a GUI szálon. Ehhez meg kell hívni az AsyncOperation.Post() függvényét, ami továbbhív a SynchronizationContext.Post() függvényére. A SynchronizationContext, akár WinForms akár WPF-ről van szó, a GUI szálon végre fogja hajtatni a megadott callback függvényt (5). A callback függvény most már kiválthatja a szükséges eseményt (pl ProgressChanged), a feliratkozott eseménykezelők a GUI szálon fognak végrehajtódni.

GUI programok programlogikája

Ha esetleg érdekesnek tűnik, hogy a GUI szálon hogyan fut le az SynchronizationContext.Post() által elküldött callback, akkor meg kell ismerkedni a Windows üzenetkezelésével. A Windows natúr üzenetkezelése ma már annyira nem szembeötlő, mivel a WinForms és a WPF objektumai elrejtik ezt, illetve eseményekké formálják az üzeneteket. A háttérben azonban még mindig a több évtizedes üzenetküldési mechanizmusok működnek.

A grafikus felhasználói felülettel ellátott operációs rendszerek megjelenésével az alkalmazások addigi logikáját kicsit ki kellett forgatni. Ebben a környezetben a programok akár több ablakkal is rendelkezhettek, és egymás mellett több program is futhatott (bár egyesek ezen biztos most kuncognak, ha a korai Windows verziók eszükbe jutnak). A több program és az egy-egy beviteli eszköz viszont egy problémához vezetett. A program nyilván reagálni akart a felhasználó műveleteire, ehhez azonban tudnia kellett, hogy például mit csinál a felhasználó az egérrel, mikor nyomja meg rajta a gombot, és hol. Ha csak egy program uralja a gépet, akkor a program ráülhet az egérre, folyamatosan monitorozva azt. Több program esetén ez azonban már nem működik. Amellett, hogy egy eszközt több program akar elérni, a folyamatos monitorozás felesleges erőforrásokat visz el, ahogyan ez a sorozatindító cikkben az eszközök esetében láttuk. Ha viszont azt szeretnénk, hogy az eszköz (mint az egér) szóljon, ha történik valami, akkor kérdés, hogy melyik programnak kell szólnia?

A megoldás az lett, hogy az egér, billentyűzet és egyéb beviteli eszközöket az operációs rendszer kezeli, azaz az egér (egér drivere) az operációs rendszer megfelelő komponensének adja át az eseményeket. Innen az operációs rendszernek kell a megfelelő programhoz továbbítania a megfelelő információkat. Honnan tudja az operációs rendszer, hogy mikor hova kell továbbítani az eseményt?

A grafikus felülettel rendelkező operációs rendszerek programjai is (többnyire) grafikus felülettel rendelkeznek. Windows operációs rendszer alatt a grafikus felület egységei az ablakok. Bár mi, felhasználók csak a program “nagy” ablakait, illetve floating toolbárokat, meg amit általában mozgatni tudunk, azt nevezzük ablaknak, a Windows számára a nyomógombok, edit mezők, lista dobozok, check box-ok, stb, mind-mind ablak. Az ablak tehát a Windows számára egy alapegység, ő ezt egy erőforrásnak kezeli, és az ablakait nyilvántartja. Minden ablaknak van egy azonosító száma, amit Window Handle-nek (HWND-nek) nevezünk. Az ablakokról a Windows nyilvántart még egy nagyon fontos dolgot, mégpedig azt, hogy melyik szál hozta létre. Ha egy szál létrehoz egy ablakot, a Windows úgy tekinti, hogy ezentúl azzal az ablakkal az adott szál fog foglalkozni. Ez egy nagyon fontos kitétel, az egész rendszert úgy tervezték, hogy másik szál ne is piszkálhassa az ablakot, mert ezzel jelentősen egyszerűsíteni lehetett az operációs rendszer ezen részének architektúráját, hiszen lényegében ki lehetett iktatni a szinkronizációs mechanizmusok igényét. Tehát amelyik szál létrehozta az ablakot, a továbbiakban ő és csak ő felel érte.

Amikor történik egy esemény (kattint a felhasználó, vagy akárcsak mozdít egyet az egéren, vagy leüt egy billentyűt), akkor az operációs rendszer megnézi, hogy ez melyik ablakot érinti. Egér esetében általában azt, amelyik ablak felett az egér éppen van, billentyűk esetén, amelyik ablak birtokolja az úgynevezett fókuszt. Miután az operációs rendszer kigondolta, hogy melyik ablak érintett, akkor a nyilvántartásából előkeresi, hogy melyik szál felelős az ablakért (melyik hozta létre). Ezután veszi ezt a szálat, és tudatja vele az eseményt. Mit jelent ez az utóbbi mondat?

Üzenetre várva

Pár bekezdéssel ezelőtt már említésre került, hogy a grafikus felhasználói felülettel rendelkező programok logikája a korábbi programokhoz képest eléggé ki lett fordítva. Ezek a programok általában “nem tudják” mit fognak csinálni a következő pillanatban, sőt, maguktól többnyire nem is csinálnak semmit, csak várakoznak, hogy a felhasználó a program felületén tevékenykedjen. Amikor a felhasználó a felületen az egér vagy billentyűzet segítségével csinál valamit, az operációs rendszer ezt az érintett ablakot létrehozó szállal tudatja. Maga az ablakot létrehozó szál nagy valószínűséggel pont erre a dologra várt, és most elkezdhet csinálni valamit, hogy reagáljon a felhasználó műveletére.

Azt a dolgot, amivel az operációs rendszer “szól” egy szálnak, üzenetnek nevezzük. Az üzenet egy nagyon egyszerű felépítésű valami, lényegében egy szám-négyes (négy darab szám). Az első szám az ablak azonosítója (HWND). Erre szüksége van a szálnak, mivel az több ablakot hozhatott létre, és tudnia kell, hogy melyik ablak érintett. Egy Windows-os programnál egyébként az a jellemző, hogy az alkalmazás összes ablakát ugyanaz a szál hozza létre, bár ez nem követelmény. Ezt a szálat szokták GUI szálnak nevezni egyébként. A lényeg, hogy a szál (GUI szál) a HWND alapján be tudja azonosítani az érintett ablakot. Az üzenet második száma egy üzenet azonosító. Ez írja le, hogy az üzenet miért keletkezett. Egy program legtöbbször egérműveletekről kap üzenetet, de ezekből is nagyon sok féle van. Van üzenet arra, hogy a felhasználó az egeret megmozdította (WM_MOUSEMOVE = 512). Akkor is keletkezik üzenet, ha a felhasználó úgy mozdítja az egeret, hogy az ablak területét az elhagyta (WM_MOUSELEAVE = 675). Az operációs rendszer arra is figyel, ha az ablakok átfedésének megváltozása miatt egy ablakot újra kell rajzolni. Ekkor egy WM_PAINT (=15) üzenetet küld, amire az ablakot kezelő szálnak újra kell rajzolnia az ablakot.

Az üzenetkód sokat elárul egy eseményről, azonban önmagában nem képes mindent leírni. Emiatt az üzenetekhez tartozik két szám, amelyek jelentése mindig változik. Egérmozgatásnál (WM_MOUSEMOVE) például az egyik paraméterbe bele van kódolva az X és Y koordináta, hogy a kurzor hová mozdult, a másik paraméterben pedig benne van az egérgombok állapota, sőt, a SHIFT és CONTROL gombok állapota is. Mivel sokszor sokféle információt kell ezekbe a paraméterekbe kódolni, a kezdeti Windows programozóknak elég sokat kellett varázsolni, mire az üzenetek paramétereit kibontották.

Tudjuk most tehát, hogy az operációs rendszer üzenetet küld az érintett szálaknak, illetve van fogalmunk arról, hogy az üzenetek hogyan néznek ki. Azt is tudjuk, hogy a GUI szálak jellemzően csak várnak az üzenetre. De mit jelent ez a várakozás pontosan? És hogy veszi át a szál az üzenetet?

Message Queue

Windows operációs rendszer alatt minden szál rendelkezhet egy úgynevezett üzenetsorral (Message Queue). Nem lesz minden szálnak ilyene, mivel a szál létrehozásakor az operációs rendszer nem hozza azt létre automatikusan. Akkor lesz csak létrehozva, ha szükség van rá, például mert az operációs rendszer üzenetet akar küldeni, vagy mert a szál elkezd várakozni üzenetekre.

Amikor az operációs rendszer üzenetet küld egy szálnak, az nem jelent mást, mint hogy az üzenetet belehelyezi a szálhoz tartozó üzenetsorba. Az, hogy a szál egy üzenetre vár, szimplán annyit jelent, hogy a szál programkódja meghív egy Windows API GetMessage() függvényt. Ennek a függvénynek az implementációja kiszedi az üzenetsorból a következő üzenetet, illetve ha nincs ilyen, akkor várakozás állapotba kerül. Ezt a várakozási állapotot az operációs rendszer támogatja, hogy ne kelljen felesleges ciklusokkal ellenőrizni (pollozni), hogy vajon van-e üzenet vagy nincs. Emiatt ha egy szál üzenetre vár, akkor az operációs rendszer nem is allokál neki processzoridőt, amíg nincs üzenet az üzenetsorában. A békénhagyott programok így tulajdonképpen nem fogyasztják a processzor idejét.

Amikor egy szál megkapott egy üzenetet, azt fel kell dolgoznia. Van azonban egy csomó olyan üzenet, aminél mindig ugyanazokat kell csinálni. Másik oldalról, vagy egy csomó ablaktípus, ami adott üzenetre mindig ugyanazt csinálja. Milyen ablaktípusról beszélünk? Például egy nyomógomb egy hagyományos (nem WPF-es) alkalmazásban egy ablaktípus. Minimális paraméterezhetőséggel mindig ugyanúgy rajzolódik ki, Ha felévisznek egy egeret, mindig ugyanúgy jelzi, ha kattintanak rajta szintén.

Az ablakok, mint operációs rendszer erőforrások, rendelkeznek egy eddig nem említett attribútummal. Ez az attribútum a Window Class. Az egymáshoz hasonló ablakok, mint a nyomógombok, meg az editbox-ok, ugyanabba a Window Class-ba tartoznak (tehát van Window Class-a a Windows alap nyomógombjainak, és egy másik az editboxoknak). A két említett példa az operációs rendszerrel jön, de a programozó maga is készíthet Windows Class-okat, sőt, a főablak számára ezt meg is kell tennie (WinForm-os, WPF-es programozóknak ezt megteszi a keretrendszer). A Window Class jópár paraméterét meghatározza egy ablaknak. Innen tudja az operációs rendszer, hogy milyen kurzort kell rajzolni az ablak felett. Innen tudja, hogy egy ablaknak kell-e fejléct rajzolni, kell e-minimalizáló gomb, egyáltalán milyen keretet rajzoljon neki. De ami most a legfontosabb, a Window Class leír egy függvénycímet, amely függvényt az ablakon keletkező üzenetek feldolgozására szánnak. Ha tehát egy ablak a nyomógombok Window Class-ába tartozik, akkor van egy üzenetkezelő függvény, ami elvileg úgy van megírva, hogy minden nyomógombnak szánt eseménnyel tudja, mit kell kezdeni. Ezt a nyomógomb eseménykezelő függvényt a Windows operációs rendszer fejlesztői készítették el. Ha mi készítünk egy programot, annak minden valószínűség szerint lesz egy főablaka, aminek egyedül mi ismerjük a működését. Emiatt ezt nem tudjuk készen kapott Window Class-ba sorolni, nekünk kell egyet létrehozni, kell írnunk egy eseménykezelő függvényt, amit az új Window Class-hoz rendelhetünk. Ezzel meghatározzuk a főablak működését és kinézetét. Hogyan?

A hagyományos (nem WPF-es) nyomógombok azért néznek ki olyan egyformán, mert amikor az operációs rendszer küld egy WM_PAINT üzenetet, akkor mindig ugyanahhoz a Window Class-hoz, a nyomógombok Class-ához tartozó üzenetkezelő fog lefutni, ami pedig egy-két paramétert figyelembevéve ugyanúgy rajzolja ki az ablakot. Elvileg ezért nézett ki a közelmúltig minden Windows-os program ugyanúgy. A Borland Delphis programok azért voltak másmilyenek, mert a Borland észrevette, hogy az eddig magyarázott mechanizmust a fejlesztők nagyon nem fogják szeretni, ezért feléraktak egy sajátot, készítettek új ablakkészletet (kontrolokat) saját Window Class-al, saját üzenetkezelő függvényekkel, sajátos módon reagálva a WM_PAINT-ra.

Most tehát tudjuk, hogy minden ablak egy Window Class-sal rendelkezik, és minden Window Class-hoz tartozik egy függvény, amely arra hivatott, hogy az üzeneteket feldolgozza. Másik oldalról viszont addig jutottunk, hogy egy szál várakozik egy üzenetre, és azt meg tudja kapni egy GetMessage() WinAPI-s hívással. Hogyan jut most el ez az üzenet az ablak Window Class-ához rendelt üzenetkezelő függvényhezt? Szerencsére nem kell kézzel Window Class-okat bogarászni, az operációs rendszer ugyanis ad egy kész mechanizmust, ami előkeresi az üzenetben levő HWND-hez a Window Class-t, abból az üzenetkezelő függvényt, és átadja neki az üzenetet feldolgozásra. Ez a mechanizmus a Windows API DispatchMessage() hívásával érhető el.

Message Loop

A fentiek alapján legtöbb GUI szál tehát azt csinálja, hogy egy ciklusban hívja a GetMessage() Windows API függvényt, és amikor kap egy üzenetet, azt feldolgoztatja a DispatchMessage() Windows API függvénnyel. Ez egy egyszerű pársoros ciklus, és ezt a ciklust szokták Message Loop-nak nevezni. Szinte minden GUI programnak van egy Message Loop-ja. Hol van ez WinForms-ban? Hol van WPF-ben? Amikor elindul egy WinForms vagy WPF alkalmazás, akkor a Main függvény a főablak létrehozása után hív egy Application.Run() függvényt, ami érdekes módon vissza sem tér, amig az alkalmazás fut. Na vajon mi van az Application.Run()-ban? Igen, a Message Loop.

Az eddigieket összefoglalhatjuk a következő ábrán. Elindul a program, ehhez mindenképpen szüksége van egy szálra (1). Minden szálnak van egy azonosítója, amit az operációs rendszer oszt ki, ennek a szálnak most 0105. A GUI-s programok főfüggvénye általában egyszerű, szinte azonnal belépnek a Message Loop-ba (2). Mielőtt azonban belépnének, általában létrehoznak egy ablakot, ami az alkalmazás főablaka lesz (3). Ez a három lépés megvolt a régi programoknál is, és megvan ma is némi álcával, de az Application.Run(new MainForm()) pontosan az eddig ismertetett három lépést teszi, akárcsak a WPF-es alkalmazások.

Amikor az alkalmazás létrehozta az ablakot (ablakokat) (3), az operációs rendszer ezekhez hozárendelte a későbbiekben szükséges információkat. A (3)-as ábra körül a nagyobb ablakban van egy gomb, ami az operációs rendszer számára ugyancsak egy ablak. Ennek az ablaknak a Window Class-a a “BUTTON” (kövesd a kék kicsi nyilat az ok gombtól). Tudja továbbá az operációs rendszer, hogy ezt az ablakot a 0105-ös szál hozta létre, ezért minden eseményt majd a 0105 szál üzenetsorába továbbít. A BUTTON Class-ból azt is lehet tudni, hogy az ablaknak küldött üzeneteket melyik függvény fogja feldolgozni (lásd WndProc).

Miután a GUI szál belépett a Message Loop-ba, (2), az alkalmazás reagálni tud a felhasználó tevékenységére. Tegyük fel, hogy a felhasználó megnyomta az egér bal gombját az OK gomb felett (4). Ekkor az egér drivere meghívja az operációs rendszer megfelelő függvényét, amire az operációs rendszer egy hosszabb folyamatba kezd. Először megkeresi, hogy melyik ablak felett történt az esemény. Ezután megnézi, hogy az ablakot melyik szál hozta létre. Összerakja most a 4 értékből álló üzenetet, majd belehelyezi a 0105 szál üzenetsorába (5).

A 0105 szál a Message Loop-ot futtatva eddig alvó állapotban volt, mivel utoljára a GetMessage() függvényt hívta. Most az operációs rendszer, amikor az ütemezést végzi, hogy melyik szálnak adjon processzoridőt, látja, hogy már ez a szál is feléleszthető. Igazából olyan rendes az operációs rendszer, hogy az így felélesztett szálaknak még kicsit magasabb prioritást is ad (boost), hogy minél hamarabb elvégezék a felhasználó igenyeinek megfelelően a feladatot. A lényeg, hogy a (6)-pontban a GetMessage() hívás visszatér az üzenettel (az üzenet útját a bordó nyíl mutatja). Ekkor a kód önmagában is foglalkozhatna az üzenet feldolgozásával, de inkább szokás ezt az operációs rendszerre bízni egy DispatchMessage()-vel. (7)

DispatchMessage() implementációja az üzenet HWND paramétere alapján megkeresi, hogy az ablak melyik Window Class-ba tartozik, előveszi az üzenetkezeléshez használt függvényt, és meghívja (8). Az üzenetkezelők általában egy hatalmas switch szerkezetek, ami az üzenet kódja alapján meghívja a megfelelő műveleteket. Ez ma már megint nem látszik a keretrendszerek miatt, de a WinForms keret kódjában turkálva ezt könnyen megtalálhatjuk (Control class WndProc függvénybe érdemes reflectorral belenézni). Miután az üzenetkezelő elvégezte a dolgát (9), visszaadja a vezérlést, és a Message Loop újrakezdődik. Ezek alapján az is látszik, miért fagy be egy GUI-s alkalmazás, ha az üzenetkezelőkben hosszú műveletet végzünk: egyszerűen megszakad a loop, nem lesz ami reagáljon a felhasználó által kiváltott üzenetekre.

Eddig szép, de khm, a SynchronizationContext.Post()…

Teljesen jogos, hogy talán elkanyarodtunk a témától, de a SynchronizationContext.Post() a fentiek nélkül nem érthető meg. Igazából még most sem tartunk ott, ezért egy újabb körutazást kell tenni, csak most a WinForms kontroljainak terén.

WinAPI vs Objektum Orientáltság

Az eddigiekben ismertetet mechanizmus elég komplikáltnak tűnik, és az is. Natívan Windows alá elég körülményes programokat írni. Erre érzett rá nagyon jól a Borland, és így ért el jelentős sikereket a Borland Deplhi-vel. Ez ráült a Windows üzenetkezelésre, újragyártotta a szerencsétlen, kis tudású kontrolkészletet, és kényelmes felületet adott a fejlesztőknek, ahol egy esemény kezelése nem egy nagy switch barkácsolását jelentette. A .NET WinForms lényegében a Borland Delphi ötletei alapján készült. Ez az ötlet nem annyira bonyolult, a natív üzenetkezelés át van fordítva eseménykezelők hívására, illetve HWND-k helyett objektumok vannak, és az események az objektumok eseménykezelőin futnak le.

Amikor egy WinForm-os Control-ból származtatott példány létrejön, ő ugyanúgy megcsinálja az operációs rendszer ablakát, tehát lesz neki egy HWND-je. Ezzel a folyamatok ugyanúgy működnek, mint régen. A WinForms azonban karbantart egy táblázatot arról, hogy melyik Control példány melyik HWND-n ül rajta. Amikor beesik egy üzenet, akkor a WinForms Message Loop-ja az üzenet HWND alapján előkeresi a WinForms Control példányt, és meghívja az üzenetkezelő függvényét. A Control példány üzenetkezelője (a brutálisnagy switch) az üzenetkód alapján megfelelő .NET-es osztályra konverálja a nyers wparam és lparam értékeket. Ezután triggereli a megfelelő .NET eseményt (esetleg OnXXX függvényt hív). A WinForms Message Loop tehát ezzel a trükkel bonyolultabb, mint a natív message loop.

WPF esetén a gombok, listbox-ok már nem Windows operációs rendszer által kezelt ablakok, hanem a WPF maga rendereli le. Egy WPF alkalmazás esetében tehát a legfelső ablakot látja csak az operációs rendszer, és minden üzenetet ennek küld (mivel mindig azt hiszi, hogy arra az ablakra kattintottak, nem a gombra, amit a WPF belerajzolt), ezután a WPF osztja szét az üzeneteket. Tehát a WPF már majdnem kiiktatta a régi üzenetkezelést, de alapjaiban még mindig rá támaszkodik.

Az üzenetkezelésen kívül az ablakozós rendszereknek van egy másik oldala is. Bizonyos esetekben az ablakokat meg kell változtatni. Ez jelenthet átszínezést, vagy új szöveg kiírását. Az ablakozós rendszer az eddigiek fényében láthatóan egy szálhoz kötött, az üzenetkezelésnél most már ezt könnyű elfogadni. Azonban a Windows megköveteli, hogy az ablakok paramétereinek a megváltoztatása sem történhet más szálból. Na jó, nem követeli meg, igazából natív Windows alatt ez megtehető, csak nem biztonságos. Ezért a Windows ablakozós rendszerére ráült keretrendszerek nem engednek ablakot módosítani, csak a GUI szálból. Lehetett volna implementálni ennél engedékenyebb rendszert, a szálak közötti szinkronizálás azonban rendkívüli módon lassította volna és bonyolultá tette volna az implementációt. A többszálúságot így most csak annyiban kezelik, hogy minden Control példány megjegyzi, hogy melyik szál hozta létre, és erre minden módosítást/lekérdezést intéző függvény ráellenőriz, és kivételt dob, ha rossz szálról hívják.

Ez a megkötés viszont kicsit természetellenessé teszi a kontrolok használatát. Amiatt, hogy ne tartsa fel a fejlesztő a Message Loop-ot, nem végezhet hosszú műveleteket az eseménykezelőkből. Arra van hát kényszerülve, hogy más szálakat használjon. Viszont amint megvan az időigényes folyamat eredménye, nem használhatja a kontrolt ennek kijelzésére, mivel másik szálban van, mint amin létre lett hozva. Pont emiatt dolgozták ki az EAP-ot, és pont emiatt vagyunk most ennek a témának a közepén.

Control.Invoke()/BeginInvoke()

Azt sajnos le kell nyelni, hogy egy kontrolt csak egy kitűntetett szálból használhatunk. De ha már így alakult, a Control osztály legalább ad megoldást arra, hogy egy nem GUI szálból könyebben végrehajtassunk műveletet a GUI szálon. Mi ez a megoldás?

A Control osztálynak van egy Invoke() és egy BeginInvoke() függvénye. Ezek a függvények egy delegate-et vesznek át paraméterként, és a két függvény azt ajánlja, hogy a delegate-eket azon a szálon fogja végrehajtani, amelyiken a Control példány létre lett hozva. Nézzük meg, hogyan teszi ezt.

Gondolhatjuk, hogy az Invoke() és a BeginInvoke() önmagában nem tud csinálni túl sokat, amikor belehívunk ezeknek a függvényeknek a kódja még mindig a “rossz” szálon lesz. A Control példányok azonban tudnak gyártani maguknak egylistát. Ebbe a listába fűzik fel azokat a delegate-eket, amelyeket majd le kell futtatniuk a GUI szálon. Ennek a listának a neve Thread Callback List, egy eleme a listának pedig Thread Method Entry. A Thread Method Entry a delegate-en kívül még tartalmaz egy paramétert, illetve az aktuális szál ExecutionContext-ét. Ezzel lesz biztosítva, hogy az Invoke()/BeginInvoke() egy logikai szálat visz tovább.

Most, hogy a Control példány Thread Callback List-je feltöltésre került, rá kell bírni a GUI szálat, hogy ezeket hajtsa végre. A GUI szál minden valószínűség szerint egy Message Loop-ban ül. Innen csak egy üzenettel lehet kibillenteni, ráadásul olyan üzenet kellene, amelyet utána a szóban forgó Control példánynak továbbít, és akkor a Control példány, amely így a GUI szálon futtat majd kódot, meg tudja hívogatni a Thread Callback List elemeit.

Szerencsére nem csak az operációs rendszer tud egy szál üzenetsorába üzenetet tenni, hanem bármely közönséges kód is. Ehhez csak az adott szálon létrejött HWND-re, vagy magának a szálnak az azonosítójára van szükség. A Control példány azt szeretné elérni, hogy az üzenet hozzá csapódjon be, így bár tudja a thread id-t is, ebben az esetben a Control által kezelt natív ablak HWND-jét használja. Rendelkezésére áll a PostMessage() WinAPI-s hívás, ezt meghívva, egy speciális üzenetkóddal egy új üzenetet tesz a GUI szál üzenetsorába.

Ekkor a GUI szál felébred, veszi az üzenetet, a HWND alapján előszedi a Control példányt, és meghívja az üzenetkezelő függvényét. Ez az üzenetkezelő ismeri az előbb használt üzenetkódot, a hatalmas switch-ben ehhez a kódhoz egyébként a InvokeMarshaledCallbacks() hívása tartozik. Az InvokeMarshaledCallbacks() fogja a Thread Callback List-et, és sorban meghívogatja a lista elemeiben levő delegate-et, ráadásul a megfelelő ExecutionContext-tel.

A Control.Invoke() és BeginInvoke() között az a különbség, hogy az Invoke() megvárja a művelet végét. Ezt úgy teszi, hogy az Invoke() függvény pontosan ugyanúgy kezd, mint a BeginInvoke() miután azonban elküldte az üzenetet, elkezd várakozni a Thread Method Entry-ben szükség esetén létrejövő “event” szinkronizációs objektumra. Aki nem ismeri, az Event egy olyan dolog, aminek van szignált és nem szigált állapota. Egy szál tud várakozni arra, hogy az Event szignált legyen, és ezt a várakozást az operációs rendszer támogatja, azaz nem oszt ki időt, amig az Event nem szignált, tehát a várakozó szál nem fogyaszt processzoridőt. Egy másik szálon, ha az Eventet szignálják, akkor az operációs rendszer feléleszti a várakozó szálat, ami tovább működhet.

Az Invoke() hívás tehát elkezd várakozni a Thread Method Entry Event-jére, amit viszont a GUI szálon lefutó, előbb említett InvokeMarshaledCallbacks() függvény akkor szignál, miután a callback delegate lefutott.

A következő ábra szemlélteti a Control.BeginInvoke() folyamatát. Egy GUI-tól független szálon történik a hívás a Control.BeginInvoke()-ra (1). Ez átad egy delegate-et, amit végre kellene hajtani a GUI szálon. A Control.BeginInvoke() kódja készít egy Thread Method Entry-t, benne a delegate-tel, amit majd meg kell hívni, a delegate paraméterével, és a szálon érvényes ExecutionContext-tel. Ezt az új Thread method Entry-t egy listába felfűzi. (2). Ezután meghívja a WinAPI PostMessage() függvényét olyan paraméterekkel, hogy azt a HWND-t adja meg, amelyik a Control példány alattí natív ablak. Üzenetkódnak egy speciális értéket használ (annyiban speciális, hogy külön erre a célra használt, nem pedig alami WM_xxx, de egyébként egy közösnéges számról van szó). (3). A PostMessage a HWND alapján megtalálja a GUI szálat, és ennek az üzenetsorába helyezi az új üzenetet (4). A GUI szál a Message Loop-ot futtatja, az új üzenettel a GetMessage() hívás visszatér (5) WinForms-ról lévén szó, a HWND alapján meg kell keresni a Control példányt, és annak meghívni az üzenetkezelő függvényét (6). Ez az üzenetkezelő függvény a speciális üzenetkód alapján meghívja az InvokeMarshaledCallbacks() függvényt (az ábrán ez nem szerepel), ami sorra veszi a Thread method Entry-ket, és meghívja ezek alapján a callback függvényeket (7). Jó eséllyel egyébként csak egy Thread Method Entry-t fog találni.

És most: SynchronizationContext.Post()

Végre eljutottunk arra a szintre, hogy meg tudjuk mondani mint csinál a SynchronizationContext.Post() hívás. Amikor a WinForms esetén a SynchronizationContext létrejön, akkor keres egy Control-ból származtatott példányt. Ennek a példánynak a referenciáját eltárolja. Később, amikor egy SynchronizationContext.Post() hívás érkezik, ez nem csinál mást, mint áthív az eltárolt példány BeginInvoke() függvényébe, és ezzel készen is van.

A korábbiak alapján tovább pontosíthatjuk az EAP működéséről alkotott képet:

Az alkalmazás indulásakor általában létrejön a főablak, ami egy Control osztályból származtatott példány. Ez létrehoz egy SynchronizationContext-et, ami viszont eltárolja a Control példány referenciáját (1). Ezután az alkalmazás egy Application.Run() hívással indítja a message loop-ot (2), innentől kezdve az alkalmazás reagál a felhasználó tevékenységére. Egy ilyen tevékenység során, mondjuk egy gomb lenyomására vagy menü kiválasztására egy üzenet képződik a message queue-ban, amelyik feldolgozásra kerül a message loop által. Tegyük fel, hogy ennek az üzenetnek az üzenetkezelője egy EAP komponens segítségével egy aszinkron műveletet indít el. Ezzel meghívódik az EAP komponens xxxAsync() függvénye (3, kék nyíl). A függvényen belül létrejön az AsyncOperation példány magába zárva a GUI szál SynchronizationContext-et, amely ugyanaz a példány, mint ami az (1) pontban létrejött, csak az ábrán meg kellett kettőzni az áttekinthetőség miatt. A SynchronizationContext továbbra is tartja a legelső létrejött Control példány referenciáját, az ábrán szintén megduplázva.

Az xxxAsync() függvény második lépése, hogy valamilyen mechanizmussal egy új szálra, például Thread Pool szálra tereli a vezérlést. Az ábrán ezt a (3) pont körül bordó nyíl megjelenése szemlélteti. Az új szál elkezdi végrehajtani a feladatát, az eredeti hívás (kék nyíl) pedig visszatér a message loop-ba. Később a Thread Pool szál által végrehajtott munka egy üzenetet akar visszaküldeni a GUI szálra. Ehhez meghívja az AsyncOperation.Post() függvényt, egy delegate-et paraméterként átadva (4). Az AsyncOperation.Post() azonnal továbbhív a SynchronizationContext.Post()-ra. Ez veszi a létrejöttekor eltárolt Control példány referenciáját, és meghívja a BeginInvoke() függvényét, átadva ennek a paraméterként eddig passzolgatott delegate-et. A Control.BeginInvoke(), ha eddig nem volt neki, létrehozza a Thread Callback List-et, létrehoz egy új Thread Method Entry-t, eltárolva benne a BeginInvoke()-nak átadott delegate-et és egyéb paramétereket (5). Ezután hív egy WinAPI PostMessage() függvény-t úgy paraméterezve, hogy a cél ablak a Control példány alatt levő natív ablak, az üzenet kód pedig a speciális, direkt erre a folyamatban lévő esetre használt kód. (6). A PostMessage() hívásra az operációs rendszer elhelyez egy üzenetet a GUI szál üzenetsorába, mivel a Control által kezelt natív ablak a GUI szálon jött létre az (1)-es pontban. A PostMessage() hívás után a Thread Pool szál visszatér az EAP komponens által végrehajtandó feladat számításához (bordó nyíl (6)-tól (4)-ig.

Eközben a GUI szálon a message loop folytatja a munkákat, és a (7)-es pontban a GetMessage() hívással megkapja az előbb a message queue-ba helyezett üzenetet. A natív ablak azonosító (HWND) alapján beazonosításra kerül a Control példány, majd meghívásra kerül az üzenetkezelő függvénye (8). Ez a függvény az üzenet kódjának megfelelően meghívja azt a függvényt, ami a Thread Method Entry-kben tárolt delegate-eket sorban meghívja, így meghívásra kerül a Thread Pool szálon megadott callback is. Ez a callback most kiválthatja a megfelelő eseményt, mint a ProgressChanged vagy xxxCompleted események. Az eseménykezelők lefutása után a vezérlés visszatér a message loop-ba (ez az ábrán már nincs jelölve)

BackgroundWorker

Található néhány EAP-ot használó objektum a .NET Frameworkben, mint például SoundPlayer, illetve egyes tool-ok is tudnak EAP burkot generálni az osztályaik közé, például a WCF-es svcutil. A leggyakrabban azonban talán az EAP kapcsán az általánosra megírt BackgroundWorker osztállyal találkozik a fejlesztő. A BackgroundWorker leginkább számításorientált vagy szinkron I/O műveletekhez használható. A EAP patternjának megfelelően van egy RunWorkerAsync függvénye a művelet indításához, egy RunWorkerCompleted eseménye, amely meghívódik, ha a háttérmunka befejeződött. A BackgroundWorker támogatja továbbá a művelet megszakítását (CancelAsync, ahogy az EAP pattern előírja), illetve ha a háttérmunka támogatja, akkor a ProgressChanged eseményre feliratkozva kaphatunk a munka menetéről visszajelzés. Ez volt eddig a BackgroundWorker EAP-nak megfelelő külső burka. De a BackgroundWorker-t arra tervezték, hogy egyfajta adapterként működjön a saját feladatainkhoz, így van egy másik interfésze, amihez nekünk kell igazodni, ha a BackgroundWorker-t szeretnénk használni a háttérmunkáinkhoz.

Elsőként, hogyan adjuk meg BackgroundWorker-nek, hogy milyen háttérmunkán dolgozzon? Bár az ilyen helyzetekre a call back függvények használata lenne kézenfekvő, a BackgroundWorker itt is eseményeket használ, egyszerűen azért, hogy a GUI designerből egy-két kattintással odataláljunk a háttérmunka kódjához. Az esemény, amihez a háttérmunkánkat rá kell kötni, az a DoWork. Mivel eseményről van szó, több eseménykezelőt (azaz háttérmunkát) is rá lehet kötni az eseményre, de ezek nem párhuzamosan, hanem egymás után fognak meghívódni.

Ha megvan a háttérmunkánk, akkor eldönthetjük, hogy tudunk-e visszajelzést adni a munka folyamatáról. A visszajelzés-t a BackgroundWorker.ReportProgress() hívásával tehetjük meg, egyszerűen megadva egy százalékos értéket.

A BackgroundWorker használatát mutatja be a következő ábra, kihasználva az EAP kattingatós lehetőségeit. Nulladik lépésként létre kell hozni egy Windows Formos alkalmazást, és fel kell dobni egy gombot a képernyőre. Ezután a Toolbox-ból ki kell választani egy BackgroundWorker komponenst, és a form-ra dobni (1). Ezután, mivel majd meg akarjuk mutatni hol áll a munkafolyamat, illetve lehetőséget adunk annak megszakítására, a WorkerReportsProgress és WorkerSupportsCancellation property-ket állítsuk igazra. (2). Ezután megírjuk a háttérmunkát, ehhez át kell kapcsolni a Properties ablakot, hogy az eseményeket mutassa (3), majd duplán kattintani a DoWork eseményre (4) (kezdetben a DoWork melletti mező üres, csak én előre dolgoztam, azért látszik az ábrán az eseménykezelő függvény neve). Ekkor az (5)-ös pontba jutunk, ahol egy háttérmunkát szimuláló kódot valósítunk meg. A munka 100 lépésből áll, minden lépés után egy kis szünet, majd a munka állásának riportolása következik.

Most azt a kódot kell megírni, ami a button1 lenyomására elkezdi a háttérmunkát. Ehhez a Designer képernyőre kell menni (6), kiválasztani a button1 gombot (7), majd duplán kattintani a Click eseményen (8). Az eseménykezelőben, az EAP-nak megfelelően a RunWorkerAsync() hívással lehet elindítani a háttérszámítást (9). Ekkor az alkalmazást elindítva az már működőképes, bár sokat nem lehet látni. A háttérben azonban egy Thread Pool szálon lefut a 100-as ciklus. A button1-et türelmetlenül nyomkodva az alkalmazás ki fog fagyni, mivel a BackgroundWorker nem támogatja több párhuzamos folyamat végrehajtását. Ezt elkerülendő, ellenőrizni lehet, hogy a BackgroundWorker dolgozik-e, egy “if (backgroundWorker1.IsBusy == false)” megoldással.

Ha szeretnénk látni hogyan halad a háttérmunka, akkor a Toolbox-ból dobjunk fel egy ProgressBar-t a formra (10). Most be kell állítani, hogy a progressbar mutassa a BackgroundWorker által eseményként riportolt állapotot. Ehhez válasszuk ki a backgroundWorker1-et (11), majd írassunk kódot a ProgressChanged eseményére (12). Az eseménykezelő csak szimplán beállítja a ProgressBar értékét a BackgroundWorker által riportolt értékre (13). Most elindítva az alkalmazást és a button1-gyet megnyomva a ProgressBar lassan kúszik felfele.

A Cancel lehetőséghez vegyünk fel egy új gombot (14) majd a Click eseményére (15) hívjuk meg a BackgroundWorker.CancelAsync() függvényét. (16) Ez a függvény csak egy flag-et állít be, így a háttérmunkánkat úgy kell módosítani, hogy a flag-et figyelemmel kísérje. Válasszuk hát ki a DoWork esemény eseménykezelőjét (17) mivel ebben van a háttérmunka, és módosítsuk, hogy figyelembe vegye a CancellationPending flag-et (18)

Összefoglalás

Az EAP-os komponensek az APM-nél és a Thread Pool-oknál jóval kényelmesebben használhatóak GUI alkalmazások esetében. A kényelmet két dolog biztosítja. Egyrészt callback függvények helyett események használhatóak az elvégzett feladat eredményének átvételére, másrészt az eredmények feldolgozása visszairányításra kerül az aszinkron műveletet indító szálra, például a GUI szálra. Ugyanakkor az EAP többnyire csak egy egyszerű wrapper az APM vagy a Thread Pool mechanizmusai körül. A fenti mechanizmusok ismeretében könnyebb saját EAP-ot megvalósító komponenst készíteni, illetve hamarabb megérthető az MSDN erről szóló Walkthrough-ja is.

  1. #1 by botond.kopacz on 2011. January 30. - 22:43

    Ez igazán remek és alapos cikk volt!🙂

  1. async vs. ASP.NET - pro C# tology - devPortal

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: