Bem-vindo à aula 45 do nosso curso de Rust! Hoje vamos mergulhar no Tokio, o runtime assíncrono mais popular do ecossistema Rust. Você aprenderá a construir aplicações concorrentes e eficientes, aproveitando o modelo de programação assíncrona que o Tokio oferece. Vamos explorar desde a configuração básica do runtime até a criação de tarefas e operações de I/O assíncrono, com exemplos práticos que você pode executar em seu próprio ambiente.

O Tokio é uma biblioteca poderosa que permite escrever código assíncrono de forma ergonômica, sem sacrificar a performance. Ele fornece um runtime multi-threaded, um executor de tarefas e primitivas de I/O não bloqueantes. Nesta aula, focaremos nos fundamentos: como o runtime funciona, como criar e gerenciar tarefas com tokio::spawn, e como realizar operações de I/O assíncrono básicas, como ler arquivos e comunicação via TCP. Ao final, você terá uma base sólida para construir aplicações de rede, servidores web e ferramentas de linha de comando assíncronas.

Runtime

O runtime é o coração do Tokio. Ele gerencia a execução de tarefas assíncronas, agendando-as em threads e garantindo que o código assíncrono seja executado de forma eficiente. Para usar Tokio, você precisa iniciar um runtime. A forma mais comum é usar a macro #[tokio::main] no seu main, que configura e inicia o runtime automaticamente. Alternativamente, você pode criar um runtime manualmente com Runtime::new() e usar métodos como block_on para executar futures.

O runtime padrão do Tokio é multi-threaded, o que significa que ele pode executar tarefas em paralelo em múltiplas threads. Isso é ideal para aplicações que exigem alta concorrência, como servidores web. Você pode configurar o runtime com opções avançadas, como o número de threads, usando Builder. Vamos ver um exemplo simples:

use tokio::runtime::Runtime;

fn main() {
    let rt = Runtime::new().unwrap();
    rt.block_on(async {
        println!("Olá do runtime Tokio!");
    });
}

Aqui, criamos um runtime e usamos block_on para executar um future que imprime uma mensagem. O método block_on bloqueia a thread principal até que o future seja concluído. Em aplicações reais, você normalmente usará #[tokio::main] para simplificar o código. Vamos ver como fica:

#[tokio::main]
async fn main() {
    println!("Olá do runtime Tokio!");
}

Nesse caso, a macro transforma a função main em uma função assíncrona e cria o runtime automaticamente. É a forma mais concisa e recomendada para a maioria dos projetos.

tokio::spawn

tokio::spawn é a função que permite criar uma nova tarefa assíncrona. Ela recebe um future e o agenda para execução no runtime. A tarefa é executada de forma concorrente com outras tarefas, e você pode obter o resultado dela através de um JoinHandle. Isso é fundamental para executar múltiplas operações ao mesmo tempo, como lidar com várias conexões de rede simultaneamente.

Vejamos um exemplo básico: criar duas tarefas que imprimem mensagens em ordem alternada. Como as tarefas são agendadas pelo runtime, a ordem de execução não é garantida, mas elas rodam concorrentemente.

#[tokio::main]
async fn main() {
    let handle1 = tokio::spawn(async {
        for i in 1..=5 {
            println!("Tarefa 1: {}", i);
            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
        }
    });

    let handle2 = tokio::spawn(async {
        for i in 1..=5 {
            println!("Tarefa 2: {}", i);
            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
        }
    });

    let _ = handle1.await;
    let _ = handle2.await;
}

Neste exemplo, usamos tokio::time::sleep para simular um atraso, permitindo que as tarefas alternem. O .await nos handles espera a conclusão de cada tarefa. Se você executar este código, verá que as mensagens são intercaladas, demonstrando a concorrência.

Além disso, tokio::spawn retorna um JoinHandle que pode ser usado para obter o resultado da tarefa. Se a tarefa retornar um valor, você pode usar handle.await para obtê-lo. Por exemplo:

#[tokio::main]
async fn main() {
    let handle = tokio::spawn(async {
        42
    });
    let result = handle.await.unwrap();
    println!("Resultado: {}", result);
}

Note que handle.await retorna um Result, pois a tarefa pode falhar se entrar em pânico. Você deve tratar isso adequadamente.

Tarefas

Tarefas são unidades de trabalho assíncrono que podem ser executadas concorrentemente. Elas são semelhantes a threads, mas muito mais leves, pois são gerenciadas pelo runtime em vez do sistema operacional. Isso permite criar milhares de tarefas sem sobrecarregar o sistema. Além de tokio::spawn, você pode usar tokio::task::spawn_blocking para executar código síncrono que bloqueia, como operações de CPU intensivas ou chamadas a bibliotecas que não são assíncronas.

Um aspecto importante das tarefas é a comunicação entre elas. Tokio fornece canais (mpsc, oneshot, etc.) para trocar dados. Por exemplo, você pode ter uma tarefa produtora que envia mensagens e uma consumidora que as recebe. Vamos ver um exemplo com mpsc (múltiplos produtores, um consumidor):

use tokio::sync::mpsc;

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

    let producer = tokio::spawn(async move {
        for i in 0..10 {
            tx.send(i).await.unwrap();
        }
    });

    let consumer = tokio::spawn(async move {
        while let Some(msg) = rx.recv().await {
            println!("Recebido: {}", msg);
        }
    });

    let _ = producer.await;
    let _ = consumer.await;
}

Neste código, criamos um canal com capacidade para 32 mensagens. O produtor envia números de 0 a 9, e o consumidor imprime cada mensagem recebida. O canal é assíncrono: send e recv são futures que podem aguardar se o canal estiver cheio ou vazio, respectivamente. Isso permite uma comunicação eficiente entre tarefas.

Além disso, você pode usar JoinSet para gerenciar várias tarefas e aguardar todas elas. Isso é útil quando você precisa executar um lote de tarefas e esperar que todas terminem. Por exemplo:

use tokio::task::JoinSet;

#[tokio::main]
async fn main() {
    let mut set = JoinSet::new();
    for i in 0..5 {
        set.spawn(async move {
            println!("Tarefa {}", i);
            i * 2
        });
    }
    while let Some(res) = set.join_next().await {
        println!("Resultado: {:?}", res);
    }
}

O JoinSet permite aguardar cada tarefa conforme ela termina, obtendo o resultado. Isso é mais flexível do que aguardar todas de uma vez.

I/O assíncrono básico

Uma das principais vantagens do Tokio é o suporte a operações de I/O assíncrono, como leitura e escrita em arquivos, sockets, e comunicação com processos. Isso permite que seu programa não fique bloqueado enquanto espera por I/O, liberando a thread para executar outras tarefas. Nesta seção, veremos como ler e escrever arquivos de forma assíncrona usando tokio::fs e como criar um servidor TCP simples com tokio::net.

Para ler um arquivo assincronamente, usamos tokio::fs::File e métodos como read_to_end ou read_to_string. Vamos ver um exemplo que lê um arquivo e imprime seu conteúdo:

use tokio::fs::File;
use tokio::io::AsyncReadExt;

#[tokio::main]
async fn main() -> Result<(), Box> {
    let mut file = File::open("exemplo.txt").await?;
    let mut contents = String::new();
    file.read_to_string(&mut contents).await?;
    println!("Conteúdo: {}", contents);
    Ok(())
}

Note que usamos AsyncReadExt para obter o método read_to_string. O File::open retorna um future que resolve para um arquivo. A leitura é não bloqueante, permitindo que outras tarefas sejam executadas enquanto o arquivo é lido.

Para escrever em um arquivo, usamos AsyncWriteExt. Por exemplo:

use tokio::fs::File;
use tokio::io::AsyncWriteExt;

#[tokio::main]
async fn main() -> Result<(), Box> {
    let mut file = File::create("saida.txt").await?;
    file.write_all(b"Olá, Tokio!").await?;
    Ok(())
}

Além de arquivos, o Tokio fornece suporte para TCP e UDP. Vamos criar um servidor TCP que ecoa as mensagens recebidas. O servidor escuta em uma porta, aceita conexões e, para cada conexão, cria uma tarefa que lê dados e os envia de volta.

use tokio::net::{TcpListener, TcpStream};
use tokio::io::{AsyncReadExt, AsyncWriteExt};

#[tokio::main]
async fn main() -> Result<(), Box> {
    let listener = TcpListener::bind("127.0.0.1:8080").await?;
    println!("Servidor escutando em 127.0.0.1:8080");

    loop {
        let (mut socket, _addr) = listener.accept().await?;
        tokio::spawn(async move {
            let mut buf = [0; 1024];
            loop {
                let n = match socket.read(&mut buf).await {
                    Ok(n) if n == 0 => return, // conexão fechada
                    Ok(n) => n,
                    Err(e) => {
                        eprintln!("Erro ao ler: {}", e);
                        return;
                    }
                };
                if let Err(e) = socket.write_all(&buf[0..n]).await {
                    eprintln!("Erro ao escrever: {}", e);
                    return;
                }
            }
        });
    }
}

Este código cria um servidor TCP que aceita conexões e, para cada uma, inicia uma tarefa que lê dados do socket e os escreve de volta (eco). O loop principal permanece ativo, aceitando novas conexões. Isso demonstra como o Tokio permite lidar com múltiplas conexões concorrentemente de forma eficiente.

Boas práticas e observações finais

Ao trabalhar com Tokio, é importante seguir algumas boas práticas para garantir eficiência e evitar erros comuns. Primeiro, evite bloquear o runtime com operações síncronas longas; use spawn_blocking para tais operações. Segundo, sempre trate erros adequadamente, especialmente ao usar JoinHandle::await, pois a tarefa pode ter falhado. Terceiro, use as primitivas de sincronização do Tokio, como Mutex e RwLock, que são assíncronas e não bloqueiam o executor.

Além disso, lembre-se de que o Tokio é uma biblioteca em constante evolução; consulte a documentação oficial para obter informações atualizadas e exemplos. Pratique criando pequenos projetos, como um servidor HTTP simples ou um cliente TCP, para consolidar o aprendizado.

Referências

Exercícios

  1. Escreva um programa que usa #[tokio::main] e cria duas tarefas: uma que imprime números pares de 0 a 10 e outra que imprime ímpares de 1 a 9. Use tokio::time::sleep para alternar a execução.

    ✓ Resposta:
    #[tokio::main]
    async fn main() {
        let even = tokio::spawn(async {
            for i in (0..=10).step_by(2) {
                println!("Par: {}", i);
                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
            }
        });
        let odd = tokio::spawn(async {
            for i in (1..=9).step_by(2) {
                println!("Ímpar: {}", i);
                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
            }
        });
        let _ = even.await;
        let _ = odd.await;
    }
  2. Crie um programa que use tokio::spawn para calcular a soma de 1 a 100 em uma tarefa e a soma dos quadrados de 1 a 100 em outra. Use JoinHandle para obter os resultados e imprima a soma total.

    ✓ Resposta:
    #[tokio::main]
    async fn main() {
        let sum_task = tokio::spawn(async {
            (1..=100).sum::()
        });
        let sum_sq_task = tokio::spawn(async {
            (1..=100).map(|x| x * x).sum::()
        });
        let sum = sum_task.await.unwrap();
        let sum_sq = sum_sq_task.await.unwrap();
        println!("Soma: {}", sum);
        println!("Soma dos quadrados: {}", sum_sq);
        println!("Total: {}", sum + sum_sq);
    }
  3. Implemente um programa que usa um canal mpsc para enviar 5 mensagens de uma tarefa produtora para uma consumidora, que as imprime. Use tokio::spawn para ambas.

    ✓ Resposta:
    use tokio::sync::mpsc;
    
    #[tokio::main]
    async fn main() {
        let (tx, mut rx) = mpsc::channel(10);
    
        let producer = tokio::spawn(async move {
            for i in 0..5 {
                tx.send(format!("Mensagem {}", i)).await.unwrap();
            }
        });
    
        let consumer = tokio::spawn(async move {
            while let Some(msg) = rx.recv().await {
                println!("Recebida: {}", msg);
            }
        });
    
        let _ = producer.await;
        let _ = consumer.await;
    }
  4. Escreva um programa que lê um arquivo chamado "input.txt" e conta o número de linhas. Use tokio::fs e AsyncReadExt.

    ✓ Resposta:
    use tokio::fs::File;
    use tokio::io::AsyncReadExt;
    
    #[tokio::main]
    async fn main() -> Result<(), Box> {
        let mut file = File::open("input.txt").await?;
        let mut contents = String::new();
        file.read_to_string(&mut contents).await?;
        let lines = contents.lines().count();
        println!("Número de linhas: {}", lines);
        Ok(())
    }
  5. Implemente um servidor TCP que aceita conexões e envia uma mensagem de boas-vindas ao cliente antes de fechar a conexão. Use tokio::net::TcpListener e AsyncWriteExt.

    ✓ Resposta:
    use tokio::net::{TcpListener, TcpStream};
    use tokio::io::AsyncWriteExt;
    
    #[tokio::main]
    async fn main() -> Result<(), Box> {
        let listener = TcpListener::bind("127.0.0.1:8080").await?;
        println!("Servidor escutando em 127.0.0.1:8080");
    
        loop {
            let (mut socket, _addr) = listener.accept().await?;
            tokio::spawn(async move {
                let msg = b"Bem-vindo ao servidor Tokio!\n";
                let _ = socket.write_all(msg).await;
                // A conexão é fechada ao sair do escopo
            });
        }
    }