C# Cancelamento
Um download está demorando e a Ana clica em Cancelar. Uma busca passou de 5 segundos e o programa deve desistir. A Ana aperta Ctrl+C e quer que o programa salve o arquivo antes de fechar. Em todos esses casos, alguém precisa pedir para uma tarefa parar, e a tarefa precisa ouvir o pedido. No .NET, isso é feito com uma dupla: o CancellationTokenSource ("fonte do sinal de cancelamento") e o CancellationToken ("sinal de cancelamento").
- A ideia do cancelamento combinado: quem pede e quem obedece.
- Criar um
CancellationTokenSource, entregar oTokene chamarCancel(). - Obedecer com
ThrowIfCancellationRequested()ou comIsCancellationRequested. - Passar o token para os métodos do .NET (
Task.Delay(100, token)) e tratar aOperationCanceledException. - Tempo limite com
CancelAfter, o Ctrl+C comConsole.CancelKeyPress,Registere tokens ligados. - Os erros: a tarefa que ignora o token,
CS7036,TaskCanceledExceptionnão tratada eObjectDisposedException.
Todas as saídas desta aula são reais: foram feitas com o .NET 10 (10.0.112) na máquina fictícia da Ana, na pasta ~/cs/cancelar, com dotnet run arquivo.cs. Os programas usam async, await e Task (veja async e await e Task).
A ideia: o juiz e o apito
Num jogo de futebol, o juiz não entra em campo e arranca a bola do pé de ninguém. Ele apita, e os jogadores, que estão sempre de ouvido atento, param a jogada. Se um jogador estiver no meio de um chute, ele termina o chute e então para. Ninguém é derrubado à força.
O cancelamento no .NET funciona igual: é um pedido, não uma ordem à força. O .NET nunca "mata" uma tarefa no meio de uma linha (isso poderia deixar um arquivo pela metade). A própria tarefa confere o sinal de tempos em tempos e para num ponto seguro.
| No futebol | No C# | O que é |
|---|---|---|
| O juiz, com o apito na mão | CancellationTokenSource | Quem pode pedir o cancelamento. Fica com quem manda (o botão Cancelar, o Ctrl+C). |
| O som do apito, que todos ouvem | CancellationToken | O sinal que vai junto com o trabalho. Só serve para ouvir: com ele, ninguém consegue cancelar. |
| Apitar | cts.Cancel() | Pede para parar. |
| O jogador para a jogada | token.ThrowIfCancellationRequested() | A tarefa confere e para. |
A separação é de propósito: o método que baixa o arquivo recebe só o CancellationToken. Ele pode saber que pediram para parar, mas não pode cancelar o trabalho dos outros.
1. O primeiro cancelamento
A Ana baixa um arquivo em 10 partes, de 100 milissegundos cada. Depois de 350 milissegundos, ela clica em Cancelar:
using var cts = new CancellationTokenSource();
Task baixar = Baixar(cts.Token);
await Task.Delay(350);
Console.WriteLine("Ana clicou em Cancelar");
cts.Cancel();
try
{
await baixar;
}
catch (OperationCanceledException)
{
Console.WriteLine("Download cancelado.");
}
static async Task Baixar(CancellationToken token)
{
for (int parte = 1; parte <= 10; parte++)
{
token.ThrowIfCancellationRequested();
Console.WriteLine($"Baixando parte {parte} de 10...");
await Task.Delay(100);
}
Console.WriteLine("Download completo!");
}
$ dotnet run download.cs Baixando parte 1 de 10... Baixando parte 2 de 10... Baixando parte 3 de 10... Baixando parte 4 de 10... Ana clicou em Cancelar Download cancelado.
Linha por linha:
- using var cts = new CancellationTokenSource();
- Cria o "juiz" (o nome
ctsé a sigla do tipo, e é o nome que quase todo mundo usa). Ousinglibera os recursos dele no fim do programa (veja using e IDisposable). - Task baixar = Baixar(cts.Token);
- Começa o download e entrega a ele o sinal,
cts.Token. Repare que não háawaitaqui: o download segue sozinho enquanto o programa principal continua (veja Task). - await Task.Delay(350);
- Espera 350 milissegundos: é a Ana olhando a barra de progresso.
- cts.Cancel();
- "Cancele": o apito. A partir daqui,
cts.Tokenpassa a dizer que o cancelamento foi pedido. Só isso: oCancel()não para nada sozinho. - await baixar;
- Espera o download terminar (de um jeito ou de outro).
- catch (OperationCanceledException)
- Quando uma tarefa para por cancelamento, ela termina com essa exceção ("exceção de operação cancelada"). Aqui, ela é o jeito normal de dizer "parei porque pediram", e o programa a trata com uma mensagem amigável.
- CancellationToken token
- O método que faz o trabalho recebe o sinal como parâmetro. Por costume, ele é o último parâmetro e se chama
tokenoucancellationToken. - token.ThrowIfCancellationRequested();
- "Lance uma exceção se o cancelamento foi pedido". Se ninguém apitou, não faz nada e o laço segue. Se apitaram, lança a
OperationCanceledExceptione o método para ali. É o jogador olhando para o juiz antes de cada jogada.
O momento exato em que o cancelamento chega depende do relógio (a Ana viu 4 partes; noutra execução poderiam ser 3). O que não muda é a ordem: a tarefa só para quando chega ao próximo ThrowIfCancellationRequested().
Missão: aqui não há relógio: a própria Ana clica em Cancelar logo depois da parte 3 (o cts.Cancel() dentro do if). Mas o laço não está ouvindo o sinal, e o download vai até o fim. Acrescente token.ThrowIfCancellationRequested(); como primeira linha dentro do for e rode. A saída tem de terminar em Parte 3 e Download cancelado na parte 4.
using var cts = new CancellationTokenSource();
CancellationToken token = cts.Token;
int parte = 0;
try
{
for (parte = 1; parte <= 10; parte++)
{
Console.WriteLine($"Parte {parte}");
if (parte == 3)
{
cts.Cancel(); // a Ana clicou em Cancelar
}
}
Console.WriteLine("Download completo!");
}
catch (OperationCanceledException)
{
Console.WriteLine($"Download cancelado na parte {parte}.");
}
using var cts = new CancellationTokenSource();
CancellationToken token = cts.Token;
int parte = 0;
try
{
for (parte = 1; parte <= 10; parte++)
{
token.ThrowIfCancellationRequested();
Console.WriteLine($"Parte {parte}");
if (parte == 3)
{
cts.Cancel(); // a Ana clicou em Cancelar
}
}
Console.WriteLine("Download completo!");
}
catch (OperationCanceledException)
{
Console.WriteLine($"Download cancelado na parte {parte}.");
}
2. Passar o token para o .NET
Quase todo método do .NET que pode demorar aceita um CancellationToken no fim: Task.Delay(100, token), File.ReadAllTextAsync(caminho, token), HttpClient.GetStringAsync(endereço, token). Passando o token adiante, a espera é interrompida na hora, sem precisar chegar ao próximo ThrowIfCancellationRequested():
«… o começo, igual ao download.cs …»
catch (OperationCanceledException e)
{
Console.WriteLine($"Download cancelado ({e.GetType().Name}).");
}
static async Task Baixar(CancellationToken token)
{
for (int parte = 1; parte <= 10; parte++)
{
Console.WriteLine($"Baixando parte {parte} de 10...");
await Task.Delay(100, token);
}
Console.WriteLine("Download completo!");
}
$ dotnet run download2.cs Baixando parte 1 de 10... Baixando parte 2 de 10... Baixando parte 3 de 10... Baixando parte 4 de 10... Ana clicou em Cancelar Download cancelado (TaskCanceledException).
- await Task.Delay(100, token);
- "Espere 100 milissegundos, mas pare se o token apitar." A espera da parte 4 foi interrompida no meio.
- TaskCanceledException
- O
Task.Delayavisa com aTaskCanceledException("exceção de tarefa cancelada"), que é uma filha daOperationCanceledException(veja Herança). Por isso o mesmocatch (OperationCanceledException)pega as duas. Prefira sempre pegar a mãe.
Cancel() lá em cima chega até a última espera lá embaixo.
A mensagem e o token da exceção
using var cts = new CancellationTokenSource();
cts.Cancel();
try
{
cts.Token.ThrowIfCancellationRequested();
}
catch (OperationCanceledException e)
{
Console.WriteLine(e.GetType().FullName);
Console.WriteLine(e.Message);
Console.WriteLine(e.CancellationToken == cts.Token);
}
$ dotnet run excecao.cs System.OperationCanceledException The operation was canceled. True
A mensagem vem em inglês ("a operação foi cancelada"), como todas as exceções do .NET no Linux. A propriedade e.CancellationToken diz qual token causou o cancelamento: útil quando há mais de um (seção 5).
3. Parar com calma: IsCancellationRequested
Nem sempre você quer uma exceção. Às vezes o certo é parar o laço e devolver o que já foi feito. Para isso, em vez de ThrowIfCancellationRequested(), confira a propriedade IsCancellationRequested ("o cancelamento foi pedido?"), que é um bool:
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(250));
int somadas = await Somar(cts.Token);
Console.WriteLine($"Parei. Somei {somadas} notas antes do tempo acabar.");
static async Task<int> Somar(CancellationToken token)
{
int quantas = 0;
while (!token.IsCancellationRequested)
{
quantas++;
await Task.Delay(100);
}
return quantas;
}
$ dotnet run educado.cs Parei. Somei 3 notas antes do tempo acabar.
- new CancellationTokenSource(TimeSpan.FromMilliseconds(250))
- Um juiz com cronômetro: ele mesmo apita depois de 250 milissegundos, sem ninguém chamar
Cancel(). É o jeito de dar um tempo limite (em inglês, timeout) a um trabalho. - while (!token.IsCancellationRequested)
- "Enquanto não pediram para parar." O
!é o "não" (veja Operadores Lógicos). - return quantas;
- Sem exceção: o método termina normalmente e devolve o resultado parcial.
| Forma | O que acontece | Quando usar |
|---|---|---|
token.ThrowIfCancellationRequested() | Lança OperationCanceledException. | O padrão. Quem chamou fica sabendo que o trabalho não terminou. |
if (token.IsCancellationRequested) break; | Só um bool; você decide o que fazer. | Quando um resultado parcial faz sentido (as notas somadas até agora). |
4. Tempo limite e Ctrl+C
Tempo limite: CancelAfter
Além do cronômetro no construtor, existe cts.CancelAfter(250) ("cancele depois de 250 milissegundos"), que você pode chamar a qualquer momento. Se ninguém tratar a exceção, o programa termina com erro:
using var cts = new CancellationTokenSource();
cts.CancelAfter(250);
await Task.Delay(1000, cts.Token);
Console.WriteLine("Fim");
$ dotnet run semtratar.cs; echo "código de saída: $?" Unhandled exception. System.Threading.Tasks.TaskCanceledException: A task was canceled. at Program.<Main>$(String[] args) in /home/ana/cs/cancelar/semtratar.cs:line 3 at Program.<Main>(String[] args) código de saída: 134
O Fim nunca apareceu: a espera de 1 segundo foi cortada aos 250 milissegundos, e a TaskCanceledException ("uma tarefa foi cancelada") subiu até o topo sem catch. O código de saída 134 é o de toda exceção não tratada no Linux (veja Exceções).
O Ctrl+C: Console.CancelKeyPress
Quando a Ana aperta Ctrl+C, o programa normalmente morre na hora. Com o evento Console.CancelKeyPress ("tecla de cancelar apertada"), ele pode transformar esse aperto num cancelamento educado (veja Eventos):
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (sender, e) =>
{
e.Cancel = true; // não feche o programa na hora
Console.WriteLine();
Console.WriteLine("Ctrl+C recebido, terminando com calma...");
cts.Cancel();
};
Console.WriteLine("Contando. Aperte Ctrl+C para parar.");
int segundos = 0;
try
{
while (true)
{
await Task.Delay(1000, cts.Token);
segundos++;
Console.WriteLine($"{segundos} s");
}
}
catch (OperationCanceledException)
{
Console.WriteLine($"Parei depois de {segundos} s. Arquivo salvo.");
}
Para a saída ser reprodutível, em vez de apertar as teclas a Ana usou o timeout -s INT 3.5, que manda ao programa, depois de 3,5 segundos, o mesmo sinal (SIGINT) que o Ctrl+C manda:
$ timeout -s INT 3.5 dotnet run ctrlc.cs Contando. Aperte Ctrl+C para parar. 1 s 2 s 3 s Ctrl+C recebido, terminando com calma... Parei depois de 3 s. Arquivo salvo.
- Console.CancelKeyPress += (sender, e) => { … };
- Registra uma lambda que roda quando o Ctrl+C chega.
- e.Cancel = true;
- "Cancele o fechamento": sem esta linha, o programa fecharia logo depois da lambda, sem passar pelo
catch. Cuidado com a confusão de nomes: aqui,Cancel = truequer dizer "não feche". - cts.Cancel();
- Transforma o aperto da tecla num pedido de cancelamento, que chega ao
Task.Delay.
O .NET do editor roda dentro do navegador, onde não existem os sinais do Linux (o SIGINT). Lá, a própria linha do Console.CancelKeyPress += … já para o programa, com Unhandled exception. System.PlatformNotSupportedException: Operation is not supported on this platform. e o código 134. O Ctrl+C (ou o botão Parar) da caixa só interrompe o programa. Os outros exemplos da aula (CancelAfter, Register, tokens ligados) funcionam igual nos dois lugares.
5. Register, CancellationToken.None e tokens ligados
Ser avisado: Register
using var cts = new CancellationTokenSource();
CancellationToken token = cts.Token;
token.Register(() => Console.WriteLine("Aviso: alguém cancelou!"));
Console.WriteLine($"Pode cancelar? {token.CanBeCanceled}");
Console.WriteLine($"Cancelado? {token.IsCancellationRequested}");
cts.Cancel();
Console.WriteLine($"Cancelado? {token.IsCancellationRequested}");
CancellationToken nenhum = CancellationToken.None;
Console.WriteLine($"None pode cancelar? {nenhum.CanBeCanceled}");
$ dotnet run registrar.cs Pode cancelar? True Cancelado? False Aviso: alguém cancelou! Cancelado? True None pode cancelar? False
- token.Register(() => …)
- "Registre": guarda uma lambda que roda no momento do
Cancel(). Repare que o aviso saiu antes do segundoCancelado?. - token.CanBeCanceled
- "Pode ser cancelado?":
truequando o token veio de umCancellationTokenSource. - CancellationToken.None
- Um token que nunca apita. Serve para chamar um método que exige um token quando você não quer cancelar nada.
O token opcional: = default
Nos seus métodos, deixe o token opcional, como o .NET faz. Quem não se importa com cancelamento chama sem ele:
await Baixar();
Console.WriteLine("Baixou sem token");
using var cts = new CancellationTokenSource();
await Baixar(cts.Token);
Console.WriteLine("Baixou com token");
static async Task Baixar(CancellationToken token = default)
{
await Task.Delay(100, token);
}
$ dotnet run padrao.cs Baixou sem token Baixou com token
O default de um CancellationToken é o mesmo que CancellationToken.None (veja Parâmetros Opcionais e Nomeados).
Dois motivos para parar: CreateLinkedTokenSource
Um download deve parar se a Ana clicar em Cancelar ou se passar do tempo limite. Um token "ligado" apita quando qualquer um dos outros apitar:
using var usuario = new CancellationTokenSource();
using var tempo = new CancellationTokenSource();
using var qualquerUm = CancellationTokenSource.CreateLinkedTokenSource(usuario.Token, tempo.Token);
tempo.Cancel(); // o tempo acabou
Console.WriteLine($"Usuário cancelou? {usuario.IsCancellationRequested}");
Console.WriteLine($"Tempo acabou? {tempo.IsCancellationRequested}");
Console.WriteLine($"Ligado cancelou? {qualquerUm.IsCancellationRequested}");
$ dotnet run ligados.cs Usuário cancelou? False Tempo acabou? True Ligado cancelou? True
O trabalho recebe qualquerUm.Token. No catch, a propriedade e.CancellationToken e o IsCancellationRequested de cada fonte dizem qual foi o motivo.
Experimente: rode e depois troque tempo.Cancel() por usuario.Cancel(). Depois, apague essa linha: o token ligado continua sem cancelar.
using var usuario = new CancellationTokenSource();
using var tempo = new CancellationTokenSource();
using var qualquerUm = CancellationTokenSource.CreateLinkedTokenSource(usuario.Token, tempo.Token);
qualquerUm.Token.Register(() => Console.WriteLine("O token ligado apitou!"));
tempo.Cancel(); // o tempo acabou
Console.WriteLine($"Usuário cancelou? {usuario.IsCancellationRequested}");
Console.WriteLine($"Tempo acabou? {tempo.IsCancellationRequested}");
Console.WriteLine($"Ligado cancelou? {qualquerUm.IsCancellationRequested}");
Quando dá errado
«… o começo, igual ao download.cs …»
static async Task Baixar(CancellationToken token)
{
for (int parte = 1; parte <= 5; parte++)
{
Console.WriteLine($"Baixando parte {parte} de 5...");
await Task.Delay(100);
}
Console.WriteLine("Download completo!");
}
$ dotnet run ignora.cs Baixando parte 1 de 5... Baixando parte 2 de 5... Baixando parte 3 de 5... Baixando parte 4 de 5... Ana clicou em Cancelar Baixando parte 5 de 5... Download completo!
O que aconteceu: o método recebe o token, mas nunca olha para ele: nem ThrowIfCancellationRequested(), nem IsCancellationRequested, nem Task.Delay(100, token). O Cancel() foi chamado, mas ninguém estava ouvindo. Não aparece erro nenhum. É o erro mais comum com cancelamento.
Como resolver: confira o token no laço e passe-o para todo método que aceitar um: await Task.Delay(100, token);.
error CS7036: Não há nenhum argumento fornecido que corresponda ao parâmetro necessário 'token'
await Baixar();
static async Task Baixar(CancellationToken token)
{
await Task.Delay(100, token);
}
$ dotnet run falta.cs /home/ana/cs/cancelar/falta.cs(1,7): error CS7036: Não há nenhum argumento fornecido que corresponda ao parâmetro necessário 'token' de 'Baixar(CancellationToken)' Ocorreu uma falha no build. Corrija os erros de build e execute novamente.
O que aconteceu: o método exige um token e a chamada não passou nenhum (em inglês, There is no argument given that corresponds to the required parameter 'token').
Como resolver: passe um token (Baixar(cts.Token)), ou CancellationToken.None, ou deixe o parâmetro opcional: CancellationToken token = default.
Unhandled exception. System.Threading.Tasks.TaskCanceledException: A task was canceled.
O que aconteceu: o cancelamento funcionou, mas ninguém tratou a exceção, e o programa terminou com código 134 (veja o semtratar.cs da seção 4).
Como resolver: ponha o await num try com catch (OperationCanceledException) e mostre uma mensagem amigável. Não use catch (Exception) para isso: ele esconderia os erros de verdade junto com o cancelamento.
System.ObjectDisposedException: The CancellationTokenSource has been disposed.
var cts = new CancellationTokenSource(); cts.Dispose(); cts.Cancel();
$ dotnet run descartado.cs; echo "código de saída: $?" Unhandled exception. System.ObjectDisposedException: The CancellationTokenSource has been disposed. at System.Threading.CancellationTokenSource.Cancel() at Program.<Main>$(String[] args) in /home/ana/cs/cancelar/descartado.cs:line 3 código de saída: 134
O que aconteceu: o CancellationTokenSource já tinha sido liberado ("descartado") quando alguém chamou Cancel(). Na vida real, isso acontece quando o using termina antes de a tarefa acabar, e um botão ou evento tenta cancelar depois.
Como resolver: mantenha o CancellationTokenSource vivo enquanto o trabalho e quem pode cancelá-lo existirem; só libere no fim. Um CancellationTokenSource também não "volta": depois de cancelado, crie outro para o próximo trabalho.
Referência rápida
| Código | O que faz |
|---|---|
using var cts = new CancellationTokenSource(); | Cria quem pode cancelar. |
new CancellationTokenSource(TimeSpan.FromSeconds(5)) | Cancela sozinho depois de 5 segundos. |
cts.Token | O sinal, para entregar ao trabalho. |
cts.Cancel() | Pede o cancelamento. |
cts.CancelAfter(250) | Pede o cancelamento daqui a 250 ms. |
token.ThrowIfCancellationRequested() | Lança OperationCanceledException se pediram. |
token.IsCancellationRequested | true se pediram. |
token.Register(() => …) | Roda a lambda no momento do cancelamento. |
CancellationToken.None / default | Um token que nunca cancela. |
CancellationTokenSource.CreateLinkedTokenSource(a, b) | Cancela quando a ou b cancelar. |
await Task.Delay(ms, token) | Espera, mas para na hora se cancelarem. |
catch (OperationCanceledException) | Trata o cancelamento (pega também a TaskCanceledException). |
Console.CancelKeyPress += (s, e) => { e.Cancel = true; cts.Cancel(); }; | Transforma o Ctrl+C em cancelamento. |
Exercícios
1. Pedir para parar
Complete a linha que pede o cancelamento:
2. Obedecer
Complete a linha que para o laço com uma exceção se o cancelamento foi pedido:
3. Esperar sem surdez
Complete a espera de 500 ms para ela parar na hora se cancelarem:
4. Quem pode chamar Cancel()?
5. Qual catch trata tanto a OperationCanceledException quanto a TaskCanceledException, sem esconder outros erros?
6. O Cancel() foi chamado, mas a tarefa foi até o fim. A causa mais provável é:
Missão final: processar os pedidos da lojinha
Missão: o método Processar recebe um token, mas o ignora: a loja fecha (o Cancel()) depois do pedido 2, e ele processa os 5 mesmo assim. Dentro do foreach, antes de processar, acrescente if (token.IsCancellationRequested) break;. O método deve devolver quantos processou, sem exceção, e a saída deve terminar em Parei: processados 2 de 5.
using var cts = new CancellationTokenSource();
string[] pedidos = ["Caderno", "Lápis", "Borracha", "Régua", "Estojo"];
int feitos = Processar(pedidos, cts, cts.Token);
Console.WriteLine($"Parei: processados {feitos} de {pedidos.Length}.");
static int Processar(string[] pedidos, CancellationTokenSource loja, CancellationToken token)
{
int feitos = 0;
foreach (string pedido in pedidos)
{
feitos++;
Console.WriteLine($"Pedido {feitos} pronto");
if (feitos == 2)
{
loja.Cancel(); // a loja fechou
}
}
return feitos;
}
using var cts = new CancellationTokenSource();
string[] pedidos = ["Caderno", "Lápis", "Borracha", "Régua", "Estojo"];
int feitos = Processar(pedidos, cts, cts.Token);
Console.WriteLine($"Parei: processados {feitos} de {pedidos.Length}.");
static int Processar(string[] pedidos, CancellationTokenSource loja, CancellationToken token)
{
int feitos = 0;
foreach (string pedido in pedidos)
{
if (token.IsCancellationRequested) break;
feitos++;
Console.WriteLine($"Pedido {feitos} pronto");
if (feitos == 2)
{
loja.Cancel(); // a loja fechou
}
}
return feitos;
}
Resumo
- Cancelar é um pedido combinado: o
CancellationTokenSourcepede (Cancel()) e a tarefa, que recebeu oCancellationToken, obedece num ponto seguro. - Obedeça com
token.ThrowIfCancellationRequested()(exceção) outoken.IsCancellationRequested(umbool, para devolver o resultado parcial). - Passe o token adiante:
Task.Delay(ms, token)e os métodos…Asyncdo .NET param na hora. - Trate com
catch (OperationCanceledException), que pega também aTaskCanceledException. - Tempo limite:
new CancellationTokenSource(TimeSpan…)ouCancelAfter. Ctrl+C:Console.CancelKeyPresscome.Cancel = true. - Nos seus métodos, o token vem por último e é opcional:
CancellationToken token = default.