Guía completa de concurrencia en Rust
Rust ofrece primitivas y garantías que hacen la concurrencia más segura por diseño. Esta guía te lleva desde los conceptos clave (ownership, Send/Sync) hasta patrones prácticos (threads, channels, Mutex, async con Tokio), con ejemplos completos, estructura de proyecto y consejos para evitar errores comunes.
Conceptos clave: por qué Rust cambia las reglas
- Ownership y borrowing: impiden condiciones de carrera en tiempo de compilación cuando aplicas las reglas correctamente.
- Send y Sync: traits automáticos que definen si un tipo puede moverse entre hilos (Send) o ser referenciado concurrentemente (&T es seguro) (Sync).
- Tipos de sincronización: Mutex/RwLock, canales (mpsc), atomics; cada uno tiene un propósito y coste distinto.
- Async vs hilos: async (futuros y runtime como Tokio) es cooperativo y eficiente en I/O; threads son mejores para trabajo paralelo de CPU pesado.
Quick checks: cómo saber si un tipo es Send/Sync
En tu crate puedes forzar verificaciones con:
fn assert_send() {}
fn assert_sync() {}
// usa: assert_send<MyType>();
Ejemplo 1 — Hilos básicos
Cuando mueves ownership al hilo, evita referencias inválidas usando move:
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("thread got: {:?}", v);
});
handle.join().unwrap();
}
Por qué: move transfiere ownership de v al closure, evitando que el hilo enfrente referencias a memoria que ya no existe.
Ejemplo 2 — Canales (mpsc) para comunicar hilos
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
tx.send(42).unwrap();
});
println!("got {}", rx.recv().unwrap());
}
Por qué: canales permiten pasar valores entre hilos sin compartir memoria mutable. Son buenos para pipelines y tareas productores/consumidores.
Ejemplo 3 — Contador compartido con 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 c = Arc::clone(&counter);
handles.push(thread::spawn(move || {
let mut num = c.lock().unwrap();
*num += 1;
}));
}
for h in handles { h.join().unwrap(); }
println!("Counter: {}", *counter.lock().unwrap());
}
Por qué: Arc permite compartir ownership en múltiples hilos; Mutex garantiza acceso exclusivo. Evita pasar &mut entre hilos.
Ejemplo 4 — Async con Tokio (I/O eficiente)
Usa async cuando necesitas muchas tareas I/O-bound sin tantas threads:
// Cargo.toml: add tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
#[tokio::main]
async fn main() {
let handle = tokio::spawn(async {
println!("hello from task");
5
});
let res = handle.await.unwrap();
println!("res {}", res);
}
Por qué: Tokio usa un runtime que multiplexa muchas tareas ligeras (futuros) sobre un pool de hilos, reduciendo overhead para mucha I/O concurrida.
Estructura de ejemplo de proyecto
Si quieres experimentar, crea un repo con ejemplos aislados:
concurrency-guide/
threads/
Cargo.toml
src/
main.rs
channels/
Cargo.toml
src/
main.rs
mutex/
Cargo.toml
src/
main.rs
async_tokio/
Cargo.toml
src/
main.rs
Cada subcrato contiene el ejemplo completo mostrado arriba. Así puedes compilar y ejecutar aislado, y comparar comportamientos y rendimiento.
Errores comunes y cómo evitarlos
- Compartir &mut entre hilos: el compilador lo bloquea, pero algunos intentos de workaround (unsafe) introducen data races. Evita unsafe salvo que entiendas invariantes.
- Bloqueo largo dentro de Mutex: no hagas I/O o esperas largas manteniendo un lock; extrae la lógica fuera para minimizar contención.
- Deadlocks: ordena la adquisición de varios locks de forma consistente o usa try_lock con retroceso y backoff.
- Confusión async vs Send: algunos futures no son Send; cuando uses tokio::spawn asegúrate que el futuro implementa Send.
Buenas prácticas rápidas
- Prefiere mover ownership (Arc + datos inmutables) antes que compartir mutabilidad.
- Usa canales para comunicación y separación de responsabilidades.
- Minimiza la duración de los locks y prefiere RwLock si hay muchas lecturas y pocas escrituras.
- Para I/O intenso, usa async + runtime; para CPU-bound, usa threads y particiona trabajo.
- Incluye pruebas de estrés y herramientas de sanitizers (por ejemplo Miri o TSAN via wrappers) en etapas tempranas.
Herramientas y debugging
- RUSTFLAGS="-Z sanitizer=thread" con nightly para detectar data races (experimental).
- println! y logs son válidos, pero para tracing de concurrencia usa crates como
tracingpara correlación de spans. - Mira
cargo benchy Criterion para comparar overhead de sincronización.
Patrones recomendados
- Actor model con canales para sistemas con muchas entidades independientes.
- Work-stealing y thread pools para carga CPU-bound (rayon para data-parallel).
- Futuros Send + canales mpsc para combinar async con work dispatch.
Si necesitas, puedo generar los archivos Cargo.toml y main.rs listos para cada subproyecto del árbol de ejemplo arriba, o un ejemplo más avanzado combinando async y bloqueo mínimo (p. ej. un servidor pequeño con Tokio y workers). Sigue con lo que prefieras: generar código completo para probar localmente, recomendaciones de benchmarking o un checklist de seguridad para revisiones de código.
Consejo avanzado: para evitar deadlocks al adquirir múltiples Mutex, asigna una orden total a los recursos y adquiere locks siguiendo esa orden; si no es posible, usa try_lock y reintenta con backoff. Esto reduce la posibilidad de ciclos de bloqueo y es más fácil de razonar en revisiones de código.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación