Read-write semaphores: muchos leen, uno escribe
rw_semaphore, down_read y down_write, cuándo permitir muchos lectores o un escritor exclusivo, downgrade_write, y el coste real del rwsem frente a RCU en datos de lectura mayoritaria.
Muchos datos del kernel se leen constantemente y se escriben rara vez: tablas de configuración, el árbol de regiones de memoria de un proceso, metadatos de inodos. Obligar a los lectores a excluirse entre sí sería un desperdicio. El rwsem deja que muchos lectores entren a la vez, y solo un escritor cuando toca modificar. Pero “compartido” no significa “gratis”, y ahí empieza lo interesante.
- Coger en lectura compartida (
down_read) y en escritura exclusiva (down_write). - Decidir cuándo el patrón lector/escritor compensa de verdad.
- Usar
downgrade_writey entender por qué no existe el upgrade. - Comparar el coste real del rwsem con el de RCU.
Lectura compartida, escritura exclusiva
Un rw_semaphore (#include <linux/rwsem.h>) es un lock durmiente con dos modos. En modo lectura, N tareas pueden tenerlo a la vez; en modo escritura, una sola, en exclusión total con todos los lectores y con los demás escritores.
struct rw_semaphore {
atomic_long_t count; /* nº de lectores + flags de estado */
atomic_long_t owner; /* task del escritor, o marca de lector */
struct optimistic_spin_queue osq; /* spinning para el escritor */
raw_spinlock_t wait_lock;
struct list_head wait_list;
};
El uso es simétrico: down_read/up_read para leer, down_write/up_write para escribir. Hay una operación especial, downgrade_write, que convierte atómicamente tu lock de escritura en uno de lectura sin soltarlo, dejando entrar a los lectores que esperaban:
#include <linux/rwsem.h>
static DECLARE_RWSEM(config_sem);
static struct config actual;
static int leer_config(struct config __user *out)
{
down_read(&config_sem); /* muchos lectores en paralelo */
/* la sección puede dormir: copy_to_user (nivel 12) puede fallar
* de página y bloquear. Con un rwsem es perfectamente legal. */
if (copy_to_user(out, &actual, sizeof(actual))) {
up_read(&config_sem);
return -EFAULT;
}
up_read(&config_sem);
return 0;
}
static int escribir_config(const struct config *nueva)
{
if (down_write_killable(&config_sem)) /* escritor exclusivo */
return -EINTR;
actual = *nueva;
downgrade_write(&config_sem); /* ahora soy lector: dejo pasar lectores */
/* ...trabajo de solo lectura tras la actualización... */
up_read(&config_sem);
return 0;
}
No existe un upgrade_read a escritura: dos lectores intentando promocionarse a la vez se bloquearían mutuamente para siempre. Si necesitas escribir, suelta el lock de lectura y coge en escritura, asumiendo que el mundo pudo cambiar entre medias.
Cuándo compensa (y cuándo no)
El rwsem solo gana cuando los lectores dominan de verdad y sus secciones no son triviales. Ejemplos canónicos: mm->mmap_lock, que protege el árbol de regiones de memoria de cada proceso, o el i_rwsem de los inodos. Ahí, decenas de lecturas por cada escritura justifican el mecanismo.
Si las escrituras son frecuentes, o las secciones de lectura son muy cortas, el rwsem suele ser más lento que un simple mutex: su contabilidad interna es más cara y los escritores no ganan nada al no poder compartir. La regla mental: lectura mayoritaria con secciones que valen la pena. En caso de duda, empieza por un mutex y mide.
flowchart TD L[Libre] -->|down_read| R[N lectores en paralelo] L -->|down_write| W[1 escritor exclusivo] R -->|otro down_read| R R -->|down_write espera a que no queden lectores| Wq[Escritor en cola] W -->|down_read se bloquea| Rq[Lectores en cola] W -->|downgrade_write| R W -->|up_write| L R -->|up_read del ultimo lector| L style R fill:#a6e3a1,color:#11111b style W fill:#f38ba8,color:#11111b
El coste frente a RCU
Aquí está la pregunta de fondo. Un down_read parece gratis —no excluye a otros lectores—, pero no lo es: internamente hace una operación atómica de lectura-modificación-escritura sobre el campo count, una línea de caché compartida. Con muchas CPUs leyendo el mismo rwsem, esa línea rebota de caché en caché (cache-line bouncing), y el rendimiento de lectura se desploma aunque no haya ni un solo escritor. Los lectores no se excluyen lógicamente, pero contienden físicamente por la coherencia de caché.
RCU ataca justo ese coste. En el lado de lectura, rcu_read_lock no escribe en ninguna línea compartida: en la práctica solo delimita una región (deshabilitando la preempción o tocando un contador por-tarea). Cero tráfico entre CPUs, escalado casi perfecto a cientos de núcleos. El precio se paga en otro lado:
| Aspecto | rwsem | RCU |
|---|---|---|
| Coste del lector | Atómico en línea compartida | Casi cero, sin escritura compartida |
| ¿El lector puede dormir? | Sí | No, en RCU clásico |
| Vista del lector | Siempre la última | Puede ser ligeramente vieja |
| Coste del escritor | Bloquea a los lectores | Copia y espera un grace period |
| Escala con N CPUs | Mal en lectura intensa | Casi perfecta |
Justicia: evitar que alguien se muera de hambre
Un rwsem ingenuo tiene un peligro: si los lectores no paran de llegar, un escritor podría esperar eternamente (inanición del escritor); y favorecer siempre al escritor mataría de hambre a los lectores. El rwsem moderno equilibra ambos con dos mecanismos complementarios:
Lock stealing
Un escritor que llega puede “robar” el lock si está libre en ese instante, sin pasar por la cola, ganando latencia cuando no hay contención real.
Handoff
Si un aspirante lleva demasiado en cabeza de la cola, se activa el handoff: se le cede el lock directamente y se corta el robo, garantizando que nadie espera para siempre.
La consecuencia práctica: no asumas un orden estricto entre lectores y escritores. El rwsem prioriza el rendimiento agregado y solo garantiza ausencia de inanición, no un turno FIFO exacto. Si de verdad no puedes bloquear, tienes las variantes trylock:
/* coge en lectura solo si nadie escribe ahora mismo */
if (!down_read_trylock(&config_sem))
return -EAGAIN; /* había o venía un escritor */
...leer sin dormir esperando...
up_read(&config_sem);
Aun así, down_read_trylock solo evita dormir: no evita escribir en la línea de caché compartida del contador, que es justo el coste que analizamos a continuación.
Más allá: percpu_rw_semaphore
Cuando el lado lector es sagrado y las escrituras son rarísimas, el rwsem normal aún cobra su peaje de caché. La respuesta del kernel es percpu_rw_semaphore: da a cada CPU su propio contador de lectores, de modo que percpu_down_read no escribe en ninguna línea compartida.
static struct percpu_rw_semaphore fs_freeze_sem;
/* lector: baratísimo, sin rebote de caché entre CPUs */
percpu_down_read(&fs_freeze_sem);
...operación normal del sistema de archivos...
percpu_up_read(&fs_freeze_sem);
El coste entero se traslada al escritor: percpu_down_write debe sincronizarse con todas las CPUs (apoyándose en RCU) antes de entrar, lento pero excepcional. Es justo el perfil de congelar un sistema de archivos o bloquear cgroups: lectura omnipresente, escritura casi inexistente. Un rwsem normal se ahogaría ahí en tráfico de coherencia; este no.
El error mental más común sobre los locks de lectura es creer que, como los lectores no se excluyen, son gratis. No lo son. Cada down_read escribe en la línea de caché del contador, y en una máquina de 64 núcleos leyendo a la vez, esa única línea se convierte en el cuello de botella: las CPUs se pelean por poseerla en modo exclusivo para modificarla, y el sistema pasa más tiempo moviendo la línea que trabajando. Este es el momento en que un ingeniero de kernel deja de pensar en “quién excluye a quién” y empieza a pensar en “qué líneas de caché se tocan y desde cuántas CPUs”. Es el salto de la concurrencia lógica a la concurrencia física. RCU existe precisamente porque, para datos de lectura extrema, el problema ya no es la exclusión mutua —no la hay— sino el tráfico de coherencia. Cuando veas un rwsem en un camino calentísimo de solo lectura, pregúntate si en realidad querías RCU. Y cuando midas escalabilidad, no cuentes locks: cuenta líneas de caché compartidas y las CPUs que las tocan. Esa es la métrica que gobierna el rendimiento real de un sistema multinúcleo.
- Protege una estructura de configuración con un
DECLARE_RWSEMy expón lecturas y escrituras desde un char device. - Lanza tantos hilos lectores como CPUs (
num_online_cpus), cada uno haciendodown_read/up_readen bucle. - Mide el throughput agregado de lecturas por segundo con 1, 2, 4 y todos los núcleos.
- Observa si el throughput escala linealmente o se estanca: ese estancamiento es el rebote de la línea de caché de
count. - Investiga cómo
mm->mmap_lock(antesmmap_sem) usa este patrón y por qué migrar partes a RCU o a locks por-VMA fue un gran esfuerzo reciente del kernel.