Como Executar uma Função em Outra Thread em Rust

Aprenda a executar uma função em outra thread em Rust: passe argumentos com move, receba resultados com join e trate erros sem perder o controle da execução.

Para executar uma função em outra thread em Rust, use std::thread::spawn. Sem argumentos, passe a própria função: thread::spawn(calcular). Com argumentos, passe uma closure: thread::spawn(move || calcular(dados)). Guarde o JoinHandle e chame join() para esperar a thread terminar e receber seu resultado.

Esse padrão não exige Tokio nem uma crate externa. É útil em programas de terminal, ferramentas de processamento e integrações com operações bloqueantes. Neste tutorial, você vai separar a função que faz o trabalho da parte que controla sua execução, sem recorrer a dados globais ou a pausas artificiais com sleep.

Resposta rápida: qual forma usar?

NecessidadeForma indicadaObservação
Chamar uma função sem argumentosthread::spawn(minha_funcao)Passe a função, não seu resultado
Enviar um String ou Vec para o worker`thread::spawn(move
Obter o valor retornadohandle.join()Bloqueia a thread chamadora até a conclusão
Emprestar dados locais sem cloná-losthread::scope e scope.spawnTodas as threads terminam antes da saída do escopo
Compartilhar estado mutávelArc<Mutex<T>>, quando necessárioNão é obrigatório para apenas enviar argumentos
Processar muitos itens CPU-boundUm pool, como RayonEvite uma thread nativa por item
Executar trabalho bloqueante em Tokiotokio::task::spawn_blockingControle também o número de trabalhos concorrentes

Para consultar outras operações do módulo, veja a referência de std::thread. Aqui, o foco é o fluxo função → argumentos → resultado → erro.

1. Passe a função sem executá-la antes

Crie um projeto com Cargo:

cargo new funcao-em-thread
cd funcao-em-thread

Substitua src/main.rs por este programa completo:

use std::thread;

fn calcular() -> u64 {
    (1_u64..=100_000).sum()
}

fn main() {
    let handle = thread::spawn(calcular);

    println!("A thread principal pode fazer outro trabalho.");

    let total = handle.join().expect("A thread de cálculo entrou em pânico");
    assert_eq!(total, 5_000_050_000);
    println!("Resultado: {total}");
}

Execute com cargo run. O resultado numérico será 5000050000; a mensagem da thread principal não demonstra, por si só, que houve ganho de desempenho. A soma é pequena e o custo de criar a thread pode superar qualquer benefício.

A diferença entre calcular e calcular() é essencial. O primeiro é a função que será chamada pela nova thread. O segundo chama a função imediatamente na thread atual. Portanto, thread::spawn(calcular()) tenta passar um u64 onde a API espera algo que possa chamar, e não compila.

O spawn retorna antes de o trabalho necessariamente terminar. O sistema operacional decide quando cada thread roda; não conte com uma ordem específica de mensagens entre elas.

2. Use uma closure com move para passar argumentos

thread::spawn não recebe uma lista adicional de parâmetros para sua função. Ele recebe uma função ou closure sem argumentos. A closure resolve essa diferença ao capturar os valores necessários:

use std::thread;

fn contar_palavras(texto: String) -> usize {
    texto.split_whitespace().count()
}

fn main() {
    let texto = String::from("Rust executa funções em outra thread");
    let handle = thread::spawn(move || contar_palavras(texto));

    let quantidade = handle.join().expect("A contagem entrou em pânico");
    assert_eq!(quantidade, 6);
    println!("Palavras: {quantidade}");
}

move faz a closure capturar texto por valor. Como String não implementa Copy, sua posse é transferida; você não pode usar essa mesma variável na thread principal depois do spawn.

Se a thread principal ainda precisar do texto, escolha conscientemente entre clonar, compartilhar uma estrutura adequada ou emprestar com um escopo. Clonar pode ser suficiente para uma entrada pequena. Não coloque um Mutex em tudo apenas para satisfazer o compilador: neste exemplo, não existe estado mutável compartilhado que precise de um lock.

Por que aparecem Send e ‘static?

A closure e seu retorno precisam satisfazer Send, pois atravessam a fronteira entre threads. O requisito 'static de thread::spawn impede que o trabalho carregue empréstimos locais que possam expirar antes de ele terminar.

Isso não significa que um String enviado à thread precisa permanecer vivo para sempre. Um valor possuído pode satisfazer o requisito sem conter referências de curta duração. Já move sobre &String só move a referência; não estende seu lifetime.

Para aprofundar essas regras, consulte Send e Sync e o tutorial de concorrência.

3. Separe erro da função e panic da thread

Se a função retorna Result<T, E>, join() produz duas camadas de resultado:

  • a camada externa informa se a thread terminou normalmente ou entrou em panic;
  • a camada interna informa se a operação da aplicação teve sucesso ou falhou.

Este exemplo recebe um caminho, lê um arquivo em outra thread e trata os dois casos separadamente:

use std::{env, fs, io, process, thread};

fn contar_linhas(caminho: String) -> io::Result<usize> {
    let conteudo = fs::read_to_string(caminho)?;
    Ok(conteudo.lines().count())
}

fn main() {
    let Some(caminho) = env::args().nth(1) else {
        eprintln!("Uso: cargo run -- caminho/do/arquivo.txt");
        process::exit(2);
    };

    let handle = thread::spawn(move || contar_linhas(caminho));

    match handle.join() {
        Ok(Ok(linhas)) => println!("Linhas: {linhas}"),
        Ok(Err(erro)) => {
            eprintln!("Não foi possível ler o arquivo: {erro}");
            process::exit(1);
        }
        Err(_) => {
            eprintln!("A thread entrou em pânico");
            process::exit(1);
        }
    }
}

Para testar, crie entrada.txt com duas linhas e execute:

cargo run -- entrada.txt
cargo run -- arquivo-inexistente.txt

A segunda execução deve reportar o erro de leitura e sair com código 1. Um arquivo ausente é uma falha esperada da operação, não um motivo para chamar panic!.

join().expect(...) é conveniente nos primeiros exemplos, mas não substitui uma política de tratamento de falhas. Com a estratégia de panic baseada em unwinding, join permite observar o panic do worker. Se o binário usa panic = "abort", um panic encerra o processo, e essa recuperação não acontece. Veja também o guia de tratamento de erros com thiserror e anyhow.

4. Quando usar thread::scope em vez de clone

Se o trabalho precisa apenas ler dados locais e terminar antes de a função chamadora continuar, um escopo permite emprestá-los diretamente. O programa abaixo mantém a posse do vetor na thread principal:

use std::thread;

fn somar(dados: &[u64]) -> u64 {
    dados.iter().sum()
}

fn main() {
    let dados = vec![10_u64, 20, 30, 40];

    let total = thread::scope(|scope| {
        let handle = scope.spawn(|| somar(&dados));
        handle.join().expect("A soma entrou em pânico")
    });

    assert_eq!(total, 100);
    println!("Total: {total}; vetor preservado: {dados:?}");
}

thread::scope aguarda todas as threads criadas nele antes de retornar. Esse limite de duração torna o empréstimo seguro. A API está disponível no Rust estável desde a versão 1.63.

O escopo não torna qualquer acesso compartilhado permitido: as regras de empréstimo e os requisitos de segurança entre threads continuam valendo. Para dividir um vetor mutável em regiões independentes ou criar vários workers, consulte scoped threads.

5. Join não é cancelamento nem timeout

join() espera a thread acabar e consome o handle. Ele não recebe um prazo máximo e não interrompe o trabalho.

Descartar o JoinHandle também não cancela a execução: a thread fica desanexada. Se main terminar e o processo sair, o trabalho restante não tem garantia de conclusão. É por isso que usar sleep na thread principal para “dar tempo” ao worker é frágil; duração estimada não é sincronização.

Para tarefas longas, implemente cancelamento cooperativo: envie uma mensagem por um canal ou compartilhe um sinal que o worker consulta periodicamente. Um timeout ao esperar uma mensagem informa que a espera expirou; não mata automaticamente o worker. Ele ainda precisa obedecer ao protocolo de parada.

Threads, Rayon ou Tokio: como escolher?

Uma thread nativa é uma escolha simples para um worker dedicado ou uma operação bloqueante isolada. Não crie uma thread por registro de um arquivo ou por requisição de uma API sem limite: cada uma exige recursos, incluindo uma pilha cujo tamanho depende da configuração e da plataforma.

Para paralelismo de dados, como transformar uma grande coleção, Rayon oferece um pool e operações como par_iter. Meça o trabalho serial e o paralelo; o número de threads não é uma promessa de aceleração.

Em aplicações Tokio, uma função async não deve executar leitura síncrona pesada ou cálculo demorado diretamente nas threads do executor. spawn_blocking atende operações bloqueantes, mas um volume alto de cálculos CPU-bound ainda exige limites de concorrência ou um pool apropriado. tokio::spawn agenda uma tarefa assíncrona; não significa criar uma thread nativa dedicada.

Checklist antes de usar o padrão no projeto

  • A função foi passada como valor ou chamada dentro de uma closure?
  • Os argumentos foram movidos, emprestados por um escopo ou compartilhados deliberadamente?
  • A thread principal mantém o handle e observa o resultado?
  • Erros esperados da operação são distintos de panics?
  • O programa espera os workers antes de encerrar?
  • Existe um limite para a quantidade de trabalho concorrente?
  • A medição justifica a complexidade adicional?

Como exercício de portfólio, adapte o leitor de arquivo para aceitar vários caminhos, mantendo um limite de workers e registrando falhas por arquivo. Essa evolução demonstra domínio de ownership, concorrência e tratamento de erros — competências úteis ao avaliar vagas Rust. Para construir essa base com uma sequência de estudos, veja o curso de Rust.

Perguntas frequentes

Preciso de Tokio para chamar uma função em outra thread?

Não. Os exemplos deste tutorial usam apenas a biblioteca padrão. Tokio é indicado quando você precisa de um runtime assíncrono e de seu ecossistema de I/O, não como requisito para std::thread::spawn.

Posso passar uma função que recebe argumentos diretamente?

Não se sua assinatura exige argumentos. spawn espera algo chamável sem parâmetros. Envolva a chamada em move || minha_funcao(argumentos) ou use uma closure com empréstimos dentro de thread::scope.

Como recebo o retorno da função?

Chame join() no handle. O Ok externo contém o retorno da função. Se ela retorna um Result, trate também essa camada interna, como no exemplo de leitura de arquivo.

move resolve qualquer erro de lifetime?

Não. Mover uma referência não prolonga a vida dos dados referenciados. Envie dados possuídos, use compartilhamento apropriado ou escolha thread::scope para empréstimos locais.

Posso deixar a thread rodando depois de main?

Não conte com isso: o encerramento do processo termina as threads restantes. Aguarde os trabalhos que precisam concluir antes da saída, com join ou outro protocolo explícito de sincronização.

Referências