wandres.dev
MEMORIA COMPARTIDA Y BARRERAS · per-CPU, seqlock, memory model

Barreras de compilador: barrier(), READ_ONCE y WRITE_ONCE

Antes de domar la CPU hay que domar al compilador. barrier() como valla total, y READ_ONCE/WRITE_ONCE como acceso quirúrgico que impide tearing, invención, fusión y reordenamiento en accesos concurrentes.

⏱ 15 min

El reordenamiento (nivel 18.1) tiene dos culpables: el compilador y la CPU. Se domestican con herramientas distintas y en ese orden. Esta lección ataca al compilador. Su arma total es barrier(); su bisturí, READ_ONCE() y WRITE_ONCE(), que no cuestan ni una instrucción de CPU pero impiden que el optimizador destroce silenciosamente tus accesos compartidos.

🎯 Al terminar esta lección sabrás
  • Entender barrier() como valla de compilador de coste cero.
  • Dominar READ_ONCE() y WRITE_ONCE() y su implementación con volatile.
  • Reconocer los cuatro pecados del compilador: tearing, fusión, invención y reordenamiento.
  • Saber qué NO garantizan, y su relación con KCSAN y las carreras de datos.

barrier(): la valla mínima

La barrera de compilador más simple es una línea de ensamblador vacía con un clobber de memoria:

/* include/linux/compiler.h */
#define barrier() __asm__ __volatile__("" ::: "memory")

El clobber "memory" le dice a GCC o Clang: “asume que esta instrucción lee y escribe toda la memoria”. Efecto: no puede mover ningún acceso a memoria a través de la valla, ni conservar valores en registros de un lado al otro. No emite ni una sola instrucción; su coste en la CPU es cero. Es puramente una restricción al compilador, no al silicio.

El problema de barrier() es que es un instrumento romo: vala toda la memoria. Normalmente solo quieres proteger una variable compartida sin frenar la optimización del resto. Para eso existen READ_ONCE y WRITE_ONCE.

READ_ONCE y WRITE_ONCE: acceso quirúrgico

Viven en include/asm-generic/rwonce.h. Su esencia es un cast a volatile alrededor de un único acceso:

/* Versión simplificada; la real añade comprobación de tipo y tamaño */
#define READ_ONCE(x)        (*(const volatile typeof(x) *)&(x))
#define WRITE_ONCE(x, val)  (*(volatile typeof(x) *)&(x) = (val))

El volatile obliga al compilador a emitir exactamente una carga o escritura, del ancho exacto del tipo, en el punto exacto del código. Ni la duplica, ni la elimina, ni la fusiona, ni la parte. Fíjate en que el volatile califica el acceso, no la variable: esa es la diferencia clave con declarar la variable volatile, práctica prohibida en el kernel (Documentation/process/volatile-considered-harmful.rst).

Los cuatro pecados que evitan

🪓

Tearing (desgarro)

El compilador parte una escritura en varias (una constante de 64 bits en dos mitades de 32, o byte a byte). Un lector concurrente ve un valor a medio escribir. WRITE_ONCE fuerza una escritura única del ancho completo.

🔗

Fusión e izado

Funde varias cargas en una, o iza una carga fuera de un bucle a un registro. Es el bug clásico del spin infinito: el compilador lee la bandera una vez y gira sobre el registro para siempre.

👻

Invención

Inventa escrituras que no están en el fuente. if (c) x = 1; puede volverse x = 1; if (!c) x = viejo; — un valor transitorio que otro núcleo puede observar. WRITE_ONCE prohíbe inventar escrituras a esa dirección.

↔️

Reordenamiento

Dos accesos volatile no se reordenan entre sí en el compilador. Pero NO se ordenan respecto a accesos normales, y NO emiten barrera de CPU: en un núcleo débil el hardware aún los reordena (nivel 18.3).

El pecado más didáctico es el izado. Este código, correcto a la vista, cuelga la máquina:

/* MAL: el compilador iza la carga de ready a un registro */
while (ready == 0)
	;
usar(data);

Si ready valía 0 al entrar, el compilador la lee una vez, la deja en un registro y gira sobre él eternamente, aunque otro núcleo la ponga a 1. La corrección es un acceso fresco por iteración:

/* BIEN: una carga real de memoria en cada vuelta */
while (READ_ONCE(ready) == 0)
	cpu_relax();   /* pista al núcleo: es una espera activa */
usar(data);

El desgarro en concreto

El tearing no es teórico. En una arquitectura de 32 bits, escribir un u64 puede compilarse a dos escrituras de 32 bits, y un lector concurrente ve la mitad nueva y la mitad vieja. Incluso cuando el tipo cabe en una palabra, el compilador puede construir un valor grande por partes si el inmediato no cabe cómodo en la instrucción:

u64 estado;

estado = 0x00000000FFFFFFFFULL;              /* podría emitirse en dos mitades */
WRITE_ONCE(estado, 0x00000000FFFFFFFFULL);   /* garantiza una única escritura */

WRITE_ONCE garantiza atomicidad de copia única (single-copy atomicity) solo para escalares alineados de tamaño palabra o menor. Por eso READ_ONCE incorpora un compiletime_assert_rwonce_type: si le pasas un tipo mayor que una palabra, no compila, obligándote a repensar el diseño en vez de asumir una atomicidad que el hardware no ofrece.

barrier() frente a READ_ONCE: cuestión de ámbito

barrier() vala toda la memoria en un punto: sirve cuando lo que importa es el lugar de la valla y no una variable concreta (tras preempt_disable para fijar el planificador, o dentro de cpu_relax en una espera). READ_ONCE y WRITE_ONCE valan un solo acceso y dejan que el compilador optimice todo lo demás. La regla práctica: si otro hilo, otro núcleo o una interrupción pueden tocar la variable, cada acceso normal a ella es un bug latente, así que márcalo. Si el dato es privado del hilo, o está bajo un lock que ya aporta sus barreras (nivel 18.4), los accesos normales son correctos —aunque muchos mantenedores los marcan igual para que KCSAN no tenga que adivinar la intención.

Qué NO hacen, y KCSAN

READ_ONCE y WRITE_ONCE son solo para el compilador. En ARM la CPU todavía puede reordenar dos READ_ONCE entre sí; para ordenar entre núcleos necesitas las barreras smp_* (nivel 18.3) o la semántica acquire/release (nivel 18.4). Además exigen un escalar alineado de tamaño palabra o menor: sobre eso garantizan atomicidad; sobre tipos mayores, no.

Su segundo papel es documental. El Kernel Concurrency Sanitizer (KCSAN) trata los accesos normales a datos compartidos como candidatos a carrera de datos, y los accesos marcados (READ_ONCE, WRITE_ONCE, atómicos) como concurrencia intencionada. Bajo el LKMM, una carrera entre dos accesos normales es un bug por definición. La macro data_race() marca un acceso como carrera benigna deliberada y silencia a KCSAN de forma explícita y auditable.

La distinción que ordena toda la concurrencia del kernel

Aquí nace la separación mental que sostiene los cuatro niveles restantes: no es lo mismo “el compilador no debe romper mi acceso” que “la CPU no debe reordenar mi acceso”. El primer problema lo resuelve un volatile sobre el acceso —coste cero, ni una instrucción— y el segundo exige una barrera de hardware real, con su coste. READ_ONCE/WRITE_ONCE atacan solo el primero. Confundirlos es la fuente inagotable de bugs de concurrencia: gente que pone smp_mb() donde bastaba READ_ONCE, o READ_ONCE donde hacía falta una barrera de CPU. Y hay una lección de diseño más honda: el kernel prohíbe volatile como cualificador de variable porque volatiliza todos los accesos, incluidos los que el compilador podría optimizar sin peligro. La granularidad correcta de la concurrencia no es la variable: es el acceso. Por eso el kernel volatiliza accesos, uno a uno, con nombre y a la vista. Es C ejercido con precisión de cirujano.

⚔️ Provoca y cura el bug del izado
  1. Escribe el bucle de espera SIN READ_ONCE, compílalo con -O2 y desensámblalo: localiza la carga izada fuera del bucle.
  2. Añade READ_ONCE y confirma en el ensamblador que ahora hay una carga por iteración.
  3. Busca tres usos reales de READ_ONCE en el árbol del kernel y clasifica qué pecado evita cada uno.
  4. Lee Documentation/process/volatile-considered-harmful.rst y resume por qué se prohíbe el cualificador.
  5. Describe qué marcaría KCSAN en el ejemplo flag+datos del nivel 18.1 si dejas los accesos sin marcar.