.NET: Eszközök többszálú és aszinkronizáláshoz. 1. rész

Az eredeti cikket a Habrról teszem közzé, melynek fordítása a vállalati lapon felkerül blogbejegyzés.

A számítógépek megjelenése előtt megvolt az igény, hogy valamit aszinkron módon, az eredmény itt és most megvárása nélkül kell elvégezni, vagy a nagy munkát több, az azt végző egység között fel kell osztani. Eljövetelükkel ez az igény nagyon is kézzelfoghatóvá vált. Most, 2019-ben egy 8 magos Intel Core processzoros laptopon írom ezt a cikket, amelyen több mint száz folyamat fut párhuzamosan, és még több szál. A közelben van egy kicsit kopott, pár éve vásárolt telefon, 8 magos processzor van benne. A tematikus források tele vannak cikkekkel és videókkal, amelyekben szerzőik megcsodálják az idei zászlóshajó okostelefonokat, amelyek 16 magos processzort tartalmaznak. Az MS Azure 20 magos processzorral és 128 TB RAM-mal rendelkező virtuális gépet biztosít óránként kevesebb mint 2 dollárért. Sajnos lehetetlen kihozni a maximumot és kihasználni ezt az erőt anélkül, hogy kezelni tudnánk a szálak interakcióját.

terminológia

Folyamat - OS objektum, elszigetelt címtér, szálakat tartalmaz.
cérna - egy operációs rendszer objektum, a végrehajtás legkisebb egysége, egy folyamat része; a szálak megosztják egymással a memóriát és az egyéb erőforrásokat egy folyamaton belül.
multitasking - OS tulajdonság, több folyamat egyidejű futtatásának lehetősége
Többmagos - a processzor egy tulajdonsága, több mag felhasználásának lehetősége az adatfeldolgozáshoz
Többszörös feldolgozás - a számítógép tulajdonsága, több processzorral való egyidejű fizikai munkavégzés képessége
Többszálú — egy folyamat tulajdonsága, az adatfeldolgozás több szál között történő elosztásának képessége.
Párhuzamosság - időegység alatt több cselekvés fizikai egyidejű végrehajtása
Aszinkron — egy művelet végrehajtása anélkül, hogy megvárná a feldolgozás befejezését, a végrehajtás eredménye később feldolgozható.

metafora

Nem minden definíció jó, és néhány további magyarázatra szorul, ezért a formálisan bevezetett terminológiát a reggeli főzéséről szóló metaforával egészítem ki. A reggeli elkészítése ebben a metaforában egy folyamat.

Reggel reggeli elkészítése közben (CPU) kimegyek a konyhába (számítógép). 2 kezem van (Erek). Számos készülék van a konyhában (IO): sütő, vízforraló, kenyérpirító, hűtőszekrény. Bekapcsolom a gázt, ráteszek egy serpenyőt, és olajat öntök bele anélkül, hogy megvárnám, míg felmelegszik (aszinkron módon, nem blokkoló-IO-vár), kiveszem a tojásokat a hűtőből és tányérra töröm, majd egy kézzel felverem (Szál #1), és a második (Szál #2) tartja a tányért (Megosztott erőforrás). Most szeretném bekapcsolni a vízforralót, de nincs elég kezem (Téma Éheztetés) Ezalatt felmelegszik a serpenyő (Az eredmény feldolgozása), amibe beleöntöm, amit felvertem. A vízforraló után nyúlok, bekapcsolom és hülyén nézem, ahogy forr benne a víz (Blokkolás-IO-Várjon), bár ezalatt kimoshatta volna a tányért, ahol az omlettet felvert.

Csak 2 kézzel főztem egy omlettet, és nincs több, ugyanakkor az omlett felverésének pillanatában egyszerre 3 művelet zajlott: az omlett felverése, a tányér fogása, a serpenyő felmelegítése A CPU a számítógép leggyorsabb része, az IO az, ami miatt legtöbbször minden lelassul, ezért gyakran hatékony megoldás az, ha lefoglaljuk valamivel a CPU-t, miközben az IO-tól adatokat fogadunk.

Folytatva a metaforát:

  • Ha az omlett készítése közben megpróbálnék átöltözni, ez a többfeladatos munka példája lenne. Egy fontos árnyalat: a számítógépek sokkal jobbak ebben, mint az emberek.
  • Konyha több szakácssal, például egy étteremben - többmagos számítógép.
  • Sok étterem egy bevásárlóközpontban található étteremben - adatközpont

.NET-eszközök

A .NET jól működik a szálakkal, mint sok más dologgal. Minden új verzióval egyre több új eszközt vezet be a velük való munkához, új absztrakciós rétegeket az operációs rendszer szálain. Amikor az absztrakciók felépítésével dolgoznak, a keretrendszer fejlesztői olyan megközelítést alkalmaznak, amely magas szintű absztrakció használatakor meghagyja a lehetőséget, hogy egy vagy több szinttel lejjebb menjen. Leggyakrabban erre nincs szükség, sőt megnyitja az ajtót, hogy sörétes puskával lábon lődd magad, de néha, ritka esetekben, ez az egyetlen módja annak, hogy megoldjunk egy olyan problémát, amely a jelenlegi absztrakciós szinten nem megoldott. .

Eszközök alatt mind a keretrendszer, mind a harmadik féltől származó csomagok által biztosított alkalmazásprogramozási felületeket (API-kat), valamint a teljes szoftvermegoldásokat értem, amelyek leegyszerűsítik a többszálú kóddal kapcsolatos problémák keresését.

Egy szál indítása

A Thread osztály a .NET legalapvetőbb osztálya a szálakkal való munkavégzéshez. A kivitelező két küldött egyikét fogadja el:

  • ThreadStart — Nincsenek paraméterek
  • ParametrizedThreadStart – egy objektum típusú paraméterrel.

A delegált az újonnan létrehozott szálban a Start metódus meghívása után kerül végrehajtásra Ha ParametrizedThreadStart típusú delegált került átadásra a konstruktornak, akkor egy objektumot kell átadni a Start metódusnak. Erre a mechanizmusra van szükség ahhoz, hogy bármilyen helyi információt továbbítsanak a folyamba. Érdemes megjegyezni, hogy egy szál létrehozása költséges művelet, és maga a szál nehéz objektum, legalábbis azért, mert 1 MB memóriát foglal le a veremben, és interakciót igényel az OS API-val.

new Thread(...).Start(...);

A ThreadPool osztály a medence fogalmát képviseli. A .NET-ben a szálkészlet egy mérnöki darab, és a Microsoft fejlesztői sok erőfeszítést tettek annak érdekében, hogy a legkülönfélébb forgatókönyvekben optimálisan működjön.

Általános koncepció:

Az alkalmazás indulásától kezdve több szálat hoz létre tartalékban a háttérben, és lehetővé teszi azok használatba vételét. Ha a szálakat gyakran és nagy számban használják, a készlet bővül, hogy megfeleljen a hívó igényeinek. Ha a megfelelő időben nincs szabad szál a készletben, akkor vagy megvárja az egyik szál visszatérését, vagy létrehoz egy újat. Ebből az következik, hogy a szálkészlet kiváló néhány rövid távú művelethez, és rosszul használható olyan műveletekhez, amelyek szolgáltatásként futnak az alkalmazás teljes működése során.

A készletből származó szál használatához van egy QueueUserWorkItem metódus, amely elfogadja a WaitCallback típusú delegált, amelynek aláírása megegyezik a ParametrizedThreadStart aláírásával, és a neki átadott paraméter ugyanazt a funkciót hajtja végre.

ThreadPool.QueueUserWorkItem(...);

A kevésbé ismert szálkészlet-módszer, a RegisterWaitForSingleObject a nem blokkoló IO-műveletek szervezésére szolgál. Az ehhez a metódushoz átadott delegált akkor hívódik meg, amikor a metódusnak átadott WaitHandle „Felengedett”.

ThreadPool.RegisterWaitForSingleObject(...)

A .NET-nek van szálidőzítője, és abban különbözik a WinForms/WPF időzítőktől, hogy a kezelője a készletből vett szálon lesz meghívva.

System.Threading.Timer

Van egy meglehetősen egzotikus módja annak, hogy egy delegált végrehajtásra küldjön egy szálhoz a készletből - a BeginInvoke metódus.

DelegateInstance.BeginInvoke

Szeretnék röviden elidőzni a függvénynél, amelyre a fenti metódusok közül sok meghívható - CreateThread a Kernel32.dll Win32 API-ból. A külső metódusok mechanizmusának köszönhetően van mód ennek a függvénynek a meghívására. Csak egyszer láttam ilyen felhívást a hagyatéki kód szörnyű példájában, és a szerző motivációja, aki pontosan ezt tette, továbbra is rejtély számomra.

Kernel32.dll CreateThread

Szálak megtekintése és hibakeresése

Az Ön által létrehozott szálak, az összes harmadik féltől származó összetevő és a .NET-készlet megtekinthetők a Visual Studio Szálak ablakában. Ez az ablak csak a szálinformációkat jeleníti meg, ha az alkalmazás hibakeresés alatt van, és Break módban van. Itt kényelmesen megtekintheti az egyes szálak veremneveit és prioritásait, és átkapcsolhatja a hibakeresést egy adott szálra. A Thread osztály Priority tulajdonságával beállíthatja egy szál prioritását, amelyet az OC és a CLR ajánlásként fog fel a processzoridő szálak közötti felosztása során.

.NET: Eszközök többszálú és aszinkronizáláshoz. 1. rész

Feladat párhuzamos könyvtár

A Task Parallel Library (TPL) a .NET 4.0-ban jelent meg. Most ez a szabvány és a fő eszköz az aszinkronizáláshoz. Minden olyan kód, amely régebbi megközelítést használ, örököltnek minősül. A TPL alapegysége a System.Threading.Tasks névtér Task osztálya. A feladat absztrakció egy szálon keresztül. A C# nyelv új verziójával elegánsan dolgozhatunk a Tasks - async/await operátorokkal. Ezek a koncepciók lehetővé tették, hogy az aszinkron kódot úgy írják, mintha az egyszerű és szinkron lenne, ez lehetővé tette még a szálak belső működéséhez kevéssé értő emberek számára is, hogy olyan alkalmazásokat írjanak, amelyek használják azokat, amelyek nem fagynak le hosszú műveletek végrehajtása során. Az async/await használata egy vagy akár több cikk témája is, de megpróbálom néhány mondatban megfogalmazni a lényeget:

  • Az async egy Task-t vagy void-ot visszaadó metódus módosítója
  • és a await egy nem blokkoló Várakozó feladat operátor.

Még egyszer: az await operátor általános esetben (vannak kivételek) tovább engedi az aktuális végrehajtási szálat, és amikor a Task befejezi a végrehajtást, és a szálat (sőt, helyesebb lenne a kontextust mondani , de erről később) folytatja a módszer végrehajtását. A .NET-en belül ez a mechanizmus a hozamhozamhoz hasonlóan valósul meg, amikor az írott metódusból egy egész osztály lesz, ami egy állapotgép, és ezektől az állapotoktól függően külön-külön is végrehajtható. Bárki, akit érdekel, bármilyen egyszerű kódot írhat az asynс/await segítségével, lefordíthatja és megtekintheti az összeállítást a JetBrains dotPeek segítségével, a fordító által generált kóddal.

Nézzük meg a Task indításának és használatának lehetőségeit. Az alábbi kódpéldában létrehozunk egy új feladatot, amely semmi hasznosat (Thread.Sleep (10000)), de a való életben ennek bonyolult CPU-igényes munkának kell lennie.

using TCO = System.Threading.Tasks.TaskCreationOptions;

public static async void VoidAsyncMethod() {
    var cancellationSource = new CancellationTokenSource();

    await Task.Factory.StartNew(
        // Code of action will be executed on other context
        () => Thread.Sleep(10000),
        cancellationSource.Token,
        TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness,
        scheduler
    );

    //  Code after await will be executed on captured context
}

A feladat számos lehetőséggel jön létre:

  • A LongRunning arra utal, hogy a feladat nem fejeződik be gyorsan, ami azt jelenti, hogy érdemes megfontolni, hogy ne vegyünk ki egy szálat a készletből, hanem készítsünk egy külön szálat ehhez a feladathoz, hogy ne okozzunk kárt másoknak.
  • AttachedToParent – ​​A feladatok hierarchiába rendezhetők. Ha ezt az opciót használták, akkor a Feladat olyan állapotban lehet, hogy maga is befejeződött, és gyermekei végrehajtására vár.
  • PreferFairness - azt jelenti, hogy jobb lenne a végrehajtásra küldött feladatokat korábban végrehajtani, mielőtt a később elküldötteket. De ez csak egy ajánlás, és az eredmény nem garantált.

A metódusnak átadott második paraméter a CancellationToken. A művelet elindítása utáni törlésének megfelelő kezeléséhez a végrehajtott kódot fel kell tölteni a CancellationToken állapotának ellenőrzésével. Ha nincsenek ellenőrzések, akkor a CancellationTokenSource objektumon meghívott Cancel metódus csak az indulás előtt tudja leállítani a feladat végrehajtását.

Az utolsó paraméter egy TaskScheduler típusú ütemező objektum. Ez az osztály és leszármazottai a Tasks szálak közötti elosztási stratégiák vezérlésére szolgálnak; alapértelmezés szerint a Task egy véletlenszerű szálon kerül végrehajtásra a készletből.

A létrehozott Task-ra az await operátor kerül alkalmazásra, ami azt jelenti, hogy az utána írt kód, ha van, ugyanabban a kontextusban (gyakran ez ugyanazon a szálon) kerül végrehajtásra, mint az await előtti kód.

A metódus async void-ként van megjelölve, ami azt jelenti, hogy használhatja a await operátort, de a hívó kód nem tud várni a végrehajtásra. Ha egy ilyen szolgáltatás szükséges, akkor a metódusnak vissza kell adnia a Task-ot. Az aszinkron ürességgel jelölt metódusok meglehetősen gyakoriak: ezek általában eseménykezelők vagy más módszerek, amelyek a tűz és felejts elven működnek. Ha nem csak lehetőséget kell adnia arra, hogy várjon a végrehajtás végéig, hanem vissza is adja az eredményt, akkor a Feladatot kell használnia.

A StartNew metódus által visszaadott Task-on és bármely máson is meghívhatja a ConfigureAwait metódust false paraméterrel, majd az await utáni végrehajtás nem a rögzített kontextuson, hanem egy tetszőlegesen folytatódik. Ezt mindig meg kell tenni, ha a végrehajtási környezet nem fontos a kód számára a várakozás után. Ez egyben az MS ajánlása is olyan kód írásakor, amelyet egy könyvtárba csomagolva szállítanak ki.

Foglalkozzunk még egy kicsit azzal, hogyan lehet megvárni egy Feladat befejezését. Az alábbiakban egy példa a kódra, megjegyzésekkel arra vonatkozóan, hogy az elvárás mikor teljesített feltételesen jól és mikor feltételesen rosszul.

public static async void AnotherMethod() {

    int result = await AsyncMethod(); // good

    result = AsyncMethod().Result; // bad

    AsyncMethod().Wait(); // bad

    IEnumerable<Task> tasks = new Task[] {
        AsyncMethod(), OtherAsyncMethod()
    };

    await Task.WhenAll(tasks); // good
    await Task.WhenAny(tasks); // good

    Task.WaitAll(tasks.ToArray()); // bad
}

Az első példában megvárjuk, amíg a Task befejeződik a hívó szál blokkolása nélkül, az eredmény feldolgozásához csak akkor térünk vissza, ha már megvan, addig a hívó szál a saját kezére van bízva.

A második lehetőségben blokkoljuk a hívó szálat, amíg a metódus eredményét ki nem számítjuk. Ez nem csak azért rossz, mert egyszerű tétlenséggel foglaltunk el egy szálat, a program ilyen értékes erőforrását, hanem azért is, mert ha az általunk meghívott metódus kódja várat tartalmaz, és a szinkronizálási kontextus megköveteli a hívó szálhoz való visszatérést. vár, akkor holtpontot kapunk : A hívó szál megvárja az aszinkron metódus eredményének kiszámítását, az aszinkron metódus hiába próbálja folytatni a végrehajtását a hívó szálban.

Ennek a megközelítésnek egy másik hátránya a bonyolult hibakezelés. Az a tény, hogy az aszinkron kód hibáit az async/await használatakor nagyon könnyű kezelni - ugyanúgy viselkednek, mintha a kód szinkron lenne. Míg ha szinkron várakozási ördögűzést alkalmazunk egy Task-ra, akkor az eredeti kivétel AggregateException-vé válik, azaz. A kivétel kezeléséhez meg kell vizsgálni az InnerException típust, és egy if-láncot kell írnia egy fogási blokkba, vagy a catch-et kell használnia a construct-ban, a C# világban megszokott catch blokkok lánca helyett.

A harmadik és az utolsó példa is ugyanebből az okból rossz, és ugyanazokat a problémákat tartalmazza.

A WhenAny és WhenAll metódusok rendkívül kényelmesek egy feladatcsoportra való várakozáshoz; egy feladatcsoportot tömörítenek, amely akkor aktiválódik, amikor a csoportból egy feladatot először aktiválnak, vagy amikor mindegyik befejezte a végrehajtását.

A szálak leállítása

Különféle okok miatt szükség lehet az áramlás leállítására, miután megindult. Ennek számos módja van. A Thread osztálynak két megfelelően elnevezett metódusa van: Elvetél и Megszakítás. Az első használata nagyon nem ajánlott, mert bármely véletlenszerű pillanatban történő hívás után, bármely utasítás feldolgozása során kivételt dob ​​a rendszer ThreadAbortedException. Ugye, nem számítasz arra, hogy egy ilyen kivétel megjelenjen az egész változók növelésekor? És ha ezt a módszert alkalmazzuk, ez egy nagyon valós helyzet. Ha meg kell akadályoznia, hogy a CLR ilyen kivételt generáljon a kód egy bizonyos szakaszában, akkor azt hívásokba csomagolhatja. Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Bármilyen kód, amelyet egy végleges blokkba írunk, ilyen hívásokba csomagolják. Emiatt a keretkód mélyén találhatunk olyan blokkokat, amelyek üres próbálkozással rendelkeznek, de végül nem. A Microsoft annyira elutasítja ezt a módszert, hogy nem építette be a .net magba.

A megszakítási módszer kiszámíthatóbban működik. Kivétellel megszakíthatja a szálat ThreadInterruptedException csak azokban a pillanatokban, amikor a szál várakozó állapotban van. Ebbe az állapotba kerül, miközben a WaitHandle-re, a zárolásra vagy a Thread.Sleep meghívása után lóg.

Mindkét fent leírt lehetőség rossz a kiszámíthatatlanságuk miatt. A megoldás egy szerkezet használata CancellationToken és osztály CancellationTokenSource. A lényeg a következő: létrejön a CancellationTokenSource osztály példánya, és csak az tudja leállítani a műveletet, aki a tulajdonosa a metódus meghívásával. Mégsem. Csak a CancellationToken kerül átadásra magának a műveletnek. A CancellationToken tulajdonosok nem törölhetik maguk a műveletet, csak ellenőrizhetik, hogy a műveletet törölték-e. Erre van egy logikai tulajdonság IsCancelationRequested és módszer ThrowIfCancelRequested. Ez utóbbi kivételt jelent TaskCancelledException ha a Mégse metódust a CancellationToken példányon hívták meg. És ezt a módszert javaslom. Ez előrelépés az előző opciókhoz képest azáltal, hogy teljes irányítást szerez afelől, hogy mikor szakítható meg egy kivételművelet.

A szál leállításának legbrutálisabb módja a Win32 API TerminateThread függvényének meghívása. Előfordulhat, hogy a CLR viselkedése a függvény meghívása után megjósolhatatlan. Az MSDN-en a következőt írják erről a funkcióról: „A TerminateThread egy veszélyes funkció, amelyet csak a legszélsőségesebb esetekben szabad használni. "

A régi API konvertálása feladat alapúvá a FromAsync módszerrel

Ha elég szerencsés egy olyan projekten dolgozni, amely a Tasks bevezetése után indult, és már nem okozott csendes rémületet a legtöbb fejlesztő számára, akkor nem kell sok régi API-val megküzdenie, mind a harmadik féltől származó API-kkal, mind a csapatával. kínzott a múltban. Szerencsére a .NET Framework csapata vigyázott ránk, bár talán az volt a cél, hogy vigyázzunk magunkra. Bárhogy is legyen, a .NET számos eszközzel rendelkezik a régi aszinkron programozási megközelítésekkel írt kódok fájdalommentes konvertálásához az újba. Az egyik a TaskFactory FromAsync metódusa. Az alábbi kódpéldában a WebRequest osztály régi aszinkron metódusait egy Task-ba csomagolom ezzel a módszerrel.

object state = null;
WebRequest wr = WebRequest.CreateHttp("http://github.com");
await Task.Factory.FromAsync(
    wr.BeginGetResponse,
    we.EndGetResponse
);

Ez csak egy példa, és valószínűleg nem kell ezt megtennie beépített típusokkal, de minden régi projekt egyszerűen hemzseg a BeginDoSomething metódusoktól, amelyek visszaadják az IAsyncResult és EndDoSomething metódusokat, amelyek fogadják.

A korábbi API konvertálása feladat alapúvá a TaskCompletionSource osztály használatával

Egy másik fontos mérlegelendő eszköz az osztály TaskCompletionSource. Funkcióit, célját és működési elvét tekintve némileg a ThreadPool osztály RegisterWaitForSingleObject metódusára emlékeztethet, amiről fentebb írtam. Ennek az osztálynak a használatával egyszerűen és kényelmesen becsomagolhatja a régi aszinkron API-kat a Tasks-ba.

Azt fogod mondani, hogy már beszéltem a TaskFactory osztály ilyen célokra szánt FromAsync metódusáról. Itt meg kell emlékeznünk a Microsoft által az elmúlt 15 évben kínált .net aszinkron modellek fejlesztésének teljes történetére: a Task-Based Asynchronous Pattern (TAP) előtt létezett az Asynchronous Programming Pattern (APP), amely módszerekről szólt KezdődikDoSomething visszatér IAsyncResult és módszerek végA DoSomething, amely elfogadja, és ezeknek az éveknek az örökségére a FromAsync módszer tökéletes, de idővel felváltotta az esemény alapú aszinkron minta (EAP), amely azt feltételezte, hogy az aszinkron művelet befejeződésekor egy esemény megtörténik.

A TaskCompletionSource tökéletes a Tasks és az eseménymodell köré épített örökölt API-k burkolásához. Munkájának lényege a következő: egy ebbe az osztályba tartozó objektum rendelkezik egy Task típusú publikus tulajdonsággal, melynek állapota a TaskCompletionSource osztály SetResult, SetException stb. metódusaival szabályozható. Azokon a helyeken, ahol a await operátort alkalmazták ehhez a feladathoz, az végrehajtódik vagy meghiúsul, a TaskCompletionSource-hoz alkalmazott metódustól függően. Ha még mindig nem világos, nézzük meg ezt a kódpéldát, ahol egy régi EAP API egy TaskCompletionSource használatával van csomagolva egy Task-ba: amikor az esemény elindul, a Task Completed állapotba kerül, és a await operátort alkalmazó metódus. ehhez a feladathoz az objektum kézhezvétele után folytatja a végrehajtását eredményez.

public static Task<Result> DoAsync(this SomeApiInstance someApiObj) {

    var completionSource = new TaskCompletionSource<Result>();
    someApiObj.Done += 
        result => completionSource.SetResult(result);
    someApiObj.Do();

    result completionSource.Task;
}

TaskCompletionSource Tippek és trükkök

A régi API-k becsomagolása nem minden, amit a TaskCompletionSource használatával lehet megtenni. Ennek az osztálynak a használata érdekes lehetőséget nyit meg különféle API-k tervezésére olyan Tasks-eken, amelyek nem foglalnak el szálakat. És a stream, mint emlékszünk, drága erőforrás, és számuk korlátozott (főleg a RAM mennyisége miatt). Ez a korlátozás könnyen elérhető például egy bonyolult üzleti logikával rendelkező betöltött webalkalmazás fejlesztésével. Vegyük fontolóra azokat a lehetőségeket, amelyekről beszélek, amikor olyan trükköt alkalmazunk, mint a Long-Polling.

A trükk lényege röviden a következő: az API-tól információt kell kapni az oldalán előforduló eseményekről, míg az API valamiért nem tudja jelenteni az eseményt, csak az állapotot tudja visszaadni. Példa erre az összes olyan API, amely HTTP-re épült a WebSocket ideje előtt, vagy amikor valamilyen okból lehetetlen volt ezt a technológiát használni. A kliens megkérdezheti a HTTP szervert. A HTTP szerver maga nem tud kommunikációt kezdeményezni az ügyféllel. Egy egyszerű megoldás a szerver lekérdezése egy időzítővel, de ez további terhelést jelent a szerveren és további késleltetést jelent átlagosan TimerInterval / 2. Ennek megkerülésére kitalálták a Long Polling nevű trükköt, ami a válasz késleltetését jelenti. szervert, amíg az Időtúllépés le nem jár, vagy egy esemény bekövetkezik. Ha esemény történik, akkor azt feldolgozza, ha nem, akkor a kérést újra elküldi.

while(!eventOccures && !timeoutExceeded)  {

  CheckTimout();
  CheckEvent();
  Thread.Sleep(1);
}

De egy ilyen megoldás szörnyűnek fog bizonyulni, amint megnő a rendezvényre váró ügyfelek száma, mert... Minden ilyen kliens egy egész szálat foglal el, és vár egy eseményre. Igen, és további 1 ms késleltetést kapunk az esemény indításakor, ez legtöbbször nem jelentős, de miért rontjuk el a szoftvert, mint amilyen lehet? Ha eltávolítjuk a Thread.Sleep(1)-et, akkor hiába töltünk be egy processzormagot 100%-ban üresjáratban, haszontalan ciklusban forogva. A TaskCompletionSource használatával egyszerűen újrakészítheti ezt a kódot, és megoldhatja a fent azonosított összes problémát:

class LongPollingApi {

    private Dictionary<int, TaskCompletionSource<Msg>> tasks;

    public async Task<Msg> AcceptMessageAsync(int userId, int duration) {

        var cs = new TaskCompletionSource<Msg>();
        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 gyártásra kész, hanem csak egy demó. A valós esetekben történő használathoz legalább azt a helyzetet is kezelni kell, amikor egy üzenet olyan időpontban érkezik, amikor senki sem várja: ebben az esetben az AsseptMessageAsync metódusnak egy már befejezett feladatot kell visszaadnia. Ha ez a leggyakoribb eset, akkor fontolóra veheti a ValueTask használatát.

Amikor üzenetkérést kapunk, létrehozunk és elhelyezünk egy TaskCompletionSource-t a szótárban, majd megvárjuk, hogy mi történik először: a megadott időintervallum lejár, vagy üzenet érkezik.

ValueTask: miért és hogyan

Az async/await operátorok a hozamvisszatérítési operátorhoz hasonlóan állapotgépet generálnak a metódusból, ez pedig egy új objektum létrehozása, ami szinte mindig nem fontos, de ritka esetekben problémát okozhat. Ez az eset egy nagyon gyakran hívott módszer lehet, másodpercenként tíz- és százezer hívásról beszélünk. Ha egy ilyen metódus úgy van megírva, hogy az esetek többségében minden várakozási metódust megkerülő eredményt ad vissza, akkor ennek optimalizálására a .NET kínál egy eszközt - a ValueTask struktúrát. Hogy világos legyen, nézzünk egy példát a használatára: van egy gyorsítótár, amelybe nagyon gyakran megyünk. Vannak benne értékek, majd egyszerűen visszaadjuk őket, ha nem, akkor valami lassú IO-hoz megyünk, hogy megszerezzük őket. Ez utóbbit aszinkron módon szeretném megtenni, ami azt jelenti, hogy az egész módszer aszinkronnak bizonyul. Így a metódus megírásának kézenfekvő módja a következő:

public async Task<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return val;
    return await RequestById(id);
}

A kis optimalizálási vágy és egy enyhe félelem miatt, hogy Roslyn mit fog generálni a kód összeállításakor, átírhatja ezt a példát 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 lenne, nevezetesen az érték kinyerése a szótárból felesleges kiosztás és a GC terhelése nélkül, míg azokban a ritka esetekben, amikor még mindig az IO-ba kell mennünk adatokért. , minden plusz / mínusz 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 közelebbről ezt a kódrészletet: ha van érték a cache-ben, akkor létrehozunk egy struktúrát, különben az igazi feladat egy értelmesbe csomagolódik. A hívó kódnak nem mindegy, hogy a kód melyik útvonalon futott le: A ValueTask C# szintaxis szempontjából ugyanúgy fog viselkedni, mint egy normál Task ebben az esetben.

TaskSchedulers: feladatindítási stratégiák kezelése

A következő API, amelyet figyelembe szeretnék venni, az osztály feladat ütemező és származékai. Fentebb már említettem, hogy a TPL képes kezelni a Tasks szálak közötti elosztási stratégiáit. Az ilyen stratégiákat a TaskScheduler osztály leszármazottai határozzák meg. Szinte minden stratégia megtalálható a könyvtárban. ParallelExtensionsExtras, amelyet a Microsoft fejlesztett, de nem része a .NET-nek, hanem Nuget-csomagként szállítják. Nézzünk meg ezek közül néhányat röviden:

  • CurrentThreadTaskScheduler — végrehajtja a Tasks-t az aktuális szálon
  • LimitedConcurrencyLevelTaskScheduler — korlátozza az egyidejűleg végrehajtott Feladatok számát a konstruktorban elfogadott N paraméterrel
  • OrderedTaskScheduler — a LimitedConcurrencyLevelTaskScheduler(1) néven van definiálva, így a feladatok egymás után kerülnek végrehajtásra.
  • WorkStealingTaskScheduler - eszközöket munkalopás a feladatelosztás megközelítése. Lényegében ez egy különálló ThreadPool. Megoldja azt a problémát, hogy a .NET ThreadPool egy statikus osztály, egy minden alkalmazás számára, ami azt jelenti, hogy túlterhelése vagy helytelen használata a program egyik részében mellékhatásokhoz vezethet egy másikban. Ezenkívül rendkívül nehéz megérteni az ilyen hibák okát. Hogy. Szükség lehet külön WorkStealingTaskSchedulers használatára a program olyan részein, ahol a ThreadPool használata agresszív és kiszámíthatatlan lehet.
  • QueuedTaskScheduler — lehetővé teszi a feladatok prioritási sorszabályok szerinti végrehajtását
  • ThreadPerTaskScheduler — külön szálat hoz létre minden rajta végrehajtott feladathoz. Hasznos lehet olyan feladatoknál, amelyek elvégzése beláthatatlanul sok időt vesz igénybe.

Van egy jó részletes cikk a TaskSchedulersről a microsoft blogon.

A Tasks-hoz kapcsolódó összes probléma kényelmes hibakereséséhez a Visual Studio rendelkezik egy Tasks ablakkal. Ebben az ablakban láthatja a feladat aktuális állapotát, és ugorhat az éppen futó kódsorra.

.NET: Eszközök többszálú és aszinkronizáláshoz. 1. rész

PLinq és a párhuzamos osztály

A Feladatokon és minden róluk elmondotton kívül van még két érdekes eszköz a .NET-ben: a PLinq (Linq2Parallel) és a Parallel osztály. Az első az összes Linq-művelet párhuzamos végrehajtását ígéri több szálon. A szálak száma a WithDegreeOfParallelism kiterjesztési módszerrel konfigurálható. Sajnos a PLinq az alapértelmezett módban legtöbbször nem rendelkezik elegendő információval az adatforrás belső tulajdonságairól ahhoz, hogy jelentős sebességnövekedést biztosítson, másrészt a próbálkozás költsége nagyon alacsony: csak meg kell hívni az AsParallel metódust, mielőtt a Linq módszerek láncolatát, és futtasson teljesítményteszteket. Ezenkívül lehetőség van további információk átadására a PLinq-nek az adatforrás természetéről a Partíciók mechanizmus segítségével. Bővebben olvashatsz itt и itt.

A Parallel static osztály metódusokat biztosít a Foreach gyűjtemény párhuzamos iterációjához, a For ciklus végrehajtásához és több delegátus párhuzamos Invoke végrehajtásához. Az aktuális szál végrehajtása leáll a számítások befejezéséig. A szálak száma a ParallelOptions utolsó argumentumként történő átadásával állítható be. A TaskScheduler és a CancellationToken opciókat is megadhatja.

Álláspontja

Amikor elkezdtem írni ezt a cikket a beszámolóm anyagai és az azt követő munkám során gyűjtött információk alapján, nem számítottam rá, hogy ennyi lesz belőle. Most, amikor a szövegszerkesztő, amelyben ezt a cikket írom, szemrehányóan közli velem, hogy a 15. oldal elment, összefoglalom a közbenső eredményeket. További trükkökről, API-król, vizuális eszközökről és buktatókról a következő cikkben lesz szó.

Következtetések:

  • A modern PC-k erőforrásainak használatához ismernie kell a szálakkal, az aszinkronnal és a párhuzamossággal végzett munka eszközeit.
  • A .NET számos különféle eszközzel rendelkezik erre a célra
  • Nem mindegyik jelent meg egyszerre, így gyakran találhatunk örökölt API-kat, azonban vannak módok a régi API-k különösebb erőfeszítés nélkül konvertálására.
  • A szálakkal való munkát a .NET-ben a Thread és a ThreadPool osztály képviseli
  • A Thread.Abort, Thread.Interrupt és Win32 API TerminateThread metódusok veszélyesek, és nem ajánlottak használata. Ehelyett jobb a CancellationToken mechanizmus használata
  • Az áramlás értékes erőforrás, és kínálata korlátozott. El kell kerülni azokat a helyzeteket, amikor a szálak az eseményekre várnak. Ehhez célszerű a TaskCompletionSource osztályt használni
  • A párhuzamosság és az aszinkronizálás leghatékonyabb és legfejlettebb .NET-eszközei a Tasks.
  • A c# async/await operátorok megvalósítják a nem blokkoló várakozás koncepcióját
  • A TaskScheduler-eredetű osztályok segítségével szabályozhatja a feladatok elosztását a szálak között
  • A ValueTask struktúra hasznos lehet a hot-path-ok és a memóriaforgalom optimalizálásához
  • A Visual Studio Tasks és Threads ablakai sok hasznos információt nyújtanak a többszálú vagy aszinkron kódok hibakereséséhez
  • A PLinq egy jó eszköz, de lehet, hogy nem rendelkezik elegendő információval az adatforrásról, de ez a particionálási mechanizmussal javítható
  • Folytatás ...

Forrás: will.com

Hozzászólás