Segurança contra cancelamento em diferentes runtimes assíncronos do Rust

Por Nikita Bishonen8 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:

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.

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:

Características principais:

smol: A Abordagem Mínima

smol toma uma abordagem radicalmente diferente com:

Características principais:

Glommio: Foco em Performance de I/O

Glommio representa um runtime especializado para cargas de trabalho de I/O de alta performance:

Características principais:

Análise de Cenários de Cancelamento

A maioria dos cenários funciona de forma semelhante entre runtimes:

Mas alguns têm nuances:

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 é:

  1. Tokio tem muito mais popularidade e uso, embora tenha mais recursos para adicionar documentação;
  2. 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

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.

Abrir Artigo