Megosztom a Habr-on a cikk eredeti verzióját, amelynek fordítása a vállalati .
Az igény, hogy valamit aszinkron módon végezzünk, anélkül, hogy azonnali eredményekre várnánk, vagy hogy nagy feladatokat több végrehajtó egység között osszunk fel, már a számítógépek megjelenése előtt is létezett. A számítógépek megjelenésével ez az igény nagyon hangsúlyossá vált. Jelenleg, 2019-ben, miközben ezt a cikket gépelem egy 8 magos Intel Core processzorral rendelkező laptopon, amelynek működtetésével párhuzamosan több száz folyamat dolgozik, és a szálak száma még ennél is több. Mellette fekszik egy kicsit már megviselt, pár évvel ezelőtt vásárolt telefon, amelynek szintén 8 magos processzora van. A témába vágó forrásokon bőven találhatóak olyan cikkek és videók, ahol a szerzők csodálják az idei csúcsmodellek 16 magos processzorait. Az MS Azure kevesebb mint 20 $/órás áron kínál virtuális gépet 128 magos processzorral és 2 TB RAM-mal. Sajnos a teljesítmény maximalizálása és uralása lehetetlen, ha nem tudjuk kezelni a szálak interakcióját.
Terminológia
Folyamat (Process) — az operációs rendszer (OS) objektuma, elszigetelt címzési tér, szálakat tartalmaz.
Szál (Thread) — az operációs rendszer (OS) objektuma, a végrehajtás legkisebb egysége, a folyamat része, a szálak megosztják a memóriát és más erőforrásokat egymás között a folyamat keretein belül.
Többfeladatos működés — az operációs rendszer (OS) sajátossága, amely lehetővé teszi több folyamat egyidejű végrehajtását
Többmagosság — a processzor egy sajátossága, amely lehetővé teszi több mag felhasználását az adatok feldolgozásához
Többprocesszorosság — a számítógép sajátossága, amely lehetővé teszi, hogy fizikailag több processzorral dolgozzon egyidejűleg
Többszálúság — a folyamat sajátossága, amely lehetővé teszi az adatok feldolgozásának szétosztását több szál között.
Párhuzamosság — több cselekvés fizikailag egyidejű végrehajtása egy időegység alatt
Aszinkronitás — egy művelet végrehajtása anélkül, hogy a feldolgozás befejezésére várnánk, az eredmény pedig később feldolgozható.
Metafora
Nem minden meghatározás jó, és néhány további magyarázatra szorul, ezért a formálisan bevezetett terminológiához hozzáfűzök egy metaforát a félidő várásával kapcsolatban. A reggeli készítése ebben a metaforában — process.
Miközben készítem a reggelit reggel, én (CPU) beléptem a konyhába (Számítógép). Nekem 2 kezem van (Magok). A konyhában számos eszköz található (IO): sütő, vízforraló, pirító, hűtő. Bekapcsolom a gázt, rárakom a serpenyőt, és öntök bele olajat, anélkül, hogy megvárnám, míg felmelegszik (aszinkron, Non-Blocking-IO-Wait), kiveszem a hűtőből a tojásokat, és egy tányérba töröm őket, amelyeket aztán egy kézzel felverek (Thread#1), a másikkal (Thread#2) megfogom a tányért (Shared Resource). Most már csak a vízforralót kellene bekapcsolni, de nincs elég kezem (Thread Starvation) Ekkor a serpenyő már melegszik (Eredmény feldolgozása), amibe öntöm, amit felvertem. Elérem a vízforralót és bekapcsolom, és csak nézem, ahogy a víz forr (Blocking-IO-Wait), pedig addig megtehettem volna, hogy elmosogatom a tányért, amiben a rántottát vertem.
Két kezemet használva készítettem el a rántottát, de több nincs is, viszont a rántotta verése közben egyszerre 3 művelet zajlott: a rántotta verése, a tányér tartása és a serpenyő melegítése. A CPU a számítógép leggyorsabb része, míg az IO az, ami gyakran lelassítja, ezért gyakran hatékony döntés, hogy a CPU-t foglalkoztatjuk, míg az IO adatokat kap.
Folytatva a metaforát:
- Ha a rántotta készítése közben még átöltözni is próbálnék, az a párhuzamos feldolgozás példája lenne. Fontos részlet: a számítógépek ezzel sokkal jobban teljesítenek, mint az emberek.
- A konyha több szakáccsal, például egy étteremben - egy többmagos számítógép.
- Számos étterem az étkezési udvarban - adatközpont.
Eszközök .NET
.NET kiválóan teljesít a szálkezelés terén, akárcsak sok más területen. Minden új verzió egyre több új eszközt kínál a munkához, új absztrakciós rétegeket hoz létre az operációs rendszerek szálai felett. Az absztrakciók felépítése során a keretrendszer fejlesztői olyan megközelítést alkalmaznak, amely lehetővé teszi, hogy a magasan elvont használat mellett, lejjebb is tudjunk süllyedni egy vagy több szintre. Ekkor gyakran nincs szükség rá, sőt, néha az a lehetőség, hogy saját magunkat meglőjük a sörétes puskával, ám néha, ritka esetekben, ez lehet az egyetlen módja a probléma megoldásának, ami nem oldható meg az aktuális absztrakciós szinten.
Az eszközök alatt értem azokat a programozási interfészeket (API), amelyeket a keretrendszer és harmadik fél csomagok kínálnak, valamint az egész szoftveres megoldásokat, amelyek megkönnyítik a több szálas kóddal kapcsolatos problémák felderítését.
Szál indítása
A Thread osztály, a legalapvetőbb a .NET-ben a szálak kezelésére. Két delegált közül az egyik átadható a konstruktorban:
- ThreadStart — Nincs paraméter
- ParametrizedThreadStart — egy object típusú paraméterrel.
A delegált a frissiben létrehozott szálban hajtódik végre a Start metódus hívása után. Ha a konstruktorban a ParametrizedThreadStart típusú delegátum került átadásra, akkor a Start metódusban kell átadni egy objektumot. Ez a mechanizmus azért szükséges, hogy bármilyen helyi információt át lehessen vinni a szálhoz. Fontos megjegyezni, hogy a szál létrehozása költséges művelet, és maga a szál is egy nehéz objektum, legalábbis azért, mert 1 MB memóriát foglal el a veremben, és eredetileg kölcsönhatásba kell lépnie az operációs rendszer API-jával.
new Thread(...).Start(...);
A ThreadPool osztály a pool fogalmát képviseli. A .NET-ben a szálak poolja mérnöki mestermű, és a Microsoft fejlesztői rengeteg erőfeszítést fektettek abba, hogy optimálisan működjön a legkülönbözőbb szcenáriókban.
Általános koncepció:
A program elindulását követően a háttérben több tartalék szálat hoz létre, és biztosítja lehetőséget a használatukra. Ha a szálak gyakran és nagy mennyiségben kerülnek felhasználásra, akkor a pool kibővül, hogy kielégítse a hívó kód igényét. Ha a poolban a kívánt időpillanatban nincs szabad szál, akkor vagy várja egy szál visszatérését, vagy újat hoz létre. Ebből következik, hogy a szálak poolja kiválóan alkalmas rövid ideig tartó tevékenységekre, de nem megfelelő folyamatosan működő szolgáltatásokhoz az alkalmazás teljes futása alatt.
A poolból való szálhasználathoz van egy QueueUserWorkItem metódus, amely egy WaitCallback típusú delegáltat fogad, ami a paraméterezett ThreadStart-al egybeesik, és az átadott paraméter ugyanazt a funkciót látja el.
ThreadPool.QueueUserWorkItem(...);
A szálak pooljának kevésbé ismert metódusa, a RegisterWaitForSingleObject, blokkolásmentes IO műveletek megszervezésére szolgál. A metódusnak átadott delegált akkor hívódik meg, amikor a metódusba átadott WaitHandle „elengedve” lesz.
ThreadPool.RegisterWaitForSingleObject(...);
A .NET-ben van egy szálas időzítő, ami eltér a WinForms/WPF időzítőktől, mivel a kezelője egy poolból vett szálban fog hívódni.
System.Threading.Timer
Van egy viszonylag egzotikus módja annak, hogy delegáltat küldjünk végrehajtásra a poolbeli szálhoz — a BeginInvoke metódus.
DelegateInstance.BeginInvoke
Szeretnék egy kicsit beszélni a fent említett módszerek közül soknak a meghívásához használt funkcióról — a CreateThread-ről a Kernel32.dll Win32 API-ból. Van egy módszer, amely lehetővé teszi, hogy az extern módszereken keresztül hívjuk meg ezt a funkciót. Ezt a hívást csak egyszer láttam egy borzasztó legacy kódban, és a szerző motivációja, aki így tette, még mindig rejtély számomra.
Kernel32.dll CreateThread
Szálak megtekintése és hibakeresése
Az Ön által létrehozott, minden harmadik féltől származó komponenssel és a .NET pool-lal készült szálakat a Visual Studio Threads ablakában tekintheti meg. Ez az ablak csak akkor mutatja meg a szálakról szóló információkat, amikor az alkalmazás hibakeresés alatt áll és megszakított állapotban van. Itt kényelmesen megtekintheti minden szál címét és prioritását, valamint váltogathat a hibakeresés során egy adott szálra. A Thread osztály Priority tulajdonságával beállíthatja a szál prioritását, amelyet az OC és a CLR javaslatként fog értelmezni a szálak közötti processzoridő megosztásakor.

Feladat párhuzamos könyvtár
A Task Parallel Library (TPL) a .NET 4.0-ban jelent meg. Jelenleg ez a szabványos és fő eszköz az aszinkronitás kezelésére. Minden olyan kód, amely régebbi megközelítéseket használ, legacy-nek számít. A TPL alapegysége a Task osztály a System.Threading.Tasks névtérből. A Task egy absztrakció a szál felett. A C# nyelv új verziójával elegáns módon kezelhetjük a Task-okat — az async/await operátorok segítségével. Ezek a fogalmak lehetővé tették, hogy aszinkron kódot írjunk úgy, mintha az egyszerű és szinkron lenne, ezzel lehetőséget teremtve azok számára is, akik gyengén értik a szálak belső működését, hogy olyan alkalmazásokat írjanak, amelyek nem függnek a hosszú műveletek végrehajtása során. Az async/await használata téma lehet egy vagy akár több cikkhez, de megpróbálom néhány mondatban összefoglalni a lényeget:
- az async egy olyan módszer módosító, amely Task-t vagy void-ot ad vissza
- az await pedig egy blokkolás nélküli várakozási operátor Task-ra.
Ismét: az await operátor általában (vannak kivételek) továbbengedi a jelenlegi végrehajtási szálat, és amikor a Task befejezi a futását, a szál (valójában helyesebb lenne azt mondani, hogy a kontextus, de erről később) szabadon folytatja a metódus végrehajtását. A .NET-en belül ezt a mechanizmust úgy valósítják meg, mint a yield return, amikor az írt metódus egy egész osztályra alakul, amely állapotgép és az állapotok függvényében különböző darabokban hajtható végre. Aki érdeklődik, írhat bármilyen egyszerű kódot asynс/await használatával, lefordíthatja és megtekintheti a JetBrains dotPeek segítségével a kompilátor által generált kódot.
Nézzük meg a Task indításának és használatának lehetőségeit. Az alábbi kód példa alapján létrehozunk egy új taskot, amely nem végez hasznos munkát (Thread.Sleep(10000)), de a való életben ennek bonyolult CPU-igényes munkát kellene végeznie.
using TCO = System.Threading.Tasks.TaskCreationOptions;
public static async void VoidAsyncMethod() {
var cancellationSource = new CancellationTokenSource();
await Task.Factory.StartNew(
// A művelet kódja egy másik kontextusban kerül végrehajtásra
() => Thread.Sleep(10000),
cancellationSource.Token,
TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness,
scheduler
);
// A await utáni kód a rögzített kontextusban fog végrehajtódni
}
A Task több opcióval jön létre:
- LongRunning — ez egy jelzés arra, hogy a feladat nem fog gyorsan végbemenni, tehát érdemes lehet megfontolni, hogy ne vegyél egy szálat a poolból, hanem külön egyet létrehozni ehhez a Taskhoz, hogy ne ártson a többi feladatnak.
- AttachedToParent — a Taskok hierarchiába állíthatók. Ha ezt az opciót használták, a Task olyan állapotban lehet, amikor ő maga már befejeződött, de vár a gyermekeinek a befejezésére.
- PreferFairness — azt jelenti, hogy érdemes előbb végrehajtani azokat a Taskokat, amelyeket korábban küldtek el, mint azokat, amelyeket később küldtek. De ez csak ajánlás és az eredmény nem garantált.
A metodus második paraméterében a CancellationToken került átadásra. A művelet megszakításának helyes kezeléséhez a végrehajtás megkezdése után a végrehajtott kódnak ellenőrzéseket kell tartalmaznia a CancellationToken állapotáról. Ha nincsenek ellenőrzések, akkor a CancellationTokenSource-on végrehajtott Cancel metódus csak a Task futásának megkezdése előtt tudja leállítani a feladat végrehajtását.
Az utolsó paraméter a TaskScheduler típusú scheduler objektumot adja át. Ez a osztály és örökösei a feladatok szálak közötti elosztási stratégiáinak kezelésére szolgálnak, alapértelmezés szerint a Task egy véletlenszerű szálon fog végrehajtódni a pool-ból.
A létrehozott Task-ra alkalmazva van az await operátor, ez azt jelenti, hogy a mögötte lévő kód (ha van ilyen) ugyanabban a kontextusban fog végrehajtódni (ez gyakran azt jelenti, hogy ugyanazon a szálon), mint az await előtt lévő kód.
A metódus async void-ként van megjelölve, ez azt jelenti, hogy megengedett az await operátor használata, de a hívó kód nem tud várakozni a végrehajtásra. Ha erre szükség van, a metódusnak Task-t kell visszaadnia. Az async void-ra megjelölt metódusok meglehetősen gyakoriak: jellemzően eseménykezelők vagy más, végrehajtásra és elfelejtésre (fire and forget) épülő metódusok. Ha nemcsak azt szeretnénk lehetővé tenni, hogy várjunk a végrehajtás befejezésére, hanem eredményt is szeretnénk visszaadni, akkor Task-ot kell használnunk.
A StartNew metódussal visszaadott Task-on, akárcsak bármely másik Task-on, hívhatjuk a ConfigureAwait metódust a false paraméterrel, így a await után a végrehajtás nem a megszerzett kontextusban, hanem tetszőlegesen folytatódik. Ezt mindig meg kell tenni, amikor az await utáni kód végrehajtási kontextusa nem lényeges. Ez a Microsoft ajánlása is a könyvtár formájában kiadandó kód írásakor.
Nézzük meg kicsit részletesebben, hogyan várhatjuk meg egy Task befejezését. Az alábbi kód példa lent, megjegyzésekkel illusztrálva, hol történik a várakozás viszonylag jól és hol viszonylag rosszul.
public static async void AnotherMethod() {
int result = await AsyncMethod(); // jó
result = AsyncMethod().Result; // rossz
AsyncMethod().Wait(); // rossz
IEnumerable tasks = new Task[] {
AsyncMethod(), OtherAsyncMethod()
};
await Task.WhenAll(tasks); // jó
await Task.WhenAny(tasks); // jó
Task.WaitAll(tasks.ToArray()); // rossz
}
Az első példában úgy várjuk meg a Task befejezését, hogy nem blokkoljuk a hívó szálat, a legújabb eredményt csak akkor vizsgáljuk, amikor az már elkészült, addig a hívó szál szabadon folytathatja.
A második megoldásnál blokkoljuk a hívó szálat, amíg a metódus eredménye ki nem számításra kerül. Ez nemcsak azért rossz, mert egy értékes erőforrást, a szálat, foglalunk el egyszerű tétlenkedéssel, hanem azért is, mert ha a hívott metódus kódjában van await, és a szinkronizációs kontextus azt javasolja, hogy az await után visszatérjünk a hívó szálra, akkor deadlockhoz jutunk: a hívó szál vár arra, hogy az aszinkron metódus eredménye kiszámításra kerüljön, az aszinkron metódus pedig hiába próbálja folytatni a végrehajtását a hívó szálban.
Egy másik hátránya ennek a megközelítésnek a bonyolultabb hiba kezelés. Az a helyzet, hogy az aszinkron kódban az async/await használatával nagyon egyszerű a hibák kezelése — ugyanúgy viselkednek, mintha a kód szinkron lenne. Ezzel szemben, ha szinkron várakozást alkalmazunk egy Taskra, az eredeti kivétel AggregateExceptionbe van burkolva, így a kivétel kezeléséhez vizsgálni kell az InnerException típusát, és magunknak kell egy if láncot írnunk egy kivétel catch blokkban, vagy a catch when konstrukciót használni, a szokásosan használt catch blokk lánc helyett a C# világában.
A harmadik és utolsó példa is rossznak számít ugyanazon okból, és tartalmazza az összes említett problémát.
A WhenAny és WhenAll metódusok rendkívül hasznosak a Taskok csoportjának várakoztatásához, összefogják a Taskok csoportját egyetlen Taskba, amely vagy az első Task aktiválásakor, vagy amikor az összes befejezi a végrehajtást, aktiválódik.
Szálak leállítása
Különféle okokból szükségessé válhat egy szál leállítása a megkezdése után. Erre számos módszer létezik. A Thread osztálynak van két metódusa, amelyek megfelelő nevekkel rendelkeznek — ezek a következők: Abort és Interrupt. Az elsőt rendkívül nem ajánlott használni, mivel ennek meghívása után bármikor, bármelyik utasítás feldolgozása során kivételt dobhat. ThreadAbortedException. Ön biztosan nem várja el, hogy egy kivétel dobódjon, amikor egy egész szám változót növel, igaz? De ennek a metódusnak a használata esetén ez teljesen reális helyzet. Ha szükséges, meg lehet akadályozni, hogy a CLR generáljon ilyen kivételt egy kódrészleten belül az alábbi hívásokba burkolva: Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Bármely kód, amely a finally blokkban íródik, ilyen kihívásokkal néz szembe. Ezért a keretrendszer mélyén üres try blokkok találhatók, de nem üres finally. A Microsoft annyira nem ajánlja ezt a módszert, hogy nem is építette be a .net core-ba.
Az Interrupt módszer kiszámíthatóbban működik. Meg tudja tömni a szálat kivétellel. ThreadInterruptedException csak azokban a pillanatokban, amikor a szál várakozó állapotban van. Ebben az állapotban van, amikor a WaitHandle-tól, lock-tól függően várakozik, vagy a Thread.Sleep hívása után.
A fent említett két megoldás is rossz a kiszámíthatatlansága miatt. A megoldás a CancellationToken és a CancellationTokenSource. A lényeg az, hogy létrehozunk egy CancellationTokenSource példányt, és csak az, aki birtokolja, tudja leállítani a műveletet a Cancelmetódus hívásával. A műveletbe csak a CancellationToken kerül be. A CancellationToken birtokosai nem tudják maguk leállítani a műveletet, csak ellenőrizni tudják, hogy a művelet le lett-e állítva. Ehhez van egy boolean tulajdonság: IsCancellationRequested és egy metódus ThrowIfCancelRequested. Az utóbbi kivételt generál TaskCancelledException ha a CancellationTokenSource kapcsolt CancellationToken példányon hívják meg a Cancel metódust. És pontosan ezt a módszert ajánlom használni. Ez jobb, mint az előző lehetőségek, amelyek teljes ellenőrzést biztosítanak arról, hogy mikor lehet megszakítani a műveletet.
A szál leállításának legszigorúbb módja a Win32 API TerminateThread funkció hívása. A CLR viselkedése ezen funkció hívása után kiszámíthatatlan lehet. Az MSDN-ben az alábbiakat írják erről a funkcióról: A "TerminateThread egy veszélyes funkció, amelyet csak a legextrémebb esetekben szabad használni.”
A legacy-API Task Based-á alakítása a FromAsync metódussal.
Ha szerencsés vagy, hogy olyan projekten dolgozhatsz, amelyet már azután indítottak, hogy a Task-ok bevezetésre kerültek, és a legtöbb fejlesztőnél nem okoznak már csendes rémületet, akkor nem kell sok régi API-val foglalkoznod, sem harmadik féltől származó, sem a csapatod által a múltban megalkotottakkal. Szerencsére a .NET Framework fejlesztőcsapata gondoskodott rólunk, bár lehet, hogy az elsődleges céljuk saját maguk védelme volt. Mindenesetre a .NET-en belül számos eszköz található a régi aszinkron programozási megközelítésekre írt kód kényelmes átalakítására az új megközelítésbe. Az egyik ilyen eszköz a TaskFactory FromAsync módszere. Az alábbi kód példa segítségével régi aszinkron WebRequest metódusokat csomagolok Task-ká ennek a módszernek a segítségével.
object state = null;
WebRequest wr = WebRequest.CreateHttp("http://github.com");
await Task.Factory.FromAsync(
wr.BeginGetResponse,
wr.EndGetResponse
);
Ez csak egy példa, és valószínűleg nem kell ilyet téged beépített típusokkal csinálnod, de bármely régi projekt tele van BeginDoSomething metódusokkal, amelyek IAsyncResult-ot adnak vissza, és EndDoSomething metódusokkal, amelyek azt fogadják.
A legacy API-k Task Based átalakítása a TaskCompletionSource osztály segítségével
Még egy fontos eszköz, amit érdemes megemlíteni, az a TaskCompletionSource. Funkciói, célja és működési elve alapján hasonlíthat a ThreadPool osztály RegisterWaitForSingleObject metódusához, amiről korábban beszéltem. Ezen osztály segítségével könnyedén és kényelmesen burkolhatod a régi aszinkron API-kat Task-okba.
Azt mondod, hogy már beszéltem a TaskFactory FromAsync metódusáról, amely ezekre a célokra készült. Itt vissza kell emlékezni a .NET aszinkron modellek fejlődésének történetére, amelyet a Microsoft az utolsó 15 évben kínált: a Task-Based Asynchronous Pattern (TAP) előtt létezett az Asynchronous Programming Pattern (APP), amely a BeginDoSomething metódusokra vonatkozott, amelyek visszatértek IAsyncResult -ra, és az End DoSomething metódusokra, amelyek azt fogadták. A régi évek legacy-jához kiválóan illik a FromAsync metódus, de idővel helyébe lépett az Event Based Asynchronous Pattern (EAP), amely azt feltételezte, hogy az aszinkron művelet befejezése után eseményt hívnak meg.), amely azt feltételezte, hogy az aszinkron művelet befejezésekor esemény fog bekövetkezni.
A TaskCompletionSource is ideal for wrapping legacy API tasks built around an event-driven model. Its operation is based on the following: this class has a public property of type Task, the state of which can be managed through methods such as SetResult, SetException, and others. In the places where the await operator has been applied to this Task, it will be completed or thrown an exception depending on the method applied to the TaskCompletionSource. If this is still unclear, let's look at this example code, where an old EAP-era API is wrapped in a Task using TaskCompletionSource: upon event triggering, the Task will be transitioned to the Completed state, and the method that applied the await operator to this Task will resume execution, receiving the object. eredmény.
public static Task DoAsync(this SomeApiInstance someApiObj) {
var completionSource = new TaskCompletionSource();
someApiObj.Done +=
result => completionSource.SetResult(result);
someApiObj.Do();
return completionSource.Task;
}
TaskCompletionSource Tippek és Trükkök
A régi API-k becsomagolása nem az egyetlen dolog, amit a TaskCompletionSource segítségével meg lehet valósítani. Ennek a classnak a használata érdekes lehetőséget nyújt különböző, Task-alapú API-k tervezésére, amelyek nem foglalnak le szálakat. Mint tudjuk, a szálak erőforrások, és korlátozott mennyiségben állnak rendelkezésre (főleg a RAM mennyisége miatt). Ezt a korlátozást könnyen elérhetjük, ha például egy terhelt webalkalmazást fejlesztünk bonyolult üzleti logikával. Nézzük meg azokat a lehetőségeket, amelyekről beszélek, egy olyan trükk megvalósításán keresztül, mint a Long-Polling.
Röviden az a lényeg, hogy API-tól információt kell kapnia néhány eseményről, amelyek a háttérben zajlanak, de az API valamilyen okból nem tudja megmondani az eseményt, csupán az állapotot tudja visszaadni. Ilyen példák a HTTP alapú API-k, amelyek a WebSocket technológia megjelenése előtt léteztek, vagy amikor valamilyen okból nem lehet ezt a technológiát használni. Az ügyfél HTTP kiszolgálótól kérdezhet. A HTTP kiszolgáló nem tudja saját magától provokálni a kommunikációt az ügyféllel. Az egyszerű megoldás az, hogy időzítővel lekérdezzük a kiszolgálót, de ez extra terhelést okoz a kiszolgálón, és átlagosan egy plusz késlekedést jelent a TimerInterval / 2 időtartamig. Ennek megkerülésére találták ki a Long Polling trükköt, amely azt feltételezi, hogy a választ késleltetjük a kiszolgálótól, amíg le nem jár a Timeout, vagy amíg nem történik esemény. Ha esemény történik, azt feldolgozzuk, ha nem, a kérés újraküldésre kerül.
while(!eventOccurs && !timeoutExceeded) {
CheckTimeout();
CheckEvent();
Thread.Sleep(1);
}
De ez a megoldás szörnyen teljesít, ahogy a várakozó ügyfelek száma nő, mivel minden egyes ilyen ügyfél egy teljes szálat foglal el. Ráadásul 1 ms-os késlekedést is okoz az esemény bekövetkezésekor, ami gyakran nem jelentős, de miért csináljunk ilyen szoftvert, amikor jobbra is képes? Ha eltávolítjuk a Thread.Sleep(1) parancsot, akkor feleslegesen 100%-ra terheljük az egyik processzormagot, körforgásban egy értelmetlen ciklusban. A TaskCompletionSource segítségével ezt a kódot könnyen átalakíthatjuk, és megoldhatjuk a fent említett problémákat:
class LongPollingApi {
private Dictionary<int, TaskCompletionSource> tasks;
public async Task AcceptMessageAsync(int userId, int duration) {
var cs = new TaskCompletionSource();
tasks[userId] = cs;
await Task.WhenAny(Task.Delay(duration), cs.Task);
return cs.Task.IsCompleted ? cs.Task.Result : null;
}
public void SendMessage(int userId, Msg m) {
if (tasks.TryGetValue(userId, out var completionSource))
completionSource.SetResult(m);
}
}
Ez a kód nem production-ready, csupán demonstrációs jellegű. Valódi esetekben legalább annyit kell figyelembe venni, amikor az üzenet olyan pillanatban érkezik, amikor senki sem várja: ilyen esetben az AcceptMessageAsync metódusnak egy már befejezett Task-ot kell visszaadnia. Ha pedig ez a helyzet a leggyakoribb, akkor érdemes mérlegelni a ValueTask használatát is.
Ha üzenetküldési kérelem érkezik, létrehozunk és elhelyezünk egy TaskCompletionSource-t egy szótárban, majd várunk arra, hogy mi történik előbb: lejár a megadott időintervallum, vagy érkezik egy üzenet.
ValueTask: miért és hogyan
Az async/await operátorok, valamint a yield return operátor állapotgépet generál egy módszerből, ami új objektum létrehozását jelenti, ami szinte mindig nem számít, de ritka esetekben problémát okozhat. Ez az eset egy gyakran hívott metódus lehet, amely akár tizedesével vagy százasával hívódik másodpercenként. Ha egy ilyen metódust úgy írnak, hogy a legtöbb esetben az await metódusokat megkerülve tér vissza az eredménnyel, akkor a .NET eszközt biztosít ennek optimalizálására - a ValueTask szerkezetet. Hogy világos legyen, vizsgáljunk meg egy példát a használatára: van egy gyorsítótárunk, amelyhez nagyon gyakran hozzáférünk. Vannak benne értékek, és akkor ezeket egyszerűen visszaadjuk; ha nincsenek, akkor megyünk valamilyen lassú IO-hoz értük. Az utóbbit aszinkron módon szeretnénk végrehajtani, így az egész metódus aszinkron lesz. Ezért egy nyilvánvaló mód a metódus írására a következő:
public async Task<string> GetById(int id) {
if (cache.TryGetValue(id, out string val))
return val;
return await RequestById(id);
}
A kissé optimalizálási vágy és a Roslyn által generált kód miatti kis félelem következtében ezt a példát át lehet írni a következőképpen:
public Task<string> GetById(int id) {
if (cache.TryGetValue(id, out string val))
return Task.FromResult(val);
return RequestById(id);
}
Valójában az optimális megoldás ebben az esetben a hot-path optimalizálása, azaz az értékek megszerzése a szótárból teljesen felesleges allokációk és a GC terhelése nélkül, míg azokban a ritka esetekben, amikor szükség van az IO-hoz való hozzáférésre, minden marad +/- a régi módon:
public ValueTask<string> GetById(int id) {
if (cache.TryGetValue(id, out string val))
return new ValueTask<string>(val);
return new ValueTask<string>(RequestById(id));
}
Nézzük meg részletesebben ezt a kódrészletet: ha az érték a gyorsítótárban található, akkor egy struktúrát hozunk létre; ellenkező esetben a valódi task érték üzemmódba kerül. A hívó kódnak mindegy, hogy mely úton hajtották végre ezt a kódot: a ValueTask a C# szintaxisa szempontjából úgy működik, mint egy hagyományos Task ebben az esetben.
TaskScheduler-ek: a Task-ok végrehajtási stratégiáinak kezelése
A következő API, amelyet szeretnék megvizsgálni, ez a klassz TaskScheduler és annak származékait. Már említettem korábban, hogy a TPL lehetőséget ad a Taskok szálak közötti elosztási stratégiájának kezelésére. Az ilyen stratégiák a TaskScheduler osztály leszármazottaiban vannak meghatározva. Szinte bármely stratégia, amire szükség lehet, megtalálható a könyvtárban. ParallelExtensionsExtras, amelyet a Microsoft fejlesztett ki, de nem része a .NET-nek, hanem Nuget csomagként érhető el. Röviden áttekintjük néhányat közülük:
- CurrentThreadTaskScheduler — a Taskokat a jelenlegi szálon hajtja végre.
- LimitedConcurrencyLevelTaskScheduler — korlátozza a párhuzamosan végrehajtható Taskok számát az N paraméterrel, amit a konstruktorban adunk meg.
- OrderedTaskScheduler — a LimitedConcurrencyLevelTaskScheduler(1) szerint van meghatározva, így a feladatok sorban, egymás után fognak végrehajtódni.
- WorkStealingTaskScheduler — megvalósítja megközelítést a feladatok elosztásában. Lényegében egy külön ThreadPool. Megoldja azt a problémát, hogy a .NET ThreadPool egy statikus osztály, amely az összes alkalmazásra vonatkozik, ezért az egyes alkalmazásrészek terhelése vagy helytelen használata mellékhatásokat okozhat más részekben. Ráadásul nagyon nehéz megérteni az ilyen hibák okát. Ezért szükség lehet arra, hogy külön WorkStealingTaskScheduler-eket használjunk azokban a programrészekben, ahol a ThreadPool használata agresszív és kiszámíthatatlan lehet.
- QueuedTaskScheduler — lehetővé teszi a feladatok végrehajtását prioritásokkal rendelkező várakozási szabályok szerint.
- ThreadPerTaskScheduler — minden Taskhoz külön szálat hoz létre, amelyen végrehajtják. Hasznos lehet az előre nem láthatóan hosszú ideig futó feladatok esetében.
Részletes jó leírás található a TaskScheduler-ekről a Microsoft blogjában.
A Taskokkal kapcsolatos összes elem kényelmes hibakereséséhez a Visual Studio-ban található a Tasks ablak. Ebben az ablakban látható a feladat jelenlegi állapota és át lehet lépni a végrehajtás alatt lévő kód aktuális sorára.

PLinq és a Parallel osztály
A .NET-ben a Task-ok mellett két érdekes eszköz van még, a PLinq (Linq2Parallel) és a Parallel osztály. Az első párhuzamos végrehajtást ígér minden Linq műveleten több szálon. A szálak számát a WithDegreeOfParallelism bővítményi módszerrel lehet konfigurálni. Sajnos a PLinq legtöbb esetben az alapértelmezett működési módban nem rendelkezik elegendő információval az adatforrás belső működéséről ahhoz, hogy jelentős sebességnövekedést biztosítson, viszont a próbálkozás ára nagyon alacsony: csak annyit kell tennie, hogy az AsParallel metódust hívja meg a Linq metódusok láncolása előtt, és elvégezzen néhány teljesítménytesztet. Ráadásul lehetőség van arra is, hogy további információkat adjon át a PLinq-nek az adatforrásának természetéről a Partitions mechanizmus segítségével. További részleteket olvashat. és .
A statikus Parallel osztály párhuzamos gyűjteménybejárási Foreach, For ciklus végrehajtására és több delegált párhuzamos Invoke végrehajtására alkalmas módszereket biztosít. A jelenlegi szál végrehajtása leáll, amíg a számítások be nem fejeződnek. A szálak számát a ParallelOptions átadásával lehet konfigurálni, mint utolsó argumentum. Az opciók segítségével meg lehet határozni a TaskScheduler-t és a CancellationToken-t is.
Következtetések
Amikor elkezdtem ezt a cikket írni a saját előadásom anyagaiból és az információkból, amiket azóta gyűjtöttem, nem vártam, hogy ennyire terjedelmes lesz. Most, amikor a szövegszerkesztő, amiben írom ezt a cikket, már a 15. oldalra figyelmeztet, összegzem a köztes eredményeket. Más trükköket, API-kat, vizuális eszközöket és buktatókat a következő cikkben fogok tárgyalni.
Következtetések:
- Ismernie kell a szálkezeléssel, aszinkronitással és párhuzamosítással kapcsolatos eszközöket ahhoz, hogy kihasználja a modern PC-k erőforrásait.
- .NET-ben sokféle eszköz áll rendelkezésre ezekhez a célokhoz.
- Nem mindegyikük azonnal megjelent, ezért gyakran találkozhatunk legacy alkalmazásokkal, de vannak eszközök a régi API-k átalakítására különösebb erőfeszítés nélkül.
- A .NET-ben a szálkezelés a Thread és ThreadPool osztályokkal valósul meg.
- A Thread.Abort, Thread.Interrupt módszerek és a Win32 API TerminateThread funkciók veszélyesek és nem ajánlott a használatuk. Helyettük jobb a CancellationToken mechanizmus használata.
- A szál egy értékes erőforrás, mennyiségük korlátozott. El kell kerülni azokat a helyzeteket, amikor a szálak eseményekre várnak. Ehhez kényelmes a TaskCompletionSource osztály használata.
- A .NET legjobb és legfejlettebb eszköze a párhuzamosság és az aszinkronitás kezelésére a Task-ok.
- A C# async/await operátorok a nem blokkoló várakozás koncepcióját valósítják meg.
- A Task-ok szálakra való elosztását a TaskScheduler osztály öröklésével lehet irányítani.
- A ValueTask struktúra hasznos lehet a hot-paths és memóriaforgalom optimalizálásában.
- A Visual Studio Tasks és Threads ablakai sok hasznos információt nyújtanak a párhuzamos vagy aszinkron kód hibakereséséhez.
- A PLinq nagyszerű eszköz, de lehet, hogy nem rendelkezik elegendő információval az adatforrásáról, ezt azonban a partíciós mechanizmus segítségével lehet javítani.
- Folytatás következik…
Forrás: habr.com
