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.

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.
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:
// 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:
# 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:
#[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:
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.
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:
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:
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:
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:
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:
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.
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:
#[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
spawnpara tarefas independentes,join!para conclusão paralela eselect!para competição - A restrição
Send + 'staticnas 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 Semaphoree canais limitados fornecem contrapressão, prevenindo o esgotamento de recursos em sistemas de produçãospawn_blockingpreenche 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
Compartilhar
Artigos relacionados

Ownership e Borrowing em Rust: Guia Completo para Entrevistas Tecnicas
Domine os conceitos de ownership, borrowing e lifetimes em Rust. Guia pratico com exemplos de codigo para se preparar para entrevistas tecnicas de programacao de sistemas.

Ownership e Borrowing em Rust: Guia Completo
Domine o sistema de ownership e borrowing do Rust. Regras de propriedade, referências, lifetimes e padrões avançados de gerenciamento de memória.

Perguntas de Entrevista sobre Rust: Guia Completo 2026
As 25 perguntas mais comuns em entrevistas sobre Rust. Ownership, borrowing, lifetimes, traits, async e concorrencia com respostas detalhadas e exemplos de codigo.