Threads são a unidade básica de concorrência em sistemas operacionais. Em Rust, a biblioteca padrão fornece suporte a threads nativas do sistema operacional através do módulo std::thread. Nesta aula, vamos explorar como criar threads, esperar sua conclusão, compartilhar dados entre elas e evitar os perigos comuns da programação concorrente.

Diferente de linguagens como C ou Java, Rust oferece garantias de segurança em tempo de compilação para evitar condições de corrida e data races. Entender como usar threads corretamente é essencial para escrever programas paralelos performáticos e seguros.

spawn e join

Para criar uma nova thread, usamos thread::spawn, que recebe uma closure contendo o código a ser executado na nova thread. A função retorna um JoinHandle, que permite esperar a thread terminar através do método join(). Sem join(), a thread principal pode terminar antes da thread filha, abortando-a.

Exemplo básico:

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("Thread filha: {}", i);
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("Thread principal: {}", i);
        thread::sleep(Duration::from_millis(1));
    }

    handle.join().unwrap(); // Aguarda a thread filha terminar
}

Note que se não chamarmos join(), a thread principal pode terminar antes da filha, e o programa encerrará a thread filha abruptamente. O método join() retorna um Result; usamos unwrap() para simplificar, mas em código real devemos tratar erros.

move em closures de thread

Closures passadas para thread::spawn podem capturar variáveis do escopo externo. No entanto, a closure pode precisar tomar posse dos valores capturados, especialmente se a thread viver mais que a variável original. Para isso, usamos a palavra-chave move antes da closure, forçando a transferência de ownership para a closure.

Exemplo sem move (não compila):

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Vetor: {:?}", v); // erro: v pode ser droppado antes
    });

    drop(v); // erro: v não pode ser movido para a closure e usado aqui

    handle.join().unwrap();
}

O compilador reclama que a closure pode sobreviver à variável v. Adicionando move, transferimos a posse de v para a thread:

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        println!("Vetor: {:?}", v); // v é movido para a closure
    });

    // drop(v); // agora não podemos mais usar v aqui, pois foi movido

    handle.join().unwrap();
}

Isso garante que a thread tenha ownership dos dados, evitando referências pendentes.

Compartilhando dados

Para compartilhar dados entre múltiplas threads, Rust oferece primitivas como Arc (Atomic Reference Counting) e Mutex (exclusão mútua). Arc permite que múltiplas threads possuam ownership compartilhado, enquanto Mutex garante que apenas uma thread acesse os dados por vez.

Exemplo com Arc<Mutex<T>>:

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();
            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Resultado: {}", *counter.lock().unwrap());
}

Note que clonamos o Arc para cada thread (incrementa a contagem de referências) e usamos lock() para acessar o mutex. O MutexGuard retornado implementa Deref para acessar o valor interno.

Riscos

Programação concorrente introduz riscos como:

  • Deadlock: duas threads esperam uma pela outra liberar um recurso. Em Rust, deadlocks podem ocorrer com múltiplos mutexes se não houver uma ordem consistente de lock.
  • Data races: acesso concorrente não sincronizado a dados mutáveis. Rust previne data races em tempo de compilação através do sistema de ownership e borrow checking, mas ainda é possível com código inseguro (unsafe).
  • Panics em threads: um panic em uma thread não afeta outras; a thread principal deve verificar o resultado de join() para detectar panics.
  • Overhead de threads: criar muitas threads pode degradar performance. Considere usar um thread pool (como rayon) para tarefas leves.

Boas práticas: minimize o escopo de locks, evite locks aninhados, e prefira std::sync::mpsc (canais) para comunicação entre threads, que é mais segura.

Referências

Exercícios

  1. Crie um programa que lance 5 threads, cada uma imprimindo seu número (0 a 4) e depois durma 1 segundo. A thread principal deve esperar todas terminarem.

    ✓ Resposta:
    use std::thread;
    use std::time::Duration;
    
    fn main() {
        let mut handles = vec![];
        for i in 0..5 {
            let handle = thread::spawn(move || {
                println!("Thread {}", i);
                thread::sleep(Duration::from_secs(1));
            });
            handles.push(handle);
        }
        for handle in handles {
            handle.join().unwrap();
        }
    }
  2. Explique por que o código abaixo não compila e corrija-o usando move.

    use std::thread;
    
    fn main() {
        let msg = String::from("Olá");
        let handle = thread::spawn(|| {
            println!("{}", msg);
        });
        drop(msg);
        handle.join().unwrap();
    }

    ✓ Resposta:O código não compila porque a closure captura msg por referência, mas depois drop(msg) tenta mover msg para fora, causando conflito de ownership. Além disso, a thread pode executar após o drop. A correção é usar move na closure, transferindo ownership de msg para a thread, e remover o drop (ou mantê-lo, mas não funcionará pois msg foi movido). Código corrigido:
    use std::thread;
    
    fn main() {
        let msg = String::from("Olá");
        let handle = thread::spawn(move || {
            println!("{}", msg);
        });
        // drop(msg); // msg foi movido, não pode mais ser usado
        handle.join().unwrap();
    }
  3. Escreva um programa que compartilhe um vetor de inteiros entre duas threads: uma thread adiciona números pares, a outra ímpares. Use Arc e Mutex.

    ✓ Resposta:
    use std::sync::{Arc, Mutex};
    use std::thread;
    
    fn main() {
        let numbers = Arc::new(Mutex::new(Vec::new()));
        let mut handles = vec![];
    
        // Thread para pares
        let nums = Arc::clone(&numbers);
        let handle = thread::spawn(move || {
            let mut data = nums.lock().unwrap();
            for i in (0..10).step_by(2) {
                data.push(i);
            }
        });
        handles.push(handle);
    
        // Thread para ímpares
        let nums = Arc::clone(&numbers);
        let handle = thread::spawn(move || {
            let mut data = nums.lock().unwrap();
            for i in (1..10).step_by(2) {
                data.push(i);
            }
        });
        handles.push(handle);
    
        for handle in handles {
            handle.join().unwrap();
        }
    
        let result = numbers.lock().unwrap();
        println!("Vetor: {:?}", *result);
    }
  4. O que é um deadlock? Dê um exemplo em Rust com dois mutexes e mostre como evitá-lo.

    ✓ Resposta:Deadlock é uma situação onde duas ou mais threads ficam bloqueadas permanentemente, cada uma esperando por um recurso que a outra detém. Exemplo de deadlock:
    use std::sync::{Arc, Mutex};
    use std::thread;
    
    fn main() {
        let m1 = Arc::new(Mutex::new(()));
        let m2 = Arc::new(Mutex::new(()));
    
        let m1_clone = Arc::clone(&m1);
        let m2_clone = Arc::clone(&m2);
        let handle1 = thread::spawn(move || {
            let _lock1 = m1_clone.lock().unwrap();
            thread::sleep(std::time::Duration::from_millis(10));
            let _lock2 = m2_clone.lock().unwrap();
        });
    
        let handle2 = thread::spawn(move || {
            let _lock2 = m2.lock().unwrap();
            thread::sleep(std::time::Duration::from_millis(10));
            let _lock1 = m1.lock().unwrap();
        });
    
        handle1.join().unwrap();
        handle2.join().unwrap();
    }
    
    Para evitar, adote uma ordem global de lock (ex.: sempre travar m1 antes de m2).
  5. Usando canais (std::sync::mpsc), crie um programa onde a thread principal envia uma string para uma thread filha, que a imprime em maiúsculas e envia de volta. A thread principal então imprime a resposta.

    ✓ Resposta:
    use std::sync::mpsc;
    use std::thread;
    
    fn main() {
        let (tx, rx) = mpsc::channel();
        let (tx_back, rx_back) = mpsc::channel();
    
        let handle = thread::spawn(move || {
            let received = rx.recv().unwrap();
            let response = received.to_uppercase();
            tx_back.send(response).unwrap();
        });
    
        tx.send("hello world".to_string()).unwrap();
        let response = rx_back.recv().unwrap();
        println!("Resposta: {}", response);
    
        handle.join().unwrap();
    }