wandres.dev
ESTADO COMPARTIDO · Arc, Mutex, channels

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.

⏱ 18 min

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.

🎯 Al terminar esta lección sabrás
  • Crear un canal con channel() y usar el par Sender / Receiver que devuelve.
  • Enviar con send entendiendo que mueve el valor, y recibir con recv o iterando el receptor.
  • Clonar el Sender para 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) devuelve Result<(), SendError<T>>: falla —devolviéndote el valor— solo si el receptor ya se destruyó y nadie podrá recibirlo.
  • recv() devuelve Result<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 existen try_recv (no bloquea) y recv_timeout.
ℹ️
Asíncrono, síncrono y acotado

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 bucle for msg in rx termina cuando el canal se cierra, y el canal solo se cierra cuando el último Sender se destruye. Los tres clones mueren al acabar sus hilos, pero el tx original sigue vivo en main; si no lo sueltas, el receptor esperará para siempre un cuarto mensaje que nunca llega. Soltarlo explícitamente cierra el conducto.
  • for msg in rx consume 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 paso de mensajes no evita la carrera de datos: la vuelve inexpresable

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.

📝
Lo esencial de los canales

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.

⚔️ Produce, transfiere y drena
  1. Monta un canal donde tres hilos productores envíen su identificador y main los recoja iterando rx. Comprueba que sin drop(tx) del emisor original el programa se cuelga, y explica por qué.
  2. Intenta usar una String después de habérsela pasado a send. Lee el error y relaciónalo con la semántica de movimiento del nivel 8.
  3. Cambia channel() por sync_channel(2) y haz que un productor genere mucho más rápido de lo que el consumidor drena; observa dónde se bloquea el send y qué es la contrapresión.
  4. 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.
  5. 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”.