Rust

Async/Await — Concorrência sem Threads com Tokio Já leu

11 min de leitura

Async/Await — Concorrência sem Threads com Tokio
No artigo anterior exploramos concorrência com threads do sistema operacional — cada thread tem sua própria stack, é gerenciada pelo OS, e tem custo real de memória e tempo de criação. Para muitos problemas, especialment

 

No artigo anterior exploramos concorrência com threads do sistema operacional — cada thread tem sua própria stack, é gerenciada pelo OS, e tem custo real de memória e tempo de criação. Para muitos problemas, especialmente aqueles dominados por I/O — chamadas de rede, leitura de banco de dados, leitura de arquivos — threads são uma ferramenta pesada demais.

Imagine um servidor web que precisa lidar com 100.000 conexões simultâneas. Criar 100.000 threads seria catastrófico — cada thread consome pelo menos 8KB de stack, resultando em gigabytes de memória só para as stacks. A solução é async/await: concorrência cooperativa que permite lidar com milhares de operações simultâneas usando apenas algumas threads.


O problema que async resolve

Em código síncrono, quando você lê um arquivo ou faz uma chamada de rede, a thread bloqueia — fica parada esperando a resposta. Nesse tempo, a thread não pode fazer nada útil.

Código assíncrono resolve isso: quando uma operação estaria para bloquear, a tarefa cede o controle para um executor, que pode executar outras tarefas enquanto espera. Quando a operação completa, a tarefa original é retomada.

Em Rust, isso é implementado através de Future — um valor que representa uma computação que pode não ter completado ainda. O executor gerencia um pool de tarefas, cada uma sendo uma Future.


Configurando o ambiente

Rust não inclui um executor async na biblioteca padrão — apenas a infraestrutura (Future, async, await). O executor mais popular é o Tokio:

# Cargo.toml
[dependencies]
tokio = { version = "1", features = ["full"] }

O mínimo para usar Tokio é decorar o main com #[tokio::main]:

use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    println!("Iniciando...");
    sleep(Duration::from_millis(100)).await;
    println!("Após 100ms");
}

O #[tokio::main] transforma o main assíncrono em código síncrono que cria o runtime do Tokio e executa a future. Internamente, expande para algo como:

fn main() {
    tokio::runtime::Runtime::new()
        .unwrap()
        .block_on(async_main());
}

async e await — a sintaxe

Funções assíncronas são declaradas com async fn. Elas retornam uma Future implicitamente. Para obter o valor de uma Future, use .await:

use tokio::time::{sleep, Duration};

async fn buscar_dados(id: u32) -> String {
    println!("Buscando dados do id {id}...");
    sleep(Duration::from_millis(100)).await; // simula I/O
    format!("Dados do registro {id}")
}

async fn processar(id: u32) -> String {
    let dados = buscar_dados(id).await; // aguarda a future completar
    format!("Processado: {dados}")
}

#[tokio::main]
async fn main() {
    let resultado = processar(42).await;
    println!("{resultado}");
}

O .await é um ponto de suspensão — quando a tarefa encontra um .await, ela pode ceder o controle para o executor se a operação não estiver pronta. Quando a operação completa, a tarefa é retomada do ponto onde parou.


Concorrência real com tokio::join!

A grande vantagem de async aparece quando você executa múltiplas operações simultaneamente. Com join!, múltiplas futures executam concorrentemente:

use tokio::time::{sleep, Duration, Instant};

async fn tarefa(nome: &str, duracao_ms: u64) -> String {
    println!("  Iniciando: {nome}");
    sleep(Duration::from_millis(duracao_ms)).await;
    println!("  Concluída: {nome}");
    format!("Resultado de {nome}")
}

#[tokio::main]
async fn main() {
    let inicio = Instant::now();

    // Sequencial — demora a soma de todas as durações
    println!("── Sequencial ──");
    let r1 = tarefa("A", 100).await;
    let r2 = tarefa("B", 150).await;
    let r3 = tarefa("C", 80).await;
    println!("Tempo: {:?}
", inicio.elapsed());

    let inicio = Instant::now();

    // Concorrente — demora a duração da mais longa
    println!("── Concorrente ──");
    let (r1, r2, r3) = tokio::join!(
        tarefa("X", 100),
        tarefa("Y", 150),
        tarefa("Z", 80),
    );
    println!("Tempo: {:?}", inicio.elapsed());

    println!("{r1}, {r2}, {r3}");
}

Saída:

── Sequencial ──
  Iniciando: A
  Concluída: A
  Iniciando: B
  Concluída: B
  Iniciando: C
  Concluída: C
Tempo: 330ms

── Concorrente ──
  Iniciando: X
  Iniciando: Y
  Iniciando: Z
  Concluída: Z
  Concluída: X
  Concluída: Y
Tempo: 150ms

No modo concorrente, as três tarefas executam simultaneamente — o tempo total é o da tarefa mais longa, não a soma de todas.


tokio::spawn — tarefas independentes

Para lançar tarefas que rodam independentemente (sem precisar aguardar todas juntas), use tokio::spawn:

use tokio::task::JoinHandle;
use tokio::time::{sleep, Duration};

async fn worker(id: u32) -> u32 {
    sleep(Duration::from_millis(id as u64 * 50)).await;
    println!("Worker {id} concluído");
    id * id
}

#[tokio::main]
async fn main() {
    let mut handles: Vec<JoinHandle<u32>> = Vec::new();

    for id in 1..=5 {
        handles.push(tokio::spawn(worker(id)));
    }

    let mut total = 0u32;
    for handle in handles {
        total += handle.await.unwrap();
    }

    println!("Soma dos quadrados: {total}"); // 55
}

tokio::spawn é análogo a thread::spawn — mas cria uma tarefa assíncrona leve em vez de uma thread do OS.


Canais assíncronos com Tokio

Tokio oferece canais otimizados para uso assíncrono:

use tokio::sync::mpsc;
use tokio::time::{sleep, Duration};

async fn produtor(tx: mpsc::Sender<String>, id: u32) {
    for i in 0..3 {
        sleep(Duration::from_millis(50)).await;
        let msg = format!("Produtor {id} — item {i}");
        tx.send(msg).await.unwrap();
    }
}

#[tokio::main]
async fn main() {
    let (tx, mut rx) = mpsc::channel(32); // buffer de 32 mensagens

    // Lança 3 produtores
    for id in 1..=3 {
        let tx_clone = tx.clone();
        tokio::spawn(produtor(tx_clone, id));
    }

    // Necessário: descarta o tx original
    drop(tx);

    // Consome todas as mensagens
    let mut total = 0;
    while let Some(msg) = rx.recv().await {
        println!("Recebido: {msg}");
        total += 1;
    }

    println!("Total recebido: {total} mensagens"); // 9
}

Tratamento de erros em código async

O operador ? funciona normalmente em funções async:

use tokio::fs;
use std::io;

async fn ler_e_processar(caminho: &str) -> Result<usize, io::Error> {
    let conteudo = fs::read_to_string(caminho).await?;
    let linhas = conteudo.lines().count();
    Ok(linhas)
}

async fn processar_multiplos(caminhos: &[&str]) -> Vec<Result<usize, io::Error>> {
    let futures: Vec<_> = caminhos
        .iter()
        .map(|&caminho| ler_e_processar(caminho))
        .collect();

    // futures::future::join_all executa todas concorrentemente
    // (requer crate futures)
    let mut resultados = Vec::new();
    for caminho in caminhos {
        resultados.push(ler_e_processar(caminho).await);
    }
    resultados
}

#[tokio::main]
async fn main() {
    match ler_e_processar("dados.txt").await {
        Ok(linhas) => println!("Arquivo tem {linhas} linhas"),
        Err(e)     => println!("Erro: {e}"),
    }
}

Um programa completo: cliente HTTP assíncrono

Vamos construir um cliente que faz múltiplas requisições HTTP em paralelo. Adicione ao Cargo.toml:

[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
use reqwest::Client;
use serde::Deserialize;
use std::time::Instant;
use tokio::time::{timeout, Duration};

#[derive(Debug, Deserialize)]
struct Post {
    id: u32,
    title: String,
    body: String,
    #[serde(rename = "userId")]
    user_id: u32,
}

#[derive(Debug, Deserialize)]
struct Usuario {
    id: u32,
    name: String,
    email: String,
}

#[derive(Debug)]
struct PostCompleto {
    post: Post,
    autor: Usuario,
}

async fn buscar_post(client: &Client, id: u32) -> Result<Post, reqwest::Error> {
    let url = format!("https://jsonplaceholder.typicode.com/posts/{id}");
    client.get(&url).send().await?.json::<Post>().await
}

async fn buscar_usuario(client: &Client, id: u32) -> Result<Usuario, reqwest::Error> {
    let url = format!("https://jsonplaceholder.typicode.com/users/{id}");
    client.get(&url).send().await?.json::<Usuario>().await
}

async fn buscar_post_completo(
    client: &Client,
    post_id: u32,
) -> Result<PostCompleto, String> {
    let post = buscar_post(client, post_id)
        .await
        .map_err(|e| format!("Erro ao buscar post {post_id}: {e}"))?;

    let autor = buscar_usuario(client, post.user_id)
        .await
        .map_err(|e| format!("Erro ao buscar usuário: {e}"))?;

    Ok(PostCompleto { post, autor })
}

async fn buscar_paralelo(
    client: &Client,
    ids: &[u32],
) -> Vec<Result<PostCompleto, String>> {
    let futures: Vec<_> = ids
        .iter()
        .map(|&id| {
            let client = client.clone();
            tokio::spawn(async move {
                let resultado = timeout(
                    Duration::from_secs(5),
                    buscar_post_completo(&client, id),
                )
                .await;

                match resultado {
                    Ok(r) => r,
                    Err(_) => Err(format!("Timeout ao buscar post {id}")),
                }
            })
        })
        .collect();

    let mut resultados = Vec::new();
    for future in futures {
        match future.await {
            Ok(resultado) => resultados.push(resultado),
            Err(e) => resultados.push(Err(format!("Erro de join: {e}"))),
        }
    }
    resultados
}

#[tokio::main]
async fn main() {
    let client = Client::new();
    let ids_para_buscar = vec![1, 2, 3, 4, 5];

    println!("Buscando {} posts em paralelo...
", ids_para_buscar.len());
    let inicio = Instant::now();

    let resultados = buscar_paralelo(&client, &ids_para_buscar).await;
    let tempo = inicio.elapsed();

    let mut sucessos = 0;
    let mut erros = 0;

    for resultado in &resultados {
        match resultado {
            Ok(post_completo) => {
                sucessos += 1;
                println!("── Post #{} ──", post_completo.post.id);
                println!("  Título : {}", post_completo.post.title);
                println!("  Autor  : {} <{}>",
                    post_completo.autor.name,
                    post_completo.autor.email);
                let preview = post_completo.post.body
                    .chars()
                    .take(60)
                    .collect::<String>();
                println!("  Preview: {preview}...
");
            }
            Err(e) => {
                erros += 1;
                println!("Erro: {e}");
            }
        }
    }

    println!("── Resumo ──");
    println!("Sucessos : {sucessos}");
    println!("Erros    : {erros}");
    println!("Tempo    : {:?}", tempo);
    println!("(Sequencial seria ~{}x mais lento)",
        ids_para_buscar.len());
}

Saída:

Buscando 5 posts em paralelo...

── Post #1 ──
  Título : sunt aut facere repellat provident occaecati...
  Autor  : Leanne Graham <Sincere@april.biz>
  Preview: quia et suscipit suscipit recusandae...

── Post #2 ──
  Título : qui est esse
  Autor  : Ervin Howell <Shanna@melissa.tv>
  Preview: est rerum tempore vitae sequi...

[...]

── Resumo ──
Sucessos : 5
Erros    : 0
Tempo    : 312ms
(Sequencial seria ~5x mais lento)

select! — corrida entre futures

Às vezes você quer executar a primeira operação que completar entre várias:

use tokio::time::{sleep, Duration};
use tokio::sync::mpsc;

#[tokio::main]
async fn main() {
    let (tx, mut rx) = mpsc::channel::<String>(1);

    // Simula chegada de mensagem após 200ms
    tokio::spawn(async move {
        sleep(Duration::from_millis(200)).await;
        tx.send(String::from("mensagem chegou!")).await.unwrap();
    });

    let timeout = sleep(Duration::from_millis(500));
    tokio::pin!(timeout);

    loop {
        tokio::select! {
            msg = rx.recv() => {
                match msg {
                    Some(m) => println!("Recebido: {m}"),
                    None    => { println!("Canal fechado."); break; }
                }
            }
            _ = &mut timeout => {
                println!("Timeout! Nenhuma mensagem a tempo.");
                break;
            }
        }
    }
}

select! aguarda múltiplas futures e executa o braço da primeira que completar — as demais são canceladas.


Async vs threads — quando usar cada um

A escolha entre threads e async não é óbvia. Uma orientação prática:

Use threads quando o trabalho é CPU-bound — cálculos pesados, processamento de imagens, computação numérica. Threads aproveitam múltiplos núcleos de CPU diretamente. rayon é a ferramenta certa aqui.

Use async quando o trabalho é I/O-bound — chamadas de rede, banco de dados, leitura de arquivos. A thread não precisa esperar ativamente — ela pode fazer outras coisas. Tokio é a ferramenta certa aqui.

// CPU-bound: use rayon (threads reais)
use rayon::prelude::*;
let resultado: Vec<_> = dados.par_iter()
    .map(|x| calculo_pesado(x))
    .collect();

// I/O-bound: use tokio (async)
let resultado = tokio::join!(
    buscar_banco_dados(id1),
    fazer_requisicao_http(url),
    ler_arquivo(caminho),
);

Você também pode combinar os dois — Tokio tem spawn_blocking para executar código CPU-bound dentro de um contexto async sem bloquear o executor:

use tokio::task;

async fn processar_arquivo_pesado(caminho: String) -> Vec<u8> {
    task::spawn_blocking(move || {
        // Código síncrono e pesado — roda em thread separada
        std::fs::read(&caminho).unwrap()
    })
    .await
    .unwrap()
}

O modelo mental de async/await

Entender async em Rust fica mais fácil com o modelo mental correto:

Uma Future é uma máquina de estado gerada pelo compilador. Cada ponto de .await é um estado possível. O executor chama poll() na future — se o trabalho está pronto, retorna Poll::Ready(valor); se não, retorna Poll::Pending e registra um waker para ser notificado quando estiver pronto.

Você raramente implementa Future manualmente — async/await gera tudo isso automaticamente. Mas saber que existe explica por que código async em Rust não tem overhead de runtime excessivo: são máquinas de estado estáticas, sem alocações de heap desnecessárias.


Fontes e leituras recomendadas

  • The Rust Programming Language, Cap. 17Async and Await — https://doc.rust-lang.org/book/ch17-00-async-await.html
  • "Asynchronous Programming in Rust" — guia completo e gratuito — https://rust-lang.github.io/async-book/
  • Tokio Tutorial — tutorial oficial do Tokio com exemplos práticos — https://tokio.rs/tokio/tutorial
  • Tokio Documentation — https://docs.rs/tokio
  • reqwest crate — cliente HTTP assíncrono — https://docs.rs/reqwest
  • "Pin and Suffering" — Without Boats — artigo sobre Pin, necessário para Futures avançadas — https://without.boats/blog/pin/
  • Jon Gjengset — "Crust of Rust: async/await" — https://www.youtube.com/watch?v=ThjvMReOXYM
Comentários

Mais em Rust

O Futuro do Rust — Async Traits, GATs, Especialização e o Roadmap da Linguagem
O Futuro do Rust — Async Traits, GATs, Especialização e o Roadmap da Linguagem

Rust — Artigo #50 O Futuro do Rust — Async Traits, GATs, Especialização e o R...

Traits — Definindo Comportamento Compartilhado
Traits — Definindo Comportamento Compartilhado

&nbsp; Nos artigos anteriores criamos structs e enums para modelar dados. Ma...

FFI — Integrando Rust com C e Outras Linguagens
FFI — Integrando Rust com C e Outras Linguagens

O mundo real raramente é monolítico. Sistemas legados em C, bibliotecas de al...