.NET: Unelte pentru lucrul cu multitasking și asincronitate. Partea 1

Public articolul original pe Habr, a cărui traducere este publicată în corporație blog.

Necesitatea de a face ceva în mod asincron, fără a aștepta rezultatul aici și acum, sau de a împărți o muncă mare între mai multe unități care o îndeplinesc, a existat și înainte de apariția computerelor. Odată cu apariția acestora, această necesitate a devenit foarte evidentă. Acum, în 2019, când compun acest articol pe un laptop cu un procesor Intel Core de 8 nuclee, pe care în paralel rulează nu o sută de procese, ci chiar mai multe fire de execuție. Lângă mine, se află un telefon puțin uzat, cumpărat acum câțiva ani, având la bord un procesor cu 8 nuclee. Pe resursele tematice sunt multe articole și videoclipuri unde autorii se minunează de smartphone-urile de top ale acestui an, care sunt echipate cu procesoare de 16 nuclee. MS Azure oferă, cu mai puțin de 20 $/oră, o mașină virtuală cu un procesor de 128 de nuclee și 2 TB RAM. Din păcate, nu este posibil să extragi maximum și să stăpânești această putere fără a ști cum să gestionezi interacțiunea firelor de execuție.

Terminologie

Proces (Process) — obiect de sistem de operare, spațiu adresabil izolat, conține fire.
Fir (Thread) — obiect de sistem de operare, cea mai mică unitate de execuție, parte a unui proces, firele împărtășesc memoria și alte resurse între ele în cadrul procesului.
Multitasking — proprietate a sistemului de operare, capacitatea de a executa mai multe procese simultan
Multi-core — proprietatea procesorului, capacitatea de a utiliza mai multe nuclee pentru procesarea datelor
Multiprocesare — proprietatea computerului, capacitatea de a lucra simultan cu mai multe procesoare fizic
Multithreading — proprietatea procesului, capacitatea de a distribui procesarea datelor între mai multe fire.
Paralelism — execuția mai multor acțiuni fizic simultan într-o unitate de timp
Asincronism — executarea unei operații fără a aștepta finalizarea acestei prelucrări, rezultatul execuției putând fi procesat ulterior.

Metaforă

Nu toate definițiile sunt bune și unele necesită explicații suplimentare, așa că la terminologia introdusă formal voi adăuga o metaforă despre gătirea micului dejun. Gătirea micului dejun în această metaforă este un proces.

Gătind micul dejun dimineața, eu (CPU) vin în bucătărie (Computer). Am 2 mâini (Cores). În bucătărie există o serie de dispozitive (IO): cuptor, fierbător, prăjitor de pâine, frigider. Pornesc gazul, pun tigaia pe foc și torn ulei în ea, fără să aștept să se încălzească (asincron, Non-Blocking-IO-Wait), scot ouăle din frigider și le sparg în farfurie, apoi le bat cu o mână (Thread#1), iar cu cealaltă (Thread#2) țin farfuria (Resursă partajată). Acum ar trebui să pornesc și fierbătorul, dar nu am suficiente mâini (Thread Starvation) Între timp, tigaia se încălzește (Prelucrarea rezultatului) în care torn ceea ce am bătut. Mă întind după fierbător și îl pornesc și mă uit cum apa din el începe să fiarbă (Blocking-IO-Wait), deși aș fi putut să spăl farfuria în acest timp, unde am bătut omleta.

Am gătit omleta folosind doar 2 mâini, da, atât am, dar în acest moment bătutul omletei se desfășura simultan cu 3 operații: baterea omletei, ținerea farfuriei, încălzirea tigăii. CPU este cea mai rapidă parte a computerului, IO este ceea ce adesea întârzie, prin urmare, o soluție eficientă este să ocupi CPU cu ceva în timp ce aștepți datele de la IO.

Continuând metafora:

  • Dacă în timpul gătitului omletei, aș fi încercat să mă îmbrac, aceasta ar fi fost un exemplu de multitasking. Un detaliu important: la computere acest lucru este mult mai eficient decât la oameni.
  • O bucătărie cu mai mulți bucătari, de exemplu într-un restaurant – un computer multicore.
  • Multe restaurante într-un food court la centrul comercial – un centru de date

Instrumente .NET

În lucrul cu firele de execuție, ca în multe alte lucruri, .NET este bun. Cu fiecare versiune nouă, reprezintă tot mai multe instrumente noi pentru a lucra cu ele, noi niveluri de abstrahere deasupra firelor de execuție ale sistemului de operare. În lucru cu construirea abtrațiilor, dezvoltatorii framework-ului folosesc o abordare care lasă posibilitatea, atunci când folosesc o abstracție de nivel înalt, să coboare pe unul sau mai multe niveluri mai jos. În majoritatea cazurilor nu este necesar, ba mai mult, aceasta deschide posibilitatea de a-și trasa singuri un glonț în picior cu un shotgun, dar uneori, în cazuri rare, aceasta poate fi singura modalitate de a rezolva o problemă care nu este rezolvabilă la nivelul de abstracție curent.

Prin instrumente mă refer la atât la interfețele de programare (API) furnizate de framework și pachetele terță parte, cât și la soluții complete de software care facilitează identificarea problemelor legate de codul multithreading.

Pornirea unui fir de execuție

Clasa Thread, cea mai de bază în .NET pentru lucrul cu fire. Constructorul acceptă unul dintre cei doi delegați:

  • ThreadStart — Fără parametrii
  • ParametrizedThreadStart — cu un singur parametru de tip object.

Delegatul va fi executat într-un nou fir creat după apelul metodei Start; dacă în constructor a fost transmis un delegat de tip ParametrizedThreadStart, atunci în metoda Start trebuie transmis un obiect. Acest mecanism este necesar pentru a transmite orice informație locală în fir. Este important de menționat că crearea unui fir este o operație costisitoare, iar firul în sine este un obiect greu, deoarece alocă cel puțin 1 MB de memorie pentru stivă și necesită interacțiune cu API-ul sistemului de operare.

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

Clasa ThreadPool reprezintă conceptul de pool. În .NET, pool-ul de fire este o realizare de inginerie, iar dezvoltatorii de la Microsoft au depus numeroase eforturi pentru a-l face să funcționeze optim în cele mai diverse scenarii.

Conceptul general:

De la start, aplicația creează în fundal câteva fire de rezervă și oferă posibilitatea de a le folosi. Dacă firele sunt utilizate frecvent și în număr mare, pool-ul se extinde pentru a satisface cerințele codului apelant. Când nu sunt disponibile fire libere în pool în acel moment, acesta va aștepta returnarea unui fir sau va crea unul nou. Din aceasta reiese că pool-ul de fire este ideal pentru acțiuni scurte și mai puțin potrivit pentru operațiuni care funcționează ca servicii pe tot parcursul aplicației.

Pentru a utiliza un fir din pool, există metoda QueueUserWorkItem, care acceptă un delegat de tip WaitCallback, ce corespunde semnăturii ParametrizedThreadStart, iar parametrul transmis acestuia îndeplinește aceeași funcție.

ThreadPool.QueueUserWorkItem(...);

O metodă mai puțin cunoscută a pool-ului de fire, RegisterWaitForSingleObject, servește la organizarea operațiunilor IO non-blocante. Delegatul transmis acestei metode va fi apelat atunci când WaitHandle-ul transmis metodei este „eliberat” (Released).

ThreadPool.RegisterWaitForSingleObject(...)

În .NET există un timer pe fir, iar acesta se deosebește de timerele WinForms/WPF prin faptul că handler-ul său va fi apelat în firul extras din pool.

System.Threading.Timer

De asemenea, există o metodă destul de exotică de a trimite un delegat pentru execuție într-un fir din pool — metoda BeginInvoke.

DelegateInstance.BeginInvoke

Vreau să mai menționez pe scurt funcția la care se reduc multe dintre metodele menționate mai sus — CreateThread din Kernel32.dll Win32 API. Există un mod, datorită mecanismului extern al metodelor, de a apela această funcție. Am văzut o astfel de apelare doar o dată într-un exemplu teribil de cod legacy, iar motivația autorului care a procedat așa rămâne în continuare o enigmă pentru mine.

Kernel32.dll CreateThread

Vizualizarea și debuggarea firelor

Firele create de tine personal, de toate componentele terțe și de pool-ul .NET pot fi vizualizate în fereastra Threads din Visual Studio. Această fereastră va afișa informații despre fire doar atunci când aplicația este în modul de debugger și în starea de oprire (Break mode). Aici poți vizualiza cu ușurință stiva, numele și prioritățile fiecărui fir, precum și să schimbi debugging-ul pe un fir specific. Utilizând proprietatea Priority a clasei Thread, poți seta prioritatea firului, pe care OC și CLR o vor percepe ca o recomandare în timpul împărțirii timpului procesor între fire.

.NET: Unelte pentru lucrul cu multitasking și asincronitate. Partea 1

Biblioteca de Paralelelism pentru Sarcini

Biblioteca de Paralelelism pentru Sarcini (TPL) a apărut în .NET 4.0. Acum este standardul și principalul instrument pentru lucrul cu asynchronicitatea. Orice cod care folosește abordări mai vechi este considerat legacy. Unitatea de bază a TPL este clasa Task din spațiul de nume System.Threading.Tasks. Task reprezintă o abstractizare a unui fir. Odată cu noua versiune a limbajului C#, am obținut un mod elegant de a lucra cu Task-uri — operatorii async/await. Aceste concepte au permis scrierea de cod asynchron în modul în care ar părea simplu și sincron, oferind astfel posibilitatea chiar și persoanelor cu cunoștințe reduse despre funcționarea internă a firelor să scrie aplicații care le utilizează, aplicații care nu se blochează în timpul operațiunilor prelungite. Utilizarea async/await este un subiect pentru un articol sau chiar mai multe, dar voi încerca să rezum esența în câteva propoziții:

  • async este un modificator de metodă care returnează Task sau void
  • iar await este operatorul de așteptare non-blocant pentru Task.

O dată în plus: operatorul await, în general (există excepții), va permite continuarea fluxului de execuție curent, iar când Task-ul își va finaliza execuția, contextul (de fapt ar fi corect să spunem context, dar despre asta mai târziu) va fi liber și va continua execuția metodei mai departe. În .NET, acest mecanism este realizat la fel ca și yield return, când metoda scrisă se transformă într-o întreagă clasă, care este o mașină de stări și poate fi executată în bucăți separate în funcție de aceste stări. Cine este interesat poate scrie orice cod simplu folosind async/await, să-l compileze și să vizualizeze construirea cu ajutorul JetBrains dotPeek cu Cod Generat de Compilator activat.

Să analizăm opțiunile de lansare și utilizare a Task-ului. În exemplul de mai jos, creăm un nou task, care nu face nimic util (Thread.Sleep(10000)), dar în viața reală aceasta ar trebui să fie o muncă complexă care să utilizeze CPU.

using TCO = System.Threading.Tasks.TaskCreationOptions;

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

    await Task.Factory.StartNew(
        // Codul acțiunii va fi executat pe un alt context
        () => Thread.Sleep(10000),
        cancellationSource.Token,
        TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness,
        scheduler
    );

    //  Codul după await va fi executat pe contextul capturat
}

Task-ul este creat cu o serie de opțiuni:

  • LongRunning — o sugestie că task-ul nu va fi finalizat rapid, așa că poate ar trebui să ne gândim să nu luăm un fir din pool, ci să creăm unul separat pentru acest Task pentru a nu afecta celelalte.
  • AttachedToParent — Task-urile pot fi organizate în ierarhii. Dacă a fost folosită această opțiune, atunci Task-ul poate fi într-o stare în care acesta s-a finalizat și așteaptă finalizarea sub-task-urilor.
  • PreferFairness — înseamnă că ar fi bine să se execute Task-urile trimise la execuție mai devreme înaintea celor trimise mai târziu. Dar aceasta este doar o recomandare și rezultatul nu este garantat.

Al doilea parametru în metodă este transmis CancellationToken. Pentru a gestiona corect anularea operației după ce aceasta a fost lansată, codul executat trebuie să fie umplut cu verificări ale stării CancellationToken. Dacă nu există verificări, atunci metoda Cancel chemată pe obiectul CancellationTokenSource va putea opri execuția Task-ului doar până la lansarea acestuia.

Ultimul parametru trecut este un obiect scheduler de tip TaskScheduler. Această clasă și subclasele sale sunt concepute pentru a gestiona strategiile de distribuție a sarcinilor (Task) pe fire, iar prin default, Task-ul va fi executat pe un fir ales aleatoriu din pool.

Task-ului creat i se aplică operatorul await, ceea ce înseamnă că codul scris după acesta, dacă există, va fi executat în același context (de obicei, aceasta înseamnă că va fi pe același fir) cu codul de dinainte de await.

Metoda este marcată ca async void, ceea ce înseamnă că este permisă utilizarea operatorului await, dar codul apelant nu va putea aștepta finalizarea acesteia. Dacă este necesară o astfel de capacitate, atunci metoda trebuie să returneze un Task. Metodele marcate async void sunt destul de frecvente: de obicei, acestea sunt gestori de evenimente sau alte metode care funcționează pe principiul 'execută și uită' (fire and forget). Dacă trebuie să oferi posibilitatea de a aștepta finalizarea execuției și de a returna un rezultat, atunci trebuie să utilizezi Task.

Pe Task-ul returnat de metoda StartNew, la fel ca și pe orice altul, poți apela metoda ConfigureAwait cu parametrul false, atunci execuția după await va continua nu pe contextul capturat, ci pe unul aleatoriu. Este recomandat să faci acest lucru întotdeauna când pentru codul de după await contextul execuției nu este esențial. Aceasta este, de asemenea, o recomandare din partea MS atunci când scrii cod care va fi livrat într-o formă împachetată ca bibliotecă.

Să ne oprim câteva momente asupra modului în care putem aștepta finalizarea execuției Task-ului. Mai jos se află un exemplu de cod, cu comentarii privind când așteptarea a fost realizată relativ bine și când relativ prost.

public static async void AnotherMethod() {

    int result = await AsyncMethod(); // bun

    result = AsyncMethod().Result; // prost

    AsyncMethod().Wait(); // prost

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

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

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

În primul exemplu, așteptăm finalizarea Task-ului fără a bloca firul apelant, iar la procesarea rezultatului vom reveni doar când acesta este disponibil; până atunci, firul apelant este lăsat să-și vadă de treabă.

În a doua variantă, blocăm firul apelant până când este calculat rezultatul metodei. Acest lucru este dezavantajos nu doar pentru că am ocupat un fir, un resurs valoros al programului, cu o activitate simplă, ci și pentru că, dacă în codul metodei pe care o apelăm există un await, iar contextul de sincronizare preconizează revenirea la firul apelant după await, vom obține un deadlock: firul apelant așteaptă ca rezultatul metodei asincrone să fie calculat, iar metoda asincronă încearcă în zadar să își continue execuția în firul apelant.

O altă deficiență a acestei abordări este că gestionarea erorilor devine mai complicată. Problema este că erorile în codul asincron utilizând async/await sunt foarte ușor de tratat — se comportă ca și cum codul ar fi sincron. Pe de altă parte, dacă aplicăm așteptarea sincronă la un Task, excepția originală este încapsulată într-o AggregateException; astfel, pentru a gestiona excepția va trebui să investigăm tipul InnerException și să scriem o serie de if-uri într-un singur bloc catch sau să folosim construcția catch when, în loc de seria mai obișnuită de blocuri catch în lumea C#.

Cele de-a treia și ultima exemple sunt, de asemenea, evidențiate ca fiind slabe din același motiv și conțin aceleași probleme.

Metodele WhenAny și WhenAll sunt extrem de utile pentru a aștepta un grup de Task-uri, ele încapsulează un grup de Task-uri într-unul singur, care se va activa fie la prima finalizare a unui Task din grup, fie atunci când toate vor finaliza execuția.

Oprirea firelor

Din diverse motive, poate apărea necesitatea de a opri un fir după ce acesta a început. Pentru aceasta există o serie de metode. Clasa Thread are două metode cu denumiri potrivite — acestea sunt Abort și Interrupt. Prima este extrem de nerecomandată pentru utilizare, deoarece după apelul său, într-un moment oarecare, în timpul procesării oricărei instrucțiuni, va fi generată o excepție. ThreadAbortedException. Nu vă așteptați că o astfel de excepție să se producă în timpul incrementării unei variabile întregi, nu-i așa? Dar utilizând această metodă, aceasta este o situație complet reală. În cazul în care este necesar, pentru a interzice CLR să genereze o astfel de excepție într-o anumită porțiune de cod, aceasta poate fi încapsulată în apeluri la Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Orice cod scris în blocul finally se transformă în provocări. Din acest motiv, în adâncurile codului framework-ului se pot găsi blocuri cu try gol, dar nu și finally gol. Microsoft recomandă cu tărie evitarea acestei metode, astfel că nu a inclus-o în .net core.

Metoda Interrupt funcționează mai predictibil. Aceasta poate întrerupe firul cu o excepție. ThreadInterruptedException doar în momentele în care firul este în stare de așteptare. Aceasta intră într-o astfel de stare suspendându-se în așteptarea WaitHandle, lock-ului sau după apelarea Thread.Sleep.

Ambele variante descrise mai sus sunt problematice prin imprevizibilitatea lor. O soluție este utilizarea structurii CancellationToken și a clasei CancellationTokenSource. Esența este următoarea: se creează o instanță a clasei CancellationTokenSource și doar cel care o deține poate opri operația apelând metoda Cancel. În operație se transmite doar CancellationToken. Deținătorii CancellationToken nu pot anula singuri operația, ci pot verifica doar dacă operația a fost anulată. Pentru aceasta există proprietatea booleană IsCancellationRequested și metoda ThrowIfCancelRequested. Cea din urmă va genera o excepție TaskCancelledException dacă metoda Cancel a fost apelată pe instanța respectivă a CancellationTokenSource. Iar această metodă o recomand să o folosești. Este mai bună decât variantele anterioare, oferind un control total asupra momentelor în care excepția poate întrerupe operația.

Cea mai drastică variantă de oprire a firului este apelarea funcției Win32 API TerminateThread. Comportamentul CLR după apelarea acestei funcții poate fi imprevizibil. MSDN menționează despre această funcție următoarele: “TerminateThread is a dangerous function that should only be used in the most extreme cases. “

Transformarea legacy-API în Task Based cu ajutorul metodei FromAsync

Dacă ai avut norocul să lucrezi la un proiect care a fost inițiat deja după ce Task-urile au fost introduse și au încetat să provoace groază tăcută majorității dezvoltatorilor, atunci nu va trebui să te confrunți cu multe API-uri vechi, fie că sunt de terță parte, fie că au fost dezvoltate de echipa ta în trecut. Din fericire, echipa de dezvoltare .NET Framework s-a gândit la noi, chiar dacă, poate, scopul lor a fost auto-protecția. Fie cum fie, în .NET există o serie de instrumente pentru a transforma fără durere codul scris în vechile abordări de programare asincronă în noua formă. Unul dintre ele este metoda FromAsync a clasei TaskFactory. În exemplul de cod de mai jos, învăluiesc vechile metode asincrone ale clasei WebRequest în Task cu ajutorul acestei metode.

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

Acesta este doar un exemplu și este puțin probabil să fie necesar să faci așa ceva cu tipurile încorporate, dar orice proiect vechi este pur și simplu plin de metode BeginDoSomething care returnează IAsyncResult și metode EndDoSomething care le primesc.

Transformarea API-urilor moștenite în Task Based cu ajutorul clasei TaskCompletionSource

Un alt instrument important de luat în considerare este clasa TaskCompletionSource. Din punct de vedere al funcțiilor, scopului și principiului de funcționare, acesta poate semăna cumva cu metoda RegisterWaitForSingleObject a clasei ThreadPool despre care am scris mai sus. Cu ajutorul acestei clase, poți învălui cu ușurință și convenabil vechile API-uri asincrone în Task-uri.

Veți spune că am vorbit deja despre metoda FromAsync a clasei TaskFactory destinată acestor scopuri. Aici va trebui să ne amintim întreaga istorie a dezvoltării modelului asincron în .NET pe care Microsoft l-a propus în ultimii 15 ani: înainte de Task-Based Asynchronous Pattern (TAP) au existat Asynchronous Programming Pattern (APP), care erau despre metodele BeginDoSomething care returnează IAsyncResult și metodele EndDoSomething care le primesc și pentru moștenirea acestor ani metoda FromAsync se potrivește perfect, dar, cu timpul, i-a luat locul Event Based Asynchronous Pattern (EAP), care presupune că, la finalizarea unei operațiuni asincrone, va fi apelat un eveniment.

TaskCompletionSource este perfect pentru a învârti Task-urile API-ului mai vechi, construite în jurul modelului bazat pe evenimente. Esența funcționării sale este următoarea: obiectul acestei clase are o proprietate publică de tip Task, al cărei statut poate fi gestionat prin metodele SetResult, SetException etc. Clasa TaskCompletionSource. În locurile unde acest Task a avut aplicat operatorul await, acesta va fi finalizat sau va genera o excepție în funcție de metoda aplicată la TaskCompletionSource. Dacă încă nu este clar, să ne uităm la acest exemplu de cod, unde un API vechi din vremurile EAP este învăluit într-un Task folosind TaskCompletionSource: atunci când evenimentul se întâmplă, Task-ul va fi transformat în stare Completed, iar metoda care a aplicat operatorul await acestui Task va relua execuția primind obiectul. rezultat.

public static Task DoAsync(this SomeApiInstance someApiObj) {

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

    return completionSource.Task;
}

Sfaturi și trucuri pentru TaskCompletionSource

Învăluirea API-urilor vechi nu este tot ceea ce se poate realiza cu ajutorul TaskCompletionSource. Utilizarea acestei clase deschide o oportunitate interesantă de a proiecta diferite API-uri bazate pe Task-uri, care nu ocupă fire de execuție. Și, după cum ne amintim, un fir de execuție este un resurs costisitor, iar numărul lor este limitat (în principal prin RAM). Această limitare este ușor de atins atunci când dezvoltăm, de exemplu, o aplicație web încărcată cu o logică de afaceri complexă. Să analizăm acele posibilități despre care vorbesc, pe implementarea unei astfel de tehnici ca Long-Polling.

Pe scurt, esența trucului este următoarea: trebuie să obțineți de la API informații despre anumite evenimente care au loc pe partea sa, iar API-ul, din diverse motive, nu poate anunța un eveniment, ci poate doar să returneze starea. Un exemplu al acestor API-uri sunt toate cele construite pe baza HTTP înainte de epoca WebSocket sau atunci când nu este posibil, dintr-un motiv oarecare, să utilizați această tehnologie. Clientul poate interoga serverul HTTP. Serverul HTTP nu poate provoca singur comunicarea cu clientul. O soluție simplă este interogarea serverului la intervale de timp, dar aceasta generează o încărcare suplimentară pe server și o întârziere suplimentară în medie de TimerInterval / 2. Pentru a o ocoli, a fost inventat un truc numit Long Polling, care presupune o întârziere a răspunsului de la server până când expiră Timeout-ul sau apare un eveniment. Dacă a avut loc un eveniment, acesta este procesat; dacă nu, cererea este trimisă din nou.

while(!eventOccures && !timeoutExceeded)  {

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

Dar această soluție se va dovedi groaznică odată ce numărul clienților care așteaptă un eveniment va crește, deoarece fiecare astfel de client care așteaptă un eveniment ocupă un întreg fir de execuție. De asemenea, obținem o întârziere suplimentară de 1ms la activarea evenimentului, care de cele mai multe ori nu este semnificativă, dar de ce să facem soft-ul mai rău decât ar putea fi? Dacă eliminăm Thread.Sleep(1), vom încărca inutil 100% dintr-un nucleu de procesor în buclă inutilă. Cu ajutorul TaskCompletionSource putem modifica cu ușurință acest cod și rezolva toate problemele menționate mai sus:

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);
    }
}

Acest cod nu este gata pentru producție, ci doar demonstrativ. Pentru a fi utilizat în cazuri reale, trebuie să procesăm, cel puțin, situația în care mesajul a sosit în momentul în care nimeni nu îl așteaptă: în acest caz, metoda AcceptMessageAsync ar trebui să returneze deja un Task finalizat. Dacă această situație este, de fapt, cea mai frecventă, putem lua în considerare și utilizarea ValueTask.

At the time of receiving a message request, we create and place a TaskCompletionSource in the dictionary, and then we wait for one of two events to occur: either the specified time interval expires, or a message is received.

ValueTask: de ce și cum

Async/await operators, like the yield return operator, generate a state machine from the method, which creates a new object. This is usually not significant, but in rare cases, it can cause issues. The case in point could be a method that is invoked very frequently, in the tens and hundreds of thousands of calls per second. If such a method is written in a way that it returns a result in most cases without hitting all await methods, then .NET provides a tool to optimize this — the ValueTask structure. To clarify, let’s consider an example of its usage: there is a cache we access very frequently. Some values are present and thus we simply return them. If not, we go to some slow IO for them. The latter is preferred to do asynchronously, which makes the entire method asynchronous. Thus, an obvious way to write the method would be as follows:

public async Task GetById(int id) {

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

Due to a desire to slightly optimize and a slight fear regarding what Roslyn might generate when compiling this code, this example can be rewritten as follows:

public Task GetById(int id) {

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

The truly optimal solution in this case would be to optimize the hot path, which is to obtain the value from the dictionary without unnecessary allocations and load on the GC, while ensuring that in those rare cases when we do need to go to IO for the data everything remains roughly the same as before:

public ValueTask GetById(int id) {

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

Let’s analyze this code fragment in more detail: when a value exists in the cache, we create a structure, whereas in the absence of a value, the actual task will be wrapped in a meaningful one. The calling code does not care which path the execution took: from C# syntax perspective, ValueTask will behave like a regular Task in this case.

TaskScheduler: gestionarea strategiilor de lansare a Task-urilor

Următorul API pe care dorim să-l discutăm este clasa TaskScheduler și derivatele sale. Am menționat mai sus că în TPL există posibilitatea de a gestiona strategiile de distribuție a Task-urilor pe fire. Aceste strategii sunt definite în clasele derivate din TaskScheduler. Practic, orice strategie necesară va fi găsită în bibliotecă. ParallelExtensionsExtras, dezvoltată de Microsoft, dar care nu face parte din .NET, fiind livrată sub formă de pachet NuGet. Să examinăm pe scurt câteva dintre ele:

  • CurrentThreadTaskScheduler — execută Task-uri pe firul curent.
  • LimitedConcurrencyLevelTaskScheduler — limitează numărul de Task-uri care pot fi executate simultan, parametrul N fiind acceptat în constructor.
  • OrderedTaskScheduler — este definit ca LimitedConcurrencyLevelTaskScheduler(1), astfel că sarcinile vor fi executate secvențial.
  • WorkStealingTaskScheduler — implementează work-stealing metoda de distribuție a sarcinilor. Practic, este un ThreadPool separat. Rezolvă problema faptului că în .NET ThreadPool este o clasă statică, una pentru toate aplicațiile, ceea ce înseamnă că supraîncărcarea sau utilizarea incorectă într-o parte a programului poate duce la efecte secundare în alta. Mai mult, înțelegerea cauzelor acestor defecte este extrem de dificilă. Astfel, poate exista necesitatea de a utiliza WorkStealingTaskScheduler-uri separate în acele părți ale programului unde utilizarea ThreadPool-ului poate fi agresivă și imprevizibilă.
  • QueuedTaskScheduler — permite executarea sarcinilor conform regulilor de prioritizare a cozii.
  • ThreadPerTaskScheduler — creează un fir separat pentru fiecare Task care este executat. Poate fi util pentru sarcini care durează imprevizibil de mult.

Există un articol detaliat bun articol despre TaskScheduler-uri pe blogul Microsoft.

Pentru o depanare convenabilă a tot ce se leagă de Task-uri în Visual Studio, există fereastra Tasks. În această fereastră, puteți vedea starea curentă a sarcinii și să mergeți la linia de cod care este executată în acel moment.

.NET: Unelte pentru lucrul cu multitasking și asincronitate. Partea 1

PLinq și clasa Parallel

În plus față de Task-uri și tot ce a fost spus despre acestea în .NET, există încă două instrumente interesante: PLinq (Linq2Parallel) și clasa Parallel. Primul promite executarea paralelă a tuturor operațiunilor Linq pe mai multe thread-uri. Numărul de thread-uri poate fi configurat prin metoda de extensie WithDegreeOfParallelism. Din păcate, de cele mai multe ori, PLinq în modul de funcționare implicit nu are suficiente informații despre interiorul sursei de date pentru a oferi un câștig semnificativ de viteză, pe de altă parte, costul încercării este foarte scăzut: trebuie doar să apelăm metoda AsParallel înainte de șirul metodologiei Linq și să efectuăm teste de performanță. Mai mult, există posibilitatea de a transmite PLinq informații suplimentare despre natura sursei de date prin intermediul mecanismului Partitions. Puteți citi mai multe detalii aici și aici.

Clasa statică Parallel oferă metode pentru parcurgerea paralelă a colecției Foreach, executarea unui ciclu For și executarea mai multor delegați în paralel prin Invoke. Executarea thread-ului curent va fi oprită până la finalizarea calculelor. Numărul de thread-uri poate fi configurat prin transmiterea ParallelOptions ca ultim argument. Prin intermediul opțiunilor se poate specifica de asemenea TaskScheduler și CancellationToken.

Conclusions

Când am început să scriu acest articol pe baza materialelor prezentării mele și a informațiilor pe care le-am adunat în timpul lucrului după aceasta, nu mă așteptam să iasă atât de mult. Acum, când editorul de texte în care redactez acest articol îmi reproșează că am ajuns la pagina a 15-a, voi trasa concluziile intermediare. Alte trucuri, API-uri, instrumente vizuale și capcane vor fi discutate în următorul articol.

Concluzii:

  • Trebuie să cunoști instrumentele de lucru cu thread-uri, asynchronism și paralelism pentru a utiliza resursele PC-urilor moderne.
  • .NET oferă multe instrumente diferite pentru aceste scopuri
  • Nu toate acestea au apărut simultan, așa că se poate întâlni adesea cod vechi, totuși există metode de transformare a API-urilor vechi fără prea multe eforturi.
  • Lucrul cu thread-uri în .NET este reprezentat de clasele Thread și ThreadPool
  • Metodele Thread.Abort, Thread.Interrupt și funcția Win32 API TerminateThread sunt periculoase și nu sunt recomandate pentru utilizare. În locul lor, este mai bine să folosești mecanismul CancellationToken.
  • Fluxul este o resursă valoroasă, iar numărul lor este limitat. Este important să evităm situațiile în care fluxurile sunt blocate așteptând evenimente. Pentru asta, este convenabil să folosim clasa TaskCompletionSource.
  • Cea mai puternică și avansată unealtă .NET pentru gestionarea paralelismului și asincronismului sunt Task-urile.
  • Operatoarele C# async/await implementează conceptul de așteptare non-blocantă.
  • Distribuția Task-urilor pe fluxuri poate fi gestionată prin clase derivate ale TaskScheduler-ului.
  • Structura ValueTask poate fi utilă în optimizarea căilor fierbinți și a traficului de memorie.
  • Feronarele Tasks și Threads din Visual Studio oferă multe informații utile pentru depanarea codului multithreading sau asincron.
  • PLinq este un instrument grozav, dar poate să nu aibă suficiente informații despre sursa dvs. de date, însă acest lucru poate fi remediat prin mecanismul de partitioning.
  • Continuarea urmează...

Sursa: habr.com

Cumpără un hosting fiabil pentru site-uri cu protecție DDoS, servere VPS VDS 🔥 Cumpără un hosting fiabil pentru site-uri cu protecție DDoS, servere VPS VDS | ProHoster