Pubblico su Habr l'originale dell'articolo, la cui traduzione è pubblicata nella corporate .
La necessità di fare qualcosa in modo asincrono, senza aspettare il risultato qui e ora, o di suddividere un grande lavoro tra più unità che lo eseguono, esisteva già prima dell'arrivo dei computer. Con la loro introduzione, questa necessità è diventata molto evidente. Ora, nel 2019, mentre scrivo questo articolo su un laptop con processore Intel Core a 8 core, che sta eseguendo parallelamente non una, ma diverse centinaia di processi, e ancora di più flussi. Vicino, c'è un telefono un po' malandato, acquistato un paio di anni fa, con a bordo un processore a 8 core. Nei blog di settore ci sono numerosi articoli e video in cui gli autori si entusiasmano per gli smartphone di punta di quest'anno che montano processori a 16 core. MS Azure offre una macchina virtuale con processore a 128 core e 2 TB di RAM per meno di 20$/ora. Purtroppo, non è possibile estrarre il massimo e domare questa potenza senza saper gestire l'interazione dei flussi.
Terminologia
Processo (Process) — oggetto del sistema operativo, spazio di indirizzamento isolato, contiene flussi.
Flusso (Thread) — oggetto del sistema operativo, la più piccola unità di esecuzione, parte di un processo, i flussi condividono la memoria e altre risorse tra di loro all'interno di un processo.
Multitasking — proprietà del sistema operativo, possibilità di eseguire più processi contemporaneamente
Multi-core — proprietà del processore, possibilità di utilizzare più core per l'elaborazione dei dati
Multiprocessore — proprietà del computer, possibilità di lavorare simultaneamente con più processori fisici
Multithreading — proprietà di un processo, possibilità di distribuire l'elaborazione dei dati tra più flussi.
Parallellismo — esecuzione di più azioni fisicamente contemporaneamente in un'unità di tempo
Asincronicità — esecuzione di un'operazione senza attendere la conclusione di tale elaborazione, tuttavia il risultato dell'esecuzione può essere elaborato in un secondo momento.
Metafora
Non tutte le definizioni sono buone e alcune necessitano di ulteriori spiegazioni, quindi, per la terminologia formalmente introdotta, aggiungerò una metafora sulla preparazione della colazione. Preparare la colazione in questa metafora è un processo.
Preparando la colazione di mattina io (CPU) arrivo in cucina (Computer). Ho 2 mani (Core). In cucina ci sono diversi dispositivi (IO): forno, kettle, tostapane, frigorifero. Accendo il gas, metto la padella e verso l'olio dentro, senza aspettare che si scaldi (asincrono, Non-Blocking-IO-Wait), tiro fuori dal frigorifero le uova e le rompo nel piatto, dopodiché le sbatto con una mano (Thread#1), con l'altra (Thread#2) tengo fermo il piatto (Shared Resource). Adesso dovrei accendere il kettle, ma ho troppe poche mani (Thread Starvation) Nel frattempo, la padella si scalda (Elaborazione del risultato) dove verso ciò che ho sbattuto. Arrivo al kettle e lo accendo, mentre guardo stupidamente come l'acqua bolle dentro (Blocking-IO-Wait), anche se avrei potuto approfittarne per lavare il piatto in cui ho sbattuto l'omelette.
Stavo preparando un'omelette usando solo 2 mani, ma non ne ho di più, e mentre battevo l'omelette si svolgevano contemporaneamente 3 operazioni: sbattere l'omelette, tenere fermo il piatto, scaldare la padella. La CPU è la parte più veloce del computer, mentre l'IO è ciò che rallenta più spesso, quindi una soluzione efficace è occupare la CPU mentre si riceveranno i dati dall'IO.
Continuando la metafora:
- Se durante la preparazione dell'omelette stessi anche cercando di cambiarmi è un esempio di multitasking. Un aspetto importante: i computer sono decisamente più bravi in ciò rispetto agli esseri umani.
- Una cucina con più cuochi, come in un ristorante, è un computer multicore.
- Molti ristoranti in un food court in un centro commerciale sono un data center
Strumenti .NET
Nel lavoro con i thread, come in molte altre cose, .NET è eccellente. Con ogni nuova versione introduce sempre più nuovi strumenti per lavorare con essi, nuovi strati di astrazione sui thread del sistema operativo. Nel processo di costruzione di astrazioni, gli sviluppatori del framework utilizzano un approccio che consente, nell'uso di un'astrazione ad alto livello, di scendere a uno o più livelli inferiori. Spesso non è necessario, anzi, ciò apre la possibilità di ferirsi con un colpo di fucile, ma talvolta, in rari casi, può rivelarsi l'unico modo per risolvere un problema non risolvibile al livello attuale di astrazione.
Con strumenti intendo sia le interfacce di programmazione (API) fornite dal framework e dai pacchetti di terze parti, sia interi strumenti software che semplificano la ricerca di eventuali problemi legati al codice multithread.
Avvio di un thread
La classe Thread, la più basilare in .NET per lavorare con i thread. Il costruttore accetta uno dei due delegati:
- ThreadStart — Senza parametri
- ParametrizedThreadStart — con un parametro di tipo object.
Il delegato verrà eseguito nel nuovo thread creato dopo la chiamata al metodo Start; se al costruttore è stato passato un delegato di tipo ParametrizedThreadStart, allora nel metodo Start è necessario passare un oggetto. Questo meccanismo è utile per passare qualsiasi informazione locale al thread. Va notato che la creazione di un thread è un'operazione costosa, e il thread stesso è un oggetto pesante, principalmente perché richiede l'allocazione di 1MB di memoria sullo stack, e richiede 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'opera ingegneristica e gli sviluppatori di Microsoft hanno investito notevoli sforzi per farlo funzionare in modo ottimale in diversi scenari.
Concetto generale:
Sin dal momento del suo avvio, l'applicazione in background crea diversi thread di riserva e fornisce la possibilità di utilizzarli. Se i thread vengono utilizzati frequentemente e in grande quantità, il pool si espande per soddisfare le esigenze del codice chiamante. Quando non ci sono thread liberi nel pool in un determinato momento, esso attenderà il ritorno di uno dei thread oppure ne creeraà uno nuovo. Questo indica che il pool di thread è perfetto per brevi azioni, ma non si presta bene a operazioni che funzionano come servizi per tutta la durata dell'applicazione.
Per utilizzare un thread dal pool, esiste il metodo QueueUserWorkItem, che accetta un delegato di tipo WaitCallback, la cui firma corrisponde a ParametrizedThreadStart, e il parametro passato a esso svolge la stessa funzione.
ThreadPool.QueueUserWorkItem(...);
Un metodo meno conosciuto del pool di thread, RegisterWaitForSingleObject, serve 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(...)
In .NET esiste un timer per i thread e si differenzia dai timer WinForms/WPF in quanto il suo gestore verrà chiamato nel thread prelevato dal pool.
System.Threading.Timer
C'è anche un modo piuttosto esotico per inviare un delegato da eseguire in un thread dal pool — il metodo BeginInvoke.
DelegateInstance.BeginInvoke
Voglio soffermarmi ancora brevemente sulla funzione alla quale si riferiscono molti dei metodi sopra menzionati: CreateThread di Kernel32.dll nell'API Win32. Esiste un modo, grazie al meccanismo di metodi extern, per chiamare questa funzione. Ho visto una tale chiamata solo una volta in un esempio terribile di codice legacy, e la motivazione dell'autore che ha fatto tutto ciò rimane ancora per me un mistero.
Kernel32.dll CreateThread
Visualizzazione e debug dei thread
I thread creati da voi, da componenti di terze parti e dal pool .NET possono essere visualizzati nella finestra Threads di Visual Studio. Questa finestra mostrerà le informazioni sui thread solo quando l'applicazione è in modalità di debug e in pausa (Break mode). Qui è possibile visualizzare facilmente lo stack, i nomi e le priorità di ogni thread, e passare il debug a un thread specifico. La proprietà Priority della classe Thread consente di impostare la priorità di un thread, che l'OC e il CLR percepiranno come una raccomandazione nella divisione del tempo di CPU tra i thread.

Library di Parallelismo delle Attività
La Library di Parallelismo delle Attività (TPL) è stata introdotta nel .NET 4.0. Ora è lo standard e il principale strumento per lavorare con l'asincronia. Qualsiasi codice che utilizza approcci più vecchi è considerato codice legacy. L'unità principale della TPL è la classe Task dello spazio dei nomi System.Threading.Tasks. Task rappresenta un'astrazione sopra un thread. Con la nuova versione del linguaggio C# abbiamo ottenuto un modo elegante per lavorare con i Task: gli operatori async/await. Queste concettualizzazioni hanno permesso di scrivere codice asincrono come se fosse semplice e sincrono, dando la possibilità anche a chi ha poca familiarità con la tecnologia dei thread di scrivere applicazioni che li utilizzano, applicazioni che non si bloccano durante l'esecuzione di operazioni lunghe. L'uso di async/await è un argomento per un articolo o anche più di uno, ma cercherò di riassumere il concetto in poche frasi:
- async è un modificatore di metodo che restituisce Task o void
- mentre await è un operatore di attesa non bloccante per un Task.
Ancora una volta: l'operatore await, in generale (ci sono eccezioni), rilascerà il thread di esecuzione attuale e quando il Task avrà completato la sua esecuzione, il thread (in realtà sarebbe più corretto dire contesto, ma ne parleremo più avanti) sarà libero di continuare l'esecuzione del metodo. All'interno di .NET, questo meccanismo è implementato in modo simile a yield return, dove il metodo scritto viene trasformato in una vera e propria classe, che funziona come una macchina a stati e può essere eseguita in pezzi a seconda di questi stati. Chi è interessato può scrivere qualsiasi codice semplice utilizzando async/await, compilarlo e visualizzare l'assemblaggio con JetBrains dotPeek con il codice generato dal compilatore attivato.
Consideriamo le opzioni per avviare e utilizzare un Task. Nell'esempio di codice qui sotto, creiamo un nuovo task che non fa nulla di utile (Thread.Sleep(10000)), ma nella vita reale dovrebbe trattarsi di un lavoro complesso che utilizza la CPU.
using TCO = System.Threading.Tasks.TaskCreationOptions;
public static async void VoidAsyncMethod() {
var cancellationSource = new CancellationTokenSource();
await Task.Factory.StartNew(
// Il codice di azione sarà eseguito in un altro contesto
() => Thread.Sleep(10000),
cancellationSource.Token,
TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness,
scheduler
);
// Il codice dopo await sarà eseguito nel contesto catturato
}
Il Task viene creato con una serie di opzioni:
- LongRunning — suggerisce che il task non verrà completato rapidamente e quindi potrebbe essere opportuno considerare di non prelevare un thread dal pool, ma di crearne uno separato per questo Task per non danneggiare gli altri.
- AttachedToParent — i Task possono essere organizzati in gerarchie. Se è stata utilizzata questa opzione, il Task può trovarsi in uno stato in cui è stato completato e sta aspettando l'esecuzione dei figli.
- PreferFairness — significa che sarebbe bene eseguire i Task inviati per primi prima di quelli inviati successivamente. Ma questa è solo una raccomandazione e il risultato non è garantito.
Il secondo parametro del metodo è il CancellationToken. Per una corretta gestione dell'annullamento di un'operazione dopo il suo avvio, il codice eseguito deve essere riempito di controlli sullo stato di CancellationToken. Se non ci sono controlli, il metodo Cancel chiamato sull'oggetto CancellationTokenSource potrà fermare l'esecuzione del Task solo fino al suo avvio.
L'ultimo parametro passato è un oggetto scheduler di tipo TaskScheduler. Questa classe e i suoi eredi sono progettati per gestire le strategie di distribuzione dei Task sui thread; per impostazione predefinita, il Task verrà eseguito su un thread casuale del pool.
Il Task creato ha applicato l'operatore await, il che significa che il codice scritto dopo di esso, se presente, verrà eseguito nello stesso contesto (spesso significa che sullo stesso thread), come il codice prima di await.
Il metodo è contrassegnato come async void, il che significa che è consentito l'uso dell'operatore await, ma il codice chiamante non potrà aspettare il completamento. Se questa possibilità è necessaria, il metodo deve restituire un Task. I metodi contrassegnati come async void sono piuttosto comuni: di solito si tratta di gestori di eventi o altri metodi che funzionano secondo il principio di esegui e dimentica (fire and forget). Se è necessario non solo consentire di attendere la fine dell'esecuzione, ma anche restituire un risultato, è necessario utilizzare Task.
Su un Task restituito dal metodo StartNew, così come su qualsiasi altro, è possibile chiamare il metodo ConfigureAwait con il parametro false, quindi l'esecuzione dopo await continuerà non nel contesto catturato, ma in uno qualsiasi. Questo deve sempre essere fatto quando il contesto di esecuzione per il codice dopo await non è rilevante. È anche una raccomandazione da parte di MS durante la scrittura di codice che sarà fornito in forma di libreria.
Fermiamoci un po' su come possiamo attendere il completamento di un Task. Ecco un esempio di codice, con commenti, per mostrare quando l'attesa è fatta in modo condizionatamente buono e quando in modo condizionatamente cattivo.
public static async void AnotherMethod() {
int result = await AsyncMethod(); // buono
result = AsyncMethod().Result; // cattivo
AsyncMethod().Wait(); // cattivo
IEnumerable tasks = new Task[] {
AsyncMethod(), OtherAsyncMethod()
};
await Task.WhenAll(tasks); // buono
await Task.WhenAny(tasks); // buono
Task.WaitAll(tasks.ToArray()); // cattivo
}
Nel primo esempio, attendiamo il completamento del Task senza bloccare il thread chiamante; ci occuperemo del risultato solo quando sarà disponibile, nel frattempo il thread chiamante è lasciato a se stesso.
Nella seconda opzione, blocchiamo il thread chiamante fino a quando non viene calcolato il risultato del metodo. Questo è problematico non solo perché abbiamo occupato un thread, una risorsa preziosa per il programma, con un'attività banale, ma anche perché se nel codice del metodo che chiamiamo ci sono await e il contesto di sincronizzazione prevede il ritorno al thread chiamante dopo await, si potrà verificare un deadlock: il thread chiamante aspetta che venga calcolato il risultato del metodo asincrono, mentre il metodo asincrono cerca invano di continuare la sua esecuzione nel thread chiamante.
Un ulteriore svantaggio di questo approccio è la complicata gestione degli errori. Infatti, gestire gli errori nel codice asincrono utilizzando async/await è molto semplice: si comportano come se il codice fosse sincrono. Al contrario, se applichiamo un'attesa sincrona a un Task, l'eccezione originale viene avvolta in un'AggregateException. Pertanto, per gestire l'eccezione sarà necessario esaminare il tipo di InnerException e scrivere una catena di if all'interno di un blocco catch oppure usare la struttura catch when, invece della più familiare catena di blocchi catch nel mondo C#.
Il terzo e ultimo esempio è anche segnato da problemi per la stessa ragione e presenta gli stessi problemi.
I metodi WhenAny e WhenAll sono estremamente utili per attendere un gruppo di Task, poiché avvolgono un gruppo di Task in uno solo, che si attiverà al primo completamento di un Task del gruppo oppure quando tutti i Task completeranno la loro esecuzione.
Arresto dei thread
Per vari motivi può sorgere la necessità di arrestare un thread dopo il suo avvio. Esistono diverse modalità per farlo. La classe Thread ha due metodi con nomi adatti: si tratta di Abort e Interrupt. Il primo non è raccomandato, poiché dopo la sua chiamata, in qualsiasi momento casuale, durante l'elaborazione di qualsiasi istruzione, verrà sollevata un'eccezione ThreadAbortedException. Non ti aspetti che un'eccezione di questo tipo si verifichi durante l'incremento di una variabile intera, giusto? Eppure, con questo metodo, questa è una situazione del tutto reale. Se è necessario impedire al CLR di generare tale eccezione in una certa area di codice, si può racchiudere il codice in chiamate a Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Qualsiasi codice scritto nel blocco finally viene gestito da tali eccezioni. Per questo motivo, nel codice del framework si possono trovare blocchi con un try vuoto, ma con un finally non vuoto. Microsoft sconsiglia così fortemente l'uso di questo metodo che non è stato incluso in .net core.
Il metodo Interrupt funziona in modo più prevedibile. Può interrompere il thread lanciando un'eccezione ThreadInterruptedException solo nei momenti in cui il thread è in attesa. In questo stato, passa rimanendo in attesa di WaitHandle, lock o dopo aver chiamato Thread.Sleep.
Entrambe le opzioni descritte sopra sono problematiche a causa della loro imprevedibilità. Una soluzione è utilizzare la struttura CancellationToken e la classe CancellationTokenSource. Il concetto è il seguente: si crea un'istanza della classe CancellationTokenSource e solo chi la possiede può interrompere l'operazione chiamando il metodo Cancel. All'operazione viene passato solo il CancellationToken. I possessori del CancellationToken non possono annullare l'operazione, ma possono solo verificare se l'operazione è stata annullata. Per questo esiste la proprietà booleana IsCancellationRequested e il metodo ThrowIfCancelRequested. L'ultimo genererà un'eccezione TaskCancelledException se il metodo Cancel è stato chiamato sull'istanza CancellationTokenSource associata al CancellationToken. Ed è proprio questo metodo che consiglio di utilizzare. È migliore delle opzioni precedenti in quanto offre un controllo completo su quando l'operazione può essere interrotta con un'eccezione.
La forma più drastica per fermare un thread è la chiamata alla funzione Win32 API TerminateThread. Il comportamento del CLR dopo la chiamata a questa funzione può essere imprevedibile. Su MSDN è scritto quanto segue: “TerminateThread is a dangerous function that should only be used in the most extreme cases.”
Conversione delle legacy-API in Task Based utilizzando il metodo FromAsync
Se hai avuto la fortuna di lavorare su un progetto iniziato dopo che i Task erano stati introdotti e avevano smesso di suscitare il terrore silenzioso nella maggior parte degli sviluppatori, allora non dovrai affrontare un gran numero di vecchi API, sia di terze parti che tirate su dalla tua squadra in passato. Fortunatamente, il team di sviluppo di .NET Framework si è preso cura di noi, anche se forse l'obiettivo era prendersi cura di sé stessi. Comunque sia, in .NET ci sono diversi strumenti per una conversione indolore del codice scritto con le vecchie modalità di programmazione asincrona in nuove. Uno di questi è il metodo FromAsync della TaskFactory. Nell'esempio di codice qui sotto, avvolgo i vecchi metodi asincroni della classe WebRequest in Task utilizzando questo metodo.
object state = null;
WebRequest wr = WebRequest.CreateHttp("http://github.com");
await Task.Factory.FromAsync(
wr.BeginGetResponse,
wr.EndGetResponse
);
Questo è solo un esempio e difficilmente dovrai fare qualcosa del genere con i tipi integrati, ma qualsiasi vecchio progetto è pieno di metodi BeginDoSomething che restituiscono IAsyncResult e metodi EndDoSomething che li ricevono.
Conversione delle API legacy in Task Based utilizzando la classe TaskCompletionSource
Un altro strumento importante da considerare è la classe TaskCompletionSource. Per quanto riguarda funzioni, scopi e principi di funzionamento, può ricordare il metodo RegisterWaitForSingleObject della classe ThreadPool di cui ho parlato sopra. Utilizzando questa classe, è facile e comodo avvolgere le vecchie API asincrone in Task.
Potresti dire che ho già parlato del metodo FromAsync della TaskFactory dedicato a questi scopi. Qui dovremmo ricordare l'intera storia dell'evoluzione dei modelli asincroni in .NET che Microsoft ha proposto negli ultimi 15 anni: prima del Task-Based Asynchronous Pattern (TAP), esisteva l'Asynchronous Programming Pattern (APP), che riguardava i metodi BeginDoSomething che restituiscono IAsyncResult e i metodi EndDoSomething che li ricevono e per il legacy di quegli anni il metodo FromAsync si adatta perfettamente, ma nel tempo, è stato sostituito dall'Event Based Asynchronous Pattern (EAP), che prevedeva che al termine dell'operazione asincrona venisse invocato un evento.
TaskCompletionSource è perfetto per incapsulare i Task delle API legacy costruite attorno a un modello basato su eventi. La sua funzionalità principale è la seguente: un oggetto di questa classe ha una proprietà pubblica di tipo Task, il cui stato può essere gestito tramite i metodi SetResult, SetException e simili. Dunque, in tutti i punti in cui è stato utilizzato l'operatore await su questo Task, esso verrà completato o rifiutato con un'eccezione, a seconda del metodo applicato al TaskCompletionSource. Se non è ancora chiaro, vediamo questo esempio di codice, in cui un vecchio API dell'epoca EAP viene ingabbiato in un Task tramite TaskCompletionSource: alla ricezione di un evento, il Task passerà allo stato Completo, e il metodo che ha applicato l'operatore await a questo Task riprenderà la sua esecuzione ricevendo un oggetto risultato.
public static Task DoAsync(this SomeApiInstance someApiObj) {
var completionSource = new TaskCompletionSource();
someApiObj.Done +=
result => completionSource.SetResult(result);
someApiObj.Do();
return completionSource.Task;
}
Suggerimenti & Trucchi su TaskCompletionSource
L'incapsulamento delle API legacy non è tutto ciò che si può realizzare con TaskCompletionSource. L'uso di questa classe apre interessanti opportunità di progettazione di varie API basate su Task che non occupano thread. E i thread, come sappiamo, sono risorse costose e il loro numero è limitato (principalmente dalla quantità di RAM). Questo limite è facile da raggiungere durante lo sviluppo, ad esempio, di un'app web sotto carico con una logica di business complessa. Esaminiamo le opportunità di cui parlo implementando un trucco come il Long-Polling.
In breve, l'essenza del trucco è questa: è necessario ricevere informazioni dell'API su alcuni eventi che si verificano sul suo lato, mentre l'API per qualche motivo non può comunicare l'evento, ma può solo restituire lo stato. Esempi di ciò sono tutte le API costruite sopra HTTP fino ai tempi di WebSocket o quando non è possibile utilizzare questa tecnologia per qualche motivo. Il client può interpellare il server HTTP. Il server HTTP non può avviare autonomamente una comunicazione con il client. Una soluzione semplice è l'interrogazione del server tramite un timer, ma ciò crea un carico aggiuntivo sul server e un ritardo medio di TimerInterval / 2. Per aggirare questo problema è stato inventato un trucco noto come Long Polling, che prevede un ritardo nella risposta dal server fino a quando non scade il Timeout o non si verifica un evento. Se l'evento si è verificato, viene gestito; se no, la richiesta viene inviata nuovamente.
while(!eventOccures && !timeoutExceeded) {
CheckTimout();
CheckEvent();
Thread.Sleep(1);
}
Ma questa soluzione si dimostrerà orribile, non appena il numero di client in attesa di un evento cresce, poiché ogni cliente in attesa occupa un intero thread. Inoltre, otteniamo un ritardo aggiuntivo di 1 ms nell'attivazione dell'evento; la maggior parte delle volte non è significativa, ma perché rendere il software peggiore di come potrebbe essere? Se invece rimuoviamo Thread.Sleep(1), caricheremo inutilmente un nucleo della CPU al 100% in un ciclo inutile. Utilizzando TaskCompletionSource, è possibile riadattare facilmente questo codice e risolvere tutti i problemi sopra menzionati:
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);
}
}
Questo codice non è pronto per la produzione, ma è solo dimostrativo. Per un utilizzo in casi reali è necessario, almeno, gestire la situazione quando un messaggio arriva in un momento in cui nessuno lo attende: in tal caso, il metodo AcceptMessageAsync dovrebbe restituire un Task già completato. Se questo caso è invece il più comune, si può considerare l'uso di ValueTask.
Quando riceviamo una richiesta per un messaggio, creiamo e inseriamo nel dizionario TaskCompletionSource, e poi aspettiamo che accada prima: che scada l'intervallo di tempo impostato o che venga ricevuto un messaggio.
ValueTask: perché e come
Gli operatori async/await, così come l'operatore yield return, generano da un metodo una macchina a stati, il che comporta la creazione di un nuovo oggetto, che di solito non è importante, ma in rari casi potrebbe creare una problematica. Questo caso potrebbe essere un metodo chiamato molto frequentemente, parliamo di decine e centinaia di migliaia di chiamate al secondo. Se tale metodo è scritto in modo tale che nella maggior parte dei casi restituisce un risultato evitando tutti i metodi await, allora .NET fornisce uno strumento per ottimizzare — la struttura ValueTask. Per rendere chiaro ciò, consideriamo un esempio del suo utilizzo: c'è una cache a cui accediamo molto spesso. Ci sono alcuni valori in essa e in tal caso li restituiamo semplicemente, se non ci sono, andiamo a cercarli in qualche operazione IO lenta. Quest'ultima vogliamo farla in modo asincrono, il che significa che l'intero metodo diventa asincrono. Pertanto, un'opzione ovvia per scrivere il metodo è la seguente:
public async Task 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 una leggera paura riguardo a cosa genererà Roslyn compilando questo codice, possiamo riscrivere questo esempio nel seguente modo:
public Task 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 sarà ottimizzare il percorso caldo, ovvero ottenere un valore dal dizionario senza allocazioni inutili e senza carico sul GC, mentre in quei rari casi in cui dobbiamo comunque andare in IO per i dati, tutto rimarrà più o meno come prima:
public ValueTask GetById(int id) {
if (cache.TryGetValue(id, out string val))
return new ValueTask(val);
return new ValueTask(RequestById(id));
}
Analizziamo più dettagliatamente questo frammento di codice: in presenza di un valore nella cache creiamo una struttura, altrimenti il vero task sarà racchiuso in uno significante. Il codice chiamante non si preoccupa del percorso percorso dal codice: il ValueTask, dal punto di vista della sintassi C#, si comporterà come un normale Task in questo caso.
TaskScheduler: gestione delle strategie di avvio dei Task
Il seguente API che vorremmo considerare è la classe TaskScheduler e le sue derivate. Ho già accennato sopra che in TPL è possibile gestire le strategie di distribuzione dei Task tra i thread. Tali strategie sono definite nei sottoclassi della classe TaskScheduler. Praticamente qualsiasi strategia possa essere necessaria sarà trovata nella libreria ParallelExtensionsExtras, sviluppata da Microsoft, ma che non fa parte di .NET, e viene fornita come pacchetto Nuget. Esaminiamo brevemente alcune di esse:
- CurrentThreadTaskScheduler — esegue i Task sul thread corrente
- LimitedConcurrencyLevelTaskScheduler — limita il numero di Task eseguiti simultaneamente in base al parametro N, che viene passato al costruttore
- OrderedTaskScheduler — è definito come LimitedConcurrencyLevelTaskScheduler(1), quindi i task verranno eseguiti in modo consecutivo.
- WorkStealingTaskScheduler — implementa il approccio alla distribuzione dei task. In sostanza, è un ThreadPool separato. Risolve il problema del fatto che in .NET il ThreadPool è una classe statica, una per tutte le applicazioni, e quindi il suo sovraccarico o un uso scorretto in una parte del programma può provocare effetti collaterali in un'altra. Inoltre, comprendere la causa di tali difetti è estremamente difficile. Pertanto, potrebbe esserci la necessità di utilizzare WorkStealingTaskScheduler separati nelle parti del programma in cui l'uso del ThreadPool può essere aggressivo e imprevedibile.
- QueuedTaskScheduler — consente di eseguire i task secondo le regole della coda con priorità
- ThreadPerTaskScheduler — crea un thread separato per ogni Task che vi viene eseguito. Può essere utile per i task che richiedono un tempo imprevedibile di esecuzione.
C'è una buona e dettagliata sui TaskScheduler nel blog di Microsoft.
Per una comoda debug di tutto ciò che è legato ai Task in Visual Studio, esiste la finestra Tasks. In questa finestra è possibile vedere lo stato attuale del task e passare alla riga di codice attualmente in esecuzione.

PLinq e la classe Parallel
Oltre ai Task e a quanto detto su di essi, in .NET ci sono altri due strumenti interessanti: PLinq (Linq2Parallel) e la classe Parallel. Il primo promette di eseguire parallelamente tutte le operazioni Linq su più thread. Il numero di thread può essere configurato utilizzando il metodo di estensione WithDegreeOfParallelism. Sfortunatamente, molto spesso PLinq in modalità di lavoro predefinita non ha abbastanza informazioni sulle interiora della tua fonte dati per garantire un notevole guadagno di velocità; d'altra parte, il costo di un tentativo è molto basso: basta chiamare il metodo AsParallel prima della catena di metodi Linq e condurre test di prestazioni. Inoltre, esiste la possibilità di fornire a PLinq informazioni aggiuntive sulla natura della tua fonte dati utilizzando il meccanismo Partitions. Maggiori dettagli possono essere letti e .
La classe statica Parallel fornisce metodi per l'iterazione parallela delle collezioni con Foreach, l'esecuzione di cicli For e l'esecuzione di più delegati in parallelo con Invoke. L'esecuzione del thread corrente sarà fermata fino al termine dei calcoli. Il numero di thread può essere configurato passando ParallelOptions come ultimo argomento. Con le opzioni è anche possibile specificare TaskScheduler e CancellationToken.
Conclusioni
Quando ho iniziato a scrivere questo articolo basato sul materiale della mia presentazione e sulle informazioni che ho raccolto nel tempo dopo di essa, non mi aspettavo che venisse così lungo. Ora, quando l'editor di testo in cui sto digitando questo articolo mi dice con rimprovero che siamo arrivati alla 15a pagina, posso fare un bilancio intermedio. Altri trucchi, API, strumenti visuali e insidie verranno trattati nel prossimo articolo.
Conclusioni:
- È necessario conoscere gli strumenti per lavorare con i thread, l'asincronicità e il parallelismo per sfruttare le risorse dei moderni PC.
- In .NET ci sono molti strumenti diversi per questi scopi
- Non sono tutti apparsi subito, quindi spesso si possono incontrare legacy, tuttavia ci sono modi per convertire le vecchie API senza particolari sforzi.
- La gestione dei thread in .NET è fornita dalle classi Thread e ThreadPool.
- I metodi Thread.Abort, Thread.Interrupt e la funzione Win32 API TerminateThread sono pericolosi e non sono raccomandati. È meglio utilizzare il meccanismo dei CancellationToken.
- Il flusso è una risorsa preziosa, la cui quantità è limitata. È necessario evitare situazioni in cui i flussi sono occupati ad attendere eventi. A questo proposito, è utile utilizzare la classe TaskCompletionSource
- Gli strumenti .NET più potenti e avanzati per lavorare con il parallelismo e l'assincronia sono i Task.
- Gli operatori c# async/await implementano il concetto di attesa non bloccante
- È possibile gestire la distribuzione dei Task tra i thread utilizzando classi derivate da TaskScheduler
- La struttura ValueTask può essere utile nell'ottimizzazione dei percorsi critici e del traffico di memoria
- Le finestre Tasks e Threads di Visual Studio offrono molte informazioni utili per il debug di codice multithreading o asincrono
- PLinq è un ottimo strumento, ma potrebbe non avere abbastanza informazioni sulla tua sorgente dati; tuttavia, questo può essere risolto utilizzando il meccanismo di partizionamento
- Continua…
Fonte: habr.com
