Logikai szálak és az ExecutionContext

Az előző részben láttuk, hogy logikailag egybefüggő folyamatok, mint például egy állomány olvasása, fizikailag több szálra szakadhatnak szét. A következőkben egy olyan aspektusból vizsgáljuk meg a szálszakadásokat, amelyeket egyébként egybefüggő fizikai szálak esetén sem gyakran szoktak a fejlesztők. Nem azért, mert haszontalan nézőpontról van szó, hanem azért, mert ezek a területek valamiért nagyon rosszul dokumentáltak.

Szükséges ismeretek

A cikk megértéséhez szükséges a többszálú programok elvének ismerete. Szükséges továbbá a hívási verem működésének ismerete, amely részben tárgyalásra került az értéktípusokról szóló cikk első felében.

A rosszhírű globális változók

Globális változókat már jóideje nem illik egy programban használni, és ennek jó oka van. Egy globális változót a kódon belül bárhol lehet módosítani, és ez nagyban csökkenti a kód átláthatóságát. Van azonban a globális változóknak egy vitathatatlan előnye. Tegyük fel, hogy van egy hívási lánc, mint F1(a,b) -> F2(a,b) -> F3(a,b,n). Az F3 függvény működéséhez szükséges az n paraméter, viszont ezt az F1 és F2 függvények nem tudják előállítani. Ekkor lényegében két lehetőségünk van. Az egyik, hogy az n paramétert a hívási láncon végigvezetjük paraméterként, mint F1(a,b,n) -> F2(a,b,n) -> F3(a,b,n). Ezzel az a probléma, hogy az F1 és F2 függvényeket olyan paraméterrel terheljük, ami a függvények logikájába nem illik bele, csúnya interfészekkel felruházva ezzel a programunkat. A másik, hogy globális változót használunk, amihez az F3 hozzáfér. Neeeem, ilyet soha? Akkor mi van az xxHelper meg yyContext statikus vagy singleton osztályok property-eivel? Vagy mielőtt a Design Patterns zombik elkezdenek hörögni, ők honnan rántják elő a Dependency Container-t? Akár hogy is álcázzuk, globális változókra ilyen vagy olyan formában szükség van ma is. De hogy ne legyen olyan rossz érzésünk, a globális változókat a következőkben kontextus változóknak fogom hívni – mivel ezek a változók gyakran a programfutás kontextusát vagy környezetét írják le.

Többszálúság és a kontextus változók

A globális változók eredendő bűne, miszerint átláthatatlan logika szerint vehetnek fel különböző hívási helyekről értékeket, a többszálúság bevezetésével egy újabb dimenziót nyer. Tegyük fel, hogy a korábbi példában az F1() függvény hívása előtt az n kontextus változót beállítom “Alma” értékre, mivel az F1()-gyel indított műveletet valamiért “Alma” kontextusban szeretném végrehajtani. Ugyanakkor a programom többszálú, és egy másik szálon arra van szükség, hogy az F1()-gyel indított művelet “Körte” kontextusban hajtódjon végre. A két szál felülírja egymás kontextusát. Arra lenne szükség, hogy a kontextus változó valamilyen módon adott szálra nézve lokális legyen, tehát a két végrehajtási szál mindegyike saját verzióval rendelkezzen.

Thread Lokális kontextus változók

Szerencsére a Windows operációs rendszer ad lehetőséget thread-re nézve lokális, de a thread-en belül a hívási lánc minden pontjáról elérhető változók használatára, és ez a lehetőség a .NET-en keresztül is rendelkezésre áll. A mechanizmust Thread Local Storage-nak hívják. Röviden arról van szó, hogy minden szálhoz az operációs rendszer karbantart egy Thread Environment Block-nak nevezett leíró struktúrát. Ez a struktúra alapvető információkat tartalmaz, mint a szálhoz tartozó verem helye és mérete a memóriában, az az érték, amit a GetLastError() hívással kapunk meg, stb. Ezek között az értékek között van egy n elemű tömb (n az operációs rendszer verziójától függő), amelynek elemeibe mutatókat lehet helyezni, amelyek a saját adatainkra mutatnak. Az adatra így egy index értékkel hivatkozunk, amely indexeket egyébként az operációs rendszer osztja ki egy egyszerű API-n keresztül, mint TlsAlloc()/TlsSetValue()/TlsGetValue()/TlsFree(). A tömböt tehát közvetlenül nem érhetjük el, hanem az a[i] = data helyett TlsSetValue(i,data) formát kell használnunk. Amiért a TlsXxx hívások a Thread Environment Block-ban dolgoznak, ezért a különböző szálakon indított hívások nem befolyásolják egymást.

A .NET ezt az amúgy egyszerű lehetőséget még tovább egyszerűsíti. Ha egy osztálynak van egy statikus mezője, akkor azt egy ThreadStatic attributummal ellátva arra utasítjuk a futtatórendszert, hogy a változó mögött Thread Local Storage mechanizmusát használja.

A következő program a ThreadStatic attributum használatát mutatja be. Adott egy egyszerű statikus osztály (Simple) aminek van egy ThreadStatic mezője (localData). Van a Simple osztálynak egy Repeater() függvénye, ami annyit csinál, hogy ötször kiírja a localData értékét, másodperces különbséggel. A program indulása után készít egy szálat, így összesen két szál fog futni. Mind a két szál ugyanazt teszi: beállítja a statikus localData értékét, ami a kontextus változónk. Ezután meghívja a Repeater() függvényt, ami kiírja a kontextus változót.

Hagyományos statikus mező esetén mind a két szál ugyanazt az értéket írná ki, hogy pontosan melyiket, az attól függ, melyik szál futtatja később a localData beállítását. A példaprogramunk azonban el tudja kerülni a kontextus változók keveredését:

public static class Simple
{
  [ThreadStatic]                     // Ez a változó minden szálból külön
  private static string localData;   // példánynak látszik

  public static string LocalData
  {
    get { return Simple.localData; }
    set { Simple.localData = value; }
  } // LocalData()

  public static void Repeater()      // Kb 5 másodpercig tartó hívás,
  {                                  // hogy lássuk, a globális LocalData
    for (int i = 0; i < 5; i++)      // párhuzamosan futó szálak esetén is
    {                                // tud szálra nézve egyedi értéket tartani
      Console.WriteLine(
        "Thread id: {0} - value {1}", 
        Thread.CurrentThread.ManagedThreadId,
        Simple.LocalData);

      Thread.Sleep(1000);
    } // for i
  } // Repeater() 
} // class Simple

public class Program
{
  public static void SecondThread(object localData) // A második szál programlogikája
  {
    Simple.LocalData = localData as string;         // Kontextus adat beállítása
    Simple.Repeater();                              // Kontextus adat riportolása
  } // StartThread()

  public static void Main(string[] args)
  {
    Thread secondThread = new Thread(SecondThread); // Második szál indítása
    secondThread.Start("SecondThread");             // Kontextus adat = "SecondThread" 
                                                    // A SecondThread függvénnyel megegyező
    Simple.LocalData = "Main";                      // Programlogika
    Simple.Repeater();

    Console.ReadKey();
  } // Main()
} // Program()

A program a következő eredményt generálja:

Thread id: 1 - value Main
Thread id: 3 - value SecondThread
Thread id: 3 - value SecondThread
Thread id: 1 - value Main
Thread id: 3 - value SecondThread
Thread id: 1 - value Main
Thread id: 1 - value Main
Thread id: 3 - value SecondThread
Thread id: 1 - value Main
Thread id: 3 - value SecondThread

A ThreadStaticAttribute nagyon kényelmes, azonban fordítási időben kell tudni, hogy milyen kontextus adatokra lesz szükség. Néha hasznos lenne olyan thread-re lokális adat, amelyet dinamikusan lehet létrehozni. Ekkor lehet használni a Thread osztály SetData()/GetData() függvényeit. Ezek nagyon alacsonyszintű függvények, és nem is igazán jól szervezhetőek, amint a következő példából látszik:

LocalDataStoreSlot slot = Thread.AllocateDataSlot(); // Thread Lokális data slot kérése
Thread.SetData(slot, "hello");                       // Adat töltése a slotba

...                                                  // Sok-sok függvényhívás

// mélyen a hívási láncban most szükség lenne az
// adatra. Ekkor azonban használnunk kell a slot-ot,
// ami az adatot azonosítja. De honnan van meg?
string data = (string)Thread.GetData(slot);          // Kontextus adat kérése

Láthatjuk, hogy az adatra egy LocalDataStoreSlot példánnyal kell hivatkozni. Azzal túl sokat nem lehet nyerni, ha az eredeti adat helyett most a LocalDataStoreSlot típusú adatot kell hordozni, hogy valahol mélyen a hívási láncban használni lehessen. Ezért ez a függvénycsalád lehetőséget ad arra, hogy az azonosító szerepét játszó LocalDataStoreSlot adatot egy szöveges azonosítóval címkézzük fel, és a címke alapján később előkeressük:

LocalDataStoreSlot slot = 
  Thread.GetNamedDataSlot("Köszöntés");              // Nevesített Thread Lokális data slot kérése
Thread.SetData(slot, "hello");                       // Adat töltése a slotba

// mélyen a hívási láncban:
LocalDataStoreSlot slot = 
  Thread.GetNamedDataSlot("Köszöntés");              // Nevesített Thread Lokális data slot kérése
string data = (string)Thread.GetData(slot);          // Kontextus adat kérése

Akár milyen esetlen, a LocalDataStoreSlot alkalmas arra, hogy egy szálnak saját kontextus változókat biztosítson. Láttuk azonban, hogy többszálú programok esetén egy logikai szál, mint egy állomány beolvasása és feldolgozása, több fizikai szálra eshet szét. Az előzőekben bemutatott két módszer viszonylag alacsony szinten van a szálakhoz kötve, és emiatt nem is képesek egy logikai szál futását követni.

Logikai szálak kontextus változói

A logikai szálakhoz a .NET egy az előzőektől független mechanizmust ajánl, a CallContext osztály segítségével. A CallContext ugyan a Remoting névtérben van, de a Remoting-tól függetlenül is használható. Valójában a CallContext implementációja a Threading névtér ExecutionContext osztályának egyik internal láthatóságú property-jét, a LogicalCallContext-jét kezeli, erre rövidesen visszatérünk.

Maga a CallContext alapja egy Hash tábla, amely kulcsként string-eket, értékként pedig object-et kezel, így bármit bele lehet tenni. Ahhoz azonban, hogy az adatok a logikai szál végrehajtásával utazni tudjanak különböző fizikai szálakon, és főleg különböző application domain-en, a Serializable attributummal kell bírniuk.

Az alábbiakban egy egyszerű példa látható. Az adat, amit kontextus változóként szeretnénk utaztatni, a következő:

[Serializable]
public class Data
{
  private int x;
  private DateTime date;

  public int X { get { return this.x; } set { this.x = value; } }
  public DateTime Date { get { return this.date; } set { this.date = value; } }

  public override string ToString()
  {
    return String.Format("Data: X = {0}, Date = {1}", X, Date);
  } // ToString()
} // class Data

A következő példa a Data osztály egy példányát a CallContext-be helyezi. Ezután a Thread Pool feladatsorába helyez egy feladatot, amely használja a Data példányt. A Thread Pool egy másik fizikai szálon fogja végrehajtani a feladatot, de remélhetőleg mégis eléri az adatunkat:

CallContext.LogicalSetData(                      // Kontextus adat beállítása
  "data",                                        // Kontextus adat hivatkozási neve
  new Data { X = 2, Date = DateTime.Now });      // Kontextus adat értéke

ThreadPool.QueueUserWorkItem(                    // Logikai szál Thread Pool-ra terelése
  (p) => Console.WriteLine(                      // Kontextus adat riportolása
           CallContext.LogicalGetData("data"))); // másik fizikai szálból

A fenti két sort lefuttatva meg is kapjuk a megfelelő értékeket, hiába tért át a vezérlés egy másik fizikai szálra.

Az ExcutionContext osztály

A saját adataink utaztatása a logikai szálon keresztül nagyon hasznos. Lehetnek azonban más információk, aminek a logikai szállal utazni kell. Ezek az információk több csoportba sorolhatók, de van egy osztály, ami összefogja őket, és ez az ExecutionContext.

Az ExecutionContext egy viszonylag egyszerű osztály, néhány mezője van csupán. Ezek közül az egyik mezőjével már közvetve találkoztunk, ez a LogicalCallContext. A CallContext osztály az ExecutionContext.LogicalCallContext mezőjét használja. A használata azért ilyen kicsavart, mert az ExecutionContext csak a .NET 2 verziójától érhető el, és addig a CallContext tényleg a Remoting-hoz tartozott.

Minden szálhoz tartozhat egy ExecutionContext példány. Ez a példány nem jön mindig létre, csak ha valamelyik kód lekérdezi, vagy éppen beállítja. Amikor a korábbi példakódban a CallContext.LogicalSetData() függvényt hívtuk, az belül hivatkozott a Thread.ExecutionContext property-re (Thread.CurrentThread. ExecutionContext ), és ez a property hivatkozás létrehozott egy új ExecutionContext példányt a szálon, alapértelmezett beállításokkal.

Az ExecutionContext által szállított adatok

Az adatok egy részét már érintettük a CallContext.LogicalGet/SetData() kapcsán. Érdemes azonban még ezt a részt kapargatni egy kicsit, mert az elnevezései miatt vicces szeglete ez a .NET-nek. Miért LogicalSetData() a függvény neve? Itt nem a logikával, mint “ésszerűséggel” kapcsolatos az elnevezés, hanem olyasmit jelenthet, mint amikor mi programlogikáról beszélünk. Az ExecutionContext maga is a “logikai szál” kapcsán merült fel, a “logikai szál”-hoz lehet adatokat rendelni a LogicalSetData()-val. De miért kell ezt így kihangsúlyozni? Igazából nem kell, és ha meg is nézzük a CallContext függvényeit, láthatjuk, hogy van egy szimpla SetData() is. Ekkor viszont az a kérdés, hogy mi a különbség a LogicalSetData() és a SetData() között?

Az ExecutionContext osztály nem csak egy LogicalCallContext mezőt foglal magában, hanem egy IllogicalCallContext-et is. Akárcsak a LogicalCallContext-et, az IllogicalCallContext-et is a CallContext osztályon keresztül lehet elérni. De hogy “egyszerűbb” legyen megérteni mi történik, közvetlenül az IllogicalCallContext nem látható. A fejlesztő, ha olyan adatot szeretne a CallContext-hez rendelni, ami “Logical”, tehát a logikai szállal együtt utazik, akár application domain-en keresztül is, akkor két választása van:

  • Meghívja a LogicalSetData() függvényt.
  • Meghívja a SetData() függvényt, de olyan adatot ad át, amely implementálja az ILogicalThreadAffinative marker interfészt. Ennek az interfésznek nincsenek függvényei, tehát tényleg csak “marker”, azaz megjelöl egy osztályt, ugyanúgy, ahogy egyébként egy attribútummal is meg lehetne.

A SetData() függvényhívás, ha az adat nem implementálja az ILogicalThreadAffinative interfészt, akkor az adatot az IllogicalCallContext mögötti hash táblába teszi, és nem fog utazni a logikai szállal, ha az fizikai szálat vált. Ebben az esetben az adat ugyanolyan kondíciókkal használható, mint a DataSlot-ok, azaz az IllogicalCallContext Thread lokális kontextus változókat adott fizikai szálon tud hordozni.

Security Context

A .NET igen kifinomul biztonsági mechanizmusokkal rendelkezik, aminek egyik részét Code Access Security-nek (CAS) hívjuk. Ennek ismertetése egy külön könyvet igényelne, itt azonban csak pár sornyi hely van rá. Emiatt egy szemléletesebb, de a CAS-sal egy izomorf példát nézünk meg előbb, ami után remélhetőleg könyebb lesz megérteni a CAS ide vontatkozó részeit néhány sor alapján is.

Tegyük fel, hogy van egy tubus (egy cső), aminek az elején van egy fényforrás, a tubusba pedig különböző tulajdonságú szűrőket illetve fényforrásokat lehet elhelyezni. A tubus végén van egy műszer, ami alapján megkérdezhetjük, hogy adott hullámhosszokon érkezik-e fény a műszerbe (egy spektroszkóp). Egy lehettséges konfigurációt mutat a következő ábra.

A tubus elején egy vörös, sárga, zöld és ibolya hullámhosszokon sugárzó fényforrás van (A). Az első szűrő (B) olyan tulajdonságú, hogy egy adott hullámhosszú fényt kiszűr, a többit átengedi. A példában zöld hullámhosszú fényt szűri ki a szűrő. A (C) szűrő olyan tulajdonságú, hogy csak egy adott hullámhosszú fényt enged át, az összes többit kiszűri. A (C) szűrő a példában csak a vörös fényt engedi át. A (D) egy fényforrás, ami adott hullámhosszal (vagy hullámhosszokon) sugároz, a példában zöld színű fénnyel. Az (E) vel jelölt spektroszkóp olyan, hogy adott hullámhosszra lehet ellenőrizni, hogy detektál-e a kérdéses hullámhosszon fényt, például zöldet.

Tegyük fel, hogy a laborban, ahol ezt a tubust használnák nincsen áram, ezért a műszerek (fényforrások, spektroszkóp) nem működnek. Egy adott konfigurációra viszont mégis meg kell mondanunk, hogy adott hullámhosszú fény megjelenik-e a tubus végén. Szerencsére a konfiguráció leírása megvan, emiatt van módunk kigondolni az eredményt. Ha az a kérdés, hogy zöld színű fény megjelenik-e a tubus végén, akkor valószínűleg a tubus vége felöl elkezdünk visszafele haladni a szegmenseken, hogy eldöntsük a kérdést. Zöld fény esetén szerencsések vagyunk, mivel hátulról a harmadik szegmensben (D) találunk egy zöld fényforrást, emiatt tovább nem is kell haladnunk. Ha az a kérdés, hogy sárga fény eléri-e a tubus végét, akkor a (D) szegmensnél még nem eldönthető a kérdés, így haladunk tovább hátra a szegmenseken. Ekkor (C) szegmensnél találunk egy szűrőt, ami csak a vörös hullámhosszú fényt engedi át, minden mást, például a sárgát kiszűri. Ebben a pontban már felesleges tovább vizsgálódni, ha érkezik a C szegmenshez sárga fény, ha nem, az biztos, hogy a (C) szegmens után már nem lesz jelen. Ha az a kérdés, hogy vörös fény megjelenik-e a tubus végén, akkor a C szegmensnél szerencsénk van, hiszen ha odáig elérkezik a vörös fény, akkor a szűrőn át is halad. Azonban még le kell ellenőrizni, hogy egyáltalán a C szegmensig eljuthat-e a vörös. Visszafele haladva, a B szűrő esetén megtudjuk, hogy az a zöld fényt ugyan kiszűri, de minden mást, így a vöröset átengedi, így ha B szűrő előtt jelen van a vörös, akkor utána is jelen lesz. Tovább kell hát haladnunk, hogy megállapítsuk, jelen van-e a vörös fény a B szegmens előtt. Így eljutunk a fényforráshoz, aminek a leírásából megtudhatjuk, hogy bocsát ki vörös fényt, és az eddigiek alapján az el is jut a tubus végéig.

A Code Access Security a fenti tubushoz teljesen hasonló módon működik, csak fény helyett engedélyek “utaznak”, a tubus maga a hívási verem, és a tubus szegmensei pedig a függvényekhez tartozó Stack Frame-ek. A Stack Frame-ekben (szegmensekben) levő szűrők nem fény hullámhosszait szűrik, hanem bizonyos engedélyeket nem engednek érvényesülni a hívási lánc adott pontja után. Ennek azért van haszna, mert a hívási verem egy későbbi pontján (egy függvényhívási lánc végén) fel lehet tenni egy olyan kérdést, hogy egy engedély rendelkezésre áll-e. Ezt a kérdést egészen pontosan a IPermission.Demand() hívással tehetjük meg. A IPermission.Demand() hívásra egy Stack Walk nevű folyamat veszi a kezdetét (az IPermission valójában az IStackWalk-tól örökli a Demand()-ot), ami megegyezik azzal, mint amikor a tubusban visszafele haladva, spektroszkóp nélkül el akartuk dönteni, hogy adott hullámhosszú fény látszik-e a tubus végén. A Stack Walk során ellenőrzésre kerül visszafele minden Stack Frame. A Stack Frame-ekben szűrők helyett Frame Security Descriptor-ok vannak, amikből három féle lehet, a három vizsgált szűrőnek (illetve fényforrásnak) megfelelően. Az egyik típus azt írja le, hogy adott ponttól fölfele a Call Stack-en adott engedély nem áll rendelkezésre. Ez az (B) szegmens szűrőjének a párja. Egy másik típus azt írja le, hogy adott ponttól felfele csak adott engedély állhat rendelkezésre feltéve, ha előzőleg is rendelkezésre állt. Ez a (C) szegmensnek felel meg. A harmadik típus azt mondja, hogy adott engedély az adott ponttól felfele rendelkezésre áll (D szegmens). Ezekben a .NET szűrőkben nincsen semmi különös, egyszerűen amikor egy művelet meg akarja változtatni az engedélyeket, egy FrameSecurityDescriptor .NET osztályt helyez a veremre az IPermission (IStackWalk) valamelyik függvényével.

Mi felel meg a CAS esetén a tubus elején lévő fényforrásnak? Ez a kérdés nagyon el tud bonyolódni, de egyszerűbb (és átlagos) esetekben az assembly engedélyei. Amikor egy programot elindítunk, akkor a programot képviselő assembly-hez engedélyek rendelődnek. Ez egy elég összetett és jól konfigurálható folyamat, a kiosztott engedélyek függnek az alkalmazás indítási helyétől, függhet a nevétől, attól, hogy egyáltalán milyen engedélyeket kér a program (nem kap több engedélyt annál, mint amennyit kér). Legtöbb programunk egyébként minden engedélyt megkap, mivel lokális gépről indított programok a .NET alapbeállításai szerint minden engedélyt megkapnak, és legtöbb program nem korlátozza magát. Szóval legtöbb program “fehér fénnyel” fut. Lehetőség van ezt átkonfigurálni, de ez nem ennek a cikknek a témája. Valójában számítanak az application domain engedélyei is, illetve mivel egy application domain tartalmazhat több másikat, és egy application domain módosíthatja a tartalmazott application domain engedélyeit, a kérdés elbonyolódhat. Leggyakrabban azonban amikor elindítunk egy programot, meghatározódnak az assembly engedélyei, ehhez készül egy application domain, ami megkapja ugyanazokat a jogokat, mint az assembly.

A CAS-os “tubust” a következő ábra mutatja:

Tegyük fel, hogy a vörös nyílnak egy file olvasási engedély felel meg a “c:\temp\data.txt” állományra. Ekkor a C szegmensben lévő szűrőhöz tarozó kód a következő:

var fileIOPermission = 
      new FileIOPermission(
        FileIOPermissionAccess.Read, 
        @"C:\temp\data.txt");
      
fileIOPermission.PermitOnly();

A fenti kód egy FrameSecurityDescriptor példányt helyez el a vermen. A spektroszkópot kiváltó folyamat, amelyet a CAS esetében Stack Walk-nak hívnak, a következő sorral indul el:

var fileIOPermission = 
      new FileIOPermission(
        FileIOPermissionAccess.Read, 
        @"C:\temp\data.txt");
      
fileIOPermission.Demand();

A vörös fény példájához hasonlóan a Demand() hívás vissza fog menni a Stack elejéig, és mivel ott még mindig nem tudja eldönteni, hogy megvan-e az engedély a file olvasására (csak azt tudja, hogy akár meg is lehet) megvizsgálja az assembly engedélyeit. Ha a kódunk teljes engedélyhalmazzal fut, vagy legalább az engedélyek lefedik az írási engedélyt a kérdéses állományra, akkor a Demand() hívás sikeres lesz, egyébként kivételt dob.

Kérdés, hogy ki használ egyáltalán IPermission.Demand() hívást? Közönséges programoknál erre ritkán van szükség. Ha azonban hívunk egy .NET Base Class Library-s függvényt, ott már könnyen előfordulhat, hogy a hívás mélyén egy IPermission.Demand() lefut. Nézzük a következő kódot:

static void f()
{
  string[] lines = File.ReadAllLines(@"C:\temp\data.txt");
  Console.WriteLine(lines.Length);
} // f()

static void Main(string[] args)
{
  var fileIOPermission = 
    new FileIOPermission(
      FileIOPermissionAccess.Read, 
      @"C:\temp\data.txt");
      
  fileIOPermission.PermitOnly();

  f();
} // Main()

A File.ReadAllText() például egészen biztos kér egy IPermission.Demand()-ot az olvasni kívánt állományra, mielőtt a tartalmát felolvasná. Fontos megérteni, hogy magának a Demand() hívójának teljesen mindegy lenne, hogy van-e engedély vagy nincs, ő maga akár el is tudná végezni a műveletet (kivéve, ha az ő általa hívott függvények tartalmaznak egy újabb Demand()-ot). Akinek nem mindegy, hogy van-e engedély, az a progamnak a felhasználója, vagy a rendszernek az adminisztrátora. Emiatt az adminisztrátor olyan programot szeretne látni, ami nem hajlandó vakon mindent beolvasni, ezért a programozó olyan könyvtárat használ, ami beállított jogosultságokat figyelembe veszi, ezért pedig a könyvtár készítője file olvasás előtt megnézi az engedélyeket. Amikor a példában a programozó meghívja az f() függvényt, akkor biztos lehet benne, hogy bármi is van az f() hívási láncban, akár a Becsület Bt. által készített “megbízható” könyvtár egyik függvénye, az biztosan nem fog tudni mást olvasni, mint a megadott állományt (írni pedig semmit nem fog tudni)

Az éles eszű olvasók most azon gondolkodnak, hogy mi értelme a Demand()-nak, ha előtte a rosszindulatú programozó (például a Becsület Bt egyik alkalmazottja az f() híváson belül) egy Assert()-tel azt az engedélyt állítja be, amire csak szükség lehet. Azonban az IPermission.Assert() hívás belsejében van egy ellenőrzés, hogy rendelkezünk-e az Assert() hívásához megfelelő engedélyekkel (SecurityPermission(SecurityPermissionFlag.Assertion). Ha nem, akkor nem tudunk Assert-et kifejező Frame Security Descriptor-t tenni a Call Stack-re. Sebaj, akkor nem a File.ReadAllText-et kell használni, hanem P/Invoke-kal kikerülni a .NET file kezelő függvényeit. Csakhogy a P/Invoke-hoz szintén engedélyek kellenek, amelyek ha nincsenek meg, nincs más mód file kezelésre, csak a .NET-es könyvtári osztályok. Ha valaki tehát jól konfigurálja az engedélyeket, nagyon nehéz (nem merek lehetetlent írni) kikerülni a Code Access Security-t. A fenti példakód például tényleg minden engedélyt leszed a hívási lánc következő függvényeitől. Legyen például a következő az f()-ben:

static void f()
{
  IPAddress[] addresses = Dns.GetHostAddresses("www.devportal.hu");
  Array.ForEach(addresses, Console.WriteLine);
} // f()

A Dns.GetHostAddresses() egy DnsPermission.Demand() hívást indít, mielőtt a munkájához hozzákezdene. Ez az f() alatt levő Stack Frame-en megbukik, így egy exception-t kapunk.

Egy részlet azonban még mindig hiányzik, ha valaki figyelt, felfedezhetett egy ellentmondást. Korábban az írtam, hogy az IPermission.Demand() hívójának nem fontos, hogy meglegyen az engedély, például a File.ReadAllText() implementációja elvileg tudna olvasni engedély nékül is. Ugyanakkor azt is írtam, hogy a PermitOnly() hívás az f() függvény Stack Frame-jétől kezdve leszűr minden engedélyt (az adott file olvasását leszámítva), ezért biztosak lehetünk benne, hogy nem történik gonoszság. Most akkor mi az igaz, a File.ReadAllText() tudna-e rosszalkodni, vagy nem? Van egy másik érdekes részlet, például, hogy a fileIOPermission.PermitOnly() hívás leszűrte többek között a natív kódok hívásának az engedélyét is, márpedig a File.ReadAllText() előbb utóbb áthív natív oldalra, hiszen az állományt az operációs rendszer függvényeivel tudja csak beolvasni. Akkor mégsem ér semmit a PermitOnly? De, azonban van egy kivételezett assembly csoport, az úgynevezett Fully Trusted Assembly-k, amelyek esetében a CAS ellenőrzések nem futnak le. Egyrészt, nem is működne a rendszer, ahogy a File.ReadAllText() natív hívási igényénél láttuk. Másrészt írdatlan módon belassítaná a rendszert, ha az minden lépésnél ellenőrizgetne, harmadrészt ez az ellenőrizgetés saját magára vonatkozna. Mivel a File osztály az mscorlib.dll-ben van, hiába lettek leszűrve a jogai, nyugodtan át tud hívni natív oldalra, és ha nagyon akarna beolvashatná bármelyik állományt a merevlemezről – feltéve, hogy azt az operációs rendszer engedi.

Stack Walk logikai szálakon

Bár a CAS sok izgalmas témakört tartalmaz, ennek a cikknek a témája csak a többszálúságot érintő része. Most már van elképzelésünk arról, hogy egy IPermission.Demand() hívás hogyan dönti el a Call Stack végigjárásával, hogy az adott engedélyek rendelkezésre állnak-e. Csakhogy, ha egy logikai szál több fizikai szálra esik szét, akkor a logikai szálhoz több Call Stack tartozik. Például az egyik Call Stack, amelyik a logikai szál második feléhez tartozó munkát a Thread Pool feladatsorába tette. Ezzel a Call Stack-el az a baj, hogy a feladat Thread Pool-ra tétele után lehet (sőt, biztos), hogy az eredeti szál több lépést visszaugrik a hívási láncban (mivel kilép egy csomó függvényből return-nel), ezáltal megszüntet Stack Frame-eket. Ha a Thread Pool szál elindul, egy rajta végrehajtott IPermission.Demand() hívásnak esélye sem lesz megvizsgálni az eredeti Call Stack-et. De még azelőtt, hogy ezen a problémán fájna a fejünk, az is baj, hogy a Thread Pool szálnak fogalma sincs róla, hogy melyik másik szál tette az adott feladatot a feladatsorba, így nem is tudhatja melyik Stack Frame-en kell folytatni egy Stack Walk-ot. Nincs is értelme ennek az információnak, fizikai szálak keletkeznek és tűnnek el, lehet, hogy az indító szál már nem is létezik.

Ezek miatt a .NET egy teljesen más stratégiát követ. A spektroszkópos példából kiindulva, ha van két tubusunk, mondjuk mert nem fért el egyben a laborban, de azt szeretnénk, hogy a második tubus végén elhelyezett spektroszkóp pont ugyanazt az eredményt mérje, mint ami akkor keletkezne, ha a két tubus egyben lenne, akkor mit tennénk? Egyik megoldás, hogy ha úgyis van egy spektroszkópunk az első tubus végén, akkor az pontosan megadja azt az információt, hogy milyen fényforrást kell tenni a második tubus elejére, és oda egy ennek megfelelő fényforrást kell tenni.

A .NET ezt a logikát követi. Amikor egy feladatot tesz a Thread Pool feladatsorba, akkor elindít előtte egy Stack Walk-ot, de most nem abból a célból, hogy adott engedélyről megtudja, hogy elérhető-e, hanem azért, hogy az összes engedélyre vonatkozó információt összeszedje. Az engedélyinformációk összeszedése annyit jelent, hogy megkeresi a Stack Frame-ekben található FrameSecurityDescriptor osztálypéldányokat. Ezt az összegyűjtött halmazt Compressed Stack-nek hívják, ami azért zavaró egy kicsit, mert a Stack csak bizonyos aspektusa olvasható ki belőle, nem az összes információ a Stack-ről. A Compressed Stack tehát leírja az összes engedélyt, ami elérhető a Stack azon részén, ahol ahonnan a Stack Walk elindult. A Comressed Stack-et egy CompressedStack .NET osztály valósítja meg, ami része egy nagyobb gyűjtő osztálynak, a SecurityContext-nek. A SecurityContext viszont az ExecutionContext része, ami a feladattal együtt a Thread Pool feladatsorába kerül. Amikor a Thread Pool thread felveszi a feladatot, akkor az ExecutionContext-ben talált CompressedStack alapján egy speciális, Context Transition Frame-et helyez el a vermen. A Context Transition Frame-nek speciális jelentése van a Security Stack Walk számára. Amikor a Stack Walk elér egy ilyen frame-et, akkor befejezi a Stack Walk-ot, és a CompressedStack-et tekinti fény (illetve engedély) forrásnak. Bár a példában Thread Pool szerepelt, a CAS információk minden más esetben is közlekednek, ahol az ExecutionContext érvényesül (lásd később). Hogy lássuk, hogyan néz ez ki a programozó szemszögéből, tekintsük a következő kódot:

static void f(object o)
{
  string[] lines = File.ReadAllLines(@"C:\temp\context.txt");
  Console.WriteLine(lines.Length);
} // f() 

static void Main(string[] args)
{
  var fileIOPermission = 
    new FileIOPermission(
      FileIOPermissionAccess.Read, 
      @"C:\temp\context.txt");
      
  fileIOPermission.PermitOnly();

  ThreadPool.QueueUserWorkItem(f);
  
  Console.ReadKey();
} // Main()

A fenti kód rendben lefut, de bármi mást próbál végezni az f() függvény (másik állományt olvasni, vagy a Dns osztályt használni), akkor exception-t dob. Hiába tehát a száltörés, a CAS rendesen működik.

Windows Identity és a szálak

A Security Context a Compressed Stack-en kívül még egy Windows Identity-t is szállít, ami a .NET Role Based Security részének egy fontos kelléke. Röviden a következőről van szó: amikor bejelentkezünk a Windows-ba adott felhasználó nevében, akkor a továbbiakban indított programok szálaihoz az operációs rendszer hozzárendeli a bejelentkezett felhasználót. Ezután a szál csak olyan műveleteket végezhet el, amihez adott felhasználónak joga van. Ettől függ, hogy egy szál tud-e olvasni adott állományt, vagy tud-e nyomtatni. Arra is lehetőség van, hogy a szálon a jogokat tovább korlátozzuk, így összességében a szálhoz egy felhasználó, és jogok bizonyos halmaza van rendelve. A .NET a Windows-hoz hasonlóan, de annál általánosabban kezel egy szálhoz rendelve “felhasználót” (.NET filozófiáját használva “Identity”-t) illetve a felhasználóhoz rendel “szerepköröket” (Roles). Ez nagyjából megfelel a Windows-os felhasználónak (mint “viktor.toth”) és a szerepköreinek (mint “User” vagy “Administrator”). A .NET tudja használni a Windows operációs rendszer által adott Identity-t és Role-okat, amely a .NET alatt egy WindowsPrincipal osztályként jelenik meg, ugyanakkor használhat a Windows-tól független Identity-t és Role-okat is, ez csak a program készítőjétől függ, hogy mit szeretne használni.

Amikor a Windows-ba belépünk a felhasználói nevünkkel, akkor az indított programok által futott szálak a mi nevünk alatt futnak. A Windows azonban lehetőséget ad arra, hogy a szál futása közben ideiglenesen egy másik felhasználó nevében fusson (feltéve, ha tudja authentikálni, például tudja az adott felhasználó jelszavát). Ez többek között akkor lehet hasznos, ha egy szerver alkalmazás speciális felhasználó név alatt fut (pl “BmiServer”), viszont bizonyos funkciókat (mint file olvasás vagy nyomtatás) egy adott, a szerverhez éppen csatlakozott felhasználó nevében tudna elvégezni. Egy ilyen szervernél a Windows User Management eszközeivel lehet karbantartani a jogosultságokat, ami a rendszer adminisztrátorai számára kényelmes lehet.

A hasznos lehetőséget, amit “impersonation”-nak neveznek, a .NET is támogatja, ugyanakkor, ha a logikai szál több fizikai szálon fut végig, akkor szükség lehet nem csak arra, hogy a .NET WindowsIdentity osztály utazzon, hanem arra is, hogy a .NET szálat megtestesítő, operációs rendszer szál is a WindowsIdentity által képviselt felhasználó neve alatt fusson. Erre azért lehet szükség, mert az erőforrások használatát (mint adott file olvasása vagy nyomtató használata) az operációs rendszer ellenőrzi. A .NET keretrendszer mindezt elvégzi, ami ki is próbálható a következő programmal:

public static void Main(string[] args)
{
  ThreadPool.QueueUserWorkItem(DumpIdentity, 1); // Új szál indítása, Identity kiírása

  IntPtr tokenHandle = IntPtr.Zero;              // Új felhasználó beléptetése
  LogonUser(                                     // P/Invoke hívást igényel.
    "TestUser",                                  // Felhasználó név
    null,                                        // Domain
    "password123",                               // Jelszó
    2,                                           // Interaktív logon
    0,                                           // Logon provider, default
    ref tokenHandle);

  WindowsImpersonationContext impersonationContext =  // A szál fusson tovább a
    WindowsIdentity.Impersonate(tokenHandle);         // a TestUser alatt
  
  CloseHandle(tokenHandle);                      // Ezt már nem használjuk

  ThreadPool.QueueUserWorkItem(DumpIdentity, 2); // A logikai szál viszi magával a 
                                                 // felhasználót az új fizikai szálra.
  impersonationContext.Undo();                   // Vissza az eredeti felhasználóra
                                                 // A logikai szál most a régi felhasználót
  ThreadPool.QueueUserWorkItem(DumpIdentity, 3); // viszi magával.
      
  Console.ReadKey();

  return;
} // Main()

static void DumpIdentity(object o)               
{                                                
  Console.WriteLine(                             // A logikai szálhoz attach-olt
    String.Format(                               // felhasználó kiírása.
      "{0}: {1}",
      o,
      WindowsIdentity.GetCurrent().Name));

  try
  {
    File.ReadAllLines(@"C:\temp\data.txt");      // File olvasási próba
  }
  catch (UnauthorizedAccessException e)
  {
    Console.WriteLine(                           // Ha van, a hiba riportolása
    string.Format("{0}: {1}", o, e.Message));
  } // catch
} // DumpIdentity()

[DllImport("advapi32.dll"]                       // WinAPI függvények egy felhasználó
public static extern bool LogonUser(             // beléptetésére
                            String lpszUsername,
                            String lpszDomain,
                            String lpszPassword,
                            int dwLogonType,
                            int dwLogonProvider,
                            ref IntPtr phToken);

[DllImport("kernel32.dll"]
public extern static bool CloseHandle(IntPtr handle);

A program kimenete a következő:

1: totvik-ws\totvik
3: totvik-ws\totvik
2: totvik-ws\TestUser
2: Access to the path 'C:\temp\data.txt' is denied.

Fontos látni, hogy a 2-es paraméterrel indított szál nem a .NET miatt nem tudta olvasni az állományt, hanem az operációs rendszer utasította vissza.

Hol állunk most?

Mostanra kialakulhatott egy kép arról, hogy mi történik akkor, ha a logikai szál fizikai szálakat keresztez. Foglaljuk össze az eddig megismerteket. Többféle kontextus adatról beszélhetünk, mint például a saját adataink, vagy a .NET biztonsági paraméterei. Ezeket az adatokat az ExecutionContext osztály fogja össze. Mielőtt egy logikai szál átlép egy másik fizikai szálra, például a Thread Pool vagy a Completion Port száljára, vagy akár a Thread.Start() által indított szálra, akkor összeszedi a kontextus adatokat. Ez az “összeszedés” valamikor egyszerűbb, mint a Logical adatok esetében, valamikor bonyolultabb, mint a CAS adatai esetében. Az ExecutionContext.Capture() függvény használható arra, hogy az adatgyűjtés lefusson. Az ExecutionContext.Capture() függvényt a thread kezelő függvények (mint ThreadPool.QueueUserWorkItem()) meghívják, és az így kapott ExecutionContext példányt eljuttatják az induló szálhoz, például a Thread Pool szálak esetében az ExecutionContext példány a felhasználó callback függvényével együtt a feladatsorba kerül.

Amikor az új szál megkezdi a működését, előveszi az ExecutionContext példányt. Az ExecutionContext osztálynak van egy Run() függvénye, ami elvégzi azokat a teendőket, amelyek ahhoz kellenek, hogy a futó szál kontextus változói érvényesüljenek. Egyes esetekben (kontkrétan a Logical adatoknál) nem kell semmit tenni, mivel a kontextus változó keresése úgyis az aktuális ExecutionContext osztályon keresztül történik. Más esetekben, mint a CAS adatok, Context Transition Frame-et kell a vermen létrehozni, vagy az operációs rendszerrel tudatni kell, hogy a szál milyen felhasználó nevében fusson. Az eddig elmondottakat foglalja össze a következő ábra:

Synchronization Context

A többszálú programok néhány esetben hasznosak, vannak azonban olyan helyzetek, ahol elbonyolítják a programlogikát. Különböző adatszerkezeteket megvalósító osztályoknál például már oda kell figyelni, ha szálbiztosra szeretnénk elkészíteni, azaz ne működjön hibásan, ha több szál fut egyszerre az adatszerkezetet kezelő kódon. Vannak azonban olyan területek, amelyek szálbiztos elkészítése szinte lehetetlen, legalábbis úgy, hogy utána az robosztus maradjon, ne szenvedjen teljesítmény és stabilitási problémáktól. Az UI kezelése egy ilyen terület. A programozó persze örülne, hogy a képernyő elemeit különböző szálakból tudná frissíteni. De gondoljunk bele, hogy mi van akkor, ha van egy olyan kontrolunk, ami tartalmaz 30-40 elemet, például egy WPF-es ListBox. A WPF-es ListBox elemei lehetnek komplexebbek, mutathatják valamilyen történések állapotát (részvények árfolyammozgása, vagy akármi). Ha a 30-40 elemet több szálból akarjuk frissíteni, nyilván valamilyen szinkronizálási mechanizmust kell támogatnia a WPF-es ListBoxItem-nek, különben a versengő szálak inkonzisztens állapotba juttathatnák. Más esetekben viszont erre a szinkronizációra nem lenne szükség, legtöbb program egy szálat használna, viszont a bevezetett szinkronizációs mechanizmusok ennél az egy szálnál is működésbe lépnének – teljesen feleslegesen. Ez ugyan sebesség csökkenést okoz, de van sokkal durvább dolog is. Eddig a layout szempontjából egy szint szinkronizációjáról beszéltünk, de a képernyő felépítése hierarchikus. Ha egy szál nem a ListBoxItem-en, hanem a ListBox-on machinál, akkor kell a ListBox-ra is valami szinkronizációs mechanizmus. Ekkor viszont mi legyen a helyzet a ListBoxItem elérésénél? Kell használni a ListBox szinkronizációját is meg a ListBoxItem-ét is? Ha azt mondjuk nem, akkor elértük a ListBox-ot szinkronizáció nélkül. Ha két szál ezt teszi, és mind a kettő olyan változást okoz a ListBoxItem-eken, ami a ListBox layout-ját érinti, akkor mi van? Szinkronizáció nélkül mind a két szál elkezdi átméretezni a dolgokat? Ha a ListBoxItem eléréséhez mégis kell a ListBox szinkronizációs mechanizmusa, akkor amellett, hogy ez egy nagyon szűk keresztmetszet (egy ListBox tartalmazhat száz meg ezer elemet is) mi legyen szinkronizációs stratégia? A ListBoxItem szinkronizációs kódja várjon arra, hogy a ListBox-ot kisajátíthassa? Ok várjon rá, de mi van, ha több ListBox van mondjuk egy panelon, akkor panelra is várni kell? És esetleg az ő szülőkontroljára is?

Szerintem nem kell tovább vizsgálni a kérdést, hogy belássuk, az UI többszálúsítása nem annyira egyszerű, tehát a WinForms, WPF és társai nem azért nem támogatják, mert béna volt implementálni ezt Microsoft. Egyszerűen nem praktikus. Ami praktikus, az az, hogy az UI elemeket csak egy szál kezelheti, és egy csapásra elfelejthetjük a fenti problémákat. Ekkor viszont, ha több szálon születnek megjeleníteni kívánt információk, kell egy módszer arra, hogy a megjelenítést végző kódot az UI szálon futtassuk le. Vannak az UI-tól különböző helyzetek is, ahol szintén az a praktikus, hogy a szinkronizáció úgy történik, hogy egy kijelölt szál végezhet el csak feladatokat. Így az egy általánosabban jelentkező igény, hogy feladatokat kell egy adott szállal elvégeztetni.

Különböző platformoknak, amelyek a fenti problémában érintettek (WinForms, WPF, ASP, COM), vannak saját mechanizmusaik arra, hogy a kizárólagos eléréssel bíró szál számára feladatokat lehessen küldeni. A WinForms esetében például a kontrolok implementálják az ISynchronizeInvoke interfészt. A ISynchronizeInvoke.Invoke() függvénynek megadható egy delegate, amelyet az ISynchronizeInvoke implementációja becsomagolva, a régi Windows-os üzenetküldést kihasználva az UI szálon fog lefuttatni. A WPF esetében Dispatcher.Invoke() használható ugyanerre a célra. Ugyanakkor a .NET 2.0-tól kezdve van egy egységes felület, amelyet használva egy kódnak nem kell törődnie azzal, hogy pontosan milyen mechanizmus működik a háttérben, azaz nem kell különböző kódot használni WinForms és WPF esetén. Az egységes felületet a SynchronizationContext osztály adja. A SynchronizationContext használata nem túl nehéz, a leggyakrabban használt két függvénye a Send() és Post(), melyek közül az előbbi szinkron módon (blokkolva) futtat adott delegate-et adott szálon, a Post() pedig aszinkron módon, azaz nem várja meg, amíg az adott szál a feladatot végrehajtja.

Ha van egy függvény, például egy APM-es callback függvény, akkor egy művelet eredményét WinForms alatt a következő módon lehetett megjeleníteni:

void f(...)
{
  if (this.Label.InvokeRequired)
  {
    this.Label.Invoke((o) => this.Label.Text = (string)o, result);
  }
  else
  {
    this.Label.Text = result;
  } // else
} // f()

WPF esetében:

void f(...)
{
  if (!this.Label.Dispatcher.CheckAccess())
  {
    this.Label.Dispatcher.Invoke((o) => this.Label.Content = o, result);
  }
  else
  {
    this.Label.Content = result;
  } // else
} // f()

Konzol alkalmazás esetén:

void f(...)
{
  // Nincs szükség szinkronizációra
  Console.WriteLine(result);
} // f()

A SynchronizationContext osztályt használva a kódok a következő módon változnak:

WinForms:

void f(...)
{
  SynchronizationContext.Current.Send((o) => this.Label.Text = (string)o, result);
} // f()

WPF:

void f(...)
{
  SynchronizationContext.Current.Send((o) => this.Label.Content = o, result);
} // f()

Konzol:

void f(...)
{
  SynchronizationContext.Current.Send((o) => Console.WriteLine(o), result);
} // f()

Honnan tudja a SynchronizationContext, hogy hogyan kell a delegate-et a cél szál számára eljuttatni, és egyáltalán mi a cél szál, amin a delegate-et futtatni kell? A SynchronizationContext osztály önmagában működöképes, de túl sokat nem csinál. A Send() metódusának az implementációja nem csinál mást, mint továbbhív a megadott delegate-re, a Post() pedig a ThreadPool feladatsorába teszi a megadott delegate-et, és visszatér. Nyilván ez az implementáció a fenti három példa esetén nem működik, csak a konzol alkalmazásnál. Mi történik WinForms és WPF esetében? Az, hogy a SynchronizationContext.Current property által visszaadott példányok a SynchronizationContext osztály leszármazottjai, WinForms esetén egy WindowsFormsSynchronizationContext példány, a WPF-nél pedig egy DispatcherSynchronizationContext. Nem nehéz megjósólni, hogy a WindowsFormsSynchronizationContext valamilyen kontrol Invoke/BeginInvoke függvényeit használja, a DispatcherSynchronizationContext pedig a Dispatcher osztály függvényeit. De mi állítja be a megfelelő típusú SynchronizationContext példányokat, hogy a SynchronizationContext.Current a használható típusú objektumot adja vissza? Hát, az a helyzet, hogy nem biztos, hogy egyáltalán valaki beállítja. WinForms esetében a Control osztály konstruktora ellenőrzi, hogy létrejött-e már a GUI szálon a SynchronizationContext. Egészen pontosan hív egy WindowsFormsSynchronizationContext.InstallIfNeeded() statikus függvényt, ami elhelyezi az új WindowsFormsSynchronizationContext példányt, ha szükséges. A WindowsFormsSynchronizationContext konstruktora egyrészt megjegyzi, hogy melyik szálon lett létrehozva, ez tipikusan a GUI szál. Másrészt keres egy kontrolt, aminek majd hívhatja az Invoke/BeginInvoke függvényeit. WinForms esetében így akkor lesz a GUI szálon beállítva a SynchronizationContext.Current, ha már egy kontrol létrejött. WPF esetében az Dispatcher objektum állítja be a SynchronizationContext.Current property-t konstrukció alatt. De ennyi elég, hogy utána a fenti példaprogramok működjenek? Miért fogja egy másik szál megtalálni egy SynchronizationContext.Current property hivatkozással az előbb létrehozott WindowsFormsSynchronizationContext példány?

Talán nem meglepetés hogy a SynchronizationContext.Current property az ExecutionContext-et használja. Az ExecutionContext-nek van egy SynchronizationContext property-je, és amikor egy logikai szál fizikai szálat vált, akkor a SynchronizationContext property ugyanúgy utazik a fizikai szálak között, mint a LogicalCallContext meg a SecurityContext. Ebből azt gondolhatnánk, hogy akkor minden rendben, azonban nem. Valamilyen rejtélyes ok miatt a ThreadPool-ok mögötti kódok úgy vannak beállítva, hogy az SynchronizationContext-et figyelmen kívül hagyják. Egészen pontosan, az ThreadPool-ok programlogikája egy adott ponton, ha a ThreadPool queue-ban levő feladat hozott magával ExecutionContext-et, akkor az ExecutionContext.Run() metódusát hívja meg a ThreadPool szálnak feladatként megadott callback függvénnyel. A Run() metódusnak viszont van egy verziója, ami egy ignoreSyncCtx paramétert vár, és a ThreadPool szálak ezt a paramétert mindig beállítják, hogy a SynchronizationContext ne lépjen életbe az adott szálon. Akárhogyis, sajnos a SynchronizationContext nem működik úgy, ahogy várnánk. Emiatt kézzel kell átvinni a megfelelő context-et, mindenféle trükköt használva. Az egyik dolog, amit az emberek tenni szoktak, hogy egyáltalán nem használják a SynchronizationContext-et, hanem Control.Invoke()-oznak vagy Dispatcher.Invoke()-oznak. A másik módszer, hogy adott ablaknak (pl MainWindow) csinálnak egy SynchronizationContext típusú property-t, amit beállítanak a program elején. Ezt a property-t használják azután a MainWindow-on megvalósított callback függvények. Íme egy egyszerű példa:

public partial class Form1 : Form
{
  private SynchronizationContext syncContext;

  public Form1()
  {
    InitializeComponent();
    this.syncContext = SynchronizationContext.Current;
  } // Form1

  private void button1_Click(object sender, EventArgs e)
  {
    Dns.BeginGetHostAddresses(
      "www.devportal.hu",
      this.Callback, 
      null);
  } // button1_Click()

  private void Callback(IAsyncResult ar)
  {
    IPAddress[] addresses = Dns.EndGetHostAddresses(ar);
    String[] addressesAsString = addresses.Select(addr => addr.ToString()).ToArray();

    this.syncContext.Send(
      (o) => this.listBox.Items.AddRange((string[])o), 
      addressesAsString);
  } // Callback()
} // class Form1 

Mindig kellenek logikai szálak?

Az ExecutionContext osztály hasznos funkciót valósít meg, ugyanakkor nem mindig van szükség arra, hogy logikai szálakban gondolkodjunk. Ekkor az ExecutionContext ugyan többnyire nem okoz bajt, viszont a Security Stack Walk, A LogicalData másolása, ezek érvényesítése felesleges időt vesz el a processzortól. Ezek miatt lehetőség van arra, hogy a kontextus másolgatását az egyik szálról a másikra kikapcsoljuk. Erre szolgál az ExecutionContext.SuppressFlow()/ExecutionContext.RestoreFlow() páros. A SuppressFlow() hatása kipróbálható a következő programon:

static void f(object state)
{
  string[] lines = File.ReadAllLines(@"C:\temp\data.txt");
  Console.WriteLine(lines.Length);
} // f()

static void Main(string[] args)
{
  

  var fileIOPermission = 
    new FileIOPermission(
      FileIOPermissionAccess.Read, 
      @"C:\temp\c.txt");
  
    
  fileIOPermission.PermitOnly();

  ExecutionContext.SuppressFlow();
  ThreadPool.QueueUserWorkItem(f);

  Console.ReadKey();
} // Main()

A program egy korábbi példához nagyon hasonló. A FileIOPermission.PermitOnly() csak azokat az engedélyeket biztosítja, amelyek a “C:\temp\c.txt” állomány olvasására vonatkoznak. Emiatt az f() függvény kivételt dobna, mivel egy másik állományt olvas. Ugyanakkor az ExecutionContext.SuppressFlow() hívás megakadályozza, hogy az ExecutionContext a feladattal együtt a Thread Pool feladatsorába kerüljön. Ekkor a Thread Pool szál nem készíti el a Context Transition Frame-t a vermén, és egy Security Stack Walk, miután elért a verem aljára, az assembly engedélyeivel veti össze a FilePermission.Demand()-ot. Mivel az alkalmazás lokális meghajtóról indult, és a gépemen levő .NET alapbeállításokkal fut, az assembly minden engedéllyel rendelkezni fog, így a data.txt olvasható lesz. Persze nem csak az engedélyek nem utaztak, hanem a logical data és az amúgy is használhatatlan Synchronization Context sem.
Thread Pool-ok esetében más lehetőség is van arra, hogy az Execution Context utaztatása miatti teljesítményvesztést elkerüljük. Egy ThreadPool.UnsafeQueueUserWorkItem() akkor is figyelmen kívül hagyja az Execution Context-et, ha az egyébként nincs felfüggesztve.

Összefoglalás

Összetettebb programoknál a végrehajtási szál működéséhez szükség lehet bizonyos környezeti, vagy kontextus változókra. Összetettebb többszálú programoknál arra is szükség lehet, hogy ezek a kontextus változók a fizikai szálakon átívelő logikai szál mentén végig elérhetőek maradjanak. A .NET Execution Context lehetőséget biztosít a kontextus változók logikai szálak mentén való kezelésére, bár vannak hiányosságai, például a SynchronizationContext esetében.

  1. #1 by Sallai Máté on 2011. January 17. - 12:02

    Tisztelt Szerkesztő!

    A Felsőfokon.hu Szakmai Blogmagazin nevében keresem levelemmel!

    A Felsőfokon.hu Szakmai Blogmagazin egy olyan közösség által szerkesztett blogportál, amely új megközelítést kínál szakmai tartalmak bemutatására és megfelelő környezetet biztosít szakmai párbeszédek kialakítására. A rendszer üzemeltetésével célunk, hogy olyan eszközt adjunk szakemberek és szervezetek kezébe, amellyel könnyen emészthető formában oszthatják meg tudásukat a szakmai tartalmak iránt érdeklődő közönséggel.

    Jelenleg olyan szakmai blog szerkesztőket keresünk, akik a saját szakterületükön értékes szakmai tartalommal rendelkező bejegyzésekkel növelik rendszerünk értékét. A blog írásával az egyes cégek és a többi felhasználó figyelmét vívhatja ki, így építve a szakmai kapcsolati hálóját, illetve pénzbeli juttatásokban is részesülhet.

    Elnézést kérek a megkeresés ezen módjáért, de elérhetőséget nem találtam.

    Ha felkeltettem az érdeklődését, akkor további információkért kérem keressen a sallaimate@gmail.com e-mail címen.

    Üdvözlettel:

    Sallai Máté
    Programozás szakág vezető

    felsőfokon.hu
    A SZAKMAI BLOGMAGAZIN

    Felsőfokon a hallgatókért Kutató, Fejlesztő
    és Kommunikációs Közhasznú Nonprofit KFT
    1093 Budapest, Közraktár u. 12/a

  2. #2 by Lajos on 2011. January 18. - 11:57

    Meg kell, hogy mondjam a csoves, spektroszkopos hasonlat a CAS-ra eleg furcsa volt az elso par mondat utan, de a vegere teljesen osszeallt a kep. Nem is tudom mikor olvastam ilyen egyertelmu magyarazatot, zsenialis🙂.

    Az ExecutionContext masolasanak tiltasanal viszont mar kicsit elvesztem. Jol ertem, hogyha en egy PermitOnly()-val csak a “C:\temp.c.txt”-re kerek engedelyt (minden mast tiltok), majd egy 3rd party library-t hivok, akkor a lib fejlesztoje megteheti, hogy indit egy szalat amihez nem masolja le az ExecutionContext-et es igy megkap minden permission-t amit az alkalmazas az indulasakor?
    Vagy a PermitOnly tiltja a thread letrehozast is?

    Koszi

    • #3 by Tóth Viktor on 2011. January 18. - 20:18

      Nagyon jó kérdés!! Az ExecutionContext.SuppressFlow() hívásához rendelkezni kell egy SecurityPermission-nal, úgy hogy az Infrastructure flag-je igaz legyen. Emiatt az SuppressFlow-t le lehet tiltani. Azonban vagy egy szépséghibája a dolgonak. Teljesítmény okok miatt a SuppressFlow() úgynevezett LinkDemand-ot használ, ami azt jelenti, hogy nem lesz minden hívásánál egy Stack Walk, hogy a jogok ellenőrzésre kerüljenek, hanem a jitter fordításkor nézi meg a jogokat, ráadásul ő sem Stack Walk-kal, aminek van egy csúnya következménye: a PermitOnly és a Deny nem tudja letiltani a szükséges engedélyeket. 4.0 előtt azt lehetett csinálni, hogy ha amúgy másra nem kell az Infrastructure flag, akkor jelezni lehetett a futtatórendszernek, hogy azt ne adja nekünk ki:
      [assembly:
      SecurityPermission(
      SecurityAction.RequestRefuse,
      Infrastructure=true)]

      Azonban ezt a 4.0 már figyelmen kívül hagyka. Ott a megbízhatatlan assembly-ket egy külön application domain-ba kell betölteni, amiről le kell szedni a jogokat. (ezt meg lehetett csinálni 4.0 előtt is, sőt igazából ezt az utat ajánlották a megbízhatatlan kódok futtatására).

      A ThreadPool.UnsafeQueueUserWorkItem()-vel ugyanez a helyzet, csak neki meg más flag-ek kellenek, hogy “egyszerűbb” legyen a helyzet (ControlEvidence és ControlPolicy flag-ek)

      A 4.0-ban sok dolog megváltozott, majd jobban utánanézek, és írok belőle egy cikket🙂

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: