Pular para o conteúdo

A armadilha Tokio/Rayon: quando async/await falha em Rust

Resumo rápido

  • Tokio resolve concorrência (muitas tarefas esperando I/O); Rayon resolve paralelismo (CPU ocupada com cálculo pesado). Não são intercambiáveis.
  • Rodar código CPU-bound direto dentro de uma async fn trava o runtime do Tokio inteiro, porque não há nenhum ponto de .await para o executor retomar outras tarefas.
  • Para combinar os dois com segurança, use tokio::task::spawn_blocking (ou, com mais cuidado, block_in_place) para tirar o trabalho pesado da thread do runtime assíncrono.

Você já deve ter caído nessa: o código está lento, alguém sugere “bota um async aí” e, de repente, o programa fica mais difícil de ler e não fica nem um pouco mais rápido. Isso costuma acontecer quando você confunde dois problemas diferentes, concorrência e paralelismo, e escolhe a ferramenta errada pra resolver cada um. Em Rust, essa confusão tem nome: a armadilha Tokio/Rayon.

O problema fundamental: async/await entrelaça concorrência

A ideia de que async/await “entrelaça” (complects, no termo cunhado por Rich Hickey) concorrência com o resto do código não é um exagero retórico. Quando você marca uma função como async, ela passa a fazer parte de uma máquina de estados que só avança em pontos de .await. Isso funciona muito bem quando existe algo real para esperar, uma resposta de rede, uma leitura de disco, uma query de banco. O problema é usar essa mesma ferramenta para trabalho que não espera nada: cálculo puro que ocupa a CPU do início ao fim.

O Tokio, como qualquer runtime assíncrono, foi desenhado para gerenciar milhares de tarefas que passam a maior parte do tempo *esperando*. Ele não foi desenhado para paralelizar cálculo. Quando você bota uma computação pesada dentro de uma async fn sem nenhum ponto de .await, o executor não tem onde interromper aquela tarefa para atender outra. Na prática, uma única tarefa CPU-bound consegue travar a worker thread inteira do Tokio, e junto com ela, todas as outras tarefas que dependiam daquela thread pra rodar.

Tokio vs Rayon: entendendo as diferenças

Tokio e Rayon resolvem problemas diferentes e não competem entre si. A confusão nasce quando alguém trata os dois como sinônimos de “rodar coisas ao mesmo tempo”.

Tokio: concorrência assíncrona

O Tokio é um runtime assíncrono projetado para gerenciar milhares de tarefas concorrentes que passam a maior parte do tempo esperando: requisições de rede, leitura de arquivos, consultas a bancos de dados. Ele é ideal quando você tem muitas operações I/O-bound.

A força do Tokio está em alternar entre tarefas quando uma delas está bloqueada esperando dados externos. Com um número pequeno de threads de worker (por padrão, uma por núcleo de CPU disponível, segundo a documentação do tokio::runtime::Builder), ele consegue gerenciar milhares de conexões simultâneas de forma eficiente.

Rayon: paralelismo de dados

O Rayon, por outro lado, é uma biblioteca de paralelismo de dados. Ela divide trabalho computacional intensivo entre múltiplos núcleos de CPU. É a escolha certa quando você precisa processar grandes volumes de dados ou executar cálculos pesados que são CPU-bound.

Rayon usa um pool de threads *work-stealing* que distribui automaticamente a carga de trabalho, o que torna trivial paralelizar operações como iteração sobre coleções com par_iter(). Segundo a documentação da função rayon::spawn, tarefas colocadas no pool global seguem ordem LIFO na thread que as criou, mas são roubadas em ordem FIFO por outras threads, um detalhe de design que mantém tarefas recentes no cache da CPU sem abrir mão do balanceamento de carga.

A armadilha em ação

A armadilha acontece quando você escolhe Tokio para um problema que Rayon resolveria melhor, ou vice-versa. Imagine processar um dataset grande em memória aplicando transformações computacionalmente pesadas:

// Abordagem errada: usando async para trabalho CPU-bound
async fn process_data(items: Vec<Data>) -> Vec<Output> {
    let mut results = Vec::new();
    for item in items {
        // Não há I/O aqui: isso trava a worker thread do Tokio inteira
        results.push(expensive_computation(item));
    }
    results
}
// Abordagem correta: usando Rayon para paralelismo
use rayon::prelude::*;

fn process_data(items: Vec<Data>) -> Vec<Output> {
    items
        .par_iter()
        .map(|item| expensive_computation(item))
        .collect()
}

No primeiro caso, você adiciona toda a complexidade do async/await sem ganho real, já que não existe operação de I/O para justificar a concorrência assíncrona. Pior: se essa função rodar num runtime multi-thread do Tokio, ela consome uma worker thread inteira sem devolver o controle ao executor, o que pode deixar outras tarefas (inclusive requisições de outros usuários, num servidor web) esperando na fila.

Quando usar cada abordagem

Use Tokio (async/await) quando:

  • Você tem muitas operações de I/O: requisições HTTP, queries de banco de dados, leitura de arquivos
  • As tarefas passam mais tempo esperando do que processando
  • Você precisa gerenciar milhares de conexões simultâneas
  • A latência é mais importante que o throughput bruto de CPU

Use Rayon (paralelismo) quando:

  • Você tem trabalho computacionalmente intensivo
  • Os dados já estão em memória
  • Você quer aproveitar múltiplos núcleos de CPU para cálculos
  • As operações são CPU-bound, não I/O-bound

Combinando ambas as abordagens (com cuidado)

Em aplicações reais, você frequentemente precisa dos dois. Um servidor web pode usar Tokio para gerenciar requisições HTTP enquanto usa Rayon para processar dados dentro de cada requisição:

async fn handle_request(raw_data: Vec<u8>) -> Response {
    // Rayon processa os dados em paralelo, fora da worker thread do Tokio
    let processed = tokio::task::spawn_blocking(move || {
        raw_data
            .par_iter()
            .map(|x| expensive_transform(x))
            .collect::<Vec<_>>()
    })
    .await
    .unwrap();

    Response::new(processed)
}

Aqui, tokio::task::spawn_blocking move o trabalho pesado para um pool de threads dedicado a operações bloqueantes, mantido separado das worker threads assíncronas. Segundo a documentação oficial, esse pool aceita por padrão até 512 threads (max_blocking_threads), o que faz sentido para I/O bloqueante como leitura de disco, mas não é a ferramenta certa pra maximizar paralelismo de CPU: para isso, o próprio Rayon com seu pool fixo de work-stealing continua sendo a opção mais eficiente, e é por isso que spawn_blocking costuma ser usado só como ponte entre os dois mundos, não como substituto do Rayon.

Existe também tokio::task::block_in_place, que roda a closure na própria thread atual avisando o executor pra realocar outras tarefas antes disso acontecer. A documentação é explícita sobre duas limitações: essa função só funciona em runtimes multi-thread (entra em pânico se chamada num runtime current_thread) e qualquer outro código concorrente rodando na mesma task fica suspenso durante a chamada, por isso a própria documentação recomenda spawn_blocking como alternativa mais segura na maioria dos casos.

Vale um alerta que vai além da documentação oficial: misturar Rayon e Tokio de forma descuidada pode gerar problemas mais sérios do que lentidão. Existe um caso documentado publicamente (o post “Mixing rayon and tokio for fun and (hair) loss”, relatando um bug real encontrado no Meilisearch) em que uma thread pertencente simultaneamente ao pool do Rayon e ao runtime do Tokio causava um pânico: uma thread do Rayon chamava block_on para iniciar um runtime Tokio, e dentro desse contexto assíncrono outra tarefa acabava disparando uma nova chamada block_on na mesma thread, gerando uma inicialização de runtime aninhada. A correção adotada ali foi simplesmente evitar esse “sanduíche” de rayon dentro de async dentro de rayon, mantendo os dois mundos comunicando por canais em vez de aninhados um dentro do outro.

Perguntas frequentes

Rodar expensive_computation() dentro de uma async fn, sem .await, já é um erro por si só?

Não é um erro de compilação, o código roda, mas some com o principal motivo de usar async: enquanto aquela função síncrona está executando, ela ocupa a worker thread do Tokio e nenhuma outra tarefa consegue avançar nela até a função terminar.

spawn_blocking e block_in_place fazem a mesma coisa?

Não. spawn_blocking manda o trabalho para outra thread do pool de blocking (até 512 por padrão) e devolve o controle da thread atual ao executor imediatamente. block_in_place continua na mesma thread, mas avisa o runtime pra migrar as outras tarefas pendentes para outro worker antes de bloquear, e só funciona em runtime multi-thread.

Dá pra usar Rayon dentro de uma task do Tokio sem spawn_blocking?

Tecnicamente dá, mas não é recomendado: se o trabalho do Rayon for rápido o bastante para não bloquear por muito tempo, talvez passe despercebido; se for pesado, ele consome a worker thread do Tokio do mesmo jeito que uma função síncrona comum, com o agravante de que combinações mais complexas (como chamar block_on de dentro de uma thread do Rayon) já causaram pânicos documentados em produção.

Existe alguma biblioteca pronta pra fazer essa ponte?

Sim, o crate tokio-rayon existe justamente para encapsular o padrão de rodar trabalho do Rayon a partir de contexto assíncrono usando spawn_blocking por baixo, evitando reescrever esse bridge manualmente em todo projeto.

Conclusão: escolha consciente

A popularidade do async/await em linguagens modernas cria a falsa impressão de que ele é sempre a melhor escolha para qualquer forma de concorrência. Em Rust, entender a distinção entre concorrência (gerenciar múltiplas tarefas que passam tempo esperando) e paralelismo (executar múltiplos cálculos ao mesmo tempo, usando múltiplos núcleos) é o que evita a armadilha Tokio/Rayon.

Antes de adicionar async ao seu código, pergunte-se: isso é I/O que vai esperar por algo externo, ou é cálculo que só precisa de mais núcleos de CPU? A resposta determina se você precisa de Tokio, de Rayon, ou de uma combinação bem delimitada dos dois, com spawn_blocking fazendo a ponte no lugar certo.

Se você lida com estruturas de dados que mudam com frequência e quer evitar reprocessamento desnecessário (um problema adjacente, mas distinto, do paralelismo puro), vale ver como computações incrementais recalculam apenas o necessário. E se o assunto é organizar esse tipo de decisão de concorrência dentro de uma base de código maior sem virar espaguete, arquitetura limpa ajuda a isolar essas escolhas de infraestrutura do restante da lógica de negócio. Para quem quer comparar como outra linguagem resolve concorrência de um jeito nativamente diferente do modelo async/await, os 5 recursos que fazem do Go a “Fórmula 1” do back-end mostram goroutines como alternativa de design.

Recomendação relacionada

The Rust Programming Language, 2nd Edition

A referência oficial da linguagem Rust, direto da equipe que mantém o projeto.

Ver na Amazon →

Como Associado Amazon, Visão Binária pode ganhar uma comissão sobre compras qualificadas feitas através deste link, sem custo adicional pra você.