.NET: Eines per treballar amb multithreading i asincronia. Part 1

Estic publicant l'article original sobre Habr, la traducció del qual es publica a l'empresa bloc.

La necessitat de fer alguna cosa de manera asíncrona, sense esperar el resultat aquí i ara, o de dividir un gran treball entre diverses unitats que la realitzaven, existia abans de l'arribada dels ordinadors. Amb el seu arribada, aquesta necessitat es va fer molt tangible. Ara, el 2019, estic escrivint aquest article en un ordinador portàtil amb un processador Intel Core de 8 nuclis, en el qual s'executen més de cent processos en paral·lel, i encara més fils. A prop, hi ha un telèfon una mica cutre, comprat fa un parell d'anys, té un processador de 8 nuclis a bord. Els recursos temàtics estan plens d'articles i vídeos on els seus autors admiren els telèfons intel·ligents emblemàtics d'aquest any que inclouen processadors de 16 nuclis. MS Azure ofereix una màquina virtual amb un processador de 20 nuclis i 128 TB de RAM per menys de 2 dòlars l'hora. Malauradament, és impossible extreure el màxim i aprofitar aquesta potència sense poder gestionar la interacció dels fils.

Terminologia

Procés - L'objecte SO, espai d'adreces aïllat, conté fils.
Fil - un objecte SO, la unitat d'execució més petita, part d'un procés, els fils comparteixen memòria i altres recursos entre ells dins d'un procés.
Multitarea - Propietat del sistema operatiu, la capacitat d'executar diversos processos simultàniament
Multi-nucli - una propietat del processador, la capacitat d'utilitzar diversos nuclis per al processament de dades
Multiprocessament - una propietat d'un ordinador, la capacitat de treballar simultàniament amb diversos processadors físicament
Multithreading — una propietat d'un procés, la capacitat de distribuir el processament de dades entre diversos fils.
Paral·lelisme - realitzar diverses accions simultàniament físicament per unitat de temps
Asincronia — execució d'una operació sense esperar que finalitzi aquest processament; el resultat de l'execució es pot processar més endavant.

Metàfora

No totes les definicions són bones i algunes necessiten una explicació addicional, així que afegiré una metàfora sobre cuinar l'esmorzar a la terminologia introduïda formalment. Cuinar l'esmorzar en aquesta metàfora és un procés.

Mentre preparava l'esmorzar al matí vaig (CPU) Vinc a la cuina (Ordinador). Tinc 2 mans (Nuclis). Hi ha diversos aparells a la cuina (IO): forn, bullidor d'aigua, torradora, nevera. Encenc el gas, hi poso una paella i hi aboco oli sense esperar que s'escalfi (de manera asíncrona, sense bloqueig-IO-Espera), trec els ous de la nevera i els trenco en un plat i després els bato amb una mà (Fil núm. 1), i segon (Fil núm. 2) subjectant el plat (Recurs Compartit). Ara m'agradaria encendre la tetera, però no tinc prou mans (Fil de fam) Durant aquest temps, la paella s'escalfa (Processament del resultat) a la qual aboco el que he muntat. Aconsegueixo la tetera i l'encenc i veig estúpidament com l'aigua bull dins (Bloqueig-IO-Espera), tot i que durant aquest temps podria haver rentat el plat on batejava la truita.

Vaig cuinar una truita amb només 2 mans, i no en tinc més, però al mateix temps, en el moment de muntar la truita, es van fer 3 operacions alhora: muntar la truita, subjectar el plat, escalfar la paella. La CPU és la part més ràpida de l'ordinador, IO és el que més sovint tot s'alenteix, de manera que sovint una solució eficaç és ocupar la CPU amb alguna cosa mentre es rep dades d'IO.

Continuant amb la metàfora:

  • Si en el procés de preparació d'una truita també intentés canviar de roba, aquest seria un exemple de multitasca. Un matís important: els ordinadors són molt millors en això que les persones.
  • Una cuina amb diversos xefs, per exemple, en un restaurant: un ordinador multinucli.
  • Molts restaurants en un pati de menjar en un centre comercial - centre de dades

Eines .NET

.NET és bo per treballar amb fils, com amb moltes altres coses. Amb cada nova versió, introdueix més i més eines noves per treballar-hi, noves capes d'abstracció sobre fils del sistema operatiu. Quan es treballa amb la construcció d'abstraccions, els desenvolupadors de marcs utilitzen un enfocament que deixa l'oportunitat, quan s'utilitza una abstracció d'alt nivell, de baixar un o més nivells per sota. Molt sovint això no és necessari, de fet, obre la porta a disparar-se al peu amb una escopeta, però de vegades, en casos rars, pot ser l'única manera de resoldre un problema que no es resol al nivell d'abstracció actual. .

Per eines, em refereixo tant a les interfícies de programació d'aplicacions (API) proporcionades pel framework com a paquets de tercers, com a solucions de programari completes que simplifiquen la cerca de qualsevol problema relacionat amb el codi multifil.

Iniciant un fil

La classe Thread és la classe més bàsica de .NET per treballar amb fils. El constructor accepta un dels dos delegats:

  • ThreadStart — Sense paràmetres
  • ParametrizedThreadStart - amb un paràmetre de tipus objecte.

El delegat s'executarà al fil de nova creació després de cridar el mètode Start. Si s'ha passat un delegat del tipus ParametrizedThreadStart al constructor, s'ha de passar un objecte al mètode Start. Aquest mecanisme és necessari per transferir qualsevol informació local al flux. Val la pena assenyalar que la creació d'un fil és una operació costosa i el fil en si és un objecte pesat, almenys perquè assigna 1 MB de memòria a la pila i requereix interacció amb l'API del sistema operatiu.

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

La classe ThreadPool representa el concepte d'un grup. A .NET, el grup de fils és una peça d'enginyeria, i els desenvolupadors de Microsoft s'han esforçat molt per assegurar-se que funcioni de manera òptima en una gran varietat d'escenaris.

Concepte general:

Des del moment en què s'inicia l'aplicació, crea diversos fils de reserva en segon pla i ofereix la possibilitat d'utilitzar-los. Si els fils s'utilitzen amb freqüència i en gran nombre, el grup s'amplia per satisfer les necessitats de la persona que truca. Quan no hi hagi fils lliures al grup en el moment adequat, s'esperarà que torni un dels fils o en crearà un de nou. Es dedueix que el grup de fils és ideal per a algunes accions a curt termini i poc adequat per a operacions que s'executen com a serveis durant tot el funcionament de l'aplicació.

Per utilitzar un fil de l'agrupació, hi ha un mètode QueueUserWorkItem que accepta un delegat del tipus WaitCallback, que té la mateixa signatura que ParametrizedThreadStart, i el paràmetre que se li passa fa la mateixa funció.

ThreadPool.QueueUserWorkItem(...);

El mètode d'agrupació de fils menys conegut RegisterWaitForSingleObject s'utilitza per organitzar operacions d'IO sense bloqueig. El delegat passat a aquest mètode es cridarà quan el WaitHandle passat al mètode sigui "Alliberat".

ThreadPool.RegisterWaitForSingleObject(...)

.NET té un temporitzador de fils i es diferencia dels temporitzadors WinForms/WPF perquè el seu controlador es cridarà en un fil extret del grup.

System.Threading.Timer

També hi ha una manera força exòtica d'enviar un delegat per a l'execució a un fil del grup: el mètode BeginInvoke.

DelegateInstance.BeginInvoke

M'agradaria detenir-me breument en la funció a la qual es poden anomenar molts dels mètodes anteriors: CreateThread de Kernel32.dll Win32 API. Hi ha una manera, gràcies al mecanisme dels mètodes externs, d'anomenar aquesta funció. Només he vist aquesta trucada una vegada en un exemple terrible de codi heretat, i la motivació de l'autor que va fer exactament això encara segueix sent un misteri per a mi.

Kernel32.dll CreateThread

Visualització i depuració de fils

Els fils creats per tu, tots els components de tercers i el grup .NET es poden veure a la finestra Fils de Visual Studio. Aquesta finestra només mostrarà informació del fil quan l'aplicació estigui en fase de depuració i en mode d'interrupció. Aquí podeu veure còmodament els noms de pila i les prioritats de cada fil i canviar la depuració a un fil específic. Utilitzant la propietat Priority de la classe Thread, podeu establir la prioritat d'un fil, que l'OC i el CLR percebran com una recomanació en dividir el temps del processador entre fils.

.NET: Eines per treballar amb multithreading i asincronia. Part 1

Biblioteca paral·lela de tasques

Task Parallel Library (TPL) es va introduir a .NET 4.0. Ara és l'estàndard i l'eina principal per treballar amb asincronia. Qualsevol codi que utilitzi un enfocament més antic es considera heretat. La unitat bàsica de TPL és la classe Task de l'espai de noms System.Threading.Tasks. Una tasca és una abstracció sobre un fil. Amb la nova versió del llenguatge C#, tenim una manera elegant de treballar amb Tasks: operadors async/wait. Aquests conceptes van permetre escriure codi asíncron com si fos senzill i sincrònic, això va permetre que fins i tot persones amb poca comprensió del funcionament intern dels fils poguessin escriure aplicacions que els utilitzen, aplicacions que no es bloquegen quan es fan operacions llargues. L'ús de async/wait és un tema per a un o fins i tot per a diversos articles, però intentaré obtenir-ne l'essència en unes quantes frases:

  • async és un modificador d'un mètode que retorna Task o void
  • i await és un operador d'espera de tasques sense bloqueig.

Una vegada més: l'operador await, en el cas general (hi ha excepcions), alliberarà encara més el fil d'execució actual, i quan la tasca acabi la seva execució, i el fil (de fet, seria més correcte dir el context). , però més sobre això més endavant) seguirà executant el mètode encara més. Dins de .NET, aquest mecanisme s'implementa de la mateixa manera que el retorn de rendiment, quan el mètode escrit es converteix en una classe sencera, que és una màquina d'estats i es pot executar en peces separades en funció d'aquests estats. Qualsevol persona interessada pot escriure qualsevol codi senzill utilitzant asynс/wait, compilar i visualitzar el conjunt mitjançant JetBrains dotPeek amb el codi generat per compilador habilitat.

Vegem les opcions per iniciar i utilitzar Task. A l'exemple de codi següent, creem una tasca nova que no fa res útil (Thread.Sleep (10000)), però a la vida real, això hauria de ser un treball complex i intensiu de CPU.

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
}

Es crea una tasca amb diverses opcions:

  • LongRunning és una pista que la tasca no es completarà ràpidament, el que significa que pot val la pena considerar no agafar un fil del grup, sinó crear-ne un de separat per a aquesta tasca per no fer mal als altres.
  • AttachedToParent: les tasques es poden organitzar en una jerarquia. Si s'ha utilitzat aquesta opció, la tasca pot estar en un estat en què s'ha completat i està esperant l'execució dels seus fills.
  • PreferFairness: significa que seria millor executar les tasques enviades per a l'execució abans que les que s'envien més tard. Però això és només una recomanació i els resultats no estan garantits.

El segon paràmetre passat al mètode és CancellationToken. Per gestionar correctament la cancel·lació d'una operació després d'haver començat, el codi que s'està executant s'ha d'omplir amb comprovacions de l'estat CancellationToken. Si no hi ha comprovacions, el mètode Cancel anomenat a l'objecte CancellationTokenSource només podrà aturar l'execució de la tasca abans que comenci.

L'últim paràmetre és un objecte planificador del tipus TaskScheduler. Aquesta classe i els seus descendents estan dissenyats per controlar estratègies per distribuir les tasques entre fils; per defecte, la tasca s'executarà en un fil aleatori del grup.

L'operador await s'aplica a la tasca creada, el que significa que el codi escrit després, si n'hi ha, s'executarà en el mateix context (sovint això vol dir en el mateix fil) que el codi abans d'await.

El mètode està marcat com a async void, el que significa que pot utilitzar l'operador await, però el codi de trucada no podrà esperar l'execució. Si aquesta característica és necessària, el mètode ha de retornar Task. Els mètodes marcats com a void async són força comuns: per regla general, es tracta de gestors d'esdeveniments o altres mètodes que funcionen amb el principi de foc i oblit. Si no només heu de donar l'oportunitat d'esperar fins al final de l'execució, sinó també de retornar el resultat, heu d'utilitzar Task.

A la tasca que va retornar el mètode StartNew, així com a qualsevol altre, podeu cridar al mètode ConfigureAwait amb el paràmetre fals, i l'execució després d'await continuarà no en el context capturat, sinó en un altre arbitrari. Això sempre s'ha de fer quan el context d'execució no és important per al codi després d'esperar. Aquesta també és una recomanació de MS quan escriu codi que es lliurarà empaquetat en una biblioteca.

Anem una mica més a veure com podeu esperar que finalitzi una tasca. A continuació es mostra un exemple de codi, amb comentaris sobre quan l'expectativa es fa condicionalment bé i quan es fa condicionalment malament.

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
}

En el primer exemple, esperem que la tasca es completi sense bloquejar el fil de trucada; tornarem a processar el resultat només quan ja hi sigui; fins aleshores, el fil de trucada es deixa al seu propi dispositiu.

En la segona opció, bloquegem el fil de crida fins que es calculi el resultat del mètode. Això és dolent no només perquè hem ocupat un fil, un recurs tan valuós del programa, amb una simple ociositat, sinó també perquè si el codi del mètode que cridem conté espera, i el context de sincronització requereix tornar al fil que crida després await, aleshores tindrem un punt mort: el fil cridant espera que es calculi el resultat del mètode asíncron, el mètode asíncron intenta en va continuar la seva execució en el fil cridant.

Un altre desavantatge d'aquest enfocament és el tractament complicat d'errors. El fet és que els errors en el codi asíncron quan s'utilitza async/wait són molt fàcils de manejar: es comporten de la mateixa manera que si el codi fos sincrònic. Mentre que si apliquem l'exorcisme d'espera sincrònic a una tasca, l'excepció original es converteix en una AggregateException, és a dir. Per gestionar l'excepció, haureu d'examinar el tipus InnerException i escriure una cadena if dins d'un bloc catch o utilitzar el catch when construeix, en lloc de la cadena més familiar de blocs catch al món C#.

El tercer i darrer exemple també es marquen com a dolent per la mateixa raó i contenen tots els mateixos problemes.

Els mètodes WhenAny i WhenAll són extremadament convenients per esperar un grup de tasques; emboliquen un grup de tasques en un, que es dispararà quan s'iniciï per primera vegada una tasca del grup o quan totes hagin completat la seva execució.

Aturant fils

Per diverses raons, pot ser necessari aturar el flux després d'haver començat. Hi ha diverses maneres de fer-ho. La classe Thread té dos mètodes amb nom adequat: Avortar и Interrompre. El primer no és molt recomanable per al seu ús, perquè després de cridar-lo en qualsevol moment aleatori, durant el processament de qualsevol instrucció, es llançarà una excepció ThreadAbortedException. No espereu que es produeixi una excepció en augmentar qualsevol variable entera, oi? I quan s'utilitza aquest mètode, aquesta és una situació molt real. Si necessiteu evitar que el CLR generi aquesta excepció en una secció determinada del codi, podeu embolicar-la en trucades. Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Qualsevol codi escrit en un bloc finally s'embolica en aquestes trucades. Per aquest motiu, a les profunditats del codi del marc podeu trobar blocs amb un intent buit, però no un buit finalment. Microsoft desaconsella tant aquest mètode que no el van incloure al nucli .net.

El mètode d'interrupció funciona de manera més previsible. Pot interrompre el fil amb una excepció ThreadInterruptedException només durant aquells moments en què el fil està en estat d'espera. Entra en aquest estat mentre es penja mentre espera WaitHandle, bloqueig o després de trucar a Thread.Sleep.

Les dues opcions descrites anteriorment són dolentes a causa de la seva impredictibilitat. La solució és utilitzar una estructura Token de cancel·lació i classe CancellationTokenSource. El punt és el següent: es crea una instància de la classe CancellationTokenSource i només el propietari pot aturar l'operació cridant al mètode Cancel·lar. Només el CancellationToken es passa a la pròpia operació. Els propietaris de CancellationToken no poden cancel·lar l'operació ells mateixos, però només poden comprovar si l'operació s'ha cancel·lat. Hi ha una propietat booleana per a això IsCancellationRequested i mètode ThrowIfCancelRequested. Aquest últim llançarà una excepció TaskCancelledException si el mètode Cancel es va cridar a la instància CancellationToken que s'està reproduint. I aquest és el mètode que recomano utilitzar. Aquesta és una millora respecte a les opcions anteriors aconseguint un control total sobre en quin moment es pot avortar una operació d'excepció.

L'opció més brutal per aturar un fil és cridar la funció TerminateThread de l'API Win32. El comportament del CLR després de cridar aquesta funció pot ser impredictible. A MSDN s'escriu el següent sobre aquesta funció: "TerminateThread és una funció perillosa que només s'ha d'utilitzar en els casos més extrems. “

Conversió de l'API heretada a basada en tasques mitjançant el mètode FromAsync

Si teniu la sort de treballar en un projecte que es va iniciar després de la introducció de Tasks i va deixar de causar un horror silenciós a la majoria de desenvolupadors, no haureu de fer front a moltes API antigues, tant de tercers com del vostre equip. ha torturat en el passat. Per sort, l'equip del .NET Framework ens va fer càrrec, tot i que potser l'objectiu era cuidar-nos. Sigui com sigui, .NET disposa d'una sèrie d'eines per convertir sense dolor el codi escrit en antics enfocaments de programació asíncrona al nou. Un d'ells és el mètode FromAsync de TaskFactory. A l'exemple de codi següent, embolcallo els antics mètodes asíncrons de la classe WebRequest en una tasca utilitzant aquest mètode.

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

Aquest és només un exemple i és poc probable que ho hàgiu de fer amb tipus integrats, però qualsevol projecte antic està ple de mètodes BeginDoSomething que retornen els mètodes IAsyncResult i EndDoSomething que el reben.

Converteix l'API heretada a basada en tasques mitjançant la classe TaskCompletionSource

Una altra eina important a tenir en compte és la classe TaskCompletionSource. Pel que fa a les funcions, el propòsit i el principi de funcionament, pot ser que recordi una mica el mètode RegisterWaitForSingleObject de la classe ThreadPool, del qual vaig escriure més amunt. Amb aquesta classe, podeu embolicar fàcilment i còmodament les antigues API asíncrones a Tasks.

Direu que ja he parlat del mètode FromAsync de la classe TaskFactory destinat a aquests propòsits. Aquí haurem de recordar tota la història del desenvolupament de models asíncrons en .net que Microsoft ha ofert durant els darrers 15 anys: abans del Task-Based Asynchronous Pattern (TAP), hi havia el Patró de programació asíncrona (APP), que tractava de mètodes ComençarFes alguna cosa que torni IAsyncResult i mètodes FinalFes alguna cosa que ho accepti i per al llegat d'aquests anys, el mètode FromAsync és perfecte, però amb el temps, es va substituir pel patró asíncron basat en esdeveniments (I AP), que suposava que es produiria un esdeveniment quan es completés l'operació asíncrona.

TaskCompletionSource és perfecte per embolicar les tasques i les API heretades creades al voltant del model d'esdeveniment. L'essència del seu treball és la següent: un objecte d'aquesta classe té una propietat pública de tipus Task, l'estat de la qual es pot controlar mitjançant els mètodes SetResult, SetException, etc. de la classe TaskCompletionSource. En els llocs on s'ha aplicat l'operador await a aquesta tasca, s'executarà o fallarà amb una excepció en funció del mètode aplicat a TaskCompletionSource. Si encara no està clar, mirem aquest exemple de codi, on una antiga API d'EAP s'embolica en una tasca mitjançant un TaskCompletionSource: quan l'esdeveniment s'activa, la tasca es transferirà a l'estat Completed i el mètode que va aplicar l'operador await. a aquesta Tasca reprendrà la seva execució havent rebut l'objecte resultat.

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

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

    result completionSource.Task;
}

TaskCompletionFont Consells i trucs

Embolicar API antigues no és tot el que es pot fer amb TaskCompletionSource. L'ús d'aquesta classe obre una interessant possibilitat de dissenyar diverses API en tasques que no ocupen fils. I el flux, com recordem, és un recurs car i el seu nombre està limitat (principalment per la quantitat de memòria RAM). Aquesta limitació es pot aconseguir fàcilment desenvolupant, per exemple, una aplicació web carregada amb una lògica de negoci complexa. Considerem les possibilitats de les quals parlo a l'hora d'implementar un truc com Long-Polling.

En resum, l'essència del truc és aquesta: cal rebre informació de l'API sobre alguns esdeveniments que es produeixen al seu costat, mentre que l'API, per algun motiu, no pot informar de l'esdeveniment, sinó que només pot retornar l'estat. Un exemple d'aquestes són totes les API construïdes sobre HTTP abans dels temps de WebSocket o quan era impossible, per algun motiu, utilitzar aquesta tecnologia. El client pot demanar al servidor HTTP. El servidor HTTP no pot iniciar la comunicació amb el client. Una solució senzilla és sondejar el servidor mitjançant un temporitzador, però això crea una càrrega addicional al servidor i un retard addicional de mitjana TimerInterval / 2. Per evitar-ho, es va inventar un truc anomenat Long Polling, que implica retardar la resposta de el servidor fins que caduqui el temps d'espera o es produeixi un esdeveniment. Si l'esdeveniment s'ha produït, es processa, si no, la sol·licitud es torna a enviar.

while(!eventOccures && !timeoutExceeded)  {

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

Però aquesta solució resultarà terrible tan bon punt augmenti el nombre de clients que esperen l'esdeveniment, perquè... Cada client d'aquest tipus ocupa un fil sencer esperant un esdeveniment. Sí, i tenim un retard addicional d'1 ms quan s'activa l'esdeveniment, la majoria de vegades això no és significatiu, però per què empitjorar el programari del que pot ser? Si eliminem Thread.Sleep(1), en va carregarem un nucli de processador 100% inactiu, girant en un cicle inútil. Utilitzant TaskCompletionSource podeu refer fàcilment aquest codi i resoldre tots els problemes identificats anteriorment:

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

Aquest codi no està preparat per a la producció, sinó només una demostració. Per utilitzar-lo en casos reals, també cal, com a mínim, gestionar la situació quan arriba un missatge en un moment en què ningú l'espera: en aquest cas, el mètode AsseptMessageAsync hauria de retornar una tasca ja completada. Si aquest és el cas més comú, podeu pensar a utilitzar ValueTask.

Quan rebem una sol·licitud d'un missatge, creem i col·loquem una TaskCompletionSource al diccionari, i després esperem que passi primer: l'interval de temps especificat caduca o es rep un missatge.

ValueTask: per què i com

Els operadors async/wait, com l'operador de retorn de rendiment, generen una màquina d'estats a partir del mètode, i això és la creació d'un objecte nou, que gairebé sempre no és important, però en casos rars pot crear un problema. Aquest cas pot ser un mètode que es diu molt sovint, estem parlant de desenes i centenars de milers de trucades per segon. Si aquest mètode s'escriu de tal manera que en la majoria dels casos retorna un resultat sense passar per alt els mètodes d'espera, aleshores .NET proporciona una eina per optimitzar-ho: l'estructura ValueTask. Per deixar-ho clar, mirem un exemple del seu ús: hi ha una memòria cau a la qual anem molt sovint. Conté alguns valors i després simplement els retornem; si no, anem a una IO lenta per obtenir-los. Vull fer això últim de manera asíncrona, el que significa que tot el mètode resulta ser asíncron. Per tant, la manera òbvia d'escriure el mètode és la següent:

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

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

A causa del desig d'optimitzar una mica i d'una lleugera por del que generarà Roslyn en compilar aquest codi, podeu reescriure aquest exemple de la següent manera:

public Task<string> GetById(int id) {

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

De fet, la solució òptima en aquest cas seria optimitzar el camí calent, és a dir, obtenir un valor del diccionari sense assignacions innecessàries i càrrega al GC, mentre que en aquells casos excepcionals en què encara hem d'anar a IO per obtenir dades. , tot seguirà sent un avantatge/menys de la manera antiga:

public ValueTask<string> GetById(int id) {

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

Fem una ullada més de prop a aquest fragment de codi: si hi ha un valor a la memòria cau, creem una estructura, en cas contrari, la tasca real s'embolicarà en una de significativa. Al codi de trucada no li importa en quin camí s'ha executat aquest codi: ValueTask, des del punt de vista de la sintaxi C#, es comportarà igual que una Task normal en aquest cas.

TaskSchedulers: gestió d'estratègies de llançament de tasques

La següent API que m'agradaria tenir en compte és la classe Planificador de tasques i els seus derivats. Ja he esmentat anteriorment que TPL té la capacitat de gestionar estratègies per distribuir les tasques entre fils. Aquestes estratègies es defineixen als descendents de la classe TaskScheduler. Gairebé qualsevol estratègia que necessiteu es pot trobar a la biblioteca. ParallelExtensionsExtres, desenvolupat per Microsoft, però no forma part de .NET, però es subministra com a paquet Nuget. Vegem-ne breument alguns d'ells:

  • Current ThreadTaskScheduler — executa tasques al fil actual
  • LimitedConcurrencyLevelTaskScheduler — limita el nombre de tasques executades simultàniament pel paràmetre N, que s'accepta al constructor
  • OrderedTaskScheduler — es defineix com a LimitedConcurrencyLevelTaskScheduler(1), de manera que les tasques s'executaran seqüencialment.
  • WorkStealingTaskScheduler - instruments robatori de feina enfocament de la distribució de tasques. Essencialment, és un ThreadPool independent. Soluciona el problema que en .NET ThreadPool és una classe estàtica, una per a totes les aplicacions, la qual cosa significa que la seva sobrecàrrega o ús incorrecte en una part del programa pot provocar efectes secundaris en una altra. A més, és molt difícil entendre la causa d'aquests defectes. Això. Pot ser que calgui utilitzar WorkStealingTaskSchedulers separats en parts del programa on l'ús de ThreadPool pot ser agressiu i impredictible.
  • QueuedTaskScheduler — us permet realitzar tasques segons les regles de la cua de prioritat
  • ThreadPerTaskScheduler — crea un fil separat per a cada tasca que s'executa en ell. Pot ser útil per a tasques que triguen un temps impredictible a completar-se.

Hi ha un bon detall article sobre TaskSchedulers al bloc de Microsoft.

Per a una depuració còmoda de tot allò relacionat amb les tasques, Visual Studio té una finestra de tasques. En aquesta finestra podeu veure l'estat actual de la tasca i saltar a la línia de codi que s'està executant.

.NET: Eines per treballar amb multithreading i asincronia. Part 1

PLinq i la classe Paral·lel

A més de les Tasques i tot el que es diu sobre elles, hi ha dues eines més interessants a .NET: PLinq (Linq2Parallel) i la classe Parallel. El primer promet l'execució paral·lela de totes les operacions de Linq en diversos fils. El nombre de fils es pot configurar mitjançant el mètode d'extensió WithDegreeOfParallelism. Malauradament, la majoria de les vegades PLinq en el seu mode predeterminat no té prou informació sobre els elements interns de la vostra font de dades per proporcionar un guany de velocitat important, d'altra banda, el cost d'intentar-ho és molt baix: només cal que truqueu al mètode AsParallel abans. la cadena de mètodes Linq i executar proves de rendiment. A més, és possible passar informació addicional a PLinq sobre la naturalesa de la vostra font de dades mitjançant el mecanisme de particions. Podeu llegir més aquí и aquí.

La classe estàtica Parallel proporciona mètodes per iterar una col·lecció Foreach en paral·lel, executar un bucle For i executar diversos delegats en paral·lel Invoke. L'execució del fil actual s'aturarà fins que s'acabin els càlculs. El nombre de fils es pot configurar passant ParallelOptions com a darrer argument. També podeu especificar TaskScheduler i CancellationToken mitjançant les opcions.

Troballes

Quan vaig començar a escriure aquest article basant-me en els materials del meu informe i la informació que vaig recopilar durant el meu treball després d'ell, no m'esperava que n'hi hagués tant. Ara, quan l'editor de text en què estic escrivint aquest article em digui amb retreu que s'ha acabat la pàgina 15, resumiré els resultats provisionals. Altres trucs, API, eines visuals i inconvenients es tractaran al proper article.

Conclusions:

  • Cal conèixer les eines per treballar amb fils, asincronia i paral·lelisme per poder utilitzar els recursos dels ordinadors moderns.
  • .NET té moltes eines diferents per a aquests propòsits
  • No van aparèixer tots alhora, de manera que sovint podeu trobar-ne de heretats, però hi ha maneres de convertir les API antigues sense gaire esforç.
  • El treball amb fils a .NET està representat per les classes Thread i ThreadPool
  • Els mètodes Thread.Abort, Thread.Interrupt i Win32 API TerminateThread són perillosos i no es recomana utilitzar-los. En canvi, és millor utilitzar el mecanisme CancellationToken
  • El flux és un recurs valuós i el seu subministrament és limitat. S'han d'evitar situacions en què els fils estan ocupats esperant esdeveniments. Per a això és convenient utilitzar la classe TaskCompletionSource
  • Les eines .NET més potents i avançades per treballar amb paral·lelisme i asincronia són Tasks.
  • Els operadors c# async/wait implementen el concepte d'espera sense bloqueig
  • Podeu controlar la distribució de les tasques entre fils mitjançant classes derivades de TaskScheduler
  • L'estructura ValueTask pot ser útil per optimitzar els camins calents i el trànsit de memòria
  • Les finestres de tasques i fils de Visual Studio proporcionen molta informació útil per depurar codi multifil o asíncron
  • PLinq és una eina fantàstica, però és possible que no tingui prou informació sobre la vostra font de dades, però això es pot solucionar mitjançant el mecanisme de partició.
  • Continuar ...

Font: www.habr.com

Afegeix comentari