.NET: strumenti per lavorare con multithreading e asincronia. Parte 1

Pubblico l'articolo originale su Habr, la cui traduzione è pubblicata nel sito aziendale blog.

La necessità di fare qualcosa in modo asincrono, senza attendere il risultato qui e ora, o di dividere un lavoro di grandi dimensioni tra più unità che lo eseguono, esisteva prima dell'avvento dei computer. Con il loro avvento, questa esigenza è diventata molto tangibile. Ora, nel 2019, sto scrivendo questo articolo su un laptop con un processore Intel Core a 8 core, sul quale vengono eseguiti più di cento processi in parallelo e anche più thread. Nelle vicinanze c'è un telefono un po 'malandato, acquistato un paio di anni fa, ha a bordo un processore a 8 core. Le risorse tematiche sono piene di articoli e video in cui i loro autori ammirano gli smartphone di punta di quest'anno dotati di processori a 16 core. MS Azure fornisce una macchina virtuale con un processore da 20 core e 128 TB di RAM per meno di $ 2/ora. Sfortunatamente, è impossibile estrarre il massimo e sfruttare questa potenza senza essere in grado di gestire l'interazione dei thread.

terminologia

Processi - Oggetto del sistema operativo, spazio di indirizzi isolato, contiene thread.
Filo - un oggetto del sistema operativo, la più piccola unità di esecuzione, parte di un processo, i thread condividono memoria e altre risorse tra loro all'interno di un processo.
multitasking - Proprietà del sistema operativo, la capacità di eseguire più processi contemporaneamente
Multicore - una proprietà del processore, la capacità di utilizzare più core per l'elaborazione dei dati
Multielaborazione - una proprietà di un computer, la capacità di lavorare fisicamente con più processori contemporaneamente
Multithreading — una proprietà di un processo, la capacità di distribuire l'elaborazione dei dati tra più thread.
Parallelismo - eseguire più azioni fisicamente contemporaneamente per unità di tempo
Asincronia — esecuzione di un'operazione senza attendere il completamento di tale elaborazione; il risultato dell'esecuzione può essere elaborato successivamente.

metafora

Non tutte le definizioni sono valide e alcune necessitano di ulteriori spiegazioni, quindi aggiungerò una metafora sulla preparazione della colazione alla terminologia introdotta formalmente. Cucinare la colazione in questa metafora è un processo.

Mentre preparavo la colazione la mattina io (CPU) Vengo in cucina (Computer). Ho 2 mani (Colori). In cucina sono presenti numerosi dispositivi (IO): forno, bollitore, tostapane, frigorifero. Accendo il gas, ci metto sopra una padella e ci verso l'olio senza aspettare che si scaldi (in modo asincrono, attesa IO non bloccante), tiro fuori le uova dal frigorifero e le rompo in un piatto, poi le sbatti con una mano (Discussione n. 1) e il secondo (Discussione n. 2) tenendo in mano il piatto (Risorsa condivisa). Ora vorrei accendere il bollitore, ma non ho abbastanza mani (Discussione Fame) Durante questo tempo si scalda la padella (Elaborazione del risultato) nella quale verso ciò che ho montato. Prendo il bollitore, lo accendo e guardo stupidamente l'acqua bollire al suo interno (Blocco-IO-Attesa), anche se durante questo tempo avrebbe potuto lavare il piatto dove montava la frittata.

Ho cucinato una frittata usando solo 2 mani, e non ne ho di più, ma contemporaneamente, al momento di mantecare la frittata, sono avvenute 3 operazioni contemporaneamente: mantecare la frittata, tenere la piastra, scaldare la padella La CPU è la parte più veloce del computer, l'IO è ciò che più spesso rallenta, quindi spesso una soluzione efficace è occupare la CPU con qualcosa mentre si ricevono dati dall'IO.

Continuando la metafora:

  • Se mentre preparo una frittata provassi anche a cambiarmi d’abito, questo sarebbe un esempio di multitasking. Una sfumatura importante: i computer sono molto più bravi in ​​questo rispetto alle persone.
  • Una cucina con diversi chef, ad esempio in un ristorante: un computer multi-core.
  • Molti ristoranti in una food court in un centro commerciale - data center

Strumenti .NET

.NET funziona bene con i thread, come con molte altre cose. Con ogni nuova versione, vengono introdotti sempre più nuovi strumenti per lavorare con essi, nuovi livelli di astrazione sui thread del sistema operativo. Quando si lavora con la costruzione di astrazioni, gli sviluppatori di framework utilizzano un approccio che lascia l'opportunità, quando si utilizza un'astrazione di alto livello, di scendere di uno o più livelli inferiori. Molto spesso questo non è necessario, anzi apre la porta a spararsi un piede con un fucile, ma a volte, in rari casi, può essere l'unico modo per risolvere un problema che non è risolto all'attuale livello di astrazione .

Per strumenti intendo sia le interfacce di programmazione delle applicazioni (API) fornite dal framework e pacchetti di terze parti, sia intere soluzioni software che semplificano la ricerca di eventuali problemi legati al codice multi-thread.

Avvio di un thread

La classe Thread è la classe più semplice in .NET per lavorare con i thread. Il costruttore accetta uno dei due delegati:

  • ThreadStart: nessun parametro
  • ParametrizedThreadStart - con un parametro di tipo oggetto.

Il delegato verrà eseguito nel thread appena creato dopo aver chiamato il metodo Start.Se un delegato di tipo ParametrizedThreadStart è stato passato al costruttore, è necessario passare un oggetto al metodo Start. Questo meccanismo è necessario per trasferire qualsiasi informazione locale allo stream. Vale la pena notare che la creazione di un thread è un'operazione costosa e il thread stesso è un oggetto pesante, almeno perché alloca 1 MB di memoria nello stack e richiede l'interazione con l'API del sistema operativo.

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

La classe ThreadPool rappresenta il concetto di pool. In .NET, il pool di thread è un pezzo di ingegneria e gli sviluppatori di Microsoft si sono impegnati molto per assicurarsi che funzioni in modo ottimale in un'ampia varietà di scenari.

Concetto generale:

Dal momento in cui l'applicazione viene avviata, crea diversi thread di riserva in background e offre la possibilità di prenderli in uso. Se i thread vengono utilizzati frequentemente e in gran numero, il pool si espande per soddisfare le esigenze del chiamante. Quando non ci sono thread liberi nel pool al momento giusto, attenderà il ritorno di uno dei thread o ne creerà uno nuovo. Ne consegue che il pool di thread è ottimo per alcune azioni a breve termine e poco adatto per le operazioni eseguite come servizi durante l'intera operazione dell'applicazione.

Per utilizzare un thread dal pool, esiste un metodo QueueUserWorkItem che accetta un delegato di tipo WaitCallback, che ha la stessa firma di ParametrizedThreadStart, e il parametro passato ad esso esegue la stessa funzione.

ThreadPool.QueueUserWorkItem(...);

Il metodo del pool di thread meno noto RegisterWaitForSingleObject viene utilizzato per organizzare operazioni di I/O non bloccanti. Il delegato passato a questo metodo verrà chiamato quando il WaitHandle passato al metodo sarà "Rilasciato".

ThreadPool.RegisterWaitForSingleObject(...)

.NET dispone di un timer del thread e differisce dai timer WinForms/WPF in quanto il relativo gestore verrà chiamato su un thread prelevato dal pool.

System.Threading.Timer

Esiste anche un modo piuttosto esotico per inviare un delegato per l'esecuzione a un thread dal pool: il metodo BeginInvoke.

DelegateInstance.BeginInvoke

Vorrei soffermarmi brevemente sulla funzione a cui possono essere chiamati molti dei metodi di cui sopra: CreateThread dall'API Win32 Kernel32.dll. Esiste un modo, grazie al meccanismo dei metodi extern, di richiamare questa funzione. Ho visto una chiamata del genere solo una volta in un terribile esempio di codice legacy, e la motivazione dell'autore che ha fatto esattamente questo rimane ancora un mistero per me.

Kernel32.dll CreateThread

Visualizzazione e debug dei thread

I thread creati dall'utente, tutti i componenti di terze parti e il pool .NET possono essere visualizzati nella finestra Thread di Visual Studio. Questa finestra visualizzerà le informazioni sul thread solo quando l'applicazione è in fase di debug e in modalità Interruzione. Qui puoi visualizzare comodamente i nomi degli stack e le priorità di ciascun thread e passare il debug a un thread specifico. Utilizzando la proprietà Priority della classe Thread, è possibile impostare la priorità di un thread, che OC e CLR percepiranno come una raccomandazione quando si divide il tempo del processore tra i thread.

.NET: strumenti per lavorare con multithreading e asincronia. Parte 1

Libreria parallela delle attività

Task Parallel Library (TPL) è stata introdotta in .NET 4.0. Ora è lo standard e lo strumento principale per lavorare con l'asincronia. Qualsiasi codice che utilizza un approccio precedente è considerato legacy. L'unità di base di TPL è la classe Task dello spazio dei nomi System.Threading.Tasks. Un'attività è un'astrazione su un thread. Con la nuova versione del linguaggio C#, abbiamo un modo elegante di lavorare con le attività: operatori asincroni/attendi. Questi concetti hanno permesso di scrivere codice asincrono come se fosse semplice e sincrono, questo ha permesso anche a persone con poca comprensione del funzionamento interno dei thread di scrivere applicazioni che li utilizzano, applicazioni che non si bloccano quando si eseguono operazioni lunghe. L'uso di async/await è un argomento per uno o anche più articoli, ma cercherò di coglierne l'essenza in alcune frasi:

  • async è un modificatore di un metodo che restituisce Task o void
  • e wait è un operatore di attesa dell'attività non bloccante.

Ancora una volta: l'operatore wait, nel caso generale (ci sono delle eccezioni), rilascerà ulteriormente il thread corrente di esecuzione, e quando il Task avrà terminato la sua esecuzione, e il thread (in effetti sarebbe più corretto dire il contesto , ma ne parleremo più avanti) continuerà a eseguire ulteriormente il metodo. All'interno di .NET, questo meccanismo è implementato allo stesso modo di yield return, quando il metodo scritto si trasforma in un'intera classe, che è una macchina a stati e può essere eseguita in parti separate a seconda di questi stati. Chiunque sia interessato può scrivere qualsiasi codice semplice utilizzando asynс/await, compilare e visualizzare l'assembly utilizzando JetBrains dotPeek con il codice generato dal compilatore abilitato.

Diamo un'occhiata alle opzioni per l'avvio e l'utilizzo di Task. Nell'esempio di codice seguente creiamo una nuova attività che non fa nulla di utile (Filo.Sonno(10000)), ma nella vita reale questo dovrebbe essere un lavoro complesso che richiede un uso intensivo della 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
}

Un'attività viene creata con una serie di opzioni:

  • LongRunning indica che l'attività non verrà completata rapidamente, il che significa che potrebbe valere la pena considerare di non prendere un thread dal pool, ma di crearne uno separato per questa attività in modo da non danneggiare gli altri.
  • attachedtoparent: le attività possono essere organizzate in una gerarchia. Se è stata utilizzata questa opzione, l'attività potrebbe trovarsi in uno stato in cui è stata completata ed è in attesa dell'esecuzione dei suoi figli.
  • PreferFairness - significa che sarebbe meglio eseguire le attività inviate per l'esecuzione prima prima di quelle inviate successivamente. Ma questa è solo una raccomandazione e i risultati non sono garantiti.

Il secondo parametro passato al metodo è CancellationToken. Per gestire correttamente l'annullamento di un'operazione dopo l'avvio, il codice in esecuzione deve essere compilato con controlli per lo stato CancellationToken. Se non sono presenti controlli, allora il metodo Cancel chiamato sull'oggetto CancellationTokenSource sarà in grado di interrompere l'esecuzione del Task solo prima che inizi.

L'ultimo parametro è un oggetto di pianificazione di tipo TaskScheduler. Questa classe e i suoi discendenti sono progettati per controllare le strategie per la distribuzione delle attività tra thread; per impostazione predefinita, l'attività verrà eseguita su un thread casuale dal pool.

L'operatore wait viene applicato all'attività creata, il che significa che il codice scritto dopo di esso, se presente, verrà eseguito nello stesso contesto (spesso significa sullo stesso thread) del codice prima di wait.

Il metodo è contrassegnato come async void, il che significa che può utilizzare l'operatore wait, ma il codice chiamante non sarà in grado di attendere l'esecuzione. Se tale funzionalità è necessaria, il metodo deve restituire Task. I metodi contrassegnati come async void sono abbastanza comuni: di norma si tratta di gestori di eventi o altri metodi che funzionano secondo il principio "fire and Forget". Se è necessario non solo dare l'opportunità di attendere fino alla fine dell'esecuzione, ma anche restituire il risultato, è necessario utilizzare Task.

Sul Task restituito dal metodo StartNew, così come su qualsiasi altro, è possibile chiamare il metodo ConfigureAwait con il parametro false, quindi l'esecuzione dopo wait continuerà non sul contesto catturato, ma su uno arbitrario. Questo dovrebbe essere sempre fatto quando il contesto di esecuzione non è importante per il codice dopo l'attesa. Questa è anche una raccomandazione di MS quando si scrive codice che verrà consegnato assemblato in una libreria.

Soffermiamoci ancora un po' su come attendere il completamento di un'attività. Di seguito è riportato un esempio di codice, con commenti su quando l'aspettativa viene eseguita condizionatamente bene e quando viene eseguita condizionatamente male.

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
}

Nel primo esempio aspettiamo il completamento del Task senza bloccare il thread chiamante; torneremo ad elaborare il risultato solo quando sarà già presente; fino ad allora il thread chiamante è lasciato a se stesso.

Nella seconda opzione blocchiamo il thread chiamante finché non viene calcolato il risultato del metodo. Ciò è negativo non solo perché abbiamo occupato un thread, una risorsa così preziosa del programma, con semplice inattività, ma anche perché se il codice del metodo che chiamiamo contiene wait, e il contesto di sincronizzazione richiede di tornare al thread chiamante dopo wait, quindi otterremo un deadlock: il thread chiamante attende il calcolo del risultato del metodo asincrono, il metodo asincrono tenta invano di continuare la sua esecuzione nel thread chiamante.

Un altro svantaggio di questo approccio è la complicata gestione degli errori. Il fatto è che gli errori nel codice asincrono quando si utilizza async/await sono molto facili da gestire: si comportano come se il codice fosse sincrono. Mentre se applichiamo l'esorcismo di attesa sincrona a un Task, l'eccezione originale si trasforma in un'AggregateException, ovvero Per gestire l'eccezione, dovrai esaminare il tipo InnerException e scrivere tu stesso una catena if all'interno di un blocco catch o utilizzare il costrutto catch When, invece della catena di blocchi catch più familiare nel mondo C#.

Anche il terzo e ultimo esempio è contrassegnato come non valido per lo stesso motivo e contiene tutti gli stessi problemi.

I metodi WhenAny e WhenAll sono estremamente convenienti per attendere un gruppo di attività; racchiudono un gruppo di attività in uno solo, che verrà attivato quando un'attività del gruppo viene attivata per la prima volta o quando tutti hanno completato la propria esecuzione.

Interruzione dei thread

Per vari motivi può essere necessario interrompere il flusso dopo che è iniziato. Esistono diversi modi per farlo. La classe Thread ha due metodi dai nomi appropriati: abortire и Interrompere. Il primo è altamente sconsigliato per l'uso, perché dopo averlo chiamato in qualsiasi momento casuale, durante l'elaborazione di qualsiasi istruzione, verrà lanciata un'eccezione ThreadAbortedException. Non ti aspetti che venga generata un'eccezione del genere quando si incrementa una variabile intera, giusto? E quando si utilizza questo metodo, questa è una situazione molto reale. Se è necessario impedire a CLR di generare un'eccezione di questo tipo in una determinata sezione di codice, è possibile racchiuderla nelle chiamate Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Qualsiasi codice scritto in un blocco final è racchiuso in tali chiamate. Per questo motivo nel profondo del codice framework si possono trovare blocchi con try vuoto, ma non con try vuoto. Microsoft scoraggia così tanto questo metodo da non includerlo nel core .net.

Il metodo Interrupt funziona in modo più prevedibile. Può interrompere il thread con un'eccezione ThreadInterruptedException solo durante quei momenti in cui il thread è in uno stato di attesa. Entra in questo stato durante il blocco in attesa di WaitHandle, blocco o dopo aver chiamato Thread.Sleep.

Entrambe le opzioni sopra descritte sono negative a causa della loro imprevedibilità. La soluzione è utilizzare una struttura CancellazioneToken e classe CancellazioneTokenSource. Il punto è questo: viene creata un'istanza della classe CancellationTokenSource e solo chi la possiede può interrompere l'operazione chiamando il metodo Annulla. All'operazione stessa viene passato solo il CancellationToken. I proprietari di CancellationToken non possono annullare l'operazione da soli, ma possono solo verificare se l'operazione è stata annullata. Esiste una proprietà booleana per questo È richiesta la cancellazione e metodo ThrowIfCancelRequested. Quest'ultimo genererà un'eccezione TaskCancelledException se il metodo Cancel è stato chiamato sull'istanza CancellationToken sottoposta a pappagallo. E questo è il metodo che consiglio di utilizzare. Si tratta di un miglioramento rispetto alle opzioni precedenti poiché consente di ottenere il pieno controllo sul punto in cui è possibile interrompere un'operazione di eccezione.

L'opzione più brutale per interrompere un thread è chiamare la funzione TerminateThread dell'API Win32. Il comportamento di CLR dopo aver chiamato questa funzione potrebbe essere imprevedibile. Su MSDN è scritto quanto segue su questa funzione: “TerminateThread è una funzione pericolosa che dovrebbe essere utilizzata solo nei casi più estremi. “

Conversione dell'API legacy in basata su attività utilizzando il metodo FromAsync

Se sei abbastanza fortunato da lavorare su un progetto che è stato avviato dopo l'introduzione di Tasks e che ha smesso di causare orrore alla maggior parte degli sviluppatori, non dovrai avere a che fare con molte vecchie API, sia quelle di terze parti che quelle del tuo team ha torturato in passato. Fortunatamente, il team di .NET Framework si è preso cura di noi, anche se forse l'obiettivo era prenderci cura di noi stessi. Comunque sia, .NET dispone di una serie di strumenti per convertire senza problemi il codice scritto con i vecchi approcci di programmazione asincrona a quello nuovo. Uno di questi è il metodo FromAsync di TaskFactory. Nell'esempio di codice riportato di seguito, racchiudo i vecchi metodi asincroni della classe WebRequest in un'attività utilizzando questo metodo.

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

Questo è solo un esempio ed è improbabile che tu debba farlo con i tipi incorporati, ma qualsiasi vecchio progetto pullula semplicemente di metodi BeginDoSomething che restituiscono i metodi IAsyncResult e EndDoSomething che lo ricevono.

Converti l'API legacy in basata su attività utilizzando la classe TaskCompletionSource

Un altro strumento importante da considerare è la classe TaskCompletionSource. In termini di funzioni, scopo e principio di funzionamento, potrebbe ricordare in qualche modo il metodo RegisterWaitForSingleObject della classe ThreadPool, di cui ho scritto sopra. Usando questa classe, puoi racchiudere facilmente e comodamente le vecchie API asincrone in Tasks.

Dirai che ho già parlato del metodo FromAsync della classe TaskFactory destinato a questi scopi. Qui dovremo ricordare tutta la storia dello sviluppo dei modelli asincroni in .net che Microsoft ha offerto negli ultimi 15 anni: prima del Task-Based Asynchronous Pattern (TAP), esisteva l'Asynchronous Programming Pattern (APP), che riguardava i metodi IniziareFai qualcosa che ritorna IAsyncResult e metodi FineFaiSomething che lo accetta e per l'eredità di questi anni il metodo FromAsync è semplicemente perfetto, ma nel tempo è stato sostituito dall'Event Based Asynchronous Pattern (EAP), che presupponeva che un evento sarebbe stato generato al completamento dell'operazione asincrona.

TaskCompletionSource è perfetto per racchiudere attività e API legacy costruite attorno al modello di eventi. L'essenza del suo lavoro è la seguente: un oggetto di questa classe ha una proprietà pubblica di tipo Task, il cui stato può essere controllato tramite i metodi SetResult, SetException, ecc. della classe TaskCompletionSource. Nei punti in cui l'operatore wait è stato applicato a questa attività, verrà eseguito o fallirà con un'eccezione a seconda del metodo applicato a TaskCompletionSource. Se non è ancora chiaro, diamo un'occhiata a questo esempio di codice, in cui alcune vecchie API EAP sono racchiuse in un'attività utilizzando un TaskCompletionSource: quando l'evento si attiva, l'attività verrà trasferita allo stato Completato e il metodo che ha applicato l'operatore di attesa a questo Task riprenderà la sua esecuzione dopo aver ricevuto l'oggetto colpevole.

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

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

    result completionSource.Task;
}

Suggerimenti e trucchi per TaskCompletionSource

Il wrapper delle vecchie API non è tutto ciò che può essere fatto utilizzando TaskCompletionSource. L'utilizzo di questa classe apre un'interessante possibilità di progettare varie API su Task che non occupano thread. E il flusso, come ricordiamo, è una risorsa costosa e il loro numero è limitato (principalmente dalla quantità di RAM). Questa limitazione può essere facilmente raggiunta sviluppando, ad esempio, un'applicazione web caricata con una logica aziendale complessa. Consideriamo le possibilità di cui sto parlando quando si implementa un trucco come il Long-Polling.

In breve, l'essenza del trucco è questa: è necessario ricevere informazioni dall'API su alcuni eventi che si verificano dalla sua parte, mentre l'API, per qualche motivo, non può segnalare l'evento, ma può solo restituire lo stato. Un esempio di questi sono tutte le API costruite su HTTP prima dei tempi di WebSocket o quando per qualche motivo era impossibile utilizzare questa tecnologia. Il client può chiedere al server HTTP. Il server HTTP non può avviare da solo la comunicazione con il client. Una soluzione semplice è interrogare il server utilizzando un timer, ma questo crea un carico aggiuntivo sul server e un ritardo aggiuntivo in media su TimerInterval / 2. Per aggirare questo problema, è stato inventato un trucco chiamato Long Polling, che implica ritardare la risposta da sul server fino alla scadenza del Timeout o fino al verificarsi di un evento. Se l'evento si è verificato, viene elaborato, altrimenti la richiesta viene inviata nuovamente.

while(!eventOccures && !timeoutExceeded)  {

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

Ma una soluzione del genere si rivelerà terribile non appena aumenterà il numero di clienti in attesa dell'evento, perché... Ciascuno di questi client occupa un intero thread in attesa di un evento. Sì, e otteniamo un ulteriore ritardo di 1 ms quando l'evento viene attivato, molto spesso questo non è significativo, ma perché rendere il software peggiore di quanto potrebbe essere? Se rimuoviamo Thread.Sleep(1), invano caricheremo un core del processore al 100% inattivo, ruotando in un ciclo inutile. Utilizzando TaskCompletionSource puoi facilmente rifare questo codice e risolvere tutti i problemi sopra identificati:

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

Questo codice non è pronto per la produzione, ma solo una demo. Per utilizzarlo nei casi reali è necessario, come minimo, gestire anche la situazione in cui un messaggio arriva in un momento in cui nessuno se lo aspetta: in questo caso il metodo AsseptMessageAsync dovrebbe restituire un Task già completato. Se questo è il caso più comune, allora puoi pensare di utilizzare ValueTask.

Quando riceviamo una richiesta per un messaggio, creiamo e inseriamo un TaskCompletionSource nel dizionario, quindi aspettiamo ciò che accade prima: scade l'intervallo di tempo specificato o viene ricevuto un messaggio.

ValueTask: perché e come

Gli operatori async/await, come l'operatore yield return, generano una macchina a stati dal metodo, e questa è la creazione di un nuovo oggetto, che quasi sempre non è importante, ma in rari casi può creare un problema. Questo caso può essere un metodo che viene chiamato davvero spesso, parliamo di decine e centinaia di migliaia di chiamate al secondo. Se un metodo di questo tipo è scritto in modo tale che nella maggior parte dei casi restituisca un risultato ignorando tutti i metodi wait, allora .NET fornisce uno strumento per ottimizzarlo: la struttura ValueTask. Per essere più chiari, vediamo un esempio del suo utilizzo: c’è una cache a cui andiamo molto spesso. Ci sono alcuni valori al suo interno e poi li restituiamo semplicemente; in caso contrario, andiamo a qualche IO lento per ottenerli. Voglio fare quest'ultimo in modo asincrono, il che significa che l'intero metodo risulta essere asincrono. Pertanto, il modo più ovvio per scrivere il metodo è il seguente:

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

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

A causa del desiderio di ottimizzare un po' e di un leggero timore per ciò che Roslyn genererà durante la compilazione di questo codice, puoi riscrivere questo esempio come segue:

public Task<string> GetById(int id) {

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

In effetti, la soluzione ottimale in questo caso sarebbe ottimizzare l'hot-path, ovvero ottenere un valore dal dizionario senza allocazioni inutili e caricare sul GC, mentre in quei rari casi in cui dobbiamo ancora andare su IO per i dati , tutto rimarrà un più/meno alla vecchia maniera:

public ValueTask<string> GetById(int id) {

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

Diamo un'occhiata più da vicino a questo pezzo di codice: se c'è un valore nella cache, creiamo una struttura, altrimenti il ​​vero compito sarà racchiuso in uno significativo. Al codice chiamante non interessa il percorso in cui è stato eseguito questo codice: ValueTask, dal punto di vista della sintassi C#, in questo caso si comporterà come un normale Task.

TaskSchedulers: gestione delle strategie di avvio delle attività

La prossima API che vorrei prendere in considerazione è la classe Utilità di pianificazione e i suoi derivati. Ho già menzionato sopra che TPL ha la capacità di gestire strategie per la distribuzione delle attività tra thread. Tali strategie sono definite nei discendenti della classe TaskScheduler. Quasi tutte le strategie di cui potresti aver bisogno possono essere trovate nella libreria. ParallelExtensionsExtra, sviluppato da Microsoft, ma non parte di .NET, ma fornito come pacchetto Nuget. Vediamone brevemente alcuni:

  • CurrentThreadTaskScheduler — esegue le attività sul thread corrente
  • LimitedConcurrencyLevelTaskScheduler — limita il numero di attività eseguite simultaneamente dal parametro N, che è accettato nel costruttore
  • OrderedTaskScheduler — è definito come LimitedConcurrencyLevelTaskScheduler(1), quindi le attività verranno eseguite in sequenza.
  • WorkStealingTaskScheduler - implementa lavoro-rubare approccio alla distribuzione dei compiti. Essenzialmente è un ThreadPool separato. Risolve il problema che in .NET ThreadPool è una classe statica, una per tutte le applicazioni, il che significa che il suo sovraccarico o l'uso errato in una parte del programma può portare a effetti collaterali in un'altra. Inoltre, è estremamente difficile comprendere la causa di tali difetti. Quello. Potrebbe essere necessario utilizzare WorkStealingTaskSchedulers separati in parti del programma in cui l'uso di ThreadPool potrebbe essere aggressivo e imprevedibile.
  • QueuedTaskScheduler — consente di eseguire attività in base alle regole della coda di priorità
  • ThreadPerTaskScheduler — crea un thread separato per ogni attività eseguita su di esso. Può essere utile per attività che richiedono un tempo imprevedibilmente lungo per essere completate.

C'è un buon dettaglio articolo su TaskSchedulers sul blog Microsoft.

Per un comodo debug di tutto ciò che riguarda le attività, Visual Studio dispone di una finestra Attività. In questa finestra puoi vedere lo stato corrente dell'attività e passare alla riga di codice attualmente in esecuzione.

.NET: strumenti per lavorare con multithreading e asincronia. Parte 1

PLinq e la classe Parallel

Oltre a Tasks e tutto quanto detto su di essi, ci sono altri due strumenti interessanti in .NET: PLinq (Linq2Parallel) e la classe Parallel. Il primo promette l'esecuzione parallela di tutte le operazioni Linq su più thread. Il numero di thread può essere configurato utilizzando il metodo di estensione WithDegreeOfParallelism. Sfortunatamente, molto spesso PLinq nella sua modalità predefinita non ha abbastanza informazioni sulla struttura interna della tua fonte dati per fornire un significativo aumento di velocità, d'altra parte, il costo del tentativo è molto basso: devi solo chiamare il metodo AsParallel prima la catena di metodi Linq ed eseguire test delle prestazioni. Inoltre, è possibile passare ulteriori informazioni a PLinq sulla natura della fonte dati utilizzando il meccanismo delle partizioni. Puoi leggere di più qui и qui.

La classe statica Parallel fornisce metodi per scorrere una raccolta Foreach in parallelo, eseguendo un ciclo For ed eseguendo più delegati in Invoke parallelo. L'esecuzione del thread corrente verrà interrotta fino al completamento dei calcoli. Il numero di thread può essere configurato passando ParallelOptions come ultimo argomento. Puoi anche specificare TaskScheduler e CancellationToken utilizzando le opzioni.

risultati

Quando ho iniziato a scrivere questo articolo basandomi sui materiali del mio rapporto e sulle informazioni che ho raccolto durante il mio lavoro successivo, non mi aspettavo che ce ne sarebbero state così tante. Ora, quando l'editor di testo in cui sto scrivendo questo articolo mi dice con rimprovero che la pagina 15 è sparita, riassumerò i risultati provvisori. Altri trucchi, API, strumenti visivi e insidie ​​​​verranno trattati nel prossimo articolo.

Conclusioni:

  • È necessario conoscere gli strumenti per lavorare con thread, asincronia e parallelismo per utilizzare le risorse dei PC moderni.
  • .NET dispone di molti strumenti diversi per questi scopi
  • Non tutte sono apparse contemporaneamente, quindi spesso puoi trovarne di legacy, tuttavia esistono modi per convertire le vecchie API senza troppi sforzi.
  • L'utilizzo dei thread in .NET è rappresentato dalle classi Thread e ThreadPool
  • I metodi TerminateThread dell'API Thread.Abort, Thread.Interrupt e Win32 sono pericolosi e non se ne consiglia l'uso. È invece meglio utilizzare il meccanismo CancellationToken
  • Il flusso è una risorsa preziosa e la sua offerta è limitata. Le situazioni in cui i thread sono occupati in attesa di eventi dovrebbero essere evitate. Per questo è conveniente utilizzare la classe TaskCompletionSource
  • Gli strumenti .NET più potenti e avanzati per lavorare con il parallelismo e l'asincronia sono Attività.
  • Gli operatori asincrono/await c# implementano il concetto di attesa non bloccante
  • Puoi controllare la distribuzione delle attività tra i thread utilizzando le classi derivate da TaskScheduler
  • La struttura ValueTask può essere utile per ottimizzare i percorsi attivi e il traffico di memoria
  • Le finestre Attività e Thread di Visual Studio forniscono molte informazioni utili per il debug di codice multi-thread o asincrono
  • PLinq è uno strumento interessante, ma potrebbe non contenere informazioni sufficienti sull'origine dati, ma il problema può essere risolto utilizzando il meccanismo di partizionamento
  • To be continued ...

Fonte: habr.com

Aggiungi un commento