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 spawn para tarefas que são naturalmente assíncronas e spawn_blocking para 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 CancellationToken ou 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; use tokio::time::sleep.

Referências

Exercícios

  1. Escreva um programa que use spawn_blocking para 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);
    }
    
  2. Implemente um programa que use select! para esperar por uma mensagem de um canal ou um sinal de Ctrl+C. Use um canal tokio::sync::mpsc e 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!");
            }
        }
    }
    
  3. 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."),
        }
    }
    
  4. Use tokio::time::timeout para 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!"),
        }
    }
    
  5. Combine select! e timeout para 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"),
        }
    }