wandres.dev
ESTADO COMPARTIDO · Arc, Mutex, channels

RwLock: muchos lectores o un solo escritor

Un RwLock afina la exclusión mutua distinguiendo lecturas de escrituras: read() admite muchos guards compartidos a la vez, write() exige uno exclusivo que excluye a todos. Es la ley de aliasing de Rust, muchos inmutables o uno mutable, llevada al plano dinámico y entre hilos. Conviene sobre Mutex cuando las lecturas dominan y no son triviales.

⏱ 17 min

Mutex<T> trata todo acceso por igual: leer y escribir compiten por el mismo turno único, aunque dos lecturas nunca podrían pisarse. Para muchas cargas eso es desperdicio: una tabla de configuración que mil hilos consultan y uno actualiza cada minuto no debería serializar las mil lecturas. RwLock<T> —lector/escritor— afina la exclusión distinguiendo dos modos: read() admite muchos lectores simultáneos, porque la lectura concurrente es inofensiva; write() exige exclusividad total, ni un lector más. Si te suena, es porque es exactamente la regla del borrow checker —muchos & o un solo &mut— pero comprobada en ejecución y a través de fronteras de hilo. Donde el compilador demuestra esa ley estáticamente para las referencias, RwLock la impone dinámicamente para el estado compartido.

🎯 Al terminar esta lección sabrás
  • Usar read() y write() y los dos guards que devuelven (RwLockReadGuard, RwLockWriteGuard).
  • Ver RwLock<T> como la ley “muchos inmutables o uno mutable” llevada al plano dinámico y multihilo.
  • Decidir con criterio cuándo RwLock gana a Mutex y cuándo es peor.
  • Conocer sus riesgos: inanición según la implementación, envenenamiento solo en escritura y el autobloqueo por reentrada.

Dos guards, dos permisos

RwLock<T> ofrece dos formas de entrar, cada una con su tipo de guard:

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

fn main() {
    let config = Arc::new(RwLock::new(String::from("v1")));

    let mut lectores = Vec::new();
    for id in 0..4 {
        let config = Arc::clone(&config);
        lectores.push(thread::spawn(move || {
            let actual = config.read().unwrap();     // guard COMPARTIDO
            println!("lector {id} ve {}", *actual);
        }));                                          // varios pueden coexistir
    }

    {
        let mut w = config.write().unwrap();          // guard EXCLUSIVO
        *w = String::from("v2");                      // espera a que no quede ningun lector
    }

    for l in lectores {
        l.join().unwrap();
    }
}
  • read() devuelve un RwLockReadGuard, que solo hace Deref a &T. Puedes tener muchos vivos a la vez en distintos hilos; ninguno molesta a los otros porque nadie muta.
  • write() devuelve un RwLockWriteGuard, que hace DerefMut a &mut T. Solo puede existir uno, y mientras vive no hay ningún lector. Si al pedir write() hay lectores activos, el hilo espera a que todos suelten su guard.

Igual que en Mutex, ambos métodos devuelven LockResult —de ahí el .unwrap()— y ambos guards liberan por RAII al salir de scope. La diferencia entera está en la cardinalidad: read es “de muchos”, write es “de uno y excluyente”.

flowchart TD
R[read pide acceso compartido] --> RM[Muchos RwLockReadGuard a la vez]
W[write pide acceso exclusivo] --> WM[Un solo RwLockWriteGuard y cero lectores]
RM --> LEY[Misma ley del borrow checker en ejecucion]
WM --> LEY
LEY --> N[Muchos and inmutables o un and mutable jamas ambos]
style R fill:#a6e3a1,color:#11111b
style W fill:#f38ba8,color:#11111b
style LEY fill:#cba6f7,color:#11111b
style N fill:#89b4fa,color:#11111b

Cuándo conviene sobre Mutex, y cuándo no

RwLock no es “un Mutex mejor”: es un Mutex con una apuesta distinta. Cambiar uno por otro solo compensa si el patrón de acceso encaja.

📖

Gana cuando: leer domina y cuesta

Muchísimas más lecturas que escrituras, y cada lectura hace trabajo no trivial. Ahí el paralelismo de lectores compensa el mayor coste interno del candado. Ejemplos: cachés, tablas de rutas, configuración caliente.

✍️

Pierde cuando: escribir domina o el turno es minúsculo

Si las escrituras son frecuentes o la sección crítica es un par de instrucciones, el candado lector/escritor es más caro y más complejo que un Mutex, sin ganar paralelismo real. Ahí un Mutex —o incluso un atómico— es más rápido y simple.

La intuición: RwLock mantiene más estado interno para contar lectores y coordinar la exclusión del escritor, así que cada operación cuesta un poco más que en un Mutex. Solo recuperas esa inversión si de verdad hay lectores concurrentes que se solapan y ese solapamiento ahorra tiempo. Para secciones críticas diminutas o cargas equilibradas, la respuesta por defecto sigue siendo Mutex; y para un simple contador o bandera, un tipo atómico (AtomicU64, AtomicBool) evita el candado por completo.

⚠️
Tres trampas del RwLock

Inanición: según el sistema, un flujo constante de lectores puede dejar al escritor esperando indefinidamente (o al revés). La política de equidad de std::sync::RwLock depende de la plataforma subyacente, así que no asumas justicia. Envenenamiento asimétrico: a diferencia del Mutex, un RwLock solo se envenena si el pánico ocurre con el guard de escritura tomado —un lector no puede romper invariantes porque no muta—. Reentrada mortal: no tomes write() mientras sostienes un read() en el mismo hilo, ni pidas dos read() anidados si un escritor espera en medio: std no soporta reentrada ni ascenso de lector a escritor, y el resultado es un autobloqueo. Ese peligro conecta directo con la última lección.

Un patrón real: caché de lectura intensiva

El caso que justifica un RwLock es la caché que casi todos consultan y casi nadie actualiza: una tabla de sesiones, un mapa de precios, una configuración caliente. Muchos hilos leen a la vez sin estorbarse, y solo la rara actualización pide exclusividad.

use std::sync::{Arc, RwLock};
use std::collections::HashMap;

type Cache = Arc<RwLock<HashMap<String, u64>>>;

fn consultar(cache: &Cache, clave: &str) -> Option<u64> {
    let mapa = cache.read().unwrap();       // lectura concurrente
    mapa.get(clave).copied()
}

fn actualizar(cache: &Cache, clave: String, valor: u64) {
    let mut mapa = cache.write().unwrap();  // exclusiva y breve
    mapa.insert(clave, valor);
}

Aquí RwLock rinde porque consultar se llama miles de veces por cada actualizar, y todas esas consultas corren en paralelo. Fíjate en la disciplina de siempre: cada guard vive lo mínimo —una expresión— para no retener el candado ni un instante de más. Si el reparto fuera al revés, con escrituras constantes, este mismo código con Mutex sería más simple y probablemente más rápido. La elección del candado es, literalmente, una hipótesis sobre la proporción entre lecturas y escrituras.

RwLock proyecta al plano dinámico la ley que el borrow checker demuestra estáticamente

Hay un teorema en el corazón de Rust, y RwLock es su segunda encarnación. El teorema dice: aliasing y mutación no pueden coexistir; puedes tener muchos accesos de solo lectura al mismo dato, o exactamente uno de escritura, nunca ambas cosas. Dentro de un hilo, el borrow checker lo demuestra en compilación con las reglas de & y &mut, y el coste en ejecución es cero porque la prueba es estática. Pero hay situaciones donde esa prueba estática es imposible: cuando el conjunto de lectores y escritores no se conoce hasta que el programa corre y vive repartido entre hilos, ningún análisis en tiempo de compilación puede saber cuántas referencias habrá ni cuándo. Para esos casos, Rust ofrece la misma ley comprobada dinámicamente: RwLock es un teorema idéntico —muchos compartidos o uno exclusivo— cuya verificación se ha movido de la fase de compilación a la de ejecución, pagando con un candado el precio de lo que ya no puede demostrarse gratis. Verlo así reordena todo el nivel. Mutex es el caso degenerado que colapsa lectura y escritura en un único “exclusivo”, más barato porque distingue menos; RwLock conserva la distinción entre & y &mut, más caro porque respeta la estructura completa de la ley. Elegir entre ambos no es elegir rendimiento a ciegas, sino decidir cuánta de la ley de aliasing necesitas pagar en ejecución: si tu carga tiene lectores concurrentes reales que se solapan, preservar la distinción rinde; si no, colapsarla ahorra. Y por eso la reentrada es mortal —tomar write teniendo un read pide “un mutable coexistiendo con un compartido”, que es justo lo que la ley prohíbe—, y por eso solo la escritura envenena —solo el mutador puede romper una invariante—. No estás aprendiendo un candado nuevo: estás viendo la misma verdad sobre datos compartidos, la que el compilador te enseñó con referencias, reaparecer allí donde la prueba estática no alcanza.

📝
Lo esencial de RwLock

RwLock<T> distingue read() —muchos guards compartidos a la vez— de write() —uno exclusivo que excluye a todo lector—. Es la ley “muchos & o un solo &mut” del borrow checker, comprobada en ejecución y entre hilos. Conviene sobre Mutex cuando las lecturas dominan y no son triviales; para escrituras frecuentes o secciones minúsculas, Mutex es más simple y a menudo más rápido. Cuida la inanición según la plataforma, el envenenamiento solo en escritura, y jamás pidas write() sosteniendo un read(): es autobloqueo.

⚔️ Mide, distingue y rompe el candado lector/escritor
  1. Monta un Arc<RwLock<Vec<i32>>>, lanza varios hilos que lo lean a la vez e imprime desde cada uno; confirma que las lecturas se solapan sin bloquearse entre sí.
  2. Añade un hilo escritor que haga write() y observa que espera hasta que no queda ningún lector. Razona por qué eso es correcto según la ley de aliasing.
  3. Provoca un autobloqueo tomando read() y, sin soltarlo, pidiendo write() en el mismo hilo. Explica qué regla estás violando.
  4. Compara mentalmente dos diseños de una caché: uno con Mutex y otro con RwLock. Indica bajo qué proporción de lecturas y escrituras elegirías cada uno.
  5. Explica por qué un RwLock solo se envenena en el modo de escritura y nunca en el de lectura.