A C# programozási nyelv

Párhuzamosság

A nyelvben a párhuzamos végrehajtás megvalósítása szálak (thread) segítségével történik. Maga a .NET keretrendszer is többszálú, hiszen egy program fő szála elindul a Main() metódussal, közben a háttérben egy másik szálon fut a Garbage Collector. A System.Threading névtér elemei nyújtanak támogatást párhuzamosság szimulálására. Ez a névtér számos osztályt tartalmaz, köztük a legfontosabbat: a Thread osztályt. A Task Parallel Library (TPL) célja a párhuzamos programok írásának egyszerűsítése és a folyamatok skálázhatóságának támogatása.

Thread class

Legfontosabb metódusai:

Start() Elindítja a szálat.
Suspend() Felfüggeszti a szál végrehajtását.
Resume() Folytatja a szál végrehajtását.
Interrupt() Megszakítja egy várakozó, alvó, vagy csatlakozó állapotú szál futását.
Join() Blokkolja a hívott szálat, amíg az véget nem ér.
Sleep(int) Felfüggeszti a szál végrehajtását a paraméterében meghatározott ideig (ezredmásodpercben).
Abort() Meghívja a szál befejezésére szolgáló eljárásokat.

Ha egy szál befejezte a futását, akkor többet nem indítható újra.

A szálakat három módon lehet megállítani: a Join(), a Suspend() és a Sleep() metódusok segítségével. Az utóbbi kettőnél a szál egyáltalán nem kap processzoridőt. Amikor a szálat Sleep() metódussal állítjuk meg, akkor a szál rögtön megállásra kényszerül, a Suspend() eljárás viszont előtte vár a CLR (Common Language Runtime) egy biztonságos állapotára. Egy szál nem tud egy másik szálon Sleep() eljárást hívni, de Suspend() -et igen. Értelemszerűen, ha egy szál még nem indult el, vagy már terminált, akkor Suspend() -et sem lehet hívni rajta. Ha egy szálat bizonytalan ideig szeretnénk alvó állapotban tartani, hívjuk a Sleep(int.Infinitive) metódust. Egy szálat csak úgy lehet felébreszteni, ha megszakítjuk egy Interrupt() vagy egy Abort() metódussal, ekkor a felébresztett szál egy ThreadInterruptedExeption -t fog kiváltani.

Amíg lehetséges, próbáljuk elkerülni a Suspend() metódus hívását, mivel az mellékhatásokkal járhat! Gondoljunk csak bele, mi lenne, ha egy elfojtott szálhoz olyan erőforrások lennének kötve, amit egy aktív szál is használ. Ennek kivédésére az egyik javasolt módszer, a szál prioritásának beállítása.

Thread.Priority felsorolási típus lehetséges értékei meghatározzák a szálakhoz tartozó fontossági sorrendeket, ami a processzoridő kiosztásánál fontos. A magasabb prioritású szálak mindig több futási időt kapnak, mint az alacsonyabb prioritású társaik. Minden szál alapbeállítása Normal.

Lehetséges Priority értékek:

Ahhoz hogy létrehozzunk egy szálat, először létre kell hozni egy példányt a Thread osztályból, a konstruktorában átadva egy ThreadStart delegált metódust. Ez a delegált metódus az, ami majd szál indításakor aktivizálódni fog. Végül a szál indításához meg kell hívnunk a Start() metódust.

A Thread.ThreadState attribútum az adott szál állapotát hivatott jelenteni a külvilágnak. Mikor egy szál még nem fut akkor azt az Unstarted állapot jelöli. A Start() metódus meghívása a Running állapotba viszi át a szálat, majd ebben is marad, míg el nem altatják, fel nem függesztik, meg nem szakítják, vagy be nem fejezi futását. Amikor a szálra Suspend() hívás érkezik, átmegy Suspended állapotba, amíg egy másik szál ismét futásra nem készteti őt. Ha egy szálat megszakítanak, vagy véget ér a futása, akkor Stopped állapotba kerül. Ezt az állapotot már nem lehet megváltoztatni. Egy szál lehet egyszerre több állapotban is például, amikor egy alvó szálon Abort() utasítást hívunk, akkor a szál egyszerre AbortRequested és WaitSleepJoin állapotba kerül.

Példa:

public static void First_Thread()
{
 // ez lesz a szál törzse
 Thread current_thread = Thread.CurrentThread;
 // ide jön a kód
}

public static void Main()
{
 ThreadStart thr_start_func = new ThreadStart (First_Thread);
 Thread fThread = new Thread (thr_start_func);
 Thread.Start (); // elindul a szál
}

Erőforráskezelés

A .NET keretrendszer számos osztályt és adattípust kínál számunkra, hogy a közös erőforrásokat kezelhessük. A CLR (Common Language Runtime) háromféle elérési módot biztosít globális változók, metódusok, osztályszintű metódusok, objektumok és blokkok számára:

Szinkronizált kódterület Szinkronizálható bármely osztályszintű és példányszintű metódus, vagy annak egy része Monitor használatával. Megjegyzendő, hogy nincs lehetőség statikus adattagok szinkronizációjára.
Klasszikus kézi szinkronizáció Számos szinkronizációs osztály használható arra, hogy összhangot teremtsünk a saját elvárásainknak megfelelően.
Szinkronizált környezet A SynchronizationAttribute használata egyszerű, automatikus szinkronizációt valósít meg ContextBoundObject objektumokon. Minden objektum közös környezetben osztozik a záron.

A Monitor osztály használatával elérhetjük, hogy egy kódrészletet egy adott időpontban csak egy szál használhasson. A Monitor osztály minden metódusa statikus, így használatához nem kell példányosítani az osztályt. A monitor indítása a Monitor.Enter(object) vagy a Monitor.TryEnter(object) metódushívással történhet. Feloldása a Monitor.Exit(object) vagy a Monitor.Wait(object) hívással történhet. Ha a monitor feloldása megtörtént, akkor a Monitor.Pulse vagy a Monitor.PulseAll hívás üzenetet küld a hozzáférési sorban álló szálaknak, hogy szabad az elérés. Amikor egy szál a lefoglalt kódrészletében Wait -et hív, akkor megszakad a hozzáférése, és bekerül egy várakozó sorba, majd a sor első elemét reprezentáló szál meghívja a Pulse vagy PulseAll metódust és az említett szál lép egyet a hozzáférési sorban. Az Enter metódus atomi, így ha két szál egyszerre hívja ugyanarra a kódrészletre, akkor is csak az egyik kapja meg a jogot a futtatásra. Javasolt a monitort belső objektumokon használni, mivel külső objektum zára deadlock -hoz vezethet. Javasolt, hogy a kódot egy try blokkban helyezzük el, és az Exit hívást, pedig a finally ágban. Ehelyett alkalmazható a lock(object) hívás, ami funkciójában megegyezik a Monitor -éval, de amikor a végrehajtás kilép a lock blokkjából, a zár feloldódik.

A WaitHandle osztály: ez az osztály az őse minden szinkronizációs objektumnak, és ez az osztály ágyazza be a Win32 szinkronizáció-kezelőt. A WaitHandle objektumok jelentik egy szál állapotát egy másik szálnak, ezzel értesítve azokat a szálakat, akiknek kizárólagos hozzáférésre van szükségük egy erőforráshoz. Ekkor ezeknek a szálnak várniuk kell az erőforrás használatával, míg a várakozáskezelő használaton kívül nem kerül. Három osztály származik a WaitHandle osztályból: Mutex, AutoResetEvent, ManualResetEvent. Ezek az osztályok alkalmaznak egy üzenetküldő mechanizmust hogy megszerezzék, illetve visszaadják a kizárólagos hozzáférés jogát az egyes erőforrásokhoz. Két állapotuk van: signaled és nonsignaled. Egy várakozáskezelő signaled állapotban van, ha egy szál sem birtokolja. Ellenkező esetben nonsignaled állapotot vesz fel. A szálak, amelyeknek birtokában van egy várakozáskezelő, hívhatnak Set() metódust, ezzel lemondanak a várakozáskezelőről. Más szálak Reset() metódust hívhatnak, hogy a várakozáskezelő állapotát unsignaled-re változtassák, vagy bármelyet a következő metódusok közül:

WaitOne() Vár, amíg az argumentumában megadott várakozáskezelő állapota nem lesz unsigned.
WaitAny() Vár, amíg az argumentumában megadott várakozáskezelők tömbjéből legalább egy nem lesz unsigned.
WaitAll() Vár, amíg az argumentumában megadott várakozáskezelők tömbjéből az összes unsigned nem lesz.

A WaitHandle objektumok kevésbé hordozhatók, és működésük több erőforrást igényel, mint a Monitoré.

A Mutex osztály: ez az osztály más módszert nyújt a szálak és folyamatok közötti szinkronizációra. Lehetőséget ad egy szálnak egy erőforrás kizárólagos hozzáféréshez, ezáltal biztosítva, hogy két szál vagy folyamat ne férjen hozzá szimultán az erőforráshoz. Az osztály azonos a Win32 CreateMutex metódussal. Egy Mutex objektum létrehozásásnál a konstruktor első paramétere egy bool típusú változó, amely meghatározza, hogy az őt hívó szálnak az ő tulajdonosának kell -e lennie, a második a mutex neve (opcionális).

Az InterLocked osztály: biztosít egy zárat, ami egyszerre véd olvasás és írás ellen is. Egyszerre akár több szál is olvashatja az adott változót, a zár csak felülírás esetén aktiválódik. Az olvasó szálak kizárólag akkor szerezhetik meg a zárat, ha nincsen a váltózót író szál. Író szálak csak akkor szerezhetik meg a zárat, ha nincs olvasó vagy író szál. Ha egy író szál zárást igényel, akkor új olvasó szálak nem kapnak hozzáférést addig, amíg az író szál vissza nem adja azt. Olvasási hozzáférést a ReaderWriterLock.AcquireReaderLock metódussal, írási hozzáférést a ReaderWriterLock.AcquireWriterLock metódussal kérhetünk. Ha egy szál, amelynek már van hozzáférése olvasásra, bejelenti az igényét írásra is, akkor blokkolódik és úgy is marad. Ehelyett javasolt a ReaderWritedrLock.UpgradeToWriterLock metódus hívása. Ugyanez fordítva: ReaderWriterLock.DowngradeFromWriterLock.

A ThreadPool osztály: ez az osztály szolgáltatásokat nyújt több szál együttes, hatékony kezeléséhez és segít takarékosabban kezelni az erőforrásokat. Ha nagy számú taszkot szeretnénk futtatni, mindegyiknek szüksége lenne egy szálra, de ilyenkor kifizetődőbb un. thread pool -t használni. Ez a többszálú működés egy formája, ahol a taszkok sorban vannak. Így lehetőséget adunk a rendszernek, hogy optimalizálja mennyi processzoridő jut az egyes szálakra. Ha egy szál várakozásra kényszerül, akkor a Pool automatikusan elindít/folytat egy olyan szálat amely nem várakozik. Folyamatonként egyszerre csak egy ThreadPool -t hozhatunk létre, és bennük is csak egy szál dolgozhat egy időben. Alapértelmezésben a Pool -ba maximum 25 szál kerülhet, de ez változtatható. A ThreadPool.QueueUserWorkItem hívással a Pool-hoz hozzá lehet adni taszkokat vagy akár éppen folyamatban lévő szálakat. A paramétere egy WaitCallBack nevű delegált metódus. A .NET keretrendszer is ezt használja aszinkron I/O műveletekhez, socket alapú kommunikációhoz, stb…

A Timer osztály: lehetőséget nyújt arra, hogy egy szálat periodikusan futtathassunk.

Backgroundworker

A Threading névtér talán egyik legfontosabb osztálya a BackgroundWorker. Ez az osztály jelentősen megkönnyíti a párhuzamos folyamatok létrehozását és kezelését. Windows alatt a szálak közti kommunikáció igen bonyolult dolog. Például ha két szál egy változóhoz akar hozzáférni akkor igen gyakran előfordul, hogy ezek a szálak összeakadnak (egymásra várnak - kölcsönös kizárás, kiéheztetés, stb.). Ez windows98-ban a program kifagyásához vezetett, a Windows XP már okosabb erőforráskezelésre képes, de a program futását mindenképpen jelentősen lassítja. A BackgroundWorker ennek a hibának a kiküszöbölésére nyújt igen kényelmes megoldást. A lényege, hogy megadjuk a másik szálon futtatni kívánt metódust, és ez időnként "jelentést" küld az állapotáról. Működése a következő példa alapján könnyen megérthető:

namespace BackgroundWorkerExample
{
 class Example
 {
  //Létrehozzuk a BackgroundWorker pédányunkat
  BackgroundWorker bg = new BackgroundWorker();

  void Start()
  {
   //Így kell megadni azt a függvényt, amit futtatni szeretnénk
   bg.DoWork += new DoWorkEventHandler(WorkToDo);
   //Ez az esemény kezeli le, ha a háttérben futtatott függvényünk üzenetet küld az állapotáról (opcionális)
   bg.ProgressChanged += new ProgressChangedEventHandler(OnProgressChange);
   //Ha használjuk, akkor engedélyezni kell
   bg.WorkerReportsProgress = true;
   //Engedélyezzük, hogy a szálunk kívülről leállítható legyen
   bg.WorkerSupportsCancellation = true;
   //Indítsuk el (ezek után már nem módosíthatóak a beállítások)
   bg.RunWorkerAsync();
  }

  //Kezeljük le a jelentett állapotváltozásokat
  void OnProgressChange(object sender, ProgressChangedEventArgs e)
  {
   //Például írjuk ki őket
   Console.WriteLine(e.ProgressPercentage.ToString()+"% "+e.UserState.ToString());
  }

  //Az eseménykezelő
  void WorkToDo(object sender, DoWorkEventArgs e)
  {
   for (int i = 0; i < 100; i++)
   {
    //Jelentésküldés (az első paraméter a folyamat %-a)
    bg.ReportProgress(i,"Progress");
   }
  }
 }
}

A trükk az, hogy a háttérben futtatott függvényt a BackgroundWorker egy új szálon-, míg az OnProgressChance függvényt az eredeti szálon futtatja. Így ebben a függvényben hozzáférhetünk az eredeti szál minden erőforrásához összeakadás nélkül.

Task Parallel Library

Taskok létrehozása

A System.Threading.Tasks névtérben található osztályok segítségével könnyen futtathatunk aszinkron műveleteket. A Task osztály reprezentál egy ilyen folyamatot. Közvetlenül létrhozhatunk és futtathatunk példányokat ebből az osztályból a Thread-hez hasonlóan, ebben az esetben azonban nem jön létre egy valódi szál, hanem a ThreadPool ban kerül végrehajtásra egy újrahasználható szálon.

// Task létrehozása lambda kifejezéssel Task taskA = new Task( () => Console.WriteLine("Hello from taskA.")); // Task indítása. taskA.Start();

Ezen kívül több lehetőségünk is van Task-okat létrehozni, például a Parallel.Invoke függvény segítségével implicit módon. A paraméterként kapott Action objektumokat vagy lambda kifejezéseket végrehajtja párhuzamosan. Ilyenkor a végrehajtás sorrendjére nincs behatásunk, viszont paraméterként megadhatunk egy ParallelOptions objektumot, amivel megadhatjuk a maximális párhuzamosítás mértékét és a CancellationToken-t.

//lambdakifejezéssel megadott task-ok Parallel.Invoke(() => DoSomeWork(), () => DoSomeOtherWork()); //opciók megadása a taskokhoz var tok = new CancellationTokenSource(); var op = new ParallelOptions(){MaxDegreeOfParallelism = 10, CancellationToken = tok.Token} Parallel.Invoke(op, () => DoSomeWork(), () => DoSomeOtherWork());

A Task-oknak lehet visszatérési értékük is. Erre a Task<T> generikus osztályt használhatjuk, a létrehozáskor használt delegáltnak pedig az adott típussal kell visszatérnie. Ezután a Result property segítségével kérdezhetjük le az eredményt, ez a hívás blokkol, amíg a Task le nem futott, kivéve ha a Task IsFaulted vagy IsCancelled állapotban van, ebben az esetben kivételt kapunk.

// érték visszaadása a taskból Task task1 = new Task(() => 1); task1.Start(); int i = task1.Result;

A Task.FromResult használatával létrehozhatunk egy már elkészült Taskot és a paraméterben megadhatjuk a Result objektumot. Ez akkor lehet hasznos, ha egyes esetekben már megvan a kiszámítandó érték pl. cache-ben, de egy programrész Task<T> objektumot vár. Az alábbi példakódban egy osztály látható, ami weboldalak szövegét tölti le és cacheli. Ha egy címet többször megadunk másodjára már nem tölti le, hanem az eltárolt adatokat adja vissza.

class CachedDownloads { // itt tárolja a letöltött adatokat static ConcurrentDictionary cachedDownloads = new ConcurrentDictionary(); // a megadott cím aszinkron letöltése public static Task DownloadStringAsync(string address) { //előszöt megnézzük, hogy megvan-e már az adott oldal string content; if (cachedDownloads.TryGetValue(address, out content)) { return Task.FromResult(content); } // ha még nincs letöltve, akkor letöltjük és hozzáadjuk a cache-hez return Task.Run(async () => { content = await new WebClient().DownloadStringTaskAsync(address); cachedDownloads.TryAdd(address, content); return content; }); } }

A Task.Factory segítségével egyszerre létrehozhatjuk és ütemezhetjük a Task-okat. A Task.Factory.StartNew() meghívása ekvivalens a példányosítással és elindítással, ha nem szükséges a kettőt szétválasztanunk, akkor érdemes ezt használni. A StartNew különböző túlterheléseivel lehetőségünk van TaskCreationOptions átadására, valamint specifikus ütemezőket, vagy paramétert a task számára.

Action<object> action = (object obj) => { Console.WriteLine("Task={0}, obj={1}, Thread={2}", Task.CurrentId, obj.ToString(), Thread.CurrentThread.ManagedThreadId); }; // task létrehozása és elindítása egy paraméterrel Task t = Task.Factory.StartNew(action, "alma"); // kimenet => Task=3, obj=alma, Thread=9

A TaskCreationOptions segítségével szabályozhatjuk a taskok viselkedését.

TaskCreationOptions.LongRunning Ezzel jelezhetjük az ütemező számára, hogy a task sokáig futhat. Ilyenkor egy külön szált kap ahelyett,
hogy a ThreadPool-ban lenne ütemezve.
TaskCreationOptions.PreferFairness Ebben az esetben az ütemező megpróbálja fair módon ütemezni a szálakat, vagyis amelyiket előbb ütemeztük, az előbb fog futni.
TaskCreationOptions.AttachedToParent A task egy szülő taskhoz lesz csatolva.
TaskCreationOptions.DenyChildAttach Megakadályozza, hogy "gyerek" taskot csatoljunk az adott taskhoz. Ha mégis megpróbáljuk egy InvalidOperationException-t kapunk."
TaskCreationOptions.None Alapértelmezett működés.

A különboző opciókat kombinálhatjuk is.

Task t2 = Task.Factory.StartNew(action, "alma",TaskCreationOptions.PreferFairness | TaskCreationOptions.AttachedToParent);
Csatolt Taskok

A csatolt gyerek taskokat egy másik task delegáltjában hozhatunk létre az AttachedToParent opcióval, ha ezt nem adjuk meg, alapértelmezésben nem lesz csatolt a task. A különbség a csatolt és csatolatlan Taskok között, hogy a szűlő task állapota függ a csatolt taskok állapotától, és a bennünk keletkező Exception-öket is továbbadja, valamint megvárja az összes csatolt gyerekének a befejezését. Az utóbbit mutatja be az alábbi példa.

var parent = Task.Factory.StartNew(() => { Console.WriteLine("Parent task executing."); var child = Task.Factory.StartNew(() => { Console.WriteLine("Attached child starting."); Thread.SpinWait(5000000); Console.WriteLine("Attached child completing."); }, TaskCreationOptions.AttachedToParent); }); parent.Wait(); Console.WriteLine("Parent has completed."); /*Parent task executing. *Attached child starting. *Attached child completing. */Parent has completed.
Taskok összefűzése

Az aszinkron műveletek befejezéskor gyakran meghívnak egy következő eljárást, aminek adatokat is átadhatnak, ezt általában callback függvényekkel valósítják meg. A TPL-ben erre is van lehetőség, a Task.CountinueWith segítségével összefűzhetünk taskokat és megadhatunk feltételeket a folytatáshoz.

// a kezdő task Task taskA = new Task(() => DateTime.Today.DayOfWeek); // a folytatás paraméterként megkapja a kezdőt Task continuation = taskA.ContinueWith((antecedent) => { return String.Format("Today is {0}.",antecedent.Result); }); // első kezdése taskA.Start(); // az eredmény kiíratása a folytatásból Console.WriteLine(continuation.Result);

A Task.Factory.ContinueWhenAll-t használhatjuk egyszerre több task folytatásának megadására.

Task[] tasks = new Task[2]; tasks[0] = new Task(() => { // Do some work... return 34; }); tasks[1] = new Task(() => { // Do some work... return 8; }); var continuation = Task.Factory.ContinueWhenAll( tasks, (antecedents) => { int answer = antecedents[0].Result + antecedents[1].Result; Console.WriteLine("The answer is {0}", answer); }); tasks[0].Start(); tasks[1].Start(); continuation.Wait();

A folyatásokhoz megadhatunk feltételeket is amelyek mellett el szeretnénk indítani az adott Task-ot. A System.Threading.Tasks.TaskContinuationOptions-t a ContinueWith metódusnak adhatjuk meg paraméterként. Ez tartalmazza a TaskCreationOptrions felsorolás elemeit is, amik úgyanúgy működnek mint, amikor egy egyszerű Taskot hozunk létre. Ezeken felül pedig az alábbi opciókat:

NotOnFaulted Csak akkor fog lefutni a folytatás, ha nem keletkezett kivétel a megelőző Task-ban.
OnlyOnFaulted Csak kivétel keletkezése esetén fog lefutni, ezt az opciót a kivételek kezelésére érdemes használni
NotOnRanToCompletion A folytatás nem fog lefutni, ha az előző Task IsCompleted állapotba került.
OnlyOnRanToCompletion Csak IsCompleted állapotú Task-ok után fog lefutni.
NotOnCanceled Az IsCancelled állapotú Task után nem fog lefutni.
OnlyOnCanceled Csak IsCancelled állapotú task után fog lefutni.

Ezeket az opciókat a ContioneWhenAll-al nem használtahtjuk, csak egy-egy Task folytatásának beállítására.

Kivételkezelés

Ha a taskban nem kezelünk le egy kivételt, akkor az propagálódik a csatlakozó szálba, amikor meghívjuk a Task.Wait-et. Ezt használhatjuk a kivétel elkapására, ha try..catch blokkba tesszük. A kapott kivétel egy AggregateException, ami magában foglalja az összes kivételt, ami a taskban kiváltódott.

var task1 = Task.Factory.StartNew(() => { throw new MyCustomException("hiba"); }); try { task1.Wait(); } catch (AggregateException ae) { // a belső kivételek feldolgozása foreach (var e in ae.InnerExceptions) { if (e is MyCustomException) { Console.WriteLine(e.Message); } }

Ha a tasknak van gyereke és abban váltódik ki egy hiba, akkor a szülő a kapott AggragateException-t becsomagolja a sajátjába. Ilyenkor használhatjuk az AggragateException.Flatten metódust, ami kilapítja, az összes beágyazott AggragateException-t és a visszaadott példány InnerExceptions property-je tartalmazza az eredeti kivételeket.

A kivételt a Task.Exception property-vel is lekérdezhetjük, ez csak abban az esetben nem null, ha a Task.isFaulted property true. Ilyenkor célszerű a Task.CountinueWith segítségével lekezelni a kivételt. A TaskCountinuationOptions.OnlyOnFaulted opció esetén a folytató task csak akkor hívódik meg, ha a kezdőben kivétel történt és garantált, hogy az Exception property nem lesz null.

var task1 = Task.Factory.StartNew(() => { throw new MyCustomException("Task1 faulted."); }) .ContinueWith((t) => { Console.WriteLine("I have observed a {0}", t.Exception.InnerException.Message); }, TaskContinuationOptions.OnlyOnFaulted);