Async/Await em Rust: Tokio, Futures e concorrência assíncrona explicados em profundidade

Mergulho profundo em async/await no Rust: o runtime Tokio, a trait Future, o lançamento de tarefas, a concorrência estruturada e os padrões práticos para construir aplicações assíncronas de alto desempenho.

Concorrência assíncrona em Rust com o runtime Tokio e o fluxo de execução de futures

O sistema async/await do Rust oferece programação assíncrona com custo zero, mas diferentemente de linguagens como JavaScript ou Python, o Rust não inclui um runtime embutido. A trait Future, as palavras-chave async/await e um executor externo como o Tokio formam um sistema de três camadas que confere controle total sobre como o trabalho concorrente é agendado e executado.

Conceito-chave

Uma async fn em Rust retorna uma máquina de estados que implementa Future. Nada é executado até que um executor (como o Tokio) faça o polling dessa future. Esse modelo de avaliação preguiçosa elimina alocações ocultas e fornece ao compilador informações suficientes para otimizar de forma agressiva.

Como as Futures do Rust diferem de outras linguagens

Em JavaScript, chamar uma função async inicia a execução imediatamente e retorna uma Promise. Em Rust, chamar uma async fn não faz nada — constrói um valor Future que permanece inativo até ser sondado. Essa distinção tem consequências práticas significativas.

A trait Future define um único método:

rust
// core::future::Future
trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

Poll::Ready(value) sinaliza a conclusão. Poll::Pending indica ao executor para estacionar a tarefa e acordá-la mais tarde através do Waker armazenado em Context. O executor nunca faz espera ativa — ele se apoia em mecanismos do sistema operacional (epoll no Linux, kqueue no macOS) para saber quando uma operação de I/O está pronta.

Esse modelo baseado em polling significa que as futures do Rust são máquinas de estados compiladas em tempo de construção, não cadeias de callbacks alocadas no heap. O compilador transforma cada ponto .await em uma variante de estado, produzindo código que rivaliza com máquinas de estados escritas manualmente em termos de desempenho.

Configurando o Tokio como runtime assíncrono

O Tokio é o runtime assíncrono mais amplamente adotado no ecossistema Rust. Em 2026, o Tokio 2.0 introduziu um agendador aprimorado com work-stealing e uma roda de temporização hierárquica que reduzem a sobrecarga em aproximadamente 40% em relação a versões anteriores.

Uma configuração mínima requer dois elementos no Cargo.toml:

toml
# Cargo.toml
[dependencies]
tokio = { version = "2", features = ["rt-multi-thread", "macros", "net", "time"] }

A macro #[tokio::main] transforma a função main em um ponto de entrada assíncrono:

main.rsrust
#[tokio::main]
async fn main() {
    let result = fetch_data().await;
    println!("Got: {result}");
}

async fn fetch_data() -> String {
    // Simulate async I/O
    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
    String::from("data loaded")
}

Nos bastidores, #[tokio::main] se expande para uma chamada Runtime::new() com um agendador multi-thread. Para aplicações que precisam de apenas uma thread (ferramentas CLI, scripts), #[tokio::main(flavor = "current_thread")] evita a sobrecarga da sincronização entre threads.

Lançamento de tarefas e concorrência estruturada

Chamadas .await sequenciais executam uma após a outra. Para executar trabalho de forma concorrente, o Tokio fornece tokio::spawn, que agenda uma future no pool de threads do runtime:

concurrent_tasks.rsrust
use tokio::task::JoinHandle;

#[tokio::main]
async fn main() {
    // Spawn two independent tasks
    let handle_a: JoinHandle<u32> = tokio::spawn(async {
        expensive_computation("dataset_a").await
    });

    let handle_b: JoinHandle<u32> = tokio::spawn(async {
        expensive_computation("dataset_b").await
    });

    // Await both results
    let (result_a, result_b) = (
        handle_a.await.expect("task A panicked"),
        handle_b.await.expect("task B panicked"),
    );

    println!("Results: {result_a}, {result_b}");
}

async fn expensive_computation(name: &str) -> u32 {
    tokio::time::sleep(std::time::Duration::from_secs(1)).await;
    println!("{name} done");
    42
}

Ambas as tarefas executam em paralelo através das threads disponíveis. O JoinHandle retornado por spawn atua como uma future que se resolve quando a tarefa lançada termina.

Uma restrição fundamental: tokio::spawn exige que a future seja 'static — ela não pode emprestar variáveis locais. Isso força uma transferência de propriedade explícita, o que previne corridas de dados em tempo de compilação.

Requisito Send + 'static

Tarefas lançadas devem ser Send + 'static. Dados compartilhados entre pontos .await dentro de uma tarefa lançada precisam ser Cloneados para a tarefa ou envolvidos em Arc. Tentar emprestar referências locais da pilha além da fronteira de um spawn gera um erro de compilação.

Unindo múltiplas Futures com tokio::join! e tokio::select!

Para um número fixo de operações concorrentes, tokio::join! executa todas as futures até a conclusão e retorna uma tupla de resultados:

join_example.rsrust
use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    // All three run concurrently, total time ~200ms (not 600ms)
    let (users, orders, inventory) = tokio::join!(
        fetch_users(),
        fetch_orders(),
        fetch_inventory()
    );

    println!("Users: {}, Orders: {}, Stock: {}", users.len(), orders.len(), inventory);
}

async fn fetch_users() -> Vec<String> {
    sleep(Duration::from_millis(200)).await;
    vec!["Alice".into(), "Bob".into()]
}

async fn fetch_orders() -> Vec<String> {
    sleep(Duration::from_millis(150)).await;
    vec!["ORD-001".into()]
}

async fn fetch_inventory() -> u32 {
    sleep(Duration::from_millis(100)).await;
    84
}

Diferentemente de spawn, join! não exige 'static — as futures emprestam livremente do escopo que as contém. Isso o torna a escolha preferida quando todos os ramos precisam ser concluídos.

tokio::select! aguarda a primeira future a ser concluída e cancela as demais:

select_example.rsrust
use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    tokio::select! {
        val = fetch_from_cache() => {
            println!("Cache hit: {val}");
        }
        val = fetch_from_database() => {
            println!("DB result: {val}");
        }
    }
}

async fn fetch_from_cache() -> String {
    sleep(Duration::from_millis(5)).await;
    "cached_value".into()
}

async fn fetch_from_database() -> String {
    sleep(Duration::from_millis(50)).await;
    "db_value".into()
}

O ramo perdedor é descartado, o que executa os destruidores e libera os recursos. Esse padrão é essencial para implementar tempos limite, cancelamento e competição entre fontes de dados.

Pronto para mandar bem nas entrevistas de Rust?

Pratique com nossos simuladores interativos, flashcards e testes tecnicos.

Tratamento de erros em Rust assíncrono

Funções assíncronas se compõem naturalmente com o tipo Result do Rust. O operador ? funciona dentro de uma async fn exatamente como em código síncrono:

error_handling.rsrust
use std::io;

#[derive(Debug)]
enum AppError {
    Network(reqwest::Error),
    Parse(serde_json::Error),
    Io(io::Error),
}

async fn load_config(url: &str) -> Result<Config, AppError> {
    let response = reqwest::get(url)
        .await
        .map_err(AppError::Network)?;

    let text = response.text()
        .await
        .map_err(AppError::Network)?;

    let config: Config = serde_json::from_str(&text)
        .map_err(AppError::Parse)?;

    Ok(config)
}

#[derive(serde::Deserialize)]
struct Config {
    db_url: String,
    port: u16,
}

Cada ponto .await é um ponto de suspensão potencial e um local de erro potencial. Mapear os erros explicitamente mantém o fluxo de controle assíncrono legível e evita o "callback hell" encontrado em outros modelos assíncronos.

Canais para comunicação assíncrona entre tarefas

O Tokio fornece canais compatíveis com a assincronia que permitem às tarefas lançadas se comunicarem sem estado mutável compartilhado:

channel_example.rsrust
use tokio::sync::mpsc;

#[tokio::main]
async fn main() {
    // Bounded channel with capacity 32
    let (tx, mut rx) = mpsc::channel::<String>(32);

    // Producer task
    let producer = tokio::spawn(async move {
        for i in 0..5 {
            tx.send(format!("message-{i}")).await.unwrap();
            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
        }
        // tx dropped here, closing the channel
    });

    // Consumer reads until channel closes
    while let Some(msg) = rx.recv().await {
        println!("Received: {msg}");
    }

    producer.await.unwrap();
}

mpsc::channel cria um canal multi-produtor, consumidor único. Para cenários de produtor único, oneshot::channel oferece uma opção mais leve. Para transmitir para múltiplos consumidores, broadcast::channel clona as mensagens para cada receptor ativo.

A capacidade limitada (32 neste exemplo) aplica contrapressão: quando o buffer enche, send().await suspende o produtor até que o consumidor se atualize. Isso previne o crescimento descontrolado de memória em pipelines de alta vazão.

Padrão do mundo real: requisições HTTP concorrentes com limitação de taxa

Um cenário comum em produção envolve fazer muitas requisições HTTP de forma concorrente respeitando os limites de taxa. tokio::sync::Semaphore controla o grau de paralelismo:

rate_limited_fetcher.rsrust
use std::sync::Arc;
use tokio::sync::Semaphore;

async fn fetch_all(urls: Vec<String>, max_concurrent: usize) -> Vec<Result<String, String>> {
    let semaphore = Arc::new(Semaphore::new(max_concurrent));
    let mut handles = Vec::new();

    for url in urls {
        let sem = Arc::clone(&semaphore);
        let handle = tokio::spawn(async move {
            // Acquire permit before making request
            let _permit = sem.acquire().await.unwrap();
            reqwest::get(&url)
                .await
                .map(|r| r.status().to_string())
                .map_err(|e| e.to_string())
            // permit dropped here, allowing next task to proceed
        });
        handles.push(handle);
    }

    let mut results = Vec::new();
    for handle in handles {
        results.push(handle.await.unwrap());
    }
    results
}

O semáforo limita as requisições ativas a max_concurrent. Todas as tarefas são lançadas imediatamente, mas apenas N executam sua chamada HTTP em qualquer momento. Quando uma tarefa termina e libera sua permissão, outra tarefa acorda e prossegue.

Escolhendo a primitiva de concorrência certa

tokio::join! se adapta a um conjunto fixo e conhecido de futures. tokio::spawn + Semaphore se adapta a uma coleção dinâmica e potencialmente grande. select! se adapta a cenários de corrida ou tempo limite. Escolher a primitiva certa evita tanto a subutilização quanto o esgotamento de recursos.

Pin e Unpin: por que o Rust assíncrono precisa deles

O tipo Pin aparece na assinatura de Future::poll por uma razão específica. Máquinas de estados assíncronas podem conter campos autorreferenciais — uma referência em um estado apontando para dados armazenados em outra parte da mesma estrutura. Mover a estrutura na memória invalidaria essa referência.

Pin<&mut Self> garante que a future não será movida após o início do polling. A maioria das futures escritas pelos desenvolvedores são Unpin (movíveis), e o compilador lida com o pinning automaticamente. O gerenciamento manual de Pin só se torna necessário ao implementar tipos Future personalizados ou ao trabalhar com padrões async_stream.

Para o uso diário do Rust assíncrono, o ponto essencial: Box::pin(future) converte qualquer future em uma forma fixada e alocada no heap que pode ser armazenada em coleções ou retornada de métodos de traits.

Características de desempenho e quando usar async

O Rust assíncrono brilha em cargas de trabalho limitadas por I/O: servidores HTTP, clientes de banco de dados, brokers de mensagens, observadores de arquivos. Cada tarefa usa apenas algumas centenas de bytes de pilha (comparado com os 8 MB padrão das threads do sistema operacional), tornando viável executar centenas de milhares de tarefas concorrentes em uma única máquina.

Para trabalho limitado por CPU, tokio::task::spawn_blocking descarrega o cálculo para um pool de threads dedicado, evitando que o executor assíncrono fique sem recursos:

spawn_blocking_example.rsrust
#[tokio::main]
async fn main() {
    let hash = tokio::task::spawn_blocking(|| {
        // CPU-intensive work runs on a blocking thread
        compute_hash(b"large dataset")
    })
    .await
    .unwrap();

    println!("Hash: {hash}");
}

fn compute_hash(data: &[u8]) -> String {
    use std::collections::hash_map::DefaultHasher;
    use std::hash::{Hash, Hasher};
    let mut hasher = DefaultHasher::new();
    data.hash(&mut hasher);
    format!("{:x}", hasher.finish())
}

Combinar spawn_blocking com I/O assíncrono mantém o loop de eventos responsivo enquanto aproveita todos os núcleos da CPU para computações pesadas.

Pronto para mandar bem nas entrevistas de Rust?

Pratique com nossos simuladores interativos, flashcards e testes tecnicos.

Conclusão

  • As futures do Rust são máquinas de estados preguiçosas — nada é executado até que um executor faça o polling, conferindo controle total sobre o agendamento e o uso de recursos
  • O Tokio 2.0 fornece o runtime, com spawn para tarefas independentes, join! para conclusão paralela e select! para competição
  • A restrição Send + 'static nas tarefas lançadas detecta corridas de dados em tempo de compilação, trocando um pouco de fricção ergonômica por segurança de threads garantida
  • Os canais (mpsc, oneshot, broadcast) desacoplam a comunicação entre tarefas sem estado mutável compartilhado
  • Semaphore e canais limitados fornecem contrapressão, prevenindo o esgotamento de recursos em sistemas de produção
  • spawn_blocking preenche a lacuna entre I/O assíncrono e computação limitada por CPU
  • Dominar essas primitivas — a trait Future, o runtime Tokio e as regras de propriedade — desbloqueia o potencial do Rust para construir serviços de alta vazão e baixa latência

Comece a praticar!

Teste seus conhecimentos com nossos simuladores de entrevista e testes tecnicos.

Tags

#rust
#async
#tokio
#futures
#concurrency

Compartilhar

Artigos relacionados