Segurança contra cancelamento em diferentes runtimes assíncronos do Rust
Por Nikita Bishonen • 8 minutos de leitura •
Para obter mais contexto, recomendo aos meus amigos que leiam ou assistam a ótima palestra feita pela Rain, onde ela descreveu sua postura em relação à “segurança contra cancelamento” no Rust assíncrono.
TL;DR para aqueles que preferem codar e não ler:
git clone https://gitlab.com/blogging1/cancellation-safety;cargo test --no-fail-fast --workspace(ou seu runtime de escolha usando-p cancel_%RUNTIME%em vez de--workspace);- Leia os erros e os testes;
- Leia
lib.rspara os runtimes em que você está interessado; - Jogue com o código para corrigir todos os testes;
O Problema
“Segurança contra cancelamento” geralmente significa ausência de efeitos inesperados quando uma sequência de operações assíncronas é interrompida antes de atingir o estado final. Posso pensar em um panic! no meio de uma sequência síncrona de operações como uma analogia.
Os princípios fundamentais estão ligados ao trait Future e aos detalhes de implementação do Runtime Assíncrono. Aqui, quero experimentar na tentativa de encontrar padrões comuns e diferenças em como Tokio, smol e glommio lidam com cancelamentos.
Future::pollé um aspecto chave aqui, pois em tempo de compilação cada implementação deFutureé uma máquina de estados, que representa uma sequência de operações, “cancelamento” significa que o Runtime (Reator, Loop de Eventos ou Escalonador) para de sondar esta máquina de estados e (espera-se) destrói seu estado;PineUnpintêm seus próprios lugares, pois tipos!Unpinprecisam manter garantias adicionais para serem considerados “seguros” (mitigar “efeitos”);- A implementação de
Wakerpode desempenhar um grande papel no tratamento de solicitações de “cancelamento”;
O Exemplo
Em nossa aventura, vamos nos juntar aos nossos fortes e orgulhosos amigos que vivem em Moria:
pub async fn mine_with_tool<F, FT>(dwarf: &mut Dwarf, mut pickaxe: F) ... {
let mut bag: Bag = Vec::new();
for i in 0..MAX_ALLOWED_SHIFTS {
pickaxe().await;
...
println!("Aqui nos Portões o rei AGUARDA");
...
bag.push(Ferrum::Dirty);
}
dwarf.bag = Some(bag);
}então nosso processo assíncrono imitará um anão trabalhando nas minas. O mine_with_tool em si é uma implementação composta de trait Future, que internamente executa MAX_ALLOWED_SHIFTS passos para atingir seu estado final (completo). Em termos simples, linha 4: pickaxe().await; será um ponto onde a máquina de estados é sondada e faz progresso. Isso significa que esse exato local pode ser usado para “cancelar” a execução.
Como se vê em uma representação intermediária de alto nível (código limpo para torná-lo menos verboso, por favor execute cargo +nightly rustc -- -Z unpretty=hir dentro da pasta moria para ver a saída completa):
async fn mine_with_tool<F, FT>(dwarf: &'_ mut Dwarf, pickaxe: F) -> /*impl Trait*/
where F: FnMut() -> FT, FT: std::future::Future |mut _task_context: ResumeTy|
{
...
let mut bag: Bag = Vec::new();
{
...
loop {
match next(&mut iter) {
None {} => break,
Some { 0: i } => {
match into_future(pickaxe()) {
mut __awaitee =>
loop {
match unsafe {
poll(new_unchecked(&mut __awaitee),
get_context(_task_context))
} {
Ready { 0: result } => break result,
Pending {} => { }
}
_task_context = (yield ());
},
};
...
bag.push(Ferrum::Dirty);
}
}
},
...
dwarf.bag = Some(bag);
...
}quase não há mais “mágica” assíncrona, temos um loop, um unsafe e um yield ().
Vamos saltar para o aspecto de “segurança”. Na minha humilde opinião, se não há código unsafe e o compilador Rust está satisfeito, a operação é segura. No entanto, isso não significa que o programa se comporta da maneira que os programadores podem esperar que ele se comporte.
... fn mine_with_tool<...>(dwarf: &mut Dwarf ... {
let mut bag: Bag = Vec::new();
...
bag.push(Ferrum::Dirty);
...
dwarf.bag = Some(bag);
}tente identificar você mesmo o que torna esta implementação de trait Future ser chamada de “não segura contra cancelamento” (eu prefiro não segura aqui, pois unsafe é importante mas um termo Rust não relacionado).
A resposta reside em como esta operação assíncrona armazena seu estado e rastreia seu próprio progresso. Ambas as coisas acontecem internamente, enquanto mantém uma referência mutável ao estado fornecido pelo lado do chamador dwarf: &mut Dwarf. Vamos ver dois exemplos desse uso de recurso dentro do runtime smol para ver por que tal implementação pode dar surpresas ao chamador se for cancelada.
pub async fn work(dwarf: &mut Dwarf) {
super::mine(dwarf)
.or(async {
Timer::after(HALF_SHIFT).await;
})
.await;
}O Anão trabalha aqui metade do tempo de turno e fez algum progresso. Mas quando vamos ver executar o teste:
let mut dwallin = Dwarf::new(Name::Dwalin);
timeout::work(&mut dwallin).await;
// A mochila está vazia, mas ouvi a música!
assert!(dwallin.bag.is_some());vemos que a assertiva de que algum trabalho foi feito falha. A razão é que o timeout aconteceu antes que Dwalin pudesse terminar seu trabalho e tudo o que ele colocou na “mochila” no estado interno da máquina de estados foi descartado assim que cancelamos a operação na primeira sondagem após o timeout. (Tente mudar Timer::after(HALF_SHIFT) para usar FULL_SHIFT e veja se isso ajuda 😉). Então isso é o que podemos dizer uma implementação não segura contra cancelamento (ou incorreta como a Rain propôs) de uma operação assíncrona que leva a um comportamento que não esperamos.
Mais interessante (e mais próximo do mundo real) pode acontecer se fizermos nossa implementação de Future mais complexa e “suja”:
pub async fn work(dwarf: &Mutex<Dwarf>) {
let mut dwarf_guard = dwarf.lock().await;
let mut old_bag = dwarf_guard.bag.take().unwrap();
timeout(HALF_SHIFT, super::mine(&mut dwarf_guard))
.await
.err()
.unwrap();
if let Some(new_bag) = dwarf_guard.bag.as_mut() {
new_bag.append(&mut old_bag)
}
}Eu sei, Anões não são bons em programação assíncrona, mas isso ilustra o problema muito bem. Nossa operação retira o estado do input, o mantém internamente, depois acumula com resultados de computação próprios e o devolve.
┌────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ ┌──────────────┐ ┌──────────────────┐ ┌───────────┐ ┌───────────────┐ ┌──────────────┐│
│ │ Travar Mutex │ -> │ Tomar Bag Antiga │ -> │ Mineração │ -> │Tomar Nova Bag │ -> │ Mesclar Bags ││
│ └──────────────┘ └──────────────────┘ └───────────┘ └───────────────┘ └──────────────┘│
│ ┌──────────────┐ ┌──────────────┐ │
│->│Liberar Travar│ -> │ Fim │ │
│ └──────────────┘ └──────────────┘ │
└────────────────────────────────────────────────────────────────────────────────────────────────────┘O problema é que a operação é “segura” do ponto de vista do mutex, pois sabemos que ninguém mais alterará o estado do dwarf. No entanto, não é “correto” se cancelarmos super::mine antes que ele seja completamente concluído. old_bag será descartado assim como new_bag não existirá (dwarf_guard.bag.as_mut() é None). Como a implementação de futuro de work é uma composição de si mesmo com a implementação de futuro de super::mine e a implementação de futuro de timeout, nossa lógica se torna quebrada, pois ambos os futuros têm comportamento incorreto do ponto de vista do sistema (enquanto isso é totalmente esperado, válido e seguro código Rust na minha opinião).
A Comparação
Visão Geral da Arquitetura do Runtime
Tokio: O Padrão da Indústria
Tokio representa o runtime assíncrono mais amplamente usado no ecossistema Rust. Sua arquitetura foca em:
- Pool de threads multi-thread para trabalhos ligados à CPU;
- Modelo de I/O baseado em completers para operações não bloqueantes;
- Cancelamento robusto de tarefas com capacidades de aborto explícito;
- Ecossistema abrangente de bibliotecas de suporte;
Características principais:
- Tokens de cancelamento integrados via
tokio_util::sync::CancellationToken; tokio::spawn()para criar tarefas;tokio::select!para operações concorrentes;- Utilitários de timeout integrados;
smol: A Abordagem Mínima
smol toma uma abordagem radicalmente diferente com:
- Executor mono-thread por padrão;
- API simplificada focando em operações assíncronas essenciais;
- Sem runtime próprio - ele traz executores existentes;
- Dependências leves e sobrecarga mínima;
Características principais:
smol::spawn()para criação de tarefas;smol::Timerpara atrasos assíncronos;smol::channelpara passagem de mensagens;- Sem tokens de cancelamento explícitos na API central;
Glommio: Foco em Performance de I/O
Glommio representa um runtime especializado para cargas de trabalho de I/O de alta performance:
- Modelo de executor local com abordagem de compartimento-zero primeiro;
- Otimizado para I/O com arquitetura thread por núcleo dedicada;
- Sem memória compartilhada entre threads por padrão;
- Futuros locais-only para melhor localidade de cache;
Características principais:
glommio::LocalExecutorpara execução mono-thread;glommio::spawn_local()para tarefas;glommio::timerpara atrasos;glommio::channels::local_channelpara comunicação local-only;
Análise de Cenários de Cancelamento
A maioria dos cenários funciona de forma semelhante entre runtimes:
- Cancelamento de Drop Simples: Use mecanismos de drop explícitos para futuros e todos os runtimes mostram perda parcial de trabalho quando futuros são dropados inesperadamente;
- Cancelamento de Timeout: Todos fornecem tratamento de timeout explícito e os testes mostram que o timeout não preserva resultados parciais;
- Operações Protegidas por Mutex:
tokio::sync::Mutex,smol::lock::Mutex,glommio::sync::RwLockcompartilham semânticas semelhantes, enquanto os testes demonstram padrões de manutenção de bloqueio e limpeza funciona como deveria, embora ainda percam nosso progresso; - Comunicação Baseada em Canal: muito semelhante, apenas com nuances de localidade;
Mas alguns têm nuances:
- Macros como
select!e Operações Concorrentes com Tokens de Cancelamento são específicos ao ecossistema de runtimeTokio, o que parece não ser algo bom ou ruim. Como a Rain descreveu em sua palestra e o que ouvi de outros desenvolvedores, esses macros às vezes são totalmente banidos em projetos devido à sua natureza não explícita (e por exemplo substituídos por futures_concurrency); - “Cancelamento Explícito”:
- em
Tokioé feito viaJoinHandle::aborte o descarte do handle não causará um cancelamento,nahdle.awaitretornará um erroJoinErrorse a implementação de futuro foi cancelada (ou resultado normal se concluiu), também vale notar que tarefasspawn_blockingnão são “cancelláveis” pois não são assíncronas (mas isso impedirá a tarefa de iniciar se ainda não o foi!); - em
smolvocê pode chamarTask::cancele esperar pelo cancelamento (que pode retornarSomese concluiu), é similar ao descarte da implementação de futuro; - em
glommioa abordagem também é idêntica asmolcomOptionsendo retornado ao aguardar o cancelamento;
- em
Do lado da documentação, este tópico é coberto apenas nos docs da API Tokio, smol tem uma pequena nota (he-he) sobre este assunto:Nota que cancelar uma tarefa acorda realmente e agenda uma última vez. Então, o executor pode destruir a tarefa simplesmente descartando seu Runnable ou invocando run()., enquanto no glommio não consegui encontrar menção sobre segurança contra cancelamento ou correção. Acho que há dois fatores por que é, o que é:
- Tokio tem muito mais popularidade e uso, embora tenha mais recursos para adicionar documentação;
- Tokio tem muito mais API “perigosa” e modelo de executor interno que torna mais fácil cair em problemas de segurança contra cancelamento usando-o;
Espero que você tenha encontrado algo novo neste post de blog, escreva seus pensamentos nos comentários e verifique recursos adicionais se desejar.
Recursos Adicionais e Fontes de Inspiração
- Ótima Palestra da Rain talk;
- Comentário atual na seção state of the book sobre segurança contra cancelamento;
- Docs do Tokio sobre isso
- Structured Concurrency lib
Comentários
Pode comentar neste artigo respondendo publicamente a este post usando uma conta Mastodon ou outra conta ActivityPub/Fediverse. Respostas não privadas conhecidas são exibidas abaixo.