Canales mpsc: comparte memoria comunicando
El paso de mensajes invierte la estrategia: en vez de compartir un dato con candados, cada valor se envía por un canal y su posesión se transfiere. std::sync::mpsc da un emisor clonable y un receptor unico. Como send mueve el valor, en cada instante hay un solo dueño y la carrera de datos deja de ser expresable.
Hasta aquí, compartir estado ha significado un dato quieto con muchos hilos pidiendo turno para tocarlo: Mutex, RwLock, candados. Hay otra estrategia, opuesta en filosofía, que resume un eslogan heredado de los lenguajes de canales: no comuniques compartiendo memoria; comparte memoria comunicando. En vez de dejar un dato inmóvil y coordinar el acceso, haces que el dato viaje: un hilo lo produce, lo envía por un canal, y en ese envío cede su posesión; otro hilo lo recibe y pasa a ser su único dueño. No hay dato compartido, luego no hay candado, luego no hay carrera de datos. La biblioteca estándar ofrece esta idea en std::sync::mpsc —multiple producer, single consumer—, y su seguridad no descansa en disciplina alguna, sino en la misma ley de ownership que aprendiste hace veinte niveles.
- Crear un canal con
channel()y usar el parSender/Receiverque devuelve. - Enviar con
sendentendiendo que mueve el valor, y recibir conrecvo iterando el receptor. - Clonar el
Senderpara tener varios productores (la parte mp de mpsc) y cerrar el canal soltándolos. - Comprender por qué la transferencia de posesión hace la carrera de datos inexpresable, sin candados.
Un emisor, un receptor, y un valor que se muda
channel() devuelve dos extremos de un mismo conducto:
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel::<String>(); // tx emisor, rx receptor
thread::spawn(move || {
let mensaje = String::from("resultado listo");
tx.send(mensaje).unwrap(); // send MUEVE mensaje al canal
// aqui 'mensaje' ya no existe: dejo de ser su dueno
});
let recibido = rx.recv().unwrap(); // bloquea hasta que llega uno
println!("main recibio: {recibido}");
}
El corazón de todo está en una sola palabra: send mueve. Tras tx.send(mensaje), el emisor ya no posee mensaje; intentar usarlo después es un error de compilación, el mismo que si lo hubieras movido a otra función. La posesión ha cruzado el canal. Cuando rx.recv() lo entrega, el receptor se vuelve su único dueño. En ningún instante dos hilos poseen el valor a la vez, y por eso no hay nada que proteger.
send(v)devuelveResult<(), SendError<T>>: falla —devolviéndote el valor— solo si el receptor ya se destruyó y nadie podrá recibirlo.recv()devuelveResult<T, RecvError>: bloquea hasta que llega un valor, y falla solo cuando todos los emisores se han soltado y el canal quedó vacío y cerrado. También existentry_recv(no bloquea) yrecv_timeout.
channel() crea un canal no acotado: send nunca espera, los valores se encolan sin límite. Si quieres contrapresión —que un productor rápido no inunde a un consumidor lento— usa sync_channel(k), que devuelve un SyncSender cuyo send se bloquea cuando hay k mensajes sin consumir. El caso extremo sync_channel(0) es un rendezvous: emisor y receptor se citan, cada envío espera a que alguien reciba justo ese valor. Elegir la capacidad es elegir cuánta memoria toleras a cambio de cuánto desacoplas los ritmos.
Muchos productores, un consumidor
La mp de mpsc significa que el Sender se puede clonar: cada clon es otra boca del mismo canal, y así varios hilos alimentan a un único receptor.
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel::<String>();
for id in 0..3 {
let tx = tx.clone(); // un emisor mas para este hilo
thread::spawn(move || {
tx.send(format!("hola desde el hilo {id}")).unwrap();
});
}
drop(tx); // soltamos el emisor original
for msg in rx { // itera hasta que el canal se cierra
println!("recibido: {msg}");
}
}
Dos sutilezas que separan el código que funciona del que se cuelga:
drop(tx)no es opcional. El buclefor msg in rxtermina cuando el canal se cierra, y el canal solo se cierra cuando el últimoSenderse destruye. Los tres clones mueren al acabar sus hilos, pero eltxoriginal sigue vivo enmain; si no lo sueltas, el receptor esperará para siempre un cuarto mensaje que nunca llega. Soltarlo explícitamente cierra el conducto.for msg in rxconsume el receptor como un iterador bloqueante: cada vuelta espera el próximo mensaje y el bucle acaba, limpiamente, cuando ya no puede llegar ninguno más. Es la forma idiomática de drenar un canal hasta el final.
El receptor es único —no se clona—, coherente con el nombre: single consumer. Si necesitas varios consumidores repartiéndose el trabajo, ese es territorio de crates como crossbeam-channel (mpmc); la biblioteca estándar se ciñe a un solo receptor.
flowchart LR P1[Productor 1 con tx clonado] --> C[Canal cola de mensajes] P2[Productor 2 con tx clonado] --> C P3[Productor N con tx clonado] --> C C --> R[Un unico receptor rx] R --> U[send movio cada valor un solo dueno a la vez] style C fill:#89b4fa,color:#11111b style R fill:#a6e3a1,color:#11111b style U fill:#cba6f7,color:#11111b
El canal como tubería: repartir y recolectar
Un uso idiomático combina las lecciones anteriores sin un solo candado: un hilo reparte trabajos, varios obreros los procesan, y los resultados vuelven por un segundo canal. La posesión de cada tarea y de cada resultado se transfiere en cada salto.
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx_res, rx_res) = mpsc::channel();
for id in 0..4 {
let tx_res = tx_res.clone();
thread::spawn(move || {
let calculo = id * id; // trabajo del obrero
tx_res.send((id, calculo)).unwrap(); // cede su resultado al canal
});
}
drop(tx_res); // soltamos el emisor sobrante
let mut total = 0;
for (id, r) in rx_res { // recolecta hasta que se cierra
println!("obrero {id} devolvio {r}");
total += r;
}
println!("suma = {total}");
}
No hay Mutex porque no hay dato compartido: cada obrero posee su tarea, produce un resultado y lo cede al canal. El hilo principal es el único consumidor y suma lo que llega. Cuando quieras coordinar hilos, esta forma —tuberías de canales— suele ser más fácil de razonar que un enjambre de candados, precisamente porque no hay estado que dos hilos puedan pisar a la vez.
El eslogan “comparte memoria comunicando” suena a consejo de estilo, y es en realidad una afirmación sobre dónde vive la invariante de seguridad. Con memoria compartida y candados, un dato tiene muchos mutadores potenciales, y la corrección descansa en que todos y cada uno respeten el protocolo del candado: una disciplina global, distribuida por todo el código, frágil porque basta un acceso olvidado para romperla. El paso de mensajes disuelve el problema en lugar de vigilarlo. Una carrera de datos exige, por definición, dos hilos accediendo a la misma memoria a la vez con al menos uno escribiendo; esa precondición necesita que dos hilos posean o alcancen el mismo dato simultáneamente. Pero send mueve: transfiere la posesión, no la comparte. En cada instante hay exactamente un dueño del valor, y “un solo dueño a la vez” es justo la ley de ownership del nivel 8, ahora extendida a través de los hilos. Si la posesión es siempre singular y la transferencia es un movimiento, la precondición de la carrera no puede darse: no hay dos accesos concurrentes porque no hay dos poseedores concurrentes. Por eso los canales no “evitan” las carreras con cuidado, como se evita un bache: las hacen inexpresables, empujando la seguridad de la concurrencia de vuelta al mismo sitio donde Rust la resuelve todo lo demás, el sistema de posesión. Y aquí está la síntesis del nivel entero: Mutex y los canales son dos respuestas a la única pregunta que importa en el estado compartido —quién puede tocar este dato ahora—, pero la responden al revés. El candado arbitra el acceso a un dato que permanece compartido; el canal traspasa la posesión para que el dato nunca sea compartido. Ninguna es superior en abstracto. Cuando el dato tiene un flujo natural de “lo produce uno, lo procesa otro”, el traspaso es más limpio y más seguro por construcción. Cuando muchas partes necesitan de verdad el mismo dato vivo a la vez —un estado central que todos consultan y actualizan—, el candado es inevitable. Saber cuál usar es saber si tu dato quiere fluir o quiere quedarse.
mpsc::channel() da un Sender clonable (varios productores) y un Receiver único (un consumidor). send mueve el valor al canal: cede la posesión, de modo que en cada instante hay un solo dueño y la carrera de datos no puede darse. recv bloquea; iterar el receptor drena hasta que todos los Sender se sueltan —por eso a veces hay que hacer drop del emisor sobrante—. sync_channel(k) añade contrapresión. Frente a Mutex, el canal traspasa el dato en vez de compartirlo: elígelo cuando el dato fluye.
- Monta un canal donde tres hilos productores envíen su identificador y
mainlos recoja iterandorx. Comprueba que sindrop(tx)del emisor original el programa se cuelga, y explica por qué. - Intenta usar una
Stringdespués de habérsela pasado asend. Lee el error y relaciónalo con la semántica de movimiento del nivel 8. - Cambia
channel()porsync_channel(2)y haz que un productor genere mucho más rápido de lo que el consumidor drena; observa dónde se bloquea elsendy qué es la contrapresión. - Implementa un patrón productor/consumidor donde cada mensaje sea un trabajo (un número) y el consumidor acumule la suma; termina limpiamente cuando el canal se cierra.
- Argumenta, para un caso concreto tuyo, si conviene un canal o un
Arc<Mutex<T>>, y justifica la elección en términos de si el dato “fluye” o “se queda”.