wandres.dev
CONCURRENCIA Y ATOMICS · SMP, preempción, atomic_t

Condiciones de carrera reales: el contador

La carrera más famosa del kernel diseccionada: por qué contador++ no es atómico, cómo se pierde un incremento, y el mapa de las cuatro herramientas de sincronización (atomics, spinlocks, mutex, RCU) y cuándo usar cada una.

⏱ 16 min

Ya sabes que el kernel es concurrente por defecto. Ahora vas a ver la carrera en carne viva: la operación más inocente de la programación, incrementar un contador, es en realidad tres pasos separados que dos núcleos pueden entrelazar hasta perder datos. Diseccionarla te da el ojo para reconocer el patrón en cualquier sitio, y el mapa de las herramientas que lo curan.

🎯 Al terminar esta lección sabrás
  • Por qué contador++ es un read-modify-write no atómico.
  • Cómo se pierde un incremento y por qué el bug no es determinista.
  • Detectar carreras con KCSAN.
  • El panorama de atomics, spinlocks, mutex y RCU, y cuándo cada uno.

El incremento que miente

Este código parece trivial e intachable:

static int contador;   /* compartido, sin proteccion */

static void incrementa(void)
{
	contador++;    /* parece una sola operacion... no lo es */
}

El problema es que contador++ no es una instrucción. La CPU no sabe incrementar memoria de golpe: la lleva a un registro, la modifica, y la devuelve. Son tres pasos —un read-modify-write (RMW)— que el compilador emite más o menos así: mov para leer contador a un registro, add para modificar el registro, y mov para escribir el registro de vuelta a contador. Entre esos tres pasos hay dos ventanas por las que otro núcleo puede colarse.

Cómo se pierde un incremento

Supón que contador vale 5 y dos núcleos ejecutan incrementa() a la vez. El resultado correcto es 7. Mira este entrelazado:

  • La CPU 0 lee contador a su registro: obtiene 5.
  • La CPU 3 lee contador a su registro: obtiene 5 también (la CPU 0 aún no ha escrito).
  • La CPU 0 modifica: su registro pasa a 6. La CPU 3 modifica: su registro pasa a 6.
  • La CPU 0 escribe 6 en contador. La CPU 3 escribe 6 en contador.

Dos incrementos, y contador vale 6, no 7. Un incremento se perdió sin dejar rastro. Se llama lost update, y no hace falta SMP: en un solo núcleo, una interrupción o una preempción entre el leer y el escribir produce exactamente lo mismo. Este es un ejemplo típico entre proceso y softirq:

static u64 paquetes_rx;    /* estadistica compartida */

/* Contexto de proceso: lee la estadistica */
static u64 leer_stats(void)
{
	return paquetes_rx;
}

/* Softirq de red: la actualiza en cada paquete */
static void rx_handler(void)
{
	paquetes_rx++;         /* carrera con cualquier lector o con otro RX */
}

Fíjate en que rx_handler corre en softirq. Aunque la máquina tuviera un solo núcleo, esa softirq puede saltar encima de otro código a medio incrementar paquetes_rx; y un lector puede leer un valor a medio escribir, porque en 32 bits un u64 se escribe en dos mitades (el fenómeno del word tearing: ver la mitad nueva y la mitad vieja de un mismo número). La carrera no necesita SMP: le bastan dos contextos y un dato compartido en medio.

💡
Ni siquiera leer es gratis: READ_ONCE y WRITE_ONCE

Aunque una carga alineada del tamaño de palabra es indivisible en el hardware, el compilador puede partirla, cachearla en un registro o reordenarla, rompiendo tus suposiciones sobre un dato compartido. Por eso el kernel envuelve los accesos sin lock en READ_ONCE() y WRITE_ONCE(): fuerzan una única lectura o escritura real de memoria, sin optimizaciones traicioneras. Son el mínimo absoluto de disciplina para un dato compartido y, no por casualidad, la base sobre la que atomic_read y atomic_set están construidos.

Por qué el bug es un fantasma

Lo que hace a las condiciones de carrera aterradoras no es el fallo, sino su no determinismo. El entrelazado maligno solo ocurre cuando los tiempos coinciden justo: el ancho de esa ventana es de nanosegundos. Tu código puede pasar un millón de ejecuciones en tu portátil y fallar una vez de cada mil millones en producción, bajo carga, en una máquina de 128 núcleos que no tienes. Es el clásico Heisenbug: al añadir un printk para depurarlo, cambias los tiempos y desaparece. No puedes depurar por reproducción lo que no se deja reproducir.

Por eso el kernel trae un cazador especializado: KCSAN (Kernel Concurrency Sanitizer). Con CONFIG_KCSAN, instrumenta los accesos a memoria y detecta data races aunque no lleguen a manifestarse, avisándote de qué dos accesos a qué dirección chocaron sin sincronización. Es a las carreras lo que KASAN es a la memoria: no esperes al crash, deja que la herramienta te lo señale antes. Su primo lockdep vigila lo complementario —el uso correcto de los locks, los órdenes de adquisición, los deadlocks potenciales— y juntos forman la red de seguridad con la que el kernel caza errores de concurrencia que ningún test reproduciría.

📝
Data race y condición de carrera no son exactamente lo mismo

Con precisión: un data race es el fenómeno técnico de dos accesos concurrentes a la misma dirección sin sincronización, con al menos uno de escritura; es lo que KCSAN detecta a nivel de instrucción. Una condición de carrera es el bug de más alto nivel: que la corrección del programa dependa del orden en que ocurren eventos concurrentes. Casi todo data race es un bug, y casi toda condición de carrera contiene uno, pero puede haber carreras lógicas cuyo daño no se reduzca a una sola variable. En la práctica del kernel se usan como sinónimos; saber la diferencia te ayuda a leer los informes de las herramientas sin confundirte.

El bug que pasa mil tests y falla en producción

Interioriza esto: la ausencia de un fallo en una condición de carrera no demuestra nada. Un bug de lógica normal es determinista —con la misma entrada, el mismo error— y por eso lo cazas con un test. Una carrera es lo contrario: la misma entrada da resultados distintos según el capricho del planificador y del bus de memoria. Puedes ejecutar tu test un billón de veces sin ver el fallo, y aun así el código estar roto. Esto invierte la epistemología del programador: no puedes testear para ganar confianza en el código concurrente, tienes que razonar sobre él. Debes poder argumentar, para cada dato compartido, por qué ningún entrelazado posible lo corrompe. Las herramientas de este nivel —atomics, locks, RCU— no son trucos de rendimiento: son las piezas con las que construyes ese argumento. Programar concurrencia correcta es demostrar un teorema, no aprobar un examen.

Si no puedes testear la corrección de este código, tienes que diseñarla. Y diseñarla es escoger, para cada dato compartido, una herramienta que niegue alguna de las tres condiciones de la carrera: volver el acceso indivisible, serializarlo, o eliminar la compartición. Ese es el arsenal.

El arsenal: cuatro herramientas y cuándo

El kernel no te da una solución única, sino un espectro, porque el coste y las restricciones importan. Estas son las cuatro grandes, de más ligera a más pesada:

⚛️

Operaciones atómicas

Para un solo entero o unos flags. atomic_inc(), set_bit(). Baratísimas, sin bloqueo, válidas en cualquier contexto. No sirven para proteger varios datos a la vez.

🔒

Spinlocks

Para secciones críticas cortas que no pueden dormir. spin_lock() gira ocupando la CPU hasta lograr el lock. Únicos usables en contexto de interrupción (spin_lock_irqsave()).

😴

Mutex

Para secciones largas en contexto de proceso. mutex_lock() duerme al que espera en lugar de girar. Prohibido en contexto de interrupción, porque bloquea.

🌊

RCU

Para datos que se leen muchísimo y se escriben rarísimo. rcu_read_lock() tiene coste casi nulo en la lectura; el escritor paga con synchronize_rcu().

La decisión se reduce a dos preguntas: ¿qué protejo? y ¿puedo dormir aquí?:

flowchart TD
D[Dato compartido] -->|un contador o unos flags| AT[atomic_t y bitops]
D -->|seccion corta que no duerme| SP[spinlock]
D -->|seccion larga o puede dormir| MU[mutex]
D -->|lectura mayoritaria escritura rara| RC[RCU]
style AT fill:#a6e3a1,color:#11111b
style SP fill:#89b4fa,color:#11111b
style MU fill:#f9e2af,color:#11111b
style RC fill:#cba6f7,color:#11111b

Existen más piezas, cada una afinada para un patrón concreto: rwlock_t admite muchos lectores o un solo escritor; seqlock_t deja leer sin bloquear y reintentar si un escritor pasó en medio (así se lee el reloj del sistema); los semáforos (struct semaphore) generalizan el mutex a N poseedores simultáneos. Todas son, en el fondo, combinaciones de las cuatro ideas anteriores, construidas —lo adivinaste— sobre operaciones atómicas.

Hay además un orden de coste que conviene grabar: un atómico es más barato que un spinlock sin contención, que es más barato que un mutex, que es más barato que dormir y despertar a un hilo. Pero el coste nunca es el primer criterio: la regla del contexto (¿puedo dormir aquí?) descarta opciones antes que el rendimiento. En este nivel 14 nos quedamos con la base de la pirámide, las operaciones atómicas, sobre la que todo lo demás se levanta. Las siguientes lecciones las abren en canal.

💡
La regla del contexto manda

Antes de elegir herramienta, responde: ¿este código puede correr en contexto de interrupción? Si la respuesta es sí, quedan descartados el mutex y todo lo que duerma; te quedan atomics y spinlocks con la variante _irqsave. La mitad de los BUG: scheduling while atomic del kernel vienen de olvidar esta pregunta.

⚔️ Provoca y diagnostica una carrera
  1. Escribe un módulo con un static int contador; global y dos kthreads (kthread_run) que hagan contador++ en un bucle de un millón de iteraciones cada uno.
  2. Al terminar, imprime contador. Compáralo con dos millones: verás que falta.
  3. Explica con el modelo read-modify-write exactamente cómo se pierde cada incremento.
  4. Compila con CONFIG_KCSAN y observa el informe de data race que emite señalando tu variable.
  5. Para cada una de las cuatro herramientas del arsenal, escribe en una frase por qué encajaría o no en este caso concreto.