Operaciones atómicas: atomic_t
La primitiva más ligera del kernel para contar sin carreras. atomic_t, atomic_read y atomic_set, la familia inc/dec/add/sub, las variantes _return y _and_test, y por qué el hardware las hace indivisibles con el prefijo lock y la coherencia de caché.
El contador que se corrompía en la lección anterior tiene una cura que no cuesta ni un lock: el tipo atomic_t. Es la promesa de que un read-modify-write ocurre de una pieza, garantizada no por el software sino por el silicio. Es la herramienta más ligera del arsenal y la base física sobre la que se construyen spinlocks, mutexes y refcounts. Dominarla es entender de dónde sale la atomicidad.
- El tipo
atomic_ty por qué no se toca su interior. atomic_readyatomic_setfrente a las operaciones RMW.- La familia
inc,dec,add,suby las variantes_returny_and_test. - Por qué el hardware las hace indivisibles.
Un entero blindado
atomic_t es un int envuelto en una estructura precisamente para que el compilador te impida tocarlo con +, ++ o = normales, que emitirían el RMW roto de la lección 2. Su definición real en include/linux/types.h es tan simple como reveladora:
typedef struct {
int counter;
} atomic_t;
Ese counter está ahí, pero nunca lo lees ni lo escribes directamente: siempre a través de la API de include/linux/atomic.h, que garantiza la atomicidad. Se inicializa y se accede así:
#include <linux/atomic.h>
static atomic_t contador = ATOMIC_INIT(0); /* inicializacion estatica */
int v = atomic_read(&contador); /* lee el valor (un READ_ONCE) */
atomic_set(&contador, 10); /* escribe el valor (un WRITE_ONCE) */
Fíjate en un matiz que confunde a todo el mundo: atomic_read y atomic_set son atómicos por separado —cada uno es una sola carga o almacenamiento alineado, indivisible por naturaleza en la arquitectura— pero no cuando los combinas. atomic_set(&c, atomic_read(&c) + 1) es exactamente el RMW roto otra vez: dos operaciones atómicas no forman una operación atómica. Para eso están las de verdad.
Que atomic_t sea un struct y no un typedef int es una decisión de seguridad deliberada: hace que contador = contador + 1 o if (contador) ni siquiera compilen. El compilador te obliga a pasar por la API atómica y te impide, en tiempo de compilación, reintroducir el RMW roto por descuido. Es type safety al servicio de la concurrencia: el propio tipo te prohíbe la operación peligrosa.
La familia read-modify-write
Aquí está el valor real de atomic_t: las operaciones que leen, modifican y escriben en un solo paso indivisible.
atomic_inc(&contador); /* +1 atomico, cura el lost update */
atomic_dec(&contador); /* -1 atomico */
atomic_add(5, &contador); /* += 5 */
atomic_sub(2, &contador); /* -= 2 */
Ninguna de estas cuatro puede entrelazarse con otra: si dos núcleos hacen atomic_inc() sobre el mismo contador que valía 5, el resultado es 7, siempre, sin excepción. El fantasma de la lección 2 queda exorcizado con un cambio de tipo y de función.
No solo hay aritmética: también existen las operaciones lógicas atómicas, para manipular máscaras de bits dentro de un entero sin abrir una carrera:
atomic_or(0x4, &flags); /* flags |= 0x4, atomico */
atomic_and(~0x1u, &flags); /* flags &= ~0x1 */
atomic_xor(0x2, &flags); /* flags ^= 0x2 */
int antes = atomic_fetch_or(0x8, &flags); /* aplica y devuelve el valor previo */
La familia _fetch_ es hermana de la _return: ambas te dan un resultado sin ventana de carrera, pero _fetch_ devuelve el valor anterior a la operación y _return, el posterior. Elegir la correcta te ahorra un atomic_read extra y la carrera que ese read reintroduciría.
Las variantes que devuelven valor
A veces no basta con modificar: quieres saber el resultado sin abrir una ventana de carrera entre la modificación y la lectura. Las variantes _return te lo dan atómicamente:
int nuevo = atomic_inc_return(&contador); /* incrementa y DEVUELVE el nuevo valor */
int otro = atomic_add_return(3, &contador);
int prev = atomic_fetch_add(3, &contador); /* devuelve el valor ANTERIOR */
Y una joya que aparece por todo el kernel, la base de los contadores de referencia del nivel 14.5: atomic_dec_and_test, que decrementa y te dice si el resultado llegó a cero, todo en un átomo:
if (atomic_dec_and_test(&contador)) {
/* El contador acaba de llegar a 0.
* Solo UN hilo puede ver esto: el que hizo el ultimo decremento.
* Es el momento seguro para liberar el recurso. */
}
Que solo un hilo vea el cero es la propiedad más útil de todas: convierte “el último en irse apaga la luz” en algo correcto sin lock alguno. Existen atomic_inc_and_test, atomic_sub_and_test y atomic_add_negative con la misma idea.
La API de atomics es enorme pero perfectamente regular, y el nombre te lo dice todo. El patrón es atomic más la operación (add, sub, inc, dec, or, and, xor, xchg, cmpxchg) más un sufijo opcional: _return devuelve el resultado, _fetch el valor previo, _and_test si el resultado quedó en cero. Añade 64 tras atomic para 64 bits, o usa atomic_long para el tamaño del puntero. Una vez ves el patrón dejas de memorizar funciones: las deduces.
De dónde sale la indivisibilidad
La atomicidad no la inventa el kernel: la exige al procesador. Cuando compilas atomic_inc(&v), el kernel emite la instrucción que cada arquitectura ofrece para un RMW indivisible:
- x86-64. El prefijo
lock.atomic_incse vuelvelock incl (%rdi), yatomic_inc_return, unlock xadd. El prefijolockhace que el núcleo tome en exclusiva la línea de caché (o el bus en casos extremos) durante toda la instrucción: ningún otro núcleo puede observar el estado intermedio. - ARM64. O bien un bucle load-linked / store-conditional con
ldxrystxrque reintenta si otro núcleo tocó la dirección, o bien, con las extensiones LSE, una única instrucción atómica comoldaddostadd.
Debajo de todo está el protocolo de coherencia de caché (MESI y familia): para escribir una línea, un núcleo debe adquirir su propiedad exclusiva, invalidándola en los demás. Ese mecanismo es el que serializa de verdad los accesos. Por eso atomic_read y atomic_set no necesitan nada especial —una carga o un almacenamiento alineado ya son indivisibles— pero el RMW sí: hay que impedir que alguien se cuele entre el leer y el escribir, y solo el hardware puede garantizarlo.
El kernel envuelve esto en capas. Tú llamas a atomic_inc; en un kernel instrumentado eso pasa por un envoltorio que avisa a KASAN y KCSAN, de ahí a raw_atomic_inc, y finalmente a arch_atomic_inc, la definición específica de tu arquitectura en arch/x86/include/asm/atomic.h o su equivalente ARM. Tú programas contra una API portable; el kernel baja a la instrucción lock correcta en cada CPU.
flowchart TD A[contador mas mas no atomico] --> A1[leer a registro] A1 --> A2[otro nucleo se cuela aqui] A2 --> A3[modificar y escribir tarde] B[atomic_inc indivisible] --> B1[prefijo lock en x86] B1 --> B2[la linea de cache queda en exclusiva] B2 --> B3[nadie observa el estado intermedio] style A2 fill:#f38ba8,color:#11111b style B3 fill:#a6e3a1,color:#11111b
Esa indivisibilidad tiene un precio. Cuando muchos núcleos martillean el mismo atomic_t, su línea de caché rebota de un núcleo a otro (cache-line bouncing) y el rendimiento se hunde. Por eso, para estadísticas de altísima frecuencia, el kernel a veces prefiere contadores per-CPU (this_cpu_inc, un nivel posterior): cada núcleo incrementa su propia copia en su propia línea, y solo se suman al leer. Un átomo es barato, pero un átomo disputado por 128 núcleos no lo es.
Aquí tocas fondo en la pila de abstracciones y es un momento para detenerse. Todo el edificio de la concurrencia del kernel —cada spinlock, cada mutex, cada estructura lock-free, cada refcount_t— se apoya, capa tras capa, sobre una única capacidad física del procesador: la de ejecutar un read-modify-write que ningún otro núcleo pueda partir por la mitad. El prefijo lock de x86 y las instrucciones LSE de ARM no son detalles de bajo nivel curiosos: son el axioma del que se deriva toda la sincronización. Si el hardware no ofreciera esta primitiva, sería literalmente imposible construir un lock correcto en software puro sobre memoria compartida (los algoritmos como el de Peterson existen, pero se derrumban ante la caché y la ejecución fuera de orden reales). Cuando escribes atomic_inc(&v), no estás llamando a una función de librería: estás alcanzando con la mano el mismísimo suelo de la máquina, la roca sobre la que se puede construir todo lo demás. La concurrencia correcta es posible porque el silicio hace una promesa, y atomic_t es el nombre de esa promesa en C.
atomic_inc garantiza indivisibilidad, pero no impone una barrera de memoria: no ordena los accesos a otras variables a su alrededor. atomic_read y atomic_set, además, no llevan barrera ninguna. Si tu corrección depende de que un escritura se vea antes que otra, la atomicidad no basta: necesitarás barreras de memoria (un nivel más adelante). Por ahora, quédate con que atómico resuelve el lost update, no el reordenamiento.
- Retoma el módulo de la lección 2 con dos kthreads. Cambia
int contadorporatomic_t contador = ATOMIC_INIT(0)ycontador++poratomic_inc(&contador). - Ejecuta de nuevo el millón de incrementos por hilo. Comprueba con
atomic_readque ahora sí da exactamente dos millones. - Añade un tercer hilo que use
atomic_dec_and_testy registre cuántas veces “vio el cero”. Razona por qué nunca lo ve más de las esperadas. - Desensambla tu módulo (
objdump -d) y localiza la instrucciónlockque el compilador generó para tuatomic_inc. - Explica por qué
atomic_set(&c, atomic_read(&c) + 1)reintroduce la carrera queatomic_incelimina.