Pubblico su Habr l'originale dell'articolo, la cui traduzione è presente in aziendale .
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, tale necessità è diventata molto evidente. Adesso, nel 2019, digitando questo articolo su un laptop con processore Intel Core a 8 core, su cui nel frattempo operano oltre cento processi, e addirittura più thread. Accanto a me c'è un telefono già un po' usurato, comprato un paio di anni fa, con un processore a 8 core. Sui siti tematici abbondano articoli e video in cui i loro autori si meravigliano degli 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. Sfortunatamente, è impossibile estrarre il massimo e domare questa potenza senza saper gestire l'interazione dei thread.
Terminologia
Processo (Process) — un oggetto OS, spazio di indirizzamento isolato, contiene thread.
Thread (Punto) — 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 del processo.
Multitasking — caratteristica del sistema operativo, capacità di eseguire più processi contemporaneamente
Multicore — caratteristica del processore, capacità di utilizzare più nuclei per elaborare dati
Multiprocessing — caratteristica del computer, capacità di lavorare fisicamente con più processori contemporaneamente
Multithreading — caratteristica del processo, capacità di distribuire l'elaborazione dei dati tra più thread.
Parallelismo — esecuzione di più azioni fisicamente contemporaneamente in un'unità di tempo
Asincronicità — esecuzione di un'operazione senza attendere il completamento di tale elaborazione; il risultato può essere elaborato successivamente.
Metafora
Non tutte le definizioni sono buone e alcune necessitano di ulteriori spiegazioni, quindi per la terminologia formalmente introdotta aggiungerò una metafora sul preparare la colazione. Preparare la colazione in questa metafora è un processo.
Preparando la colazione al mattino io (CPU) arrivo in cucina (Un computer). Ho 2 mani (Nuclei). In cucina ci sono vari dispositivi (IOFornello, bollitore, tostapane, frigorifero. Accendo il gas, metto la padella e verso l'olio dentro, senza aspettare che si riscaldi.asincrono, Non-Blocking-IO-Wait, tiro fuori le uova dal frigorifero e le rompo in un piatto, poi le sbatto con una mano,Thread#1, mentre con l'altraThread#2sostengo il piatto (Shared Resource). Ora dovrei anche accendere il bollitore, ma non ho abbastanza maniThread Starvation. Nel frattempo, la padella si scalda (Elaborazione del risultato) dove verso ciò che ho sbattuto. Raggiungo il bollitore e lo accendo e guardo semplicemente come l'acqua bolle dentro,Blocking-IO-Wait, anche se avrei potuto lavare il piatto dove ho sbattuto l'omelette nel frattempo.
Ho preparato l'omelette usando solo 2 mani, ma non ne ho di più, e nel momento in cui sbattevo l'omelette si svolgevano contemporaneamente 3 operazioni: sbattere l'omelette, sostenere il piatto, scaldare la padella. La CPU è la parte più veloce del computer, l'IO è ciò che rallenta più spesso, quindi spesso una soluzione efficace è occupare la CPU mentre si ottengono dati dall'IO.
Continuando la metafora:
- Se mentre cucini un'omelette stessi anche cercando di cambiarti, sarebbe un esempio di multitasking. Un aspetto importante: i computer lo fanno molto meglio degli esseri umani.
- Una cucina con diversi chef, ad esempio in un ristorante, è come un computer multicore.
- Numerosi ristoranti in un food court di un centro commerciale sono come un data center.
Strumenti .NET
.NET è efficace nella gestione dei thread, come in molte altre aree. Con ogni nuovo aggiornamento, vengono introdotti sempre più strumenti per lavorare con essi, nuovi livelli di astrazione sui thread del sistema operativo. Nella creazione di astrazioni, gli sviluppatori del framework adottano un approccio che consente, utilizzando un'astrazione ad alto livello, di scendere a uno o più livelli inferiori. Nella maggior parte dei casi questo non è necessario; anzi, apre la possibilità di farsi del male. Tuttavia, a volte, in rarissimi casi, potrebbe rivelarsi l'unico modo per risolvere un problema che non si può affrontare al livello attuale di astrazione.
Con strumenti intendo sia le interfacce di programmazione (API) fornite dal framework e da pacchetti esterni, sia intere soluzioni software che semplificano la ricerca di eventuali problemi legati al codice multithreading.
Avvio di un thread
La classe Thread è la più basilare in .NET per lavorare con i thread. Nel 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 nel costruttore è stato passato un delegato di tipo ParametrizedThreadStart, allora nel metodo Start è necessario passare un oggetto. Questo meccanismo è necessario per trasferire qualsiasi informazione locale nel thread. Vale la pena notare che la creazione di un thread è un'operazione costosa, e il thread stesso è un oggetto pesante, principalmente perché si riservano 1 MB di memoria per lo 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 capolavoro ingegneristico e gli sviluppatori di Microsoft hanno investito molte energie affinché funzionasse in modo ottimale in vari scenari.
Concetto generale:
Dalla sua attivazione, l'applicazione crea in background diversi thread di riserva e offre la possibilità di utilizzarli. Se i thread vengono utilizzati frequentemente e in grande quantità, il pool si espande per soddisfare la domanda del codice invocante. Quando non ci sono thread liberi disponibili nel pool al momento opportuno, esso attende il ritorno di uno dei thread o ne crea uno nuovo. Ne deriva che il pool di thread è ideale per azioni brevi, mentre è inadeguato per operazioni che funzionano come servizi durante l'intero ciclo di vita delle applicazioni.
Per utilizzare un thread dal pool, esiste il metodo QueueUserWorkItem, che accetta un delegato di tipo WaitCallback, il quale ha la stessa firma di ParametrizedThreadStart; il parametro passato esegue la stessa funzione.
ThreadPool.QueueUserWorkItem(...);
Un metodo meno noto del pool di thread, RegisterWaitForSingleObject, serve a gestire operazioni I/O non bloccanti. Il delegato passato a questo metodo verrà chiamato quando il WaitHandle passato sarà "rilasciato" (Released).
ThreadPool.RegisterWaitForSingleObject(...)
In .NET c'è un timer di thread e si differenzia dai timer WinForms/WPF in quanto il suo gestore verrà chiamato in un thread preso 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
Vorrei anche brevemente soffermarmi sulla funzione a cui si riducono molti dei metodi sopra indicati — CreateThread dall'API Win32 Kernel32.dll. Esiste un modo, grazie al meccanismo dei metodi extern, per chiamare questa funzione. Ho visto questa chiamata solo una volta in un esempio terribile di codice legacy, e la motivazione dell'autore che ha fatto così rimane ancora per me un mistero.
Kernel32.dll CreateThread
Visualizzazione e debug dei thread
I thread creati da te, insieme a tutti i componenti di terze parti e al pool .NET, possono essere visualizzati nella finestra Thread di Visual Studio. Questa finestra mostrerà informazioni sui thread solo quando l'applicazione è in fase di debug e in modalità di interruzione (Break mode). Qui puoi facilmente esaminare gli stack, i nomi e le priorità di ciascun thread, e passare al debug di un thread specifico. La proprietà Priority della classe Thread può essere utilizzata per impostare la priorità del thread, che OC e CLR considereranno come un consiglio nella divisione del tempo della CPU tra i thread.

Task Parallel Library
La Task Parallel Library (TPL) è stata introdotta in .NET 4.0. Oggi è lo standard e lo strumento principale per gestire l'asincronia. Qualsiasi codice che utilizzi approcci più vecchi è considerato legacy. L'unità principale di TPL è la classe Task dello spazio dei nomi System.Threading.Tasks. Task rappresenta un'astrazione rispetto a un thread. Con la nuova versione del linguaggio C#, abbiamo ottenuto un modo elegante per lavorare con i Task: gli operatori async/await. Queste concetti hanno consentito di scrivere codice asincrono come se fosse semplice e sincronizzato, permettendo anche a chi ha una conoscenza limitata delle problematiche interne dei thread di scrivere applicazioni che li utilizzano, evitando che le applicazioni si bloccassero durante operazioni prolungate. L'uso di async/await è un argomento per uno o addirittura più articoli, ma cercherò di riassumere il concetto in poche frasi.
- async è un modificatore di metodo che restituisce Task o void
- mentre await è l'operatore di attesa non bloccante per un Task.
Ripetiamo: l'operatore await, in generale (ci sono eccezioni), continuerà a liberare il flusso di esecuzione corrente e, quando il Task avrà completato la sua esecuzione, il flusso (è in realtà più corretto dire contesto, ma ne parleremo più tardi) sarà libero di continuare l'esecuzione del metodo. All'interno di .NET questo meccanismo è realizzato in modo simile a yield return, quando un metodo scritto si trasforma in una vera e propria classe, che funge da macchina a stati e può essere eseguita in porzioni a seconda di questi stati. Chi è interessato può scrivere qualsiasi codice non complesso utilizzando async/await, compilarlo e visualizzare l'assembly utilizzando JetBrains dotPeek con il codice generato dal compilatore abilitato.
Consideriamo le opzioni per eseguire e utilizzare un Task. Nell'esempio di codice seguente, creiamo un nuovo task che non esegue nulla di utile (Thread.Sleep(10000)), ma nella vita reale dovrebbe trattarsi di un lavoro complesso che utilizza la CPU.
utilizzando TCO = System.Threading.Tasks.TaskCreationOptions;
public static async void VoidAsyncMethod() {
var cancellationSource = new CancellationTokenSource();
await Task.Factory.StartNew(
// Il codice di azione verrà eseguito in un altro contesto
() => Thread.Sleep(10000),
cancellationSource.Token,
TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness,
scheduler
);
// Il codice dopo await verrà eseguito nel contesto catturato
}
Il Task viene creato con una serie di opzioni:
- LongRunning — è un suggerimento che indica che il task non verrà completato rapidamente, quindi potrebbe essere utile considerare di non prendere un thread dal pool, ma di creare un thread separato per questo Task, in modo da 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 ha completato la sua esecuzione e sta aspettando che i task figli vengano completati.
- PreferFairness — significa che sarebbe preferibile eseguire i Task inviati per primi rispetto a quelli inviati in seguito. Ma questo è solo un consiglio e il risultato non è garantito.
Il secondo parametro passato al metodo è un CancellationToken. Per una corretta gestione dell'annullamento dell'operazione dopo il suo avvio, il codice eseguito deve essere riempito con controlli sullo stato del CancellationToken. Se non ci sono controlli, il metodo Cancel chiamato sull'oggetto CancellationTokenSource potrà fermare l'esecuzione del Task solo prima del suo avvio.
L'ultimo parametro è un oggetto scheduler di tipo TaskScheduler. Questa classe e le sue derivate sono progettate per gestire le strategie di assegnazione dei Task ai thread; per impostazione predefinita, il Task verrà eseguito su un thread casuale del pool.
Al Task creato è stato 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) di quello precedente all'await.
Il metodo è contrassegnato come async void, il che significa che all'interno è consentito l'uso dell'operatore await, ma il codice chiamante non potrà attendere il completamento. Se è necessario avere questa possibilità, il metodo dovrebbe restituire un Task. I metodi contrassegnati come async void si presentano abbastanza frequentemente: di solito sono gestori di eventi o altri metodi che operano secondo il principio di 'eseguire e dimenticare' (fire and forget). Se è necessario non solo consentire di attendere la conclusione 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 un contesto arbitrario. Questo deve essere fatto sempre quando il contesto di esecuzione non è rilevante per il codice dopo await. È anche una raccomandazione di MS quando si scrive codice che verrà fornito in forma confezionata in una libreria.
Fermiamoci un attimo su come è possibile attendere il completamento di un Task. Di seguito un esempio di codice, con commenti, sui casi in cui l'attesa è stata implementata in modo ragionevolmente corretto e quando in modo ragionevolmente scadente.
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, aspettiamo che il Task venga completato senza bloccare il thread chiamante; il risultato verrà elaborato solo quando sarà disponibile, nel frattempo il thread chiamante è libero di proseguire.
Nel secondo caso, blocchiamo il thread chiamante fino a quando il risultato del metodo non è calcolato. Questo è negativo non solo perché stiamo occupando un thread, una risorsa preziosa del programma, con un'attività inutile, ma anche perché se nel codice del metodo che chiamiamo c'è un await e il contesto di sincronizzazione prevede il ritorno al thread chiamante dopo l'await, si verificherà un deadlock: il thread chiamante aspetta che venga calcolato il risultato del metodo asincrono, mentre il metodo asincrono cerca inutilmente di continuare la sua esecuzione nel thread chiamante.
Un ulteriore svantaggio di questo approccio è la complicata gestione degli errori. Infatti, gli errori nel codice asincrono utilizzando async/await sono molto facili da gestire — si comportano come se il codice fosse sincrono. Affinché utilizziamo l'attesa sincrona su un Task, l'eccezione originale viene incapsulata in un'AggregateException; quindi, per gestire l'eccezione, sarà necessario esaminare il tipo di InnerException e scrivere manualmente una catena di if all'interno di un blocco catch o usare una costruzione catch when, invece di una catena più familiare di blocchi catch in C#.
Il terzo e ultimo esempio è segnato male per lo stesso motivo e presenta gli stessi problemi.
I metodi WhenAny e WhenAll sono estremamente utili per attendere un gruppo di Task; incapsulano un gruppo di Task in uno che si attiverà sia al primo completamento di un Task del gruppo, sia quando tutti i Task avranno terminato la loro esecuzione.
Interruzione dei thread
Per vari motivi può sorgere la necessità di interrompere un thread dopo il suo avvio. A questo scopo esistono diversi metodi. La classe Thread ha due metodi con nomi appropriati — questo è Abort e Interrupt. Questo è altamente sconsigliato, poiché dopo la sua chiamata un'eccezione verrà sollevata in qualsiasi momento casuale durante l'elaborazione di qualsiasi istruzione. ThreadAbortedException. Non ci si aspetta che tale eccezione venga sollevata durante l'incremento di una variabile intera, giusto? Eppure, utilizzando questo metodo, questa è una situazione molto reale. Se necessario, per impedire al CLR di generare tale eccezione in una sezione specifica del codice si possono racchiudere le chiamate in Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Con queste chiamate si racchiude qualsiasi codice scritto nel blocco finally. Per questo motivo, nel profondo del codice del framework, si possono trovare blocchi con try vuoti, ma non con finally vuoto. Microsoft sconsiglia così tanto l'uso di questo metodo che non l'ha incluso in .net core.
Il metodo Interrupt funziona in modo più prevedibile. Può interrompere un thread con un'eccezione ThreadInterruptedException solo nei momenti in cui il thread è in stato di attesa. Tale stato si verifica quando è in attesa di un WaitHandle, lock o dopo la chiamata a Thread.Sleep.
Entrambe le opzioni descritte sopra sono problematiche a causa della loro imprevedibilità. La soluzione è utilizzare una struttura. CancellationToken e della classe CancellationTokenSource. La questione è la seguente: viene creato un'istanza della classe CancellationTokenSource e solo colui che ne è in possesso può interrompere l'operazione chiamando il metodo Cancel. Solo il CancellationToken viene passato all'operazione. I possessori di CancellationToken non possono annullare l'operazione da soli, ma possono solo verificare se l'operazione è stata annullata. A tal fine, esiste la proprietà booleana IsCancellationRequested e il metodo ThrowIfCancelRequested. Quest'ultima genererà un'eccezione TaskCancelledException se è stato chiamato il metodo Cancel sull'istanza di CancellationTokenSource associata al CancellationToken. Ed è proprio questo metodo che consiglio di utilizzare. È migliore delle precedenti opzioni per avere pieno controllo sui momenti in cui l'eccezione può interrompere l'operazione.
L'opzione più drastica per fermare un thread è chiamare la funzione dell'API Win32 TerminateThread. Il comportamento del CLR dopo aver chiamato questa funzione può essere imprevedibile. Su MSDN è scritto quanto segue su questa funzione: “TerminateThread is a dangerous function that should only be used in the most extreme cases.”
Conversione delle API legacy in Task Based utilizzando il metodo FromAsync
Se hai avuto la fortuna di lavorare su un progetto avviato dopo che i task erano stati introdotti e avevano smesso di terrorizzare la maggior parte degli sviluppatori, non dovrai affrontare un gran numero di vecchi API, sia di terze parti che creati 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 se stessi. Comunque, in .NET ci sono diversi strumenti per trasformare senza dolore il codice scritto con i vecchi approcci della programmazione asincrona nel nuovo. Uno di questi è il metodo FromAsync di TaskFactory. Nell'esempio di codice qui sotto, avvolgo i vecchi metodi asincroni della classe WebRequest in un Task usando 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 fare qualcosa di simile con i tipi predefiniti sarà raramente necessario, ma qualsiasi vecchio progetto è pieno di metodi BeginDoSomething che restituiscono IAsyncResult e metodi EndDoSomething che li accettano.
Conversione dell'API legacy in Task Based tramite la classe TaskCompletionSource
Un altro strumento importante da considerare è la classe TaskCompletionSourcePer funzionalità, scopo e principio di funzionamento, ricorda in qualche modo il metodo RegisterWaitForSingleObject della classe ThreadPool di cui ho parlato sopra. Con questa classe è possibile facilmente e comodamente incapsulare le vecchie API asincrone in Task.
Direte che ho già parlato del metodo FromAsync della classe TaskFactory, progettato per questi scopi. Qui è necessario ripercorrere tutta la storia dello sviluppo 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 restituisce IAsyncResult e i metodi EndDoSomething che lo accetta e per i legacy di quegli anni, il metodo FromAsync si adattava perfettamente, ma nel tempo, è stato sostituito dall'Event Based Asynchronous Pattern (EAP), che presumeva che al termine dell'operazione asincrona venisse generato un evento.
TaskCompletionSource è perfetto per avvolgere i Task delle API legacy costruite attorno a un modello basato su eventi. La sua funzionalità si basa su questo: un oggetto di questa classe ha una proprietà pubblica di tipo Task il cui stato può essere gestito attraverso i metodi SetResult, SetException, e altri. Nelle posizioni in cui è stato utilizzato l'operatore await su questo Task, esso sarà completato o interrotto con un'eccezione a seconda del metodo applicato a TaskCompletionSource. Se non è ancora chiaro, diamo un'occhiata a questo esempio di codice, dove una vecchia API risalente all'EAP viene avvolta in un Task tramite TaskCompletionSource: al verificarsi di un evento, il Task sarà portato allo stato Completed, e il metodo che ha applicato l'operatore await a questo Task riprenderà la sua esecuzione ricevendo un oggetto. result.
public static Task DoAsync(this SomeApiInstance someApiObj) {
var completionSource = new TaskCompletionSource();
someApiObj.Done +=
result => completionSource.SetResult(result);
someApiObj.Do();
return completionSource.Task;
}
Consigli e trucchi su TaskCompletionSource
L'incapsulamento di vecchi API non è l'unica cosa che si può fare con TaskCompletionSource. L'uso di questa classe apre a possibilità interessanti nella progettazione di vari API, basati su Task che non occupano thread. I thread, come sappiamo, sono una risorsa costosa e il loro numero è limitato (principalmente dalla quantità di RAM). Questa limitazione può essere facilmente raggiunta progettando, ad esempio, un'applicazione web pesante con una logica di business complessa. Esaminiamo le opportunità di cui parlo con l'implementazione di un trucco come il Long-Polling.
In breve, il trucco consiste nel fatto che è necessario ricevere informazioni dall'API riguardo ad alcuni eventi che si verificano lato server, mentre l'API, per qualche motivo, non può segnalare l'evento, ma può solo restituire uno stato. Esempi di questo sono tutte le API costruite sopra HTTP fino all'arrivo dei WebSocket o quando non è possibile utilizzare questa tecnologia per qualche motivo. Il cliente può interrogare il server HTTP. Il server HTTP non può autonomamente attivare una comunicazione con il cliente. Una soluzione semplice è quella di interrogare il server a intervalli regolari, 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 del server finché non scade il Timeout o non si verifica un evento. Se si è verificato un evento, viene elaborato; in caso contrario, la richiesta viene inviata nuovamente.
while(!eventOccures && !timeoutExceeded) {
CheckTimout();
CheckEvent();
Thread.Sleep(1);
}
Ma una soluzione del genere si comporterà male non appena il numero di clienti in attesa dell'evento aumenterà, poiché ogni cliente in attesa occupa un intero thread. Inoltre, si aggiunge un ritardo di 1 ms nell'attivazione dell'evento, che nella maggior parte dei casi non è significativo, ma perché rendere il software peggiore di quanto possa essere? Se poi si rimuove Thread.Sleep(1), si carica inutilmente un core della CPU al 100% girando in un ciclo inutile. Con TaskCompletionSource è possibile riscrivere 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 una dimostrazione. Per l'uso in casi reali, è necessario gestire almeno la situazione in cui il messaggio arriva in un momento in cui non ci si aspetta: in tal caso, il metodo AsseptMessageAsync deve restituire un Task già completato. Se questo scenario è il più comune, si può considerare anche l'uso di ValueTask.
Quando riceviamo una richiesta per un messaggio, creiamo e inseriamo nel dizionario TaskCompletionSource, e poi aspettiamo che accada prima: scada l'intervallo di tempo impostato o venga ricevuto un messaggio.
ValueTask: perché e come
Gli operatori async/await, così come l'operatore yield return, generano una macchina a stati dal metodo, creando un nuovo oggetto. Questo di solito non è importante, ma in rari casi può causare problemi. Un esempio di tale caso è un metodo che viene chiamato davvero spesso, parliamo di decine e centinaia di migliaia di chiamate al secondo. Se questo metodo è scritto in modo tale da restituire risultati nella maggior parte dei casi senza attraversare tutti i metodi await, .NET offre uno strumento per ottimizzarlo: la struttura ValueTask. Per chiarire, consideriamo un esempio del suo utilizzo: c'è una cache a cui accediamo molto frequentemente. Alcuni valori ci sono già e quindi li restituiamo immediatamente; se non ci sono, ci dirigiamo verso qualche IO lento per recuperarli. Quest'ultima operazione vogliamo farla in modo asincrono, il che rende l'intero metodo asincrono. Quindi, una soluzione ovvia per scrivere il metodo è la 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 una leggera preoccupazione riguardo a ciò che Roslyn genererà 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 questo caso, la soluzione ottimale è ottimizzare il percorso principale, cioè ottenere il valore dal dizionario senza allocazioni superflue e senza carico sul GC. Nei rari casi in cui dobbiamo comunque accedere ai dati in IO, il processo rimarrà più o meno lo stesso:
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: quando il valore è presente nella cache, creiamo una struttura, altrimenti il task reale sarà avvolto in un valore significativo. Al codice chiamante non importa quale percorso sia stato seguito per eseguire questo codice: il ValueTask, dal punto di vista della sintassi C#, si comporterà come un normale Task in questo caso.
TaskScheduler: gestione delle strategie di esecuzione dei Task
Il prossimo API che vorremmo considerare è la classe TaskScheduler e le sue derivate. Ho già menzionato in precedenza che in TPL è possibile gestire le strategie di distribuzione delle attività tra i thread. Queste strategie sono definite nei sottoclassi della classe TaskScheduler. Praticamente qualsiasi strategia necessaria può essere trovata nella libreria ParallelExtensionsExtras, sviluppata da Microsoft, ma non parte di .NET, e fornita come pacchetto Nuget. Diamo un'occhiata a alcune di esse:
- CurrentThreadTaskScheduler — esegue i Task sul thread corrente
- LimitedConcurrencyLevelTaskScheduler — limita il numero di Task eseguiti contemporaneamente in base al parametro N, che viene passato al costruttore
- OrderedTaskScheduler — è definito come LimitedConcurrencyLevelTaskScheduler(1), quindi le attività verranno eseguite in modo sequenziale.
- WorkStealingTaskScheduler — implementa approccio alla distribuzione dei compiti. Fondamentalmente è un ThreadPool separato. Risolve il problema del fatto che in .NET il ThreadPool è una classe statica, unica per tutte le applicazioni, il che significa che sovraccarichi o un uso improprio in una parte del programma possono causare effetti collaterali in un'altra. Inoltre, identificare la causa di tali difetti è estremamente difficile. Pertanto, potrebbe esserci la necessità di utilizzare WorkStealingTaskScheduler separati in quelle parti del programma dove l'uso del ThreadPool può essere aggressivo e imprevedibile.
- QueuedTaskScheduler — consente di eseguire compiti secondo regole di coda con priorità.
- ThreadPerTaskScheduler — crea un thread separato per ogni Task che viene eseguito. Può essere utile per compiti che richiedono un tempo di esecuzione imprevedibilmente lungo.
C'è una buona e dettagliata sui TaskScheduler nel blog di Microsoft.
Per facilitare il debug di tutto ciò che riguarda i Task in Visual Studio, è disponibile la finestra Tasks. In questa finestra è possibile vedere lo stato attuale del compito e passare alla riga di codice attualmente in esecuzione.

PLinq e la classe Parallel
Oltre ai Task e a tutto ciò che è stato detto riguardo a .NET, ci sono altri due strumenti interessanti: 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 con il metodo di estensione WithDegreeOfParallelism. Sfortunatamente, nella maggior parte dei casi, PLinq in modalità predefinita non ha informazioni sufficienti sulle internals della tua sorgente dati per garantire un significativo guadagno in velocità; d'altra parte, il costo del tentativo è molto basso: basta chiamare il metodo AsParallel prima della catena dei metodi Linq e condurre test di prestazioni. Inoltre, c'è la possibilità di passare a PLinq informazioni aggiuntive sulla natura della tua sorgente dati tramite il meccanismo Partitions. Puoi leggere di più in dettaglio. e .
La classe statica Parallel fornisce metodi per l'iterazione parallela delle collezioni Foreach, l'esecuzione di un ciclo For e l'invocazione di più delegati in 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. Le opzioni consentono anche di specificare TaskScheduler e CancellationToken.
Conclusioni
Quando ho iniziato a scrivere questo articolo basandomi sul materiale della mia presentazione e sulle informazioni raccolte durante il mio lavoro, non mi aspettavo di produrre tanto. Ora, mentre il mio editor di testo segnala con rimprovero che siamo alla quindicesima pagina, faccio una sintesi intermedia. Altri trucchi, API, strumenti visivi e insidie saranno trattati nel prossimo articolo.
Conclusioni:
- È fondamentale conoscere gli strumenti per lavorare con i thread, l'asincronia e il parallelismo per sfruttare le risorse dei moderni PC.
- .NET offre molti strumenti diversi per questi scopi.
- Non tutti sono emersi subito, quindi spesso si possono incontrare legacy, ma ci sono modi per adattare le vecchie API senza sforzi significativi.
- Il lavoro con i thread in .NET è rappresentato dalle classi Thread e ThreadPool
- I metodi Thread.Abort, Thread.Interrupt e la funzione TerminateThread dell'API Win32 sono pericolosi e non raccomandati. È preferibile utilizzare invece il meccanismo dei CancellationToken.
- Un thread è una risorsa preziosa e il loro numero è limitato. È importante evitare situazioni in cui i thread sono occupati ad attendere eventi. A tal fine, è utile utilizzare la classe TaskCompletionSource.
- Gli Task sono lo strumento più potente e avanzato di .NET per lavorare con la parallelizzazione e l'asincronia.
- Gli operatori C# async/await implementano il concetto di attesa non bloccante.
- È possibile gestire la distribuzione degli Task tra i thread utilizzando classi derivate da TaskScheduler.
- La struttura ValueTask può essere utile per ottimizzare i percorsi caldi e il traffico di memoria.
- Le finestre Tasks e Threads di Visual Studio forniscono molte informazioni utili per il debug di codice multithread o asincrono.
- PLinq è uno strumento potente, ma potrebbe non avere informazioni sufficienti sulla tua sorgente dati; tuttavia, è possibile risolvere il problema utilizzando il meccanismo di partizionamento.
- Continua...
Fonte: habr.com
