Task-based Asynchronous Pattern – Kivételek

A .NET kivételkezelését (illetve amit abból a c# megvalósít) szinte mindenki ismeri. Arról van szó, hogy a program adott pontján olyan helyzet adódik, ami nem teszi lehetővé a program normális “nyomvonalon” történő futását. Ezen a ponton a program “egy kivételt dob”, amely leírja a helyzetet (ami gyakran valamilyen hibajelenség). Ehhez a kivételhez aztán a futtatórendszer elkezdi megkeresni azt a kódrészt, amely a programot megfelelő állapotba tudja állítani az adott helyzetből, hogy az a normál futását folytatni tudja.

Hogyan történik a kivételt lekezelő kód keresése? A futtatórendszer a metódushívási láncon visszafele haladva keresi azt. Ezzel a keresési sorrenddel a programnak esélye van a tervezett végrehajtási útvonalhoz minél közelebb visszaállni. Ha a hívási láncon nem találni az adott kivételt kezelő kódot, jellemzően a program futása leáll.

A hívási láncot könnyű értelmezni közönséges, egyszálú programok esetén. Jóval körülményesebb azonban, ha többszálú a program, s főleg az, ha logikai szálakban gondolkodunk fizikai szálak helyett. Hogy miért, egy gondolatkísérlettel fogjuk belátni.

Ha visszaforgathatnám az idő kerekét…

Tegyük fel, hogy egy különleges képességgel rendelkezünk: vissza tudunk állni a múlt egy pontjára. Ekkor az egész világ visszaforog a régi helyzetbe, mi visszük magunkkal az “emlékeinket” a megváltoztatandó jövőről. A világ ezután újraindul az adott pontról, mi pedig el tudjuk kerülni (vagy meg tudjuk valósítani) azt, ami miatt az időt visszaforgattuk. Ekkor a helyzet tiszta, addig tudunk próbálkozni, amíg el nem érjük, amit akarunk. Tegyük fel azonban, hogy fogalmunk sincs arról, hogy hogyan lehet elérni, amit szeretnénk. Nincs kedvünk ezerszer végigélni ugyanazt – mindig kicsit másképpen. Szerencsére van egy másik különleges képességünk: nem csak visszatekerni tudjuk az időt, de adott ponton el tudjuk ágaztatni, onnan pedig két világban párhuzamosan történhetnek a dolgok. A párhuzamos világban ott van a klónunk is, aki esetleg nagyobb sikerrel jár. Nem foglalkozva a filozófiai buktatókkal, a párhuzamos világbeli énemből úgy tudok profitálni, hogy a jövő adott pontján újra tudunk egyesülni, ami annyit jelent, hogy az ő világa megszűnik, az emlékeinket megosztjuk, és újra csak egy példányban élünk tovább.

Az időutazás paradoxonjai

A gond akkor kezdődik, ha a párhuzamos énem vissza akarja forgatni az időt. Addig nincs baj, amig nem megy azon a ponton túl, ahol szétágaztunk. De mi van akkor, ha úgy látja szükségesnek, ha annyira visszatekeri az időt, hogy túljut az elágazási ponton? Ekkor, ha az elágazási pont előtt megváltoztat valamit, inkonzisztensé teszi a helyzetemet, az én múltam nem lesz többet “folyamatos”, az elágazási pont elé már nem tudom, hogy kell visszamenni, azt sem tudom, mi van ott. Abba a logikai bukfencbe már nem is érdemes belemenni, hogy ekkor egy adott világban egyszerre két időnek kellene létezni, hiszen a klónom az elágazási pontot elhagyva átkerül a saját világom egy múltbéli pontjába, de ebben a világban még én is ott vagyok.

A paradoxonokat el akarom kerülni, ezért a klónjaim nem lehetnek velem egyenrangúak, és nem mehetnek vissza az időben az elágazási pont mögé. Ez egyben azért is jó, mert ha ők úgysem mehetnek vissza az elágazási ponton túl, akkor viszont én igen – mivel az így esetleg inkonzisztensé váló múltja ekkor már nem zavarja a klónomat.

Másik oldalról, ha a klónom azt látja jónak, hogy vissza kell menni az elágazási pont elé, még nincs veszve minden. Akkor, amikor újra egyesülünk, a klón által összegyűjtött információk alapján akár dönthetek úgy, ahogy ő döntött volna, ha nem lett volna korlátozva, és visszamehetek egy időpontba az elágazás elé is. Ekkor lesz alkalom úgy korrigálni, ahogy a klón szeretett volna.

Mi köze ennek a szálakhoz?

Mi az értelme ennek a gondolatkísérletnek? Az, hogy a szálak esetében is nagyon hasonló helyzetek állnak elő. Egy logikai szál elágazó szálakra eshet szét, akár a klónunk a párhuzamos világgal. A szálak múltja a hívási verem (call stack), bár az analógia az idővel nem túl jó. Amikor egy programrész kivételt dob, a keretrendszer a hívási vermen visszafele haladva keres egy olyan pontot, ahol a program képes stabil állapotba állni. Egyetlen szál esetén ez nem probléma. Ha viszont a szál több logikai szálra bomlott szét, a helyzet ugyanaz lesz, mint a gondolatkísérlet esetén: az egyik szál úgy módosítja a vermet, hogy abból a másik szál verme már nem következik, azaz a logikai szál teljes hosszára nem lesz többé használható hívási verem.

A helyzet az, hogy nem kell ahhoz kivétel, hogy az elágazó logikai szálak tönkretegyék egymás hívási vermét, és itt jön elő az idővel való analógia korlátja. A hívási verem folyamatosan épül és bomlik le akkor is, ha a program futása normális módon halad. Ha egy metódus visszaadja a vezérlést, akkor a hívási verem teteje eltűnik. Ebből adódik, hogy ha egy logikai szál kettévált, akkor amint az egyik logikai szál éppen futó metódusa visszaadja a vezérlést az őt hívónak, a másik logikai szál verme a hívási lánc teljes hosszában már nem lesz többet érvényes.

Ez a programozókban általában nem okoz zavart, mivel ők amúgy is fizikai szálakban gondolkodnak. Amikor egy logikai szál kettéválik, akkor a gyakorlatban az történik, hogy egy új fizikai szál viszi tovább a kettévált logikai szál egyik felét, és az eredeti fizikai szál viszi tovább a logikai szálak másik felét. Maguk a .Net 4 könyvtári osztályok, mint a Task, szintén nem a logikát szálak koncepciójára épülnek, viszont használják néhány elemét, mint az Execution Context. Másik oldalról a kivételkezelés jobb megértéséhez érdemes a logikai szálak koncepcióját erőltetni még egy kicsit, illetve jól jön majd ez a gyakorlat a C# 5 lehetőségeinek megismerésekor is.

Mivel a logikai szálak szétválása a programozók fejében inkább egy új fizikai szál indításaként jelenik meg, a hívási lánc kérdése nem okoz nagy töprengést: az új fizikai szálnak megvan a saját hívási lánca (verme) aminek a gyökere az a metódus, amire például a Task példánynak átadott delegate mutat. Nyilvánvaló emiatt, hogy amikor a gyökér függvény visszaadja a vezérlést, akkor az nem a Task példányt elindító metódusba kerül vissza. Másik oldalról viszont tekinthetjük ezt úgy, hogy a logikai szál “klónja”, vagy “másik szára” nem tud visszalépni az elágazási pont mögé. Ez igaz a normál vezérlésre is, és igaz a kivételkezelésre is – ami most számunkra az érdekesebb kérdés. Mert mi van akkor, ha a logikai szálon kivétel keletkezik, és a hívási lánc használható részén nem lehet olyan pontot találni, ami a szálat érvényes állapotba állítja?

A gondolatkísérletben erre a helyzetre az volt a megoldás, hogy amikor a párhuzamos világ, illetve a klón újraegyesül, akkor el lehet dönteni, hogy szükség van-e a klón miatt visszaforgatni az időt, hogy a problémája kezelve legyen. Ez a trükk a kivételek esetében is működik. Az előző cikkben több lehetőséget láttunk arra, hogy logikai szálakat újra összefonjunk. Erre jó például a Task.Wait() vagy TaskFactory.WaitAll(). Másik példa rá, amikor egy szülőtaszk megvárja a gyerektaszkjai futását (AttachedToParent flag).

Amikor egy logikai szálon kivétel keletkezik, akkor a hagyományos kivételkezelési mechanizmus indul el. Ez végigvizsgálja a hívási láncot, de azt már láttuk, hogy nem tud visszalépni az elágazási pont mögé. Viszont amikor a logikai szálak összefonása megtörténik, ott a kivétel ismét “előugrik”, és ezzel a trükkel át tud lépni egy másik fizikai szálra, ahol esetleg az előbb említett elágazási pont mögé is vissza lehet térni a kivételkezelő rutin keresésével.

Most, hogy látjuk milyen alapokon nyugszik a kivételkezelés szétágazódó/egybefonódó logikai szálak esetén, nézzük meg a konkrét működést/implementációt. Egyszerű példával kezdve, mi történik a következő program esetében. Érdemes a kódsorokat nézni, a nyilak csak a szemet próbálják vezetni a vezérlésátadás helyére:


Miután a logikai szál kettéválik, a logikai szál egyik felét egy új fizikai szál viszi tovább (az ábra jobb oldala). Ott a hívási verem egyik pontján egy kivételt dob a kód, ami bontva visszafele a hívási láncot még a fizikai szál határa előtt lekezelésre kerül (a Szár() metódusban). Innen a program normál módon folytathatja a futását. Miután a Szár() metódus visszaadja a vezérlést, az eléri a fizikai szál határát, és ott a logikai szál futása leáll.

Most szedjük ki a Szár() metódus kivételkezelését:

Ekkor így módosul a logikai szálak futása:

A futtatókörnyezet a szokásos módon a hívási láncon visszafele haladva keresi a kivételt lekezelni képes kódot, ebben az esetben azonban el fogja érni a fizikai szál határát. A korábban ismertetett okok miatt ezen a ponton nem tud tovább haladni – ehhez át kellene térni a másik fizikai szálra, azon viszont a hívási lánc már megváltozott. De ha nem is változott meg a másik fizikai szálon a hívási lánc, amikor a kivételkezelés miatt a keretrendszer azt elkezdené visszafele lebontani, akkor a másik fizikai szál által továbbvitt logikai szál hívási láncát tenné tönkre. És ha ettől eltekintünk, a kivétel lekezelése után ekkor egy fizikai szálnak két logikai szála lenne, mint amikor a gondolatkísérletben a klónom visszajutott az én világom múltjába.

Egyszerűbb emiatt az, hogy a kivitelkezelés megáll a fizikai szál határánál. Ha eddig a pontig nem sikerül a szálat a kivétel kezelésével konzisztens állapotba hozni, akkor az egész alkalmazás inkonzisztens állapotba jut – ekkor pedig a keretrendszer leállítja a futását.

Hogy valaki kipróbálta a fenti programot, és nem is állt le? Később meglátjuk miért nem, egyelőre egészítsük ki a fenti programot egyetlen egy sorral:

static void Main(string[] args)
{
    var task = 
        Task.Factory.StartNew(
            () => Szár());

    Console.ReadLine();
    GC.Collect(); // Bang!!!
} // Main()

Most próbáljuk ki mi a helyzet akkor, amikor a logikai szálak összefonódnak, egyelőre kivételek nélkül:

A logikai szálak a következő módon alakulnak:

A logikai szálak szétválása, bár logikailag “szimmetrikus”, a megvalósítás mégsem az, mivel a logikai szál egyik felét az eredeti fizikai szál, amíg a másikat egy új szál viszi tovább. A hívási lánc szétválás előtti szakaszát csak a fizikai szál “örökli”. Emiatt az új fizikai szál nem tudja visszavezetni a vezérlést a szétválás előtti metódusokba.

Ezek miatt a “másodrendű” logikai szál hívási lánca csak a szétválási pontig tart – ha csak nem jelölünk ki egy szinkronizációs pontot. A szinkronizációs ponttal a logikai szál futását új mederbe terelhetjük, pontosabban összevonhatjuk egy másik logikai szállal, ahogy azt a fenti ábra mutatja.

Az, hogy a logikai szál a szinkronizációs ponttal új hivási láncot kapott, akkor lesz érdekes, amikor a szál kivételt dobott. Szinkronizációs pont nélkül a kivételkezeléshez a futtatórendszer csak a fizikai szál határáig tudta visszabontani a hívási láncot, különben ellentmondásokba keveredett volna. Most azonban a helyzet egyértelmű, az összevont szálak egyetlen hívási lánccal rendelkeznek, ezen pedig lehet továbbkeresni a kivételt kezelő kódot.

Nézzük az alábbi programot:

És a hozzá tartozó ábrát:

Ekkor tehát a kivétel túljutott a fizikai szál határain. A helyzet nem olyan egyszerű azonban, mint ahogy látszik. A fenti példa két logikai szál egyesülését mutatja. Könnyű elfogadni, hogy bár az egyik logikai szál tudta volna normál módon folytatni a futását a szálak egyesülése után, a másik szál inkonzisztens állapotban volt, így miatta, az ő kivételéhez meg kellett keresni a kivételkezelő kódot. Mi van azonban a következő helyzetben:

És a hozzá tartozó ábra:

Mit jelentenek a piros nyilak? Ebben az esetben két logikai szálon keletkezett kivétel, és mind a kettő esetben igaz az, hogy adott fizikai szálon nem lehet kivételkezelőt találni. A szinkronizációs pont lehetőséget ad a logikai szálaknak átlépni a fizikai szál határát, azonban most két kivételhez kellene ugyanazon a fizikai szálon, ugyanazon a hívási láncon kivételkezelőt keresni. Ez nyilván lehetetlen, nem lehet kétszer visszabontani a hívási láncot és egy fizikai szálon két különböző kivételkezelőnek engedni, hogy a szálat normál állapotba állítsa vissza. Honnan folytatódna akkor a szál futása?

Emiatt a taszkok esetében a szinkronizációs pont összegyűjti az összes kivételt, és azokat egy AggregateException osztályba teszi. A szinkronizációs pont, amikor az összes logikai szál csatlakozott, így az AggregateException osztályt dobja tovább, és ehhez a kivételhez kell megfelelő kivételkezelő a hívási láncon.

Az alábbi ábrán az elkapott AggregateException látható:

Az AggregateException azonban még ennél is lehet összetettebb. Egy logikai szál ugyanis többször elágazhat egy fa struktúrát alkotva, és utána lehet, hogy több lépésben lesznek újra összevonva. Nézzük az alábbi programot:


A programot ábrázoló diagram már nehezen követhető, a lényeg a piros nyilakon van. A logikai szálak itt többször elágaznak, és a program két szinkronizációs pontot tartalmaz. Az egyik szinkronizációs pont a Levéltetű() függvényben van, azonban az egyik szinkronizálandó szálon kivétel keletkezett. Ez a szál a szinkronizációs pontra így egy kivételt hoz, emiatt a kivétel a szinkronizációs ponttól tovább dobódik. Mint láttuk ez egy AggregateException, ami magában hordozza az eredetileg eldobott kivételt. Ehhez a kivételhez nincs kivételkezelő az aktuális szálon, és így el is éri a fizikai szál határát. Mivel a Gyökér() függvényben van egy szinkronizációs pont a szálra, ott fog folytatódni a kivétel kezelése, azonban ott újra egy AggregateException-be lesz csomagolva. Az eredeti, Tor() metódusban eldobott kivétel így már duplán be lesz csomagolva. Közben a Gyökér metódus szinkronizációs pontjára egy másik kivétel is érkezik, és így már egy kicsi “kivétel fa” fog továbbdobódni.

Nagyon hasonló a helyzet akkor, ha a taszkokat AttachedToParent flag-gel, hozzuk létre. ebben az esetben a szülőtaszk automatikusan ad egy szinkronizációs pontot a gyerektaszkoknak (ennek részleteit láttuk az előző cikkben), és ez a szinkronizációs pont összegyűjti az esetleges kivételeket. Ez a folyamat utána rekurzívan halad felfele, ahogy az alábbi ábrán látszik:

A fenti ábrának megfelelően működik az alábbi kis program:

Action<object> workItem = null;

// "Rekurzívan" működő work item, amíg nem jön létre megfelelő
// mélységben a logikai szálak fája, létrehoz új szál elágazásokat,
// egyébként a "levélen" kivételt dob.
workItem =
    (o) =>
    {
        int i = (int)o;                    // rekurzió aktuális mélysége

        if (i > 0)
        {                                  // ha még nem az alja, új taszkok    
            for (int n = 0; n < 2; n++)
            {
                Task.Factory.StartNew(
                    workItem, i - 1, TaskCreationOptions.AttachedToParent);
            } // for n
            return;
        }
        else
        {
            throw new Exception();
        }
    };

// A kezdő feladat indítása, a rekurzió 5 mélységig tart:
var root = Task.Factory.StartNew(workItem, 5);

try
{
    // szinkronizációs pont, az "exception fa" itt fog
    // kiugrani
    root.Wait();
}
catch (Exception e)
{
    Console.WriteLine(e);
}

Schrödinger macskája és a taszkok

Egy dolog zavaró lehet a fenti, és az azt megelőző példakódokban: mind a taszk példány Wait() metódusát használja, amiről pedig egy korábbi cikkben megtudtuk, hogy az egyik kevéssé szerencsés szinkronizációs megoldás.

Hogy miért használtam mégis? Azért, mert azt gondoltam, elsőre elég szokatlan lehet logikai szálakban és azokon ugráló kivételekben gondolkodni, és így késleltetni lehetett egy másik furcsa mechanizmus bemutatását. Ennek a mechanizmusnak a neve “megfigyelés” (observing). Mi az alapprobléma, ami miatt a Task Observing bevezetésre került? Írjuk át a fenti kódot úgy, hogy a root taszk eredményét egy continuation taszk dolgozza fel, ezzel el lehet kerülni a Task.Wait() által okozott szálbeakadást:

var root = Task.Factory.StartNew(workItem, 5);

root.ContinueWith(
    (t) =>
    {
       try
       {
          DoSomething();             
       }
       catch (Exception e)
       {
          Console.WriteLine(e);
       }
    });

Hol fog ebben a kódban előugrani a root taszkból, mint logikai szálból visszajutó kivétel? A korábbi példákban a szinkronizációs pont a Wait/WaitAll hívás volt, ezen keresztül jött a kivétel, amit viszont egy try blokkba lehetett helyezni, és le lehetett kezelni. Most azonban a szinkronizációs pont nem a mi kezünkben van, hanem a Task osztály implementációjában. Mire a ContinueWith() metódusnak átadott delegate elindul, a szinkronizációnak már vége (pont ezért futhat a delegate-ünk, mert megtörtént a szinkronizáció). Ebből következik, hogy nem tudjuk elég korán leírni a try blokkot, ha a kivétel a szinkronizációs ponton dobódik el. A fenti példakódban tehát a try blokk nem kezeli, csak a DoSomething() függvényben keletkező kivételeket.

Mivel ilyen esetekben nem lehet elkapni a szinkronizációs pontnál eldobott kivételeket, a keretrendszer nem is dobja el. Ehelyett vagy nekünk kell explicit vizsgálni ezeket a kivételeket, vagy olyan műveletet kell végeznünk a taszk példányon, amely nem teljesíthető faulted állapotú taszkoknál.

Bár furcsa, ezekre az esetekre mondják, hogy a “taszkot megfigyelték”. Ha például egy Task<T> példányon, ha az faulted végállapotba futott, lekérdezzük a Result property-t, ez a kérés végrehajthatatlan. Ekkor eldobásra kerül az az exception, ami az eddigi példákban a Wait hívásokból jött ki. Az exception eldobásán kívül még az is történik, hogy a taszk példányt “megfigyeltük”. Miért fontos ez? Azért, mert a taszkok állapota tükrözi, hogy megfigyelték-e vagy nem. A taszkok egy segédosztályt használnak az exception-jeik tárolására, illetve annak az információnak a tárolására, hogy a taszk observed vagy nem. Ez a segédosztály rendelkezik egy finalizerrel, ami viszont eldobja a tárolt exception-oket (egy AggregatedException-be csomagolva), ha az állapot nem observed. A finalizer szálon ezt az exception-t már nem fogja elkapni senki, és az alkalmazás leáll. Ezt szemlélteti a következő ábra:

A megfigyelés tehát kritikus a program működése szempontjából. Milyen módokon figyelhetünk meg egy taszk példányt? A lehetőségek nagy részével már találkoztunk:

  • A Task.Wait() metódus megfigyeli az adott taszk példányt és az exception-t el is dobja. Ha azonban a Wait() egy olyan verzióját használjuk, amelyik timeout-ot vagy CancellationToken-t fogad, és a Wait() ezek miatt tér vissza, akkor a taszk példány nem lesz megfigyelve (hisz ekkor a taszk még nem is kerül végállapotba, és az esetleges exception sem történt meg)
  • A Task.WaitAll() függvény megfigyeli az összes várakozott taszk példányt és az összes esetleges exception-t egy AggregateException példányba gyúrva azt el is dobja. A timeout és CancellationToken tekintetében hasonló a helyzet, mint a Wait()-nél.
  • A Task.WaitAny() nem figyeli meg a taszkot, így ha a függvény visszatér egy végállapotba került taszkkal, akkor azt külön meg kell figyelni.
  • Task<T> példány esetében a Result property lekérése megfigyeli az adott taszk példányt és az exception-t el is dobja.
  • A Task.Exception property lekérdezése megfigyeli a taszk példányt, de az exception-öket nem dobja el. Azonban ha az Exception property-t akkor kérdezzük le, amikor a taszk még nincs végállapotban, akkor a property szimplán null-t ad vissza, és nem figyeli meg a taszkot.
  • Azok a gyerek taszkok, amelyek AttachedToParent flag-gel lettek létrehozva, automatikusan meg lesznek figyelve a szülő taszk által. Ekkor a kivételek nem dobódnak el, hanem a szülőtaszk felfűzi a gyerektaszk kivételeit. Ezek az kivételek később vagy el lesznek dobva (egy AggregateException-ba csomagolva), vagy egy szinttel feljebb lévő szülőnek lesznek átadva (létrehozva így egy kivétel fát)

Kivételkezelés continuation taszkokban

Láttuk, hogy automatikus kivétel dobást a csatlakozó logikai szálak esetén pont a szinkronizációra kevéssé ajánlott Wait/WaitAll metódusok támogatják. Hogyan érdemes kezelni a hibákat a teljesítmény szempontjából jobb continuation taszkokon?

A continuation taszkokat két csoportra oszthatjuk, aszerint, hogy a futásuk egy vagy több taszktól függ. Az egy taszktól függő continuation taszkoknál egyszerű a helyzet, hiszen beállítható, hogy egy taszk csak akkor induljon el, ha az előzmény taszk végállapota nem faulted. Emiatt egy continuation taszk megtervezhető úgy, hogy annak ne kelljen foglalkozni az előzmény taszkban felmerült kivételekkel. Ekkor még mindig szükség van a hibakezelésre, hiszen ha az előzmény taszkban le nem kezelt exception keletkezett, és a taszk nem lesz observed, akkor előbb-utóbb a program futása leáll. Emiatt ezt a fajta hibakezelést a következő módon kell szervezni:

var root = Task.Factory.StartNew(workItem, 5);

root.ContinueWith(
    (t) => { DoSomething(t); }, 
    TaskContinuationOptions.NotOnFaulted);

root.ContinueWith(
    (t) => { ... kivételkezelés ... }, 
    TaskContinuationOptions.OnlyOnFaulted);

A kivételkezelés folyamán olyan dolgot kell csinálni, amely megfigyeli a taszkot. Ez nem túl nehéz, mivel a kivételek kezeléséhez bizonyosan el kell kérni azokat, ezt pedig az Exception property segítségével tehetjük meg a legegyszerűbben. Mint korábban megtudtuk, az Exception property lekérésével a taszk megfigyelt állapotba kerül, és innen már nem fog kivételt dobni a finalizer szálon:

root.ContinueWith(
    (t) => { ... t.Exception ... }, 
    TaskContinuationOptions.OnlyOnFaulted);

Egy jól megírt program azonban többnyire tesz is valamit a kivételek alapján. Ha csak simán elkérjük a kivételeket, de nem teszünk semmit, az egyenértékű azzal, mint amikor egy üres catch {} blokkot írunk le.

AggregateException class

Az Exception property egy AggregateException típusú kivételt ad vissza, ami viszont némi segítséget nyújt az általa hordozott kivételek feldolgozásában. Az AggregateException-ben az egyik szokatlan dolog az, hogy egy fa struktúrát hordozhat, hiszen az AggregateException listája tartalmazhat más AggregateException-okat is. Bizonyos esetekben szükség lehet a kivételek hierarchiájára, máskor azonban a hierarchia nem számít. Ekkor könnyebb egy sima listát feldolgozni, és az AggregateException.Flatten() metódusa el is készíti nekünk ezt a listát. A Flatten() metódus egy olyan AggregateException példánnyal tér vissza, amely az eredeti AggregateException által fa szerkezetben ábrázolt összes (AggregateException-től különböző) kivételt hordozza. Ez a lista az AggregateException.InnerExceptions property-ben érhető el, és a Flatten() hívás eredménye után elég egyszerűen végigiterálni a listán.

Az AggregateException azonban egy másik segítséget is ad. Ez a Handle() függvény, amely egy Func<Exception, bool> típusú paramétert vár. Az itt átadott delegate-nek kell megpróbálni lekezelni az AggregateException.InnerExceptions listában található összes kivételt. Attól függően, hogy ez adott kivételre sikerült, a delegate true vagy false értékkel tér vissza. A Handle() metódus nem csak annyit segít, hogy végigiterál az összes kivételen. Összegyűjti azokat, amelyeket nem sikerült lekezelni, és azokat egy új AggregateException példányba csomagolva el is dobja. Ekkor újraindul a kivételkezelőt kereső mechanizmus, és kezdődik a kör elölről. Egy példát mutat erre az alábbi program:

static void Main(string[] args)
{
    // Ennek a számlálónak az értéke kerül a dobottt kivételekbe
    var counter = 0;

    Action<object> workItem = null;

    // "Rekurzívan" működő work item, amíg nem jön létre megfelelő
    // mélységben a logikai szálak fája, létrehoz új szál elágazásokat,
    // egyébként a "levélen" kivételt dob.
    workItem =
        (o) =>
        {
            int i = (int)o;                    // rekurzió aktuális mélysége

            if (i > 0)
            {                                  // ha még nem az alja, új taszkok
                for (int n = 0; n < 2; n++)
                {
                    Task.Factory.StartNew(
                        workItem, i - 1, TaskCreationOptions.AttachedToParent);
                } // for n
                return;
            }
            else
            {
                // A kivétel szövege most a számláló értéke lesz.
                throw new Exception(
                    Interlocked.Increment(
                        ref counter).ToString());
            } // else
        };

    // A kezdő feladat indítása, a rekurzió 5 mélységig tart:
    var root = Task.Factory.StartNew(workItem, 5);

    try
    {
        try
        {
            root.Wait();
        }
        catch (AggregateException e)
        {
            // Azok a kivételek le lesznek kezelve, amelyek üzenete
            // páros szám.
            e.Flatten().Handle(i => int.Parse(i.Message) % 2 == 0);
        } // catch
    }
    catch (AggregateException e)
    {
        // Ez egy csomó páratlan számot tartalmazú kivételt fog listázni
        // (az AggregateException.ToString() kiírja az InnerExceptions listát)
        Console.WriteLine(e.ToString());
    } // catch
} // Main()

TaskScheduler.UnobservedTaskException

Korábban megtudtuk, hogy ha egy taszk faulted állapotba kerül, akkor benne létrejön egy segédobjektum, ami azt a kivételt (kivételeket) hordozza, ami a taszkot faulted állapotba léptette. A taszk “observed” állapotát is ez a segédobjektum tárolja, és ez az objektum rendelkezik egy finalizerrel is. Hívjuk ezt az objektumot ezután TaskExceptionHolder-nek.

Amikor egy taszk faulted állapota nincs lekezelve, és a taszk már nincs is referálva, azaz esély sincs a taszkot megfigyelni, akkor előbb-utóbb a finalizer szál meghívja a TaskExceptionHolder finalizer-ét, ami ekkor ad egy utolsó esélyt a kivétel(ek) kezelésére.

A TaskScheduler osztálynak van egy statikus eseménye, ami egy UnobservedTaskExceptionEventArgs paraméterrel hívja meg a feliratkozott eseménykezelőket. Ez a paraméter két fontos elemmel rendelkezik. Egyrészt van egy Exception property, nem meglepő módon AggregateException típussal. Másrészt van egy SetObserved() metódus.

Amikor az TaskExceptionHolder finalizere fut, és nem observed az állapot, akkor létrejön egy UnobservedTaskExceptionEventArgs, amely megkapja a TaskExceptionHolder kivételeit egy AggregateException-be csomagolva. Ezután a TaskExceptionHolder a TaskScheduler osztály egy statikus függvényén keresztül meghívatja az UnobservedTaskException eseményre feliratkozott eseménykezelőket, átadva nekik az előbb összerakott UnobservedTaskExceptionEventArgs példányt. Az esemény sender paramétere egyébként a faulted taszk lesz.

Az eseménykezelőknek ekkor van alkalma meghívni az UnobservedTaskExceptionEventArgs példány SetObserved() metódusát, ha a hibát le birták kezelni. A hibakezelésnél ekkor nem szabad elfelejteni, hogy a finalizer szálon vagyunk, így túl nagy varázslásokba ekkor már nem szabad belemenni.

Amikor a TaskExceptionHolder példány visszakapja a vezérlést, akkor megvizsgálja, hogy a UnobservedTaskExceptionEventArgs observed lett-e. Ha igen, a finalizer futása véget ér. Ha nem, eldobja az AggregateException-t a finalizer szálon, ami így legnagyobb valószínűség szerint az alkalmazás leállását vonja maga után.

A következő program az UnobservedTaskException esemény segítségével akadályozza meg a program leállását:

static void Main(string[] args)
{
    TaskScheduler.UnobservedTaskException += 
        (sender, e) => 
        {
            Console.WriteLine(e.Exception);
            e.SetObserved();
        }; // (sender, e) =>

    Task.Factory.StartNew(
        () => 
        { 
            throw new Exception("Hello"); 
        });

    Thread.Sleep(1000);

    GC.Collect();
    GC.WaitForPendingFinalizers();

    Console.WriteLine("Még élek!");
} // Main()

A TaskExceptionHolder finalizere így az alábbi ábra szerint működik.

Beragadó kivételek

A fenti működésből az is következik, hogy a kezeletlen kivételek addig nem éreztetik a hatásukat, amíg a faulted állapotú taszk referálva van. Másik oldalról, ha már nincs is referálva a taszk, nem lehet kiszámítani, hogy a finalizer-ek mikor hívódnak meg, jelentős idő is eltelhet addig, sőt, ha a program olyan kevés memóriát használ, a kivétel esetleg el sem lesz dobva (a TaskExceptionHolder finalizere nem dobja el a kivételt, ha már application domain unload van). Ezeket a kivételkezelés tervezésénél figyelembe kell venni.

TaskFactory.ContinueWhenAll

Az előző cikkben említésre került, hogy a TaskFactory.ContinueWhenAll/Any esetében nem érvényesül a TaskContinuationOptions.OnlyOnFaulted opció. Emiatt ugyanabban a taszkban kell kezelni a hibás ágat, mint normál futást. Ez nem feltétlenül bonyolítja a kódot, de a több csatlakozó szál miatt könnyű elrontani a hibakezelést.

Tudjuk például, hogy a Result property lekérdezése megfigyeli a taszkot, illetve eldobja a kivételt. Nézzük a következő megoldást:

var counter = 0;                  // A taszkok ezen a számlálón dolgoznak
var tasks = new Task<int>[10];    // Ezek fognak dolgozni

for (var i = 0; i < tasks.Length; i++)
{                                 // Taszkok inicializálása    
    tasks[i] = new Task<int>(
        () =>
        {                         // A "feladat":
            var result = Interlocked.Increment(ref counter);
                    
            if (result % 3 == 0)        
            {                     // Minden 3-ik feladat failed lesz
                throw new Exception();
            } // if

            return result;
        }); // () =>

    tasks[i].Start();
} // for i

Task.Factory.ContinueWhenAll(
    tasks,
    t =>
    {
        try
        {                         // Az eredmények összegzése:
            t.Sum(n => n.Result); // A Result "megfigyeli" a taszkot
        }
        catch (Exception)
        {
            Console.WriteLine("recover");
        } // catch
    }); // t =>

Console.ReadLine();
GC.Collect();                      // Bang!!!

A probléma az, hogy a fenti kód csak az első failed előzmény taszkot figyeli meg, a többi pedig unobserved marad, mivel a Sum() azokat már nem érinti. Emiatt a kód elején (vagy valamelyik részét) biztosítani kell, hogy az összes előzmény taszk meg legyen figyelve:

Task.Factory.ContinueWhenAll(
    tasks,
    t =>
    {
      bool allSucceeded = true;
      Array.ForEach(t, e => allSucceeded &= (e.Exception != null));
      // Enumerable.All() nem jó!

      if (allSucceeded)
      {
          // Az eredmények összegzése:
          t.Sum(n => n.Result); 
      }
      else
      {
          Console.WriteLine("recover");
      } // else
    }); // t =>

Másik megoldásként:

Task.Factory.ContinueWhenAll(
    tasks,
    t =>
    {
        // Az eredmények összegzése:
        t.Sum(
            n =>
            {
                var result = 0;

                try
                {
                    result = n.Result;
                }
                catch // (AggregateException)
                {
                    Console.WriteLine("recover");
                } // catch

                return result;
            }); // n =>
    }); // t =>

Összefoglalás

A többszálú programok kivételkezelése jóval bonyolultabb lehet az egyszálú programokénál. A hibakezelést már egyszálú programok esetén is figyelembe kell venni tervezéskor, és ez hatványozottan igaz többszálú programoknál: utólag normális hibakezelési stratégiát egy többszálú programba belegyúrni nem könnyű vállalkozás.

Másik oldalról a masszívan többszálú kódrészek valószínűleg valamilyen számítási/keresési feladatot végeznek, ezek esetében pedig talán ritkábban van szükség kivételkezelésre. Akárhogy is, a szokatlan kivételkezelési utak ismerete segíti a munkánkat.

  1. #1 by Török Attila on 2011. August 10. - 16:25

    Minőségi cikk. Külön tetszik az időutazásos hasonlat.
    Viszont mindig elszörnyedek az írásaid hatására, hogy mennyire összetett a .net🙂

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: