Tratando cancelamento em Rust assíncrono
(sunshowers.io)- O tratamento de cancelamento em ambientes de Rust assíncrono é conveniente, mas, se for mal utilizado, pode causar bugs inesperados e dificuldades
- Em Rust síncrono, normalmente são necessários verificações explícitas de flags ou encerramento do processo, mas em Rust assíncrono o cancelamento é muito fácil, bastando descartar um future
- Segurança de cancelamento (cancel safety) e correção de cancelamento (cancel correctness) são conceitos diferentes, e o cancelamento de um future pode causar problemas em todo o sistema
- Entre os principais padrões problemáticos relacionados a cancelamento estão Tokio mutex, a macro select,
try_joine erros no uso de futures - Não existe uma solução perfeita, mas é possível reduzir os problemas causados por cancelamento usando APIs cancel-safe, pinagem de futures e separação em tasks
Introdução
- Este post é baseado em uma apresentação da RustConf 2025 sobre cancelamento (cancellation) em Rust assíncrono
- Em exemplos comuns de código assíncrono em Rust, ao adicionar timeout a um loop de recebimento ou envio de mensagens, muitas vezes surgem problemas de perda de mensagens
- O texto aborda problemas de cancelamento e casos reais de bugs enfrentados ao usar async Rust em sistemas de grande escala, como na Oxide Computer Company
- O artigo é composto por três partes: 1) conceito de cancelamento, 2) análise do cancelamento, 3) soluções práticas
- O autor já vivenciou as vantagens e dificuldades do Rust assíncrono por meio de trabalho com signal handling em Rust e do desenvolvimento do cargo-nextest
1. O que é cancelamento?
O significado de cancelamento
- Cancelamento (cancellation) é a situação em que uma tarefa assíncrona é iniciada e depois interrompida no meio do caminho
- Exemplos: download grande/requisição de rede, leitura parcial de arquivo etc., que podem ser cancelados no meio
Como cancelar em Rust síncrono
- Em geral, existem abordagens como verificar periodicamente o cancelamento por meio de uma flag atômica, usar exceções especiais (
panic) ou forçar o encerramento de todo o processo - Alguns frameworks (como Salsa) usam payloads de panic, mas isso não funciona em todas as plataformas do Rust, especialmente em ambientes Wasm
- Encerrar apenas uma thread à força não é permitido por causa das garantias de segurança do Rust e da estrutura dos mutexes
- Em resumo, não existe um protocolo de cancelamento genérico e seguro em Rust síncrono
Rust assíncrono: o que é um Future?
- Um Future é uma máquina de estados (state machine) gerada pelo compilador do Rust e, na prática, é apenas um conjunto simples de dados em memória
- Ele não executa só por ser criado; só avança quando await ou poll é chamado
- Os Futures do Rust são passivos (inert) e, sem poll/await explícito, não processam nenhum trabalho
- Isso contrasta com Go/JavaScript/C#, onde a execução normalmente começa assim que o future é criado
O protocolo de cancelamento no Rust assíncrono
- Cancelar um Future significa simplesmente fazer drop dele ou parar de chamar poll/await
- Como ele é uma máquina de estados, o Future pode ser descartado a qualquer momento
- No Rust assíncrono, o cancelamento é muito poderoso e ao mesmo tempo muito fácil de aplicar
- Porém, por ser fácil demais, um future pode ser descartado silenciosamente e acabar cancelando em cascata seus futures filhos, por causa do modelo de ownership
- Por essa característica, o cancelamento se torna um fenômeno não local (non-local), afetando toda a cadeia de chamadas
2. Análise do cancelamento
Segurança de cancelamento e correção de cancelamento
- Segurança de cancelamento (cancel safety): propriedade de um future individual poder ser cancelado com segurança, sem efeitos colaterais
- Exemplo: o future de sleep do Tokio é cancel-safe
- Já o envio (
send) no MPSC do Tokio pode perder mensagens ao sofrer drop, portanto não é cancel-safe
- Correção de cancelamento (cancel correctness): propriedade global de o sistema inteiro manter suas propriedades essenciais em situações de cancelamento
- Se um future não cancel-safe não estiver presente no sistema, não há problema de correção
- O problema só surge quando um future não cancel-safe realmente precisa ser cancelado
- Se o cancelamento causar perda de dados, violação de invariantes ou limpeza não executada, há violação da correção de cancelamento
As dificuldades do Tokio mutex
- O Tokio mutex funciona adquirindo o lock, ajustando os dados e depois liberando-o
- Problema: se, dentro do lock, o estado for temporariamente colocado em violação (por exemplo, mudando
Option<T>paraNone) e houver umawait, o future pode ser cancelado e o dado ficar preso nesse estado incorreto - Em ambientes reais (por exemplo, no gerenciamento de estado do sled na Oxide), já ocorreram estados instáveis causados por cancelamento em pontos de
await - Assim, o cancelamento no gerenciamento de estado de código assíncrono pode ser uma fonte extremamente perigosa de falhas
Padrões e exemplos de ocorrência de cancelamento
- Chamada de future sem
.await: o Rust avisa sobre futures não usados, mas se o valor de retornoResultfor recebido em_, pode não haver aviso (é preciso usar lints recentes do Clippy) - Operações try, como
try_join: quando um future falha, os demais são cancelados (isso já gerou bugs em lógica real de parada de serviços) - Macro
select: após processar vários futures em paralelo, todos os futures que não concluíram são cancelados (em loops comselect, o risco de perda de dados aumenta) - Esses padrões são mencionados na documentação, mas, na prática, o cancelamento assíncrono pode acontecer de forma implícita em muitos lugares
3. O que pode ser feito?
- Ainda não existe uma solução fundamental e completa para problemas de correção de cancelamento
- Mesmo assim, na prática, é possível reduzir a chance de falhas relacionadas a cancelamento com abordagens como as abaixo
Reestruturar para futures cancel-safe
- Exemplo de MPSC send: separar reserva (
reserve) e envio real (send) permite obter segurança parcial contra cancelamento- A operação de reserva pode ser cancelada sem perda da mensagem relacionada
- Depois de obter o permit, o envio pode ocorrer sem preocupação com cancelamento
write_allde AsyncWrite: escrever todo o buffer comwrite_allé instável diante de cancelamento, enquantowrite_all_bufpermite acompanhar o progresso usando o cursor do buffer- Dentro de um loop, é possível retomar com segurança o progresso parcial usando
write_all_buf
- Dentro de um loop, é possível retomar com segurança o progresso parcial usando
Operar futures evitando cancelamento
- Pinagem de future: em loops com
selectetc., fixar o future com pin e fazer poll por referência evita que ele seja cancelado- Exemplo: reutilizar um future de
reservepreserva sua posição na fila de espera da reserva
- Exemplo: reutilizar um future de
- Uso de tasks: ao executar um future como task com
tokio::spawn, mesmo que o handle seja descartado, a task em si continua sendo gerenciada separadamente pelo runtime e não é cancelada à força- Em casos como o servidor HTTP Dropshot da Oxide, cada requisição roda em uma task separada, garantindo que o processamento termine mesmo se a conexão do cliente cair
Uma solução sistemática?
- Hoje, no nível de safe Rust, as opções ainda são limitadas, mas há abordagens em discussão
- Async drop: permitir a execução de código assíncrono de limpeza quando um future for cancelado
- Tipos lineares (linear types): forçar a execução de determinado código no drop ou marcar certos futures como não canceláveis
- Todas essas abordagens apresentam dificuldades de implementação
Conclusão e recomendações
- É essencial compreender, de forma fundamental, que Futures são passivos (passive)
- É preciso entender os conceitos de segurança de cancelamento (cancel safety) e correção de cancelamento (cancel correctness)
- Também é importante conhecer os principais casos de bugs e padrões de código relacionados a cancelamento para preparar estratégias de resposta com antecedência
- Algumas recomendações práticas
- Evitar o uso de Tokio mutex e considerar alternativas
- Projetar e usar APIs parcialmente concluíveis ou cancel-safe
- Para futures que não são cancel-safe, adotar uma estrutura de código que garanta sua conclusão
- Além disso, vale a pena estudar temas mais avançados, como cooperative cancellation, modelo actor, structured concurrency, panic safety e mutex poisoning
- Materiais relacionados podem ser consultados em sunshowers/cancelling-async-rust
Agradeço pela leitura. O autor agradece aos colegas da Oxide pela revisão da apresentação e dos materiais de referência, além do feedback fornecido
1 comentários
Comentário no Hacker News
send/recv; percebi que, em linguagens onde o future executa sem polling imediato enquanto ainda não foi executado, pode até ocorrer a situação oposta. Se você coloca timeout emsend, a mensagem pode ser enviada mesmo depois do timeout, mas ela não se perde, então isso é seguro; já se você coloca timeout emrecv, pode acontecer de a mensagem ser lida do canal e o timeout ser o ramo escolhido, fazendo a mensagem ser simplesmente descartada, o que pode não ser seguro. A solução é fazer oselectentre o timeout e “há algo disponível” no canal e, neste último caso, usarpeekpara inspecionar os dados com segurança.try_joincancela por erroEm todos esses casos, parece natural que, se o contexto for cancelado, o trabalho não seja concluído. Se o trabalho precisa necessariamente terminar, bastaria separá-lo em uma task independente. Fico me perguntando se estou deixando passar alguma nuance importante; sempre entendi que o design de futures pressupõe que o trabalho pode desaparecer por causa de cancellation. Se puderem explicar de novo qual é exatamente o problema, eu agradeceria.
await, o que resta são detalhes técnicos.safe/unsafedá a entender que uma opção é melhor ou pior, quando o que é desejável no comportamento de cancelamento depende da situação. Por exemplo, um future que espera uma task iniciada comspawncostuma ser chamado de "cancellation safe", mas, se ao dardropa task continuar executando, isso pode acumular trabalho desnecessário e ainda manter locks ou portas ocupadas, o que também é problemático. Por outro lado, um spawn handle que interrompe a task nodropseria chamado de "cancellation unsafe", mas pode ser um padrão essencial para a limpeza de tarefas dependentes.selecte outros primitives de concorrência.awaité sempre um potencial ponto de retorno. Convém evitar colocarawaitentre duas ações que necessariamente precisam acontecer de forma atômica.dpode acabar não sendo chamada? O cancelamento acontece emc? Ou algo acontece mais acima, ema?awaits e precise necessariamente continuar depois da pausa. Por exemplo, se eu fizer uma alteração no banco e depois registrar um audit log, e ambos precisarem obrigatoriamente ser executados, a única saída é colocar um comentário tipo “do not cancel”?Futurede Rust é um pouco como move semantics em C++: depois que oFuturetermina, ele pode ficar em um estado inválido. Como o Rust usa corrotinas stackless, ao implementar diretamente uma estrutura async baseada empollvocê precisa gerenciar o estado manualmente dentro de uma struct. Tudo isso já é uma armadilha comum. E, mais recentemente, cancellation no async Rust virou mais uma variável no gerenciamento de estado. Quando eu estava desenvolvendo a biblioteca mea (Make Easy Async), sempre documentava cancel safety quando isso não era trivial; também me lembro de um caso em que um cancelamento async descuidado causou problemas na stack de IO mea caso no redditFuture; como.awaittoma posse do future, não dá para fazerdrop(), e como future é lazy, depois de.awaiteu não tinha clareza sobre como o cancelamento funcionava. Mais tarde fui pesquisarselect!eAbortable()e entendi, mas, se essa parte fosse destacada logo no começo numa próxima apresentação, ficaria perfeito.async dropexista logo.