Menu

Task em C#: Task.Run, WhenAll, WhenAny, cancelamento e threads

Task é a unidade de trabalho assíncrono e paralelo no .NET. Veja Task.Run para trabalho limitado pela CPU, ler resultados, combinar tasks com WhenAll e WhenAny, continuações, cancelar com CancellationToken, como uma Task difere de uma Thread e quando Parallel.For é a melhor escolha.

Esta página tem editores executáveis - edite, execute e veja a saída na hora.

Uma Task é um objeto que representa um trabalho em andamento. Ele pode estar executando em uma thread do thread pool, esperando um timer ou esperando uma resposta da rede; em qualquer caso, você pode perguntar se ele terminou, obter o resultado, esperar por ele ou anexar mais trabalho a ele. async e await são a sintaxe da linguagem sobre as tasks. Esta página cobre a própria API de Task, e as threads.

Task.Run: trabalho no thread pool

Task.Run recebe uma lambda e a coloca na fila do thread pool, um conjunto de threads que o .NET mantém disponível para não criar uma nova a cada trabalho. Ele retorna uma Task, ou uma Task<T> se a lambda retorna um valor.

Saída:

Started, completed yet: False
Primes up to 200,000: 17984
Status: RanToCompletion

Result bloqueia a thread que chama até a task terminar. Isso é aceitável no Main de um programa de console, mas dentro de código async você escreveria int primes = await task;, que espera sem bloquear. O mesmo vale para task.Wait() em uma Task sem resultado.

Task.Run é para trabalho limitado pela CPU: cálculos que mantêm um núcleo ocupado. Para I/O, como ler um arquivo ou chamar uma API web, use o método async que a biblioteca já oferece (File.ReadAllTextAsync, HttpClient.GetStringAsync) e aguarde-o diretamente com await. Enquanto o I/O está pendente, uma chamada async aguardada não segura nenhuma thread, então envolvê-la em Task.Run não ganha nada e só acrescenta uma passagem pelo pool de threads. O que realmente ocupa uma thread do pool é Task.Run em volta de uma chamada síncrona bloqueante como File.ReadAllText: essa thread fica parada até o disco ou a rede responder.

Várias tasks ao mesmo tempo: WhenAll

Divida o trabalho independente em tasks, inicie todas e combine os resultados com Task.WhenAll. Em uma máquina com vários núcleos, as partes executam em paralelo.

Saída:

Parts: 14999995, 14999999, 15000003, 15000000
Total: 59999997

Cada pedaço trabalha no seu próprio intervalo e retorna a sua própria soma, então as tasks não compartilham nada e não precisam de lock. WhenAll retorna os resultados na ordem das tasks, não importa como as threads terminaram. Quando as tasks precisam atualizar um estado compartilhado, veja lock.

WhenAny e timeouts

Task.WhenAny completa quando a primeira das suas tasks completa e retorna essa task. Fazer o trabalho real competir com um Task.Delay dá um timeout:

Saída:

fast mirror answered after 100 ms
Timed out after 300 ms

WhenAny retorna a própria task vencedora, não o resultado dela, então você faz await nela de novo para obter o valor (ou a exceção, se ela falhou). Repare que as tasks perdedoras continuam executando; WhenAny não as para. Para parar de verdade o trabalho lento, cancele-o com um token como mostrado abaixo. No .NET 6 em diante, await work.WaitAsync(TimeSpan.FromMilliseconds(300)) expressa o mesmo timeout em uma chamada.

Continuações com ContinueWith

Antes do await, a forma de executar código depois que uma task terminava era ContinueWith. Você ainda vai vê-lo em código mais antigo:

Saída:

Report total: 42

Cada continuação recebe a task anterior e executa quando ela completa. await faz o mesmo com muito menos cerimônia, trata exceções de forma natural e volta ao contexto certo, então prefira-o em código novo. ContinueWith executa mesmo quando a task anterior falhou ou foi cancelada, então o código antigo precisa inspecionar t.IsFaulted ou passar TaskContinuationOptions para não chamar .Result em uma task que falhou.

Cancelamento com CancellationToken

Tasks são canceladas de forma cooperativa. Um CancellationTokenSource emite um token; o código que faz o trabalho verifica o token e se interrompe. Nada é abortado à força.

Saída:

Cancelled before finishing all 10 orders
Processed 3

O token é passado por todas as camadas, e APIs async como Task.Delay, HttpClient e chamadas de banco de dados também o aceitam, então um cancelamento as interrompe no meio da espera. Capture OperationCanceledException; a TaskCanceledException que algumas APIs lançam deriva dela. CancellationTokenSource é IDisposable, daí o using.

Thread vs Task

Uma Thread é uma thread do sistema operacional que você mesmo cria. Ela é mais pesada: cada uma tem sua própria pilha (1 MB por padrão no Windows), iniciar uma leva um tempo mensurável, e ela não dá resultado nem tratamento de exceções.

Saída:

Thread started
Result: 42
ThreadTask
Executa emUma nova thread dedicada do sistema operacionalUma thread do pool (ou nenhuma thread, para I/O)
Custo para iniciarAltoBaixo
Valor de resultadoNão, compartilhe uma variávelTask<T>.Result / await
ExceçõesAs não tratadas derrubam o processoGuardadas na task, relançadas no await
CancelamentoFlags manuaisCancellationToken embutido
Esperar por elaJoin()await, Wait(), WhenAll

Recorra a Thread só quando você precisa de algo que o pool não oferece: um laço de longa duração que nunca deveria ocupar uma thread do pool, uma prioridade específica ou um single-threaded apartment para COM. Para tasks de longa duração, Task.Factory.StartNew(work, TaskCreationOptions.LongRunning) obtém uma thread dedicada mantendo a API de tasks.

Parallel.For e Parallel.ForEach

Para "faça isto em cada item, usando todos os núcleos", System.Threading.Tasks.Parallel é mais simples que criar tasks à mão. Ele divide o intervalo entre as threads e retorna quando todas as iterações terminam:

Exemplo de saída:

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

As linhas por imagem saem em qualquer ordem em que as threads as executaram, e os IDs das threads variam a cada execução; só o total é fixo. Essa é a natureza dos laços paralelos: as iterações não podem depender da ordem umas das outras, e qualquer estado compartilhado precisa de sincronização (Interlocked.Add aqui). Parallel.ForEach faz o mesmo sobre qualquer coleção, e o .NET 6 adiciona Parallel.ForEachAsync para corpos async.

Erros comuns

  • .Result ou .Wait() dentro de código async. Bloqueia uma thread e pode causar deadlock em apps de interface; use await.
  • Capturar o tipo de exceção original em volta de .Result. As falhas chegam envolvidas em AggregateException; await as desembrulha.
  • Disparar e esquecer. Task.Run(...) sem aguardar nem guardar a task perde as exceções dela.
  • Task.Run em volta de I/O. Em volta de um método async só acrescenta uma passagem pelo pool de threads; em volta de um método bloqueante como File.ReadAllText prende uma thread do pool durante toda a espera. Aguarde o método de I/O async diretamente.
  • Ignorar o token. Passar um CancellationToken não faz nada se o trabalho não o verificar.
  • Estado compartilhado sem sincronização em código paralelo. Use Interlocked, lock, ou dê a cada task os próprios dados.

Perguntas frequentes

O que é uma Task em C#?

Uma Task representa uma operação que pode terminar no futuro: código executando no thread pool, um timer, uma requisição de I/O. Ela pode ser aguardada, informa se completou, falhou ou foi cancelada, e Task<T> também carrega um resultado. As tasks são a peça de base por baixo de async/await.

Qual a diferença entre uma Task e uma Thread em C#?

Uma Thread é uma thread do sistema operacional que você cria e gerencia, com pilha própria; ela é cara para iniciar e não retorna valor. Uma Task descreve um trabalho, e Task.Run o agenda em um pool compartilhado de threads reutilizáveis; ela pode retornar um resultado, propagar exceções, ser cancelada e ser aguardada. Use tasks, a menos que você precise de uma thread dedicada de vida longa com configurações especiais.

Quando usar Task.Run?

Para trabalho limitado pela CPU que você quer tirar da thread atual: processamento de imagens, um cálculo grande, interpretar um arquivo grande na memória, principalmente para manter uma interface responsiva. Não envolva I/O em Task.Run: métodos de I/O async como ReadAllTextAsync já liberam a thread enquanto esperam.

Como cancelar uma Task em C#?

Crie um CancellationTokenSource, passe o Token dele para o trabalho e chame Cancel() (ou CancelAfter(timeout)) na origem. O trabalho precisa cooperar: verificar token.IsCancellationRequested ou chamar token.ThrowIfCancellationRequested() no laço, e repassar o token para APIs async como Task.Delay. Aguardar uma task cancelada lança OperationCanceledException.

Qual a diferença entre Task.WhenAll e Task.WhenAny?

Task.WhenAll completa quando todas as tasks completaram e dá todos os resultados. Task.WhenAny completa assim que a primeira task completa e retorna essa task. WhenAny é a forma usual de adicionar um timeout: fazer o trabalho competir com um Task.Delay.

Por que Task.Result lança AggregateException?

.Result e .Wait() envolvem qualquer falha em uma AggregateException, porque uma task pode, em geral, guardar várias exceções. await desembrulha e relança diretamente a primeira exceção interna, o que é um dos motivos para preferir await. Se você precisa bloquear, .GetAwaiter().GetResult() também lança a exceção original.

Coddy programming languages illustration

Aprenda a programar com o Coddy

COMEÇAR