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").

Você vai aprender
  • A ideia do cancelamento combinado: quem pede e quem obedece.
  • Criar um CancellationTokenSource, entregar o Token e chamar Cancel().
  • Obedecer com ThrowIfCancellationRequested() ou com IsCancellationRequested.
  • Passar o token para os métodos do .NET (Task.Delay(100, token)) e tratar a OperationCanceledException.
  • Tempo limite com CancelAfter, o Ctrl+C com Console.CancelKeyPress, Register e tokens ligados.
  • Os erros: a tarefa que ignora o token, CS7036, TaskCanceledException não tratada e ObjectDisposedException.

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 futebolNo C#O que é
O juiz, com o apito na mãoCancellationTokenSourceQuem pode pedir o cancelamento. Fica com quem manda (o botão Cancelar, o Ctrl+C).
O som do apito, que todos ouvemCancellationTokenO sinal que vai junto com o trabalho. Só serve para ouvir: com ele, ninguém consegue cancelar.
Apitarcts.Cancel()Pede para parar.
O jogador para a jogadatoken.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). O using libera 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á await aqui: 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.Token passa a dizer que o cancelamento foi pedido. Só isso: o Cancel() 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 token ou cancellationToken.
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 OperationCanceledException e 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.Delay avisa com a TaskCanceledException ("exceção de tarefa cancelada"), que é uma filha da OperationCanceledException (veja Herança). Por isso o mesmo catch (OperationCanceledException) pega as duas. Prefira sempre pegar a mãe.
A regra de ouro: se o seu método recebeu um token, passe-o adiante para todo método que aceitar um. É assim que um único 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.
FormaO que aconteceQuando 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 = true quer dizer "não feche".
cts.Cancel();
Transforma o aperto da tecla num pedido de cancelamento, que chega ao Task.Delay.
No editor do guia, rode este no terminal

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 segundo Cancelado?.
token.CanBeCanceled
"Pode ser cancelado?": true quando o token veio de um CancellationTokenSource.
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

Cancelou, mas a tarefa foi até o fim
«… 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ódigoO que faz
using var cts = new CancellationTokenSource();Cria quem pode cancelar.
new CancellationTokenSource(TimeSpan.FromSeconds(5))Cancela sozinho depois de 5 segundos.
cts.TokenO 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.IsCancellationRequestedtrue se pediram.
token.Register(() => …)Roda a lambda no momento do cancelamento.
CancellationToken.None / defaultUm 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:

cts.;

2. Obedecer

Complete a linha que para o laço com uma exceção se o cancelamento foi pedido:

token.;

3. Esperar sem surdez

Complete a espera de 500 ms para ela parar na hora se cancelarem:

await Task.Delay(500, );

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 CancellationTokenSource pede (Cancel()) e a tarefa, que recebeu o CancellationToken, obedece num ponto seguro.
  • Obedeça com token.ThrowIfCancellationRequested() (exceção) ou token.IsCancellationRequested (um bool, para devolver o resultado parcial).
  • Passe o token adiante: Task.Delay(ms, token) e os métodos …Async do .NET param na hora.
  • Trate com catch (OperationCanceledException), que pega também a TaskCanceledException.
  • Tempo limite: new CancellationTokenSource(TimeSpan…) ou CancelAfter. Ctrl+C: Console.CancelKeyPress com e.Cancel = true.
  • Nos seus métodos, o token vem por último e é opcional: CancellationToken token = default.