Async/await: introdução
Esta aula introduz o modelo assíncrono em Rust, explicando o conceito de Futures, como usar async fn e .await, a necessidade de runtimes como Tokio e as diferenças entre programação assíncrona e threads. O conteúdo inclui exemplos práticos e exercícios para fixar o aprendizado.
Bem-vindo à aula 44 do nosso curso de Rust! Hoje vamos mergulhar no fascinante mundo da programação assíncrona com Rust. Assíncrono é uma forma de lidar com operações que podem demorar (como I/O de rede ou arquivos) sem bloquear o fluxo principal do programa. Em Rust, isso é feito através do modelo de Futures, combinado com as palavras-chave async e .await. Diferente de outras linguagens, Rust adota uma abordagem única: a execução assíncrona é baseada em máquina de estados e não requer threads para cada tarefa, o que a torna extremamente eficiente.
Nesta aula, vamos desmistificar o que são Futures, como escrever funções assíncronas, e como executá-las usando um runtime como o Tokio. Também compararemos com o modelo tradicional de threads, destacando quando usar cada abordagem. Prepare-se para uma aula repleta de exemplos práticos e exercícios para consolidar o conhecimento.
Futures
Um Future em Rust é um valor que representa uma operação que pode não ter sido concluída ainda. É uma abstração para uma computação que pode ser resolvida no futuro. Em termos de implementação, um Future é uma trait que possui um método poll, que tenta obter o resultado da operação. Se a operação ainda não estiver pronta, o Future retorna Pending; caso contrário, retorna Ready(valor).
Futures são lazy: eles não fazem nada até que sejam polled (consultados). Isso é diferente de uma função comum, que executa imediatamente ao ser chamada. Essa característica permite que o runtime (como o Tokio) gerencie eficientemente muitas operações concorrentes em uma única thread, alternando entre elas conforme necessário.
Vamos ver um exemplo simples de um Future que representa uma contagem regressiva. Normalmente, você não implementa a trait Future diretamente; em vez disso, usa async (que veremos a seguir), mas é útil entender o que acontece por baixo dos panos.
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration;
struct CountDown {
remaining: u32,
}
impl Future for CountDown {
type Output = ();
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
if self.remaining == 0 {
Poll::Ready(())
} else {
// Simula trabalho pendente
println!("Contando: {}", self.remaining);
self.remaining -= 1;
// Agenda um wake para ser polled novamente
cx.waker().wake_by_ref();
Poll::Pending
}
}
}
// Para executar um Future sem um runtime, precisamos de um executor simples.
// Na prática, usamos Tokio ou async-std.
fn main() {
// Este exemplo não executa o Future, pois precisaríamos de um executor.
// Vamos ver como fazer isso com Tokio mais adiante.
println!("Exemplo de Future - veja o código para entender a estrutura.");
}No exemplo acima, o poll é chamado repetidamente até que a contagem chegue a zero. Note que chamamos cx.waker().wake_by_ref() para indicar que o Future deve ser consultado novamente em breve. Em aplicações reais, você raramente implementa Future manualmente; usamos async.
async fn e .await
A maneira mais ergonômica de criar Futures em Rust é usando a palavra-chave async. Uma função async fn retorna um Future, e seu corpo é executado de forma preguiçosa. Dentro de uma função assíncrona, você pode usar .await para esperar o resultado de outro Future sem bloquear a thread.
Vamos criar uma função assíncrona que simula uma operação de I/O, como uma requisição HTTP.
async fn fetch_data(url: &str) -> String {
// Simula um atraso de rede
tokio::time::sleep(Duration::from_secs(2)).await;
format!("Dados de {}", url)
}
#[tokio::main]
async fn main() {
let data = fetch_data("https://example.com").await;
println!("{}", data);
}No código acima, fetch_data é uma função assíncrona que retorna um Future. Dentro dela, usamos .await na chamada a tokio::time::sleep para simular uma espera. Quando main (que também é assíncrona) chama fetch_data e usa .await, o runtime Tokio gerencia a execução: enquanto o sleep está pendente, o runtime pode executar outras tarefas.
Importante: .await só pode ser usado dentro de um contexto assíncrono (uma função async ou um bloco async). Em Rust, para iniciar a execução de um Future, você precisa de um runtime (ou um executor) que faça o polling até o Future estar pronto. O Tokio é o runtime mais popular, e veremos como usá-lo na próxima seção.
Runtimes (tokio)
Um runtime é o componente que executa Futures. Ele fornece o executor (que faz o polling), além de utilitários como timers, I/O assíncrono e agendamento de tarefas. O Tokio é o runtime assíncrono mais utilizado em Rust, com suporte a multi-thread, timers, canais, etc.
Para usar Tokio, adicione a dependência no Cargo.toml e use o atributo #[tokio::main] na função main para iniciar o runtime. O Tokio também oferece funções como tokio::spawn para executar tarefas concorrentes.
Exemplo de uso com tokio::spawn:
use tokio::time::{sleep, Duration};
async fn tarefa(nome: &str, tempo: u64) {
println!("Iniciando {}", nome);
sleep(Duration::from_secs(tempo)).await;
println!("Concluído {}", nome);
}
#[tokio::main]
async fn main() {
let handle1 = tokio::spawn(tarefa("Tarefa 1", 2));
let handle2 = tokio::spawn(tarefa("Tarefa 2", 1));
// Aguarda ambas as tarefas
let _ = handle1.await;
let _ = handle2.await;
println!("Todas as tarefas concluídas");
}Neste exemplo, tokio::spawn cria uma nova tarefa assíncrona que pode ser executada concorrentemente. O Tokio, por padrão, usa um pool de threads (o número de threads é igual ao número de núcleos da CPU), mas você pode configurar um runtime de thread única se quiser. Isso mostra a flexibilidade do modelo assíncrono: muitas tarefas podem ser gerenciadas com poucas threads.
Além do Tokio, existem outros runtimes como async-std e smol, mas Tokio é o mais maduro e amplamente adotado.
Diferença de threads
Threads são a forma clássica de concorrência. Cada thread é uma unidade de execução independente, gerenciada pelo sistema operacional. Criar threads em Rust é simples:
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
thread::sleep(Duration::from_secs(2));
println!("Thread finalizada");
});
println!("Thread principal");
handle.join().unwrap();
}Threads são adequadas para tarefas que são intensivas em CPU ou que bloqueiam (como I/O síncrono). No entanto, cada thread consome memória (tipicamente 8 MB de stack no Linux) e a troca de contexto tem custo. Para muitas operações de I/O concorrentes, threads podem ser ineficientes.
A programação assíncrona, por outro lado, permite que uma única thread gerencie milhares de tarefas concorrentes, pois as tarefas são leves e a troca de contexto é feita em nível de usuário, sem envolver o sistema operacional. Isso é especialmente útil para servidores web altamente concorrentes, onde o número de conexões pode ser enorme.
Vamos comparar um servidor simples que aceita conexões TCP. Com threads, cada conexão exigiria uma thread, o que pode esgotar os recursos. Com async, podemos lidar com muitas conexões em poucas threads.
Exemplo de servidor TCP com threads (simplificado):
use std::net::{TcpListener, TcpStream};
use std::io::{Read, Write};
use std::thread;
fn handle_client(mut stream: TcpStream) {
let mut buffer = [0; 1024];
loop {
match stream.read(&mut buffer) {
Ok(0) => break, // conexão fechada
Ok(n) => {
stream.write_all(&buffer[..n]).unwrap();
}
Err(_) => break,
}
}
}
fn main() {
let listener = TcpListener::bind("127.0.0.1:8080").unwrap();
for stream in listener.incoming() {
match stream {
Ok(stream) => {
thread::spawn(move || handle_client(stream));
}
Err(e) => eprintln!("Erro: {}", e),
}
}
}Já com Tokio, o mesmo servidor pode ser escrito assim:
use tokio::net::TcpListener;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let listener = TcpListener::bind("127.0.0.1:8080").await?;
loop {
let (mut socket, _) = listener.accept().await?;
tokio::spawn(async move {
let mut buf = [0; 1024];
loop {
let n = match socket.read(&mut buf).await {
Ok(0) => break,
Ok(n) => n,
Err(_) => break,
};
if socket.write_all(&buf[..n]).await.is_err() {
break;
}
}
});
}
}Perceba que o código assíncrono é mais compacto e pode escalar melhor. No entanto, threads ainda são úteis para tarefas que exigem paralelismo real (múltiplos núcleos) e para código que bloqueia, como operações de CPU pesadas. Em Rust, você pode combinar ambos: usar async para I/O e threads para tarefas CPU-bound.
Boas práticas
Ao trabalhar com assíncrono em Rust, algumas boas práticas são importantes:
- Use
asyncpara operações de I/O (rede, arquivos, banco de dados) e não para tarefas CPU-intensivas, pois elas podem bloquear o executor. - Evite chamadas bloqueantes dentro de funções assíncronas; prefira versões assíncronas das bibliotecas (ex.:
tokio::fsem vez destd::fs). - Use
tokio::spawnpara tarefas independentes, mas tome cuidado com o cancelamento e o gerenciamento de recursos. - Entenda o modelo de waker e poll para depurar problemas de desempenho.
- Mantenha as funções assíncronas pequenas e compostas; use
asyncblocks para agrupar operações.
Exercícios
Escreva uma função assíncrona que retorne a soma de dois números, mas com um atraso de 1 segundo (simule com
tokio::time::sleep). Use-a nomaine imprima o resultado.✓ Resposta:use tokio::time::{sleep, Duration}; async fn soma_com_atraso(a: i32, b: i32) -> i32 { sleep(Duration::from_secs(1)).await; a + b } #[tokio::main] async fn main() { let resultado = soma_com_atraso(2, 3).await; println!("Soma: {}", resultado); }Crie um programa que lance duas tarefas assíncronas com
tokio::spawnque imprimam mensagens diferentes, e aguarde ambas.✓ Resposta:use tokio::time::{sleep, Duration}; async fn mensagem(texto: &str, tempo: u64) { sleep(Duration::from_secs(tempo)).await; println!("{}", texto); } #[tokio::main] async fn main() { let h1 = tokio::spawn(mensagem("Olá", 1)); let h2 = tokio::spawn(mensagem("Mundo", 2)); let _ = h1.await; let _ = h2.await; }Explique por que o código abaixo não compila e como corrigi-lo:
async fn foo() {} fn main() { foo(); }✓ Resposta: O código não compila porquefoo()retorna um Future, mas não estamos aguardando ou executando esse Future. Para executar, precisamos de um runtime ou usarblock_on. Uma correção seria usar#[tokio::main]e.awaitemmain.Escreva um programa que use
tokio::spawnpara executar duas tarefas que retornam valores (ex.: números) e combine-os (soma) após aguardar.✓ Resposta:use tokio::task; async fn numero(n: i32) -> i32 { n } #[tokio::main] async fn main() { let h1 = task::spawn(numero(10)); let h2 = task::spawn(numero(20)); let (v1, v2) = (h1.await.unwrap(), h2.await.unwrap()); println!("Soma: {}", v1 + v2); }Diferencie concorrência assíncrona de paralelismo com threads, e dê um exemplo de quando usar cada um.
✓ Resposta: Concorrência assíncrona permite que muitas tarefas progridam em uma única thread, intercalando a execução, enquanto paralelismo com threads usa múltiplas threads para executar tarefas simultaneamente em múltiplos núcleos. Use async para I/O-bound (ex.: servidor web) e threads para CPU-bound (ex.: cálculo pesado).