Tarefas e concorrência com Tokio
LidoEsta aula explora a concorrência assíncrona em Rust usando Tokio, cobrindo spawn vs spawn_blocking, a macro select!, cancelamento de tarefas e timeouts. Você aprenderá a criar tarefas concorrentes, lidar com operações bloqueantes, combinar múltiplos futuros e controlar o tempo de execução, com exemplos práticos e exercícios.
Nesta aula, vamos mergulhar no coração da programação assíncrona em Rust com Tokio, a runtime mais popular para aplicações concorrentes. Você já conhece os fundamentos de async/await e como criar futuros; agora vamos aprender a gerenciar tarefas concorrentes de forma eficiente, lidar com operações bloqueantes sem travar o executor, e controlar o ciclo de vida das tarefas com cancelamento e timeouts.
Tokio é construído sobre uma máquina de estados que gerencia tarefas leves (green threads). Cada tarefa é um futuro que é pollado continuamente até completar. A chave para escrever código assíncrono eficiente é entender quando usar spawn para tarefas CPU-bound ou I/O-bound, quando usar spawn_blocking para trabalho bloqueante, e como usar select! para reagir a múltiplos eventos simultâneos. Vamos explorar cada um desses conceitos com exemplos práticos.
spawn vs spawn_blocking
No Tokio, tokio::spawn cria uma nova tarefa assíncrona que é executada no mesmo executor. Essa tarefa deve ser async e deve evitar operações bloqueantes (como I/O síncrono ou CPU intensiva) porque isso bloquearia o thread do executor, reduzindo a concorrência. Por outro lado, tokio::task::spawn_blocking é projetada para operações que bloqueiam o thread, como leitura de arquivos síncrona, chamadas a bibliotecas que não são async, ou cálculos pesados. Ela executa o closure em um thread separado do pool de threads de bloqueio, liberando o executor para outras tarefas.
A regra prática: use spawn para tarefas que são async e que fazem I/O assíncrono (como Tokio's TcpStream, File, etc.). Use spawn_blocking para código síncrono que pode bloquear, como operações de arquivo padrão ou CPU intensiva. Vamos ver um exemplo:
use tokio::task;
async fn async_task() {
println!("Tarefa assíncrona");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
println!("Tarefa assíncrona concluída");
}
fn blocking_task() -> u32 {
// Simula uma operação bloqueante (ex: leitura de arquivo)
std::thread::sleep(std::time::Duration::from_millis(200));
42
}
#[tokio::main]
async fn main() {
// spawn para tarefas async
let handle = tokio::spawn(async_task());
// spawn_blocking para tarefas bloqueantes
let blocking_handle = task::spawn_blocking(blocking_task);
// Aguarda ambas
let _ = handle.await.unwrap();
let result = blocking_handle.await.unwrap();
println!("Resultado do bloqueio: {}", result);
}
No exemplo, spawn_blocking executa blocking_task em um thread dedicado, enquanto spawn executa a tarefa async no executor. Se usássemos spawn para a tarefa bloqueante, o executor seria bloqueado e outras tarefas não progrediriam. Sempre que você tiver uma função síncrona que possa demorar, prefira spawn_blocking.
select!
A macro select! do Tokio permite esperar por múltiplos futuros simultaneamente, retornando quando um deles completar. É extremamente útil para lidar com múltiplas fontes de eventos, como receber dados de uma conexão de rede e também um sinal de cancelamento. A sintaxe básica é:
tokio::select! {
resultado = async_fut1 => { /* código quando fut1 completa */ },
resultado = async_fut2 => { /* código quando fut2 completa */ },
_ = tokio::signal::ctrl_c() => { /* cancelamento */ }
}
O select! cancela os futuros que não completaram (se eles forem canceláveis) e retorna o resultado do futuro que completou primeiro. Isso é essencial para implementar cancelamento cooperativo: você pode ter uma tarefa que faz trabalho e outra que aguarda um sinal de cancelamento, e usar select! para interromper a primeira quando o sinal chegar.
Vejamos um exemplo onde escutamos concorrentemente uma operação de I/O e um sinal de cancelamento:
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
async fn read_from_socket() -> Result<String, std::io::Error> {
let mut stream = TcpStream::connect("127.0.0.1:8080").await?;
let mut buf = [0u8; 1024];
let n = stream.read(&mut buf).await?;
Ok(String::from_utf8_lossy(&buf[..n]).to_string())
}
#[tokio::main]
async fn main() {
tokio::select! {
data = read_from_socket() => {
match data {
Ok(d) => println!("Recebido: {}", d),
Err(e) => eprintln!("Erro: {}", e),
}
}
_ = tokio::signal::ctrl_c() => {
println!("Cancelado pelo usuário");
}
}
}
Aqui, o programa aguarda até que ou a leitura do socket termine ou o usuário pressione Ctrl+C. Se Ctrl+C for pressionado, a leitura é cancelada (se possível) e o programa termina. Isso é uma forma elegante de cancelamento.
Cancelamento
Cancelamento em Tokio é cooperativo: uma tarefa só pode ser cancelada se ela ceder o controle ao executor (por exemplo, em um .await). Quando uma tarefa é dropada (por exemplo, quando o handle é dropado sem aguardar), o futuro é cancelado e a tarefa para de executar. No entanto, se a tarefa estiver fazendo trabalho CPU-bound sem .await, ela não será interrompida até completar. Portanto, é importante estruturar o código para que haja pontos de cancelamento.
O select! é uma ferramenta fundamental para cancelamento, pois ele cancela os futuros que não completaram quando um ramo é escolhido. Além disso, você pode usar tokio::task::AbortHandle para abortar uma tarefa explicitamente. O JoinHandle retornado por spawn tem um método abort() que cancela a tarefa.
use tokio::task;
#[tokio::main]
async fn main() {
let handle = tokio::spawn(async {
loop {
println!("Trabalhando...");
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
});
// Deixa a tarefa rodar por 1 segundo
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
// Aborta a tarefa
handle.abort();
println!("Tarefa abortada");
}
Quando abort() é chamado, a tarefa é cancelada no próximo .await. O JoinHandle retorna um erro JoinError::Cancelled se você tentar aguardar após o abort. É importante notar que abortar não espera a tarefa terminar de forma limpa; ela é simplesmente dropada. Para cancelamento cooperativo, você pode usar um CancellationToken (da crate tokio-util) ou um canal para sinalizar a tarefa.
Timeouts
Timeouts são essenciais para evitar que uma tarefa fique pendurada para sempre. Tokio fornece tokio::time::timeout que envolve um futuro e retorna Result, com Err(Elapsed) se o futuro não completar dentro do prazo. A assinatura é:
pub async fn timeout<T>(duration: Duration, future: T) -> Result<T::Output, Elapsed>
where T: Future,
Se o futuro completar antes do tempo, você obtém Ok(valor); caso contrário, Err(Elapsed) e o futuro é cancelado. Isso é útil para operações de rede, como uma chamada HTTP que pode demorar demais.
use tokio::time::{timeout, Duration};
async fn slow_operation() -> &'static str {
tokio::time::sleep(Duration::from_secs(2)).await;
"Concluído"
}
#[tokio::main]
async fn main() {
let result = timeout(Duration::from_secs(1), slow_operation()).await;
match result {
Ok(value) => println!("Operação completou: {}", value),
Err(_) => println!("Tempo esgotado! A operação foi cancelada."),
}
}
No exemplo, a operação demora 2 segundos, mas o timeout é de 1 segundo, então o timeout é atingido e a operação é cancelada. Você pode combinar timeout com select! para cenários mais complexos, como tentar várias fontes com um prazo global.
Boas práticas
- Prefira
spawnpara tarefas que são naturalmente assíncronas espawn_blockingpara código bloqueante. - Use
select!para reagir a múltiplos eventos, mas tenha cuidado com o cancelamento: certifique-se de que os futuros cancelados limpem recursos. - Para cancelamento cooperativo, use um
CancellationTokenou um canal em vez de abortar a tarefa, pois abortar pode deixar recursos inconsistentes. - Sempre defina timeouts para operações de I/O que podem falhar ou demorar, especialmente em serviços de rede.
- Evite bloquear o executor com
std::thread::sleep; usetokio::time::sleep.
Referências
- Documentação oficial de tokio::spawn
- Documentação de tokio::task::spawn_blocking
- Documentação da macro select!
- Documentação de tokio::time::timeout
- Tutorial oficial do Tokio: Spawning
- Tutorial oficial do Tokio: Selecting
- CancellationToken na tokio-util
Exercícios
Escreva um programa que use
spawn_blockingpara calcular o fatorial de um número grande (ex: 20) em um thread separado, enquanto uma tarefa assíncrona imprime "Hello" a cada 100ms. Aguarde ambas e imprima o resultado do fatorial.✓ Resposta:use tokio::task; fn fatorial(n: u64) -> u64 { (1..=n).product() } #[tokio::main] async fn main() { let handle = task::spawn_blocking(|| fatorial(20)); let async_handle = tokio::spawn(async { for _ in 0..5 { println!("Hello"); tokio::time::sleep(std::time::Duration::from_millis(100)).await; } }); let result = handle.await.unwrap(); let _ = async_handle.await; println!("Fatorial de 20 = {}", result); }Implemente um programa que use
select!para esperar por uma mensagem de um canal ou um sinal de Ctrl+C. Use um canaltokio::sync::mpsce envie uma mensagem após 2 segundos.✓ Resposta:use tokio::sync::mpsc; use tokio::time::{sleep, Duration}; #[tokio::main] async fn main() { let (tx, mut rx) = mpsc::channel::<String>(1); tokio::spawn(async move { sleep(Duration::from_secs(2)).await; tx.send("Mensagem do canal".to_string()).await.unwrap(); }); tokio::select! { msg = rx.recv() => { if let Some(m) = msg { println!("Recebido: {}", m); } } _ = tokio::signal::ctrl_c() => { println!("Ctrl+C pressionado!"); } } }Crie uma tarefa que fique em loop imprimindo "tick" a cada 500ms. Após 3 segundos, aborte a tarefa usando
abort()e verifique se ela foi cancelada.✓ Resposta:use tokio::time::{sleep, Duration}; #[tokio::main] async fn main() { let handle = tokio::spawn(async { loop { println!("tick"); sleep(Duration::from_millis(500)).await; } }); sleep(Duration::from_secs(3)).await; handle.abort(); let result = handle.await; match result { Err(e) => { if e.is_cancelled() { println!("Tarefa foi cancelada."); } else { println!("Erro: {}", e); } } Ok(_) => println!("Tarefa terminou normalmente."), } }Use
tokio::time::timeoutpara limitar uma operação que leva 3 segundos a 1 segundo. Imprima se a operação completou ou se o tempo esgotou.✓ Resposta:use tokio::time::{timeout, Duration, sleep}; async fn operacao_lenta() -> &'static str { sleep(Duration::from_secs(3)).await; "Concluída" } #[tokio::main] async fn main() { match timeout(Duration::from_secs(1), operacao_lenta()).await { Ok(valor) => println!("Operação completou: {}", valor), Err(_) => println!("Tempo esgotado!"), } }Combine
select!etimeoutpara implementar uma função que tenta ler de um canal e, se não receber nada em 2 segundos, retorna um erro. Use um canal que nunca envia.✓ Resposta:use tokio::sync::mpsc; use tokio::time::{timeout, Duration}; #[tokio::main] async fn main() { let (_tx, mut rx) = mpsc::channel::<String>(1); let result = timeout(Duration::from_secs(2), rx.recv()).await; match result { Ok(Some(msg)) => println!("Mensagem: {}", msg), Ok(None) => println!("Canal fechado"), Err(_) => println!("Timeout: nenhuma mensagem em 2 segundos"), } }