Un Task è un oggetto che rappresenta un lavoro in corso. Potrebbe essere in esecuzione su un thread del thread pool, in attesa di un timer o in attesa di una risposta di rete; in ogni caso puoi chiedergli se ha finito, ottenerne il risultato, attenderlo o collegargli altro lavoro. async e await sono la sintassi del linguaggio costruita sopra i task. Questa pagina copre l'API di Task in sé, e i thread.
Task.Run: lavoro sul thread pool
Task.Run accetta una lambda e la mette in coda sul thread pool, un insieme di thread che .NET tiene pronti per non crearne uno nuovo per ogni lavoro. Restituisce un Task, oppure un Task<T> se la lambda restituisce un valore.
Output:
Started, completed yet: False
Primes up to 200,000: 17984
Status: RanToCompletion
Result blocca il thread chiamante finché il task non ha finito. Va bene nel Main di un programma console, ma dentro codice asincrono scriveresti int primes = await task;, che attende senza bloccare. Lo stesso vale per task.Wait() su un Task senza risultato.
Task.Run serve per il lavoro legato alla CPU: calcoli che tengono occupato un core. Per l'I/O, come leggere un file o chiamare un'API web, usa il metodo asincrono che la libreria fornisce già (File.ReadAllTextAsync, HttpClient.GetStringAsync) e attendilo direttamente con await. Mentre l'I/O è in sospeso, una chiamata asincrona attesa con await non occupa alcun thread, quindi avvolgerla in Task.Run non porta nulla e aggiunge solo un passaggio dal thread pool. Il caso che occupa davvero un thread del pool è Task.Run attorno a una chiamata bloccante sincrona come File.ReadAllText: quel thread resta fermo finché il disco o la rete non rispondono.
Più task insieme: WhenAll
Dividi il lavoro indipendente in task, avviali tutti e combina i risultati con Task.WhenAll. Su una macchina con più core i pezzi vengono eseguiti in parallelo.
Output:
Parts: 14999995, 14999999, 15000003, 15000000
Total: 59999997
Ogni blocco lavora sul proprio intervallo e restituisce la propria somma, quindi i task non condividono nulla e non serve alcun lock. WhenAll restituisce i risultati nell'ordine dei task, indipendentemente dall'ordine in cui i thread hanno finito. Quando i task devono aggiornare uno stato condiviso, vedi lock.
WhenAny e timeout
Task.WhenAny si completa quando si completa il primo dei suoi task e restituisce quel task. Mettere in gara il lavoro vero contro Task.Delay ti dà un timeout:
Output:
fast mirror answered after 100 ms
Timed out after 300 ms
WhenAny restituisce il task vincente in sé, non il suo risultato, quindi lo attendi di nuovo con await per ottenere il valore (o l'eccezione, se è fallito). Nota che i task perdenti continuano a girare; WhenAny non li ferma. Per fermare davvero il lavoro lento, cancellalo con un token come mostrato più sotto. Da .NET 6 in poi, await work.WaitAsync(TimeSpan.FromMilliseconds(300)) esprime lo stesso timeout con una sola chiamata.
Continuazioni con ContinueWith
Prima di await, il modo per eseguire codice dopo la fine di un task era ContinueWith. Lo vedrai ancora nel codice più vecchio:
Output:
Report total: 42
Ogni continuazione riceve il task precedente e viene eseguita quando questo si completa. await fa la stessa cosa con molte meno cerimonie, gestisce le eccezioni in modo naturale e torna al contesto giusto, quindi preferiscilo nel codice nuovo. ContinueWith viene eseguito anche quando il task precedente è fallito o è stato cancellato, per cui il codice più vecchio deve controllare t.IsFaulted o passare TaskContinuationOptions per evitare di chiamare .Result su un task fallito.
Cancellazione con CancellationToken
I task si cancellano in modo cooperativo. Un CancellationTokenSource emette un token; il codice che svolge il lavoro controlla il token e si ferma da solo. Nulla viene interrotto con la forza.
Output:
Cancelled before finishing all 10 orders
Processed 3
Il token viene passato attraverso ogni livello, e anche le API asincrone come Task.Delay, HttpClient e le chiamate al database lo accettano, quindi una cancellazione le ferma nel mezzo dell'attesa. Cattura OperationCanceledException; la TaskCanceledException che alcune API lanciano deriva da essa. CancellationTokenSource è IDisposable, da qui lo using.
Thread vs Task
Un Thread è un thread del sistema operativo che crei tu. È più pesante: ognuno ha il proprio stack (1 MB per impostazione predefinita su Windows), avviarne uno richiede un tempo misurabile, e non ti dà né un risultato né la gestione delle eccezioni.
Output:
Thread started
Result: 42
Thread | Task | |
|---|---|---|
| Gira su | Un nuovo thread dedicato del sistema operativo | Un thread del pool (o nessun thread, per l'I/O) |
| Costo di avvio | Alto | Basso |
| Valore di ritorno | No, condividi una variabile | Task<T>.Result / await |
| Eccezioni | Quelle non gestite fanno crashare il processo | Memorizzate nel task, rilanciate su await |
| Cancellazione | Flag manuali | CancellationToken integrato |
| Attenderlo | Join() | await, Wait(), WhenAll |
Ricorri a Thread solo quando ti serve qualcosa che il pool non offre: un ciclo di lunga durata che non dovrebbe mai prendere in prestito un thread del pool, una priorità specifica, o un single-threaded apartment per COM. Per i task di lunga durata, Task.Factory.StartNew(work, TaskCreationOptions.LongRunning) ottiene un thread dedicato mantenendo l'API dei task.
Parallel.For e Parallel.ForEach
Per "fai questo su ogni elemento, usando tutti i core", System.Threading.Tasks.Parallel è più semplice che creare i task a mano. Suddivide l'intervallo tra i thread e ritorna quando tutte le iterazioni sono finite:
Output di esempio:
image 4 on thread 7
image 3 on thread 6
image 5 on thread 8
image 0 on thread 1
image 1 on thread 4
image 2 on thread 5
Total pixels: 385425
Le righe per ogni immagine escono nell'ordine in cui i thread le hanno eseguite, e gli ID dei thread cambiano da un'esecuzione all'altra; solo il totale è fisso. È la natura dei cicli paralleli: le iterazioni non devono dipendere dall'ordine delle altre, e ogni stato condiviso ha bisogno di sincronizzazione (qui Interlocked.Add). Parallel.ForEach fa lo stesso su qualsiasi collezione, e .NET 6 aggiunge Parallel.ForEachAsync per corpi asincroni.
Errori comuni
.Resulto.Wait()dentro codice asincrono. Blocca un thread e può causare deadlock nelle app con interfaccia grafica; usaawait.- Catturare il tipo di eccezione originale attorno a
.Result. Gli errori arrivano avvolti inAggregateException;awaitli scarta. - Fire and forget.
Task.Run(...)senza attendere né memorizzare il task perde le sue eccezioni. Task.Runattorno all'I/O. Attorno a un metodo asincrono aggiunge solo un passaggio dal thread pool; attorno a un metodo bloccante comeFile.ReadAllTexttiene fermo un thread del pool per tutta l'attesa. Attendi direttamente con await il metodo di I/O asincrono.- Ignorare il token. Passare un
CancellationTokennon fa nulla se il lavoro non lo controlla. - Stato condiviso non sincronizzato nel codice parallelo. Usa
Interlocked,lock, oppure dai a ogni task i propri dati.
Domande frequenti
Cos'è un Task in C#?
Un Task rappresenta un'operazione che può terminare in futuro: codice in esecuzione sul thread pool, un timer, una richiesta di I/O. Si può attendere con await, indica se è stato completato, se è fallito o se è stato cancellato, e Task<T> porta con sé anche un risultato. I task sono il mattone su cui poggiano async/await.
Qual è la differenza tra un Task e un Thread in C#?
Un Thread è un thread del sistema operativo che crei e gestisci tu, con il suo stack; avviarlo è costoso e non restituisce alcun valore. Un Task descrive un lavoro, e Task.Run lo pianifica su un pool condiviso di thread riutilizzabili; può restituire un risultato, propagare le eccezioni, essere cancellato ed essere atteso con await. Usa i task, a meno che non ti serva un thread dedicato e di lunga durata con impostazioni particolari.
Quando conviene usare Task.Run?
Per il lavoro legato alla CPU che vuoi togliere dal thread corrente: elaborazione di immagini, un calcolo pesante, il parsing in memoria di un file grande, soprattutto per mantenere reattiva un'interfaccia. Non avvolgere l'I/O in Task.Run: i metodi di I/O asincroni come ReadAllTextAsync liberano già il thread durante l'attesa.
Come cancello un Task in C#?
Crea un CancellationTokenSource, passa il suo Token al lavoro e chiama Cancel() (o CancelAfter(timeout)) sulla sorgente. Il lavoro deve collaborare: controlla token.IsCancellationRequested o chiama token.ThrowIfCancellationRequested() nel suo ciclo, e passa il token alle API asincrone come Task.Delay. Attendere con await un task cancellato lancia OperationCanceledException.
Qual è la differenza tra Task.WhenAll e Task.WhenAny?
Task.WhenAll si completa quando tutti i task sono completati e ti dà tutti i risultati. Task.WhenAny si completa appena il primo task finisce e restituisce quel task. WhenAny è il modo abituale per aggiungere un timeout: metti in gara il lavoro contro Task.Delay.
Perché Task.Result lancia AggregateException?
.Result e .Wait() avvolgono ogni errore in una AggregateException, perché in generale un task può contenere più eccezioni. await la scarta e rilancia direttamente la prima eccezione interna, ed è uno dei motivi per preferire await. Se devi proprio bloccare, anche .GetAwaiter().GetResult() lancia l'eccezione originale.