Variantes: rwlock, trylock y el coste bajo contención
Reader-writer spinlocks y por qué hoy se prefieren RCU o seqlocks; spin_trylock para no bloquear; y el cache bouncing que hace caro un spinlock disputado, con la respuesta del kernel: el queued spinlock.
El spin_lock básico es un lock exclusivo y ciego: uno entra, el resto gira. El kernel ofrece variantes para casos concretos —lectores concurrentes, intentos sin bloqueo— pero cada una arrastra un coste que hay que entender. Y bajo contención aparece el enemigo silencioso de todo lock: el rebote de la línea de cache entre núcleos. Esta lección es sobre elegir bien y sobre lo que de verdad cuesta girar.
- Usar
rwlock_ty entender por qué hoy casi siempre es mala idea. - Usar
spin_trylockpara adquirir sin bloquear. - Entender el cache bouncing y el coste de un spinlock disputado.
- Conocer el queued spinlock (
qspinlock) y qué problema resuelve.
rwlock_t: varios lectores o un escritor
Un reader-writer spinlock permite muchos lectores a la vez o un único escritor en exclusiva. La idea: si un dato se lee mucho más de lo que se escribe, no hace falta serializar a los lectores entre sí.
#include <linux/rwlock.h>
static DEFINE_RWLOCK(mi_rwlock);
/* lado lector: varios a la vez, ninguno escribe */
read_lock(&mi_rwlock);
valor = dato_compartido;
read_unlock(&mi_rwlock);
/* lado escritor: exclusivo, sin lectores ni otro escritor */
write_lock(&mi_rwlock);
dato_compartido = nuevo;
write_unlock(&mi_rwlock);
Existen las mismas variantes que en el spinlock normal: read_lock_irqsave, write_lock_irqsave, read_lock_bh, etc. La disciplina de contexto (no dormir, IRQs) es idéntica.
Por qué hoy casi siempre es mala idea
El rwlock_t suena bien y envejece mal. Tres problemas:
- También rebota la cache. Un
read_lockno es gratis: incrementa un contador en la palabra del lock, es decir, escribe. Así que los lectores también ensucian la línea de cache y la hacen rebotar entre núcleos (lo verás abajo). El ahorro frente a un spinlock exclusivo es mucho menor de lo que parece. - Inanición del escritor. Con lectores entrando sin parar, un escritor puede esperar indefinidamente. El
rwlock_tclásico no garantiza equidad. - Casi siempre hay algo mejor. Si la sección de lectura es minúscula, un
spin_locknormal es igual de rápido y más simple. Si el dato es de lectura casi pura y necesitas escalar, RCU (nivel 17) da lecturas sin coste de sincronización, o unseqlockdeja leer sin escribir en la línea. Elrwlock_tqueda en una tierra de nadie incómoda; la propia documentación del kernel desaconseja usarlo salvo casos muy concretos.
Antes de escribir rwlock_t, pregúntate: si mi sección de lectura es corta, ¿por qué no un spin_lock normal? Y si es de lectura casi pura y quiero escalar, ¿por qué no RCU o un seqlock? Si ambas respuestas te dejan sin excusa, probablemente no querías un rwlock_t.
spin_trylock: intentar sin bloquear
spin_trylock intenta coger el lock y vuelve enseguida: devuelve verdadero si lo consiguió, falso si estaba ocupado. Nunca gira.
if (spin_trylock(&dev->lock)) {
/* lo conseguimos: sección crítica */
drenar_cola(dev);
spin_unlock(&dev->lock);
} else {
/* estaba ocupado: haz otra cosa, no bloquees */
programar_reintento(dev);
}
Sirve en dos escenarios clave: cuando no puedes permitirte bloquear (código que corre en contextos delicados, o rutas donde girar sería inaceptable) y cuando quieres romper un posible deadlock de orden —intentar el segundo lock y, si falla, soltar el primero y reintentar en vez de esperar (lo verás en el nivel 15.5)—. Hay variante con máscara de IRQ: spin_trylock_irqsave.
El coste real: cache bouncing
Aquí está la física que ningún API esconde. La palabra del lock vive en una línea de cache. Cada adquisición hace una escritura atómica (un cmpxchg) sobre esa línea, y el protocolo de coherencia (MESI) exige tener la línea en estado exclusivo para escribir: eso invalida la copia de esa línea en todos los demás núcleos.
Con N núcleos martilleando un mismo lock, cada traspaso arrastra tráfico de coherencia entre caches: la línea rebota de un núcleo a otro. El lock deja de ser “unas instrucciones” y pasa a costar cientos de ciclos por handoff. Peor aún en el spinlock de tickets clásico: todos los que esperan giran leyendo la misma palabra, así que al soltar, todos se invalidan a la vez —una estampida.
flowchart LR A[CPU0 escribe el lock] --> M[Linea de cache del lock] B[CPU1 escribe el lock] --> M C[CPU2 escribe el lock] --> M M --> D[Invalidaciones MESI constantes] D --> E[Rebote de la linea bajo contencion] style M fill:#f9e2af,color:#11111b style E fill:#f38ba8,color:#11111b
La respuesta del kernel: qspinlock
Desde hace años, el spinlock de x86 SMP es el queued spinlock, basado en el algoritmo MCS. En vez de que todos giren sobre la palabra compartida, cada uno que espera se encola y gira sobre su propio nodo local por-CPU. Al soltar, el poseedor solo señala a su sucesor en la cola. Resultado: el tráfico de coherencia por traspaso se vuelve casi constante en vez de crecer con el número de núcleos, y desaparece la estampida.
/* include/asm-generic/qspinlock_types.h */
typedef struct qspinlock {
union {
atomic_t val;
struct { u8 locked; u8 pending; };
struct { u16 locked_pending; u16 tail; };
};
} arch_spinlock_t;
El qspinlock mantiene una vía rápida de un solo cmpxchg cuando no hay contención (el caso común, un lock que casi nadie disputa) y solo cae al camino de cola MCS cuando de verdad hay pelea. Todo esto ocurre por debajo de spin_lock: tú escribes el mismo código, y el arch layer te da un lock que escala.
Un vistazo al seqlock
Para el caso de lectura casi pura, el seqlock_t evita que los lectores escriban en la línea de cache. El escritor incrementa un contador de secuencia al entrar y al salir; el lector lo lee antes y después y, si cambió (o quedó impar), reintenta:
#include <linux/seqlock.h>
static DEFINE_SEQLOCK(mi_seqlock);
/* lector: no escribe nada, no bloquea al escritor */
unsigned int seq;
do {
seq = read_seqbegin(&mi_seqlock);
copia = dato_compartido;
} while (read_seqretry(&mi_seqlock, seq));
/* escritor: exclusivo solo frente a otros escritores */
write_seqlock(&mi_seqlock);
dato_compartido = nuevo;
write_sequnlock(&mi_seqlock);
Es lo que usa el kernel para el reloj monotónico: millones de lecturas baratísimas frente a escrituras raras. La contrapartida: el lector debe tolerar reintentos y no puede tomar punteros a datos que el escritor pueda liberar bajo sus pies. Para ese caso, RCU (nivel 17).
El coste de un spinlock disputado no está donde crees. No es el bucle de espera —girar es casi gratis— sino el susurro constante entre caches: cada intento de coger el lock es una escritura que obliga a invalidar esa línea en todos los demás núcleos, y esa línea cruza el interconnect una y otra vez. Un lock puede tener un tiempo de retención de nanosegundos y aun así hundir el rendimiento de una máquina de 128 núcleos, no por lo que hace dentro, sino por el tráfico que genera fuera. Ahí está la lección profunda de escalar en SMP: el enemigo no es el trabajo serializado, es la comunicación que la serialización provoca. El qspinlock es genial precisamente porque ataca eso —hace que cada quien gire sobre su propia línea, no sobre la compartida—. Y la moraleja para tu diseño trasciende los locks: en hardware moderno, la escalabilidad se gana reduciendo líneas de cache compartidas y escribibles, no solo secciones críticas. Un lock que nadie disputa es casi gratis; uno disputado te cobra en el sitio más caro de la máquina.
- Convierte un dato de lectura frecuente a
rwlock_ty luego razona si RCU o unspin_locknormal servirían mejor. - Reescribe una ruta que hoy bloquea usando
spin_trylockcon un plan B cuando falla. - Explica, en tus palabras, por qué un
read_locktambién rebota la línea de cache. - Busca
qspinlocken las fuentes y localiza su vía rápida sin contención. - Diseña un microbenchmark mental: un contador global con 1, 8 y 64 núcleos. ¿Qué crece, el trabajo o el tráfico de cache?