Threads
Nesta aula, exploramos o uso de threads em Rust com spawn e join, o uso da palavra-chave move em closures, o compartilhamento seguro de dados entre threads, e os riscos de concorrência como data races. Aprenderemos a criar programas concorrentes de forma segura utilizando o sistema de tipos do Rust.
Threads são a unidade básica de execução concorrente em sistemas operacionais. Em Rust, a biblioteca padrão fornece suporte a threads nativas através do módulo std::thread. Nesta aula, vamos aprender a criar threads, esperar que elas terminem, compartilhar dados entre elas e evitar os perigos da concorrência, como corridas de dados. O sistema de ownership e tipos do Rust nos ajuda a escrever código concorrente seguro sem a necessidade de um garbage collector.
Diferente de linguagens como C ou Java, Rust garante segurança de memória em tempo de compilação, inclusive em programas concorrentes. Isso significa que muitos erros comuns em threads, como acessar dados não sincronizados, são detectados durante a compilação. Vamos entender como isso funciona na prática.
spawn e join
A função std::thread::spawn cria uma nova thread que executa uma closure (ou função) fornecida. A thread filha é executada concorrentemente com a thread principal. Para esperar que a thread termine, usamos o método join no objeto JoinHandle retornado por spawn. Isso é essencial para garantir que a thread filha tenha terminado antes de prosseguir ou para capturar qualquer pânico que possa ter ocorrido.
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();
}Neste código, a thread principal e a filha imprimem números concorrentemente. O join garante que a thread principal espere a filha terminar. Se não houver join, a thread principal pode terminar antes, encerrando o programa e matando a thread filha.
move em closures de thread
Closures em Rust podem capturar variáveis do seu escopo por referência, por referência mutável ou por valor. Em threads, a closure passada para spawn deve ser 'static, ou seja, não pode conter referências a dados com tempo de vida menor que a thread. Para transferir ownership de variáveis para a thread, usamos a palavra-chave move antes da closure. Isso força a closure a tomar posse dos valores capturados, evitando problemas de lifetime.
Exemplo:
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("vetor: {:?}", v);
});
handle.join().unwrap();
// println!("{:?}", v); // Erro: v foi movido
}Se não usarmos move, o compilador reclamará que a closure pode precisar de uma referência a v que pode não ser válida. Com move, a ownership de v é transferida para a thread, e não podemos mais usar v na thread principal. Isso é seguro porque evita que a thread principal modifique o vetor enquanto a filha o usa.
Compartilhando dados
Compartilhar dados entre threads é um desafio porque múltiplas threads podem tentar ler e escrever simultaneamente, causando corridas de dados. Rust oferece mecanismos seguros: Arc (Atomic Reference Counting) para compartilhamento de ownership, e Mutex ou RwLock para acesso sincronizado. A combinação Arc<Mutex<T>> é comum para dados mutáveis compartilhados.
Exemplo com Arc<Mutex>:
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());
}Aqui, Arc permite que múltiplas threads possuam referências ao mesmo Mutex. O Mutex garante que apenas uma thread por vez acesse o dado interno. O método lock retorna um guarda que desbloqueia automaticamente quando sai de escopo. Isso é seguro e eficiente, embora possa causar deadlocks se não for usado com cuidado.
Riscos
Programação concorrente traz riscos como corridas de dados (data races), deadlocks e starvation. Em Rust, o sistema de tipos elimina corridas de dados em tempo de compilação: você não pode ter uma referência mutável e outras referências simultaneamente, a menos que use sincronização. No entanto, deadlocks ainda são possíveis, por exemplo, se dois mutexes forem adquiridos em ordem diferente. Além disso, pânicos em threads podem ser capturados com join, mas se não forem tratados, podem abortar o programa.
Boas práticas:
- Use
ArceMutexpara dados compartilhados, evitando variáveis globais. - Mantenha o escopo dos locks o menor possível para evitar deadlocks.
- Considere usar canais (
std::sync::mpsc) para comunicação entre threads, que é mais segura que memória compartilhada. - Teste exaustivamente com ferramentas como
cargo teste, se possível, useloompara testar concorrência.
Referências
- The Rust Programming Language - Chapter 16: Fearless Concurrency
- Documentação oficial de std::thread
- Documentação de std::sync::Mutex
- Documentação de std::sync::Arc
- Rust by Example: Threads
Exercícios
Crie um programa que lance duas threads: uma que imprime números pares de 0 a 10 e outra que imprime ímpares de 1 a 9. Use join para garantir que a thread principal espere ambas terminarem.
✓ Resposta:use std::thread; fn main() { let handle1 = thread::spawn(|| { for i in (0..=10).step_by(2) { println!("par: {}", i); } }); let handle2 = thread::spawn(|| { for i in (1..=9).step_by(2) { println!("ímpar: {}", i); } }); handle1.join().unwrap(); handle2.join().unwrap(); }Escreva um programa que use uma variável
Stringem uma thread usandomove. Tente usar a variável após o spawn e observe o erro. Depois, corrija movendo-a para a thread.✓ Resposta:use std::thread; fn main() { let s = String::from("Olá"); let handle = thread::spawn(move || { println!("{}", s); }); // println!("{}", s); // Erro: s foi movido handle.join().unwrap(); }Implemente um contador compartilhado entre 5 threads usando
Arc<Mutex<i32>>. Cada thread deve incrementar o contador 1000 vezes. Ao final, imprima o valor total.✓ Resposta:use std::sync::{Arc, Mutex}; use std::thread; fn main() { let counter = Arc::new(Mutex::new(0)); let mut handles = vec![]; for _ in 0..5 { let c = Arc::clone(&counter); let handle = thread::spawn(move || { for _ in 0..1000 { let mut num = c.lock().unwrap(); *num += 1; } }); handles.push(handle); } for handle in handles { handle.join().unwrap(); } println!("Total: {}", *counter.lock().unwrap()); }Explique por que o código abaixo não compila e como corrigi-lo:
use std::thread; fn main() { let v = vec![1, 2, 3]; let handle = thread::spawn(|| { println!("{:?}", v); }); handle.join().unwrap(); }✓ Resposta:O código não compila porque a closure capturavpor referência, mas a thread pode viver mais que a referência. Para corrigir, adicionemoveantes da closure:thread::spawn(move || { ... }). Isso move a ownership devpara a thread.Crie um programa que demonstre um deadlock simples usando dois mutexes. Dica: uma thread adquire mutex A e depois B, outra adquire B e depois A.
✓ Resposta:use std::sync::{Mutex, Arc}; use std::thread; use std::time::Duration; fn main() { let mutex_a = Arc::new(Mutex::new(())); let mutex_b = Arc::new(Mutex::new(())); let a1 = Arc::clone(&mutex_a); let b1 = Arc::clone(&mutex_b); let handle1 = thread::spawn(move || { let _lock_a = a1.lock().unwrap(); thread::sleep(Duration::from_millis(1)); let _lock_b = b1.lock().unwrap(); println!("Thread 1"); }); let a2 = Arc::clone(&mutex_a); let b2 = Arc::clone(&mutex_b); let handle2 = thread::spawn(move || { let _lock_b = b2.lock().unwrap(); thread::sleep(Duration::from_millis(1)); let _lock_a = a2.lock().unwrap(); println!("Thread 2"); }); handle1.join().unwrap(); handle2.join().unwrap(); // O programa provavelmente ficará travado (deadlock) }