3 pontos por GN⁺ 2025-10-05 | 1 comentários | Compartilhar no WhatsApp
  • 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_join e 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> para None) e houver um await, 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 retorno Result for 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 com select, 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_all de AsyncWrite: escrever todo o buffer com write_all é instável diante de cancelamento, enquanto write_all_buf permite 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

Operar futures evitando cancelamento

  • Pinagem de future: em loops com select etc., fixar o future com pin e fazer poll por referência evita que ele seja cancelado
    • Exemplo: reutilizar um future de reserve preserva sua posição na fila de espera da reserva
  • 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

 
GN⁺ 2025-10-05
Comentário no Hacker News
  • Acho muito interessante o exemplo de colocar timeout em 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 em send, a mensagem pode ser enviada mesmo depois do timeout, mas ela não se perde, então isso é seguro; já se você coloca timeout em recv, 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 o select entre o timeout e “há algo disponível” no canal e, neste último caso, usar peek para inspecionar os dados com segurança.
    • Estou pensando se esse não é exatamente o ponto central de cancellation-safety.
    • Acho uma ótima observação.
  • Gostaria de apresentar alguns materiais que escrevi sobre esse tema
    • Em 2020 escrevi uma proposta dizendo que funções async deveriam sempre executar até o fim, incluindo uma funcionalidade de graceful cancellation, e continuo achando que até hoje ninguém apresentou uma ideia melhor link da proposta
    • Também existe uma proposta para unified cancellation em Rust sync e async de forma geral ("A case for CancellationTokens") link do gist
    • E também há uma implementação prática do que foi descrito acima min_cancel_token
  • Não entendo muito bem qual é o problema de futures serem cancelados; futures não são tasks, e o próprio texto reconhece isso internamente. Sendo assim, não seria justamente esse o comportamento esperado, que um future possa não executar até o fim? E por que isso seria um problema? No exemplo, fala-se de um future “cancel unsafe”, mas me parece que o ponto central é um desencontro entre expectativa e realidade.
    • Exemplo 1: um dos try_join cancela por erro
    • Exemplo 2: os dados não são gravados quando há cancelamento
      Em 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.
    • Isso mesmo! Na prática, isso já causou muitos bugs na Oxide. Se você entende bem que futures são passivos e podem ser cancelados a qualquer momento em um ponto de await, o que resta são detalhes técnicos.
  • Gostei muito de assistir a essa apresentação na RustConf; a distinção conceitual entre cancel safety e cancel correctness é realmente útil. Fico feliz que a apresentação também tenha virado um post de blog — palestras são ótimas, mas, para compartilhar e consultar depois, o formato de blog é muito mais prático.
    • Gosto da expressão "cancel correctness" porque ela situa bem o contexto de cancellation. Já o termo "cancel safety" não me agrada muito: ele não se encaixa tão bem no conceito de safety do Rust e ainda soa desnecessariamente normativo. safe/unsafe dá 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 com spawn costuma ser chamado de "cancellation safe", mas, se ao dar drop a 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 no drop seria chamado de "cancellation unsafe", mas pode ser um padrão essencial para a limpeza de tarefas dependentes.
    • Também acho o texto do blog mais fácil e melhor de ler; concordo.
  • Achei especialmente interessante o conteúdo de https://sunshowers.io/posts/cancelling-async-rust/#the-pain-of-tokio-mutexes; eu mesmo poderia cometer esse tipo de erro com facilidade.
    • Mesmo sendo desenvolvedor Go, isso me ajuda; o Rust dá uma ajuda mais rígida com ferramentas, mas é fácil cair nas mesmas armadilhas em Go com goroutines, canais, select e outros primitives de concorrência.
  • No primeiro exemplo, não está claro qual é o comportamento desejado. Se a fila estiver cheia, é preciso escolher entre descartar, esperar ou dar panic. Colocar timeout em uma operação bloqueante costuma servir mais para detectar deadlocks. O código diz que “nem todas as mensagens vão para o canal”, mas isso é esperado se faltam recursos. Qual é o objetivo, afinal? Encerrar o programa de forma limpa? Isso já é bem difícil em ambiente de threads, e em async também não é simples. Um caso de uso real seria a troca de mensagens com uma parte remota e a necessidade de limpar o estado local quando o outro lado desconecta.
    • Idealmente, eu gostaria de manter as mensagens em um buffer até haver espaço no canal; isso é abordado mais adiante na apresentação, em "What can be done".
    • A resposta está no próprio exemplo: o código que registra log após 5 segundos sem espaço é voltado a diagnóstico, mas isso pode introduzir, de forma meio sorrateira, o risco de perda de dados. É um pouco artificial, mas é exatamente o tipo de código que pode acabar sendo espalhado pelo sistema para lidar com problemas do tipo “por que isso não está funcionando?”.
    • Só como observação, a autora deste texto usa os pronomes they/she about
  • É preciso sempre ter em mente que await é sempre um potencial ponto de retorno. Convém evitar colocar await entre duas ações que necessariamente precisam acontecer de forma atômica.
    • Tenho curiosidade sobre como isso de fato causa problema, por exemplo:
      async fn a() {
        b().await
      }
      async fn b() {
        c().await
        d().await
      }
      async fn c() {}
      async fn d() {}
      
      Nesse código, de que forma d pode acabar não sendo chamada? O cancelamento acontece em c? Ou algo acontece mais acima, em a?
    • Então isso não é meio perigoso? Claro, talvez seja inevitável, mas pode haver casos em que uma “critical section” tenha dois 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”?
  • O Future de Rust é um pouco como move semantics em C++: depois que o Future termina, ele pode ficar em um estado inválido. Como o Rust usa corrotinas stackless, ao implementar diretamente uma estrutura async baseada em poll você 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 reddit
  • Foi realmente uma ótima apresentação! Como iniciante total, eu teria gostado que no SOP fosse enfatizado logo de início que não dá para cancelar um Future; como .await toma posse do future, não dá para fazer drop(), e como future é lazy, depois de .await eu não tinha clareza sobre como o cancelamento funcionava. Mais tarde fui pesquisar select! e Abortable() e entendi, mas, se essa parte fosse destacada logo no começo numa próxima apresentação, ficaria perfeito.
    • Pergunta: aqui, o que significa SOP?
  • Excelente timing: hoje mesmo eu estava adicionando ao doc comment de uma nova função a observação de que “esta função é cancel safe”, e isso me fez pensar em várias coisas. Tomara que async drop exista logo.
    • Fiquei curioso sobre essa função; será que você poderia explicar um pouco mais?