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

Herramientas especializadas: variables per-CPU y seqlocks

La mejor sincronización es la que no ocurre. Variables per-CPU (DEFINE_PER_CPU, this_cpu_ptr) que particionan el dato y eliminan el contendido, y seqlocks para lecturas optimistas sin bloquear al escritor. El cierre del arco de barreras.

⏱ 16 min

Todo el nivel ha ido de negociar el orden entre núcleos. Esta lección da el paso final: cómo no negociar. Las variables per-CPU parten el dato en una copia por núcleo, y así no hay nada compartido que ordenar. Los seqlocks dejan que el lector sea optimista —lee sin bloquear y reintenta si un escritor se cruzó—. Son la cima del arco: sincronización que se evita partiendo el dato o siendo optimista.

🎯 Al terminar esta lección sabrás
  • Usar DEFINE_PER_CPU, this_cpu_ptr y this_cpu_* para eliminar el compartido.
  • Entender el peligro de la preferencia y la migración con datos per-CPU.
  • Dominar el seqlock: read_seqbegin/read_seqretry y write_seqlock.
  • Elegir entre per-CPU, seqlock, spinlock y RCU, y conocer la referencia canónica.

Evitar la sincronización: variables per-CPU

La sincronización más barata es ninguna. Si cada núcleo tiene su propia copia del dato, no hay compartido, no hay contención de caché ni barreras. Eso son las variables per-CPU (include/linux/percpu.h):

#include <linux/percpu.h>

/* Una copia de 'contador' por CPU */
static DEFINE_PER_CPU(unsigned long, contador);

void incrementar(void)
{
	/* accede a la copia de ESTE núcleo: sin locks, sin barreras */
	this_cpu_inc(contador);
}

Las formas de acceso tienen matices importantes:

  • this_cpu_ptr(&var) devuelve el puntero a la copia de este núcleo; exige tener la preferencia controlada.
  • this_cpu_read/write/inc/add(var) operan sobre la copia local en una sola instrucción, a salvo de interrupciones y preferencia en ese núcleo.
  • get_cpu_var(var) / put_cpu_var(var) desactivan y reactivan la preferencia alrededor de un bloque.
  • per_cpu(var, cpu) accede a la copia de un núcleo concreto, para agregar.
⚠️
El peligro: preferencia y migración

Si el kernel te interrumpe (preempt) entre this_cpu_ptr y usar el puntero, puedes migrar a otro núcleo y acabar tocando la copia equivocada. Por eso los this_cpu_* empaquetan “elige este núcleo y opera” de forma atómica frente a la preferencia local. Para un acceso de varios pasos debes rodearlo de preempt_disable() / preempt_enable(), o estar en contexto de interrupción, o sostener un lock.

Los usos abundan: estadísticas de red y de memoria, la freelist per-CPU de SLUB (nivel 20), las listas de páginas per-CPU del asignador, el estado per-CPU de RCU. Para leer el total global se suman las copias:

unsigned long total = 0;
int cpu;

for_each_possible_cpu(cpu)
	total += per_cpu(contador, cpu);

La suma es aproximada (los escritores siguen avanzando mientras lees), pero para estadísticas eso sobra, y nunca contiende con los escritores. Cero cache-line bouncing en el camino caliente.

local_lock: proteger datos per-CPU con disciplina

Rodear cada acceso de varios pasos con preempt_disable funciona, pero es frágil y no dice qué protege. Los kernels modernos usan local_lock_t (include/linux/local_lock.h):

#include <linux/local_lock.h>

struct cache_pcpu {
	local_lock_t lock;
	int          objetos[16];
};
static DEFINE_PER_CPU(struct cache_pcpu, cache) = {
	.lock = INIT_LOCAL_LOCK(lock),
};

void usar_cache(void)
{
	struct cache_pcpu *c;

	local_lock(&cache.lock);        /* fija el núcleo actual */
	c = this_cpu_ptr(&cache);
	/* ... varios pasos sobre c, sin miedo a migrar ... */
	local_unlock(&cache.lock);
}

En un kernel normal, local_lock es en esencia un preempt_disable con comprobaciones de lockdep; en PREEMPT_RT —hoy mainline— se transforma en un lock per-CPU real que puede dormir. Es la forma disciplinada y portable de blindar datos per-CPU de varios pasos, y expresa la intención que un preempt_disable suelto oculta.

Lecturas optimistas: seqlocks

El escenario: un dato pequeño, leído muchísimo y escrito poco, donde el lector debe ser rapidísimo y jamás bloquear al escritor. El caso canónico es el reloj del sistema, que gettimeofday lee miles de veces por segundo mientras una interrupción de timer lo actualiza. Un rwlock haría rebotar una línea de caché y bloquearía al escritor; RCU exigiría liberación diferida. El seqlock hace al lector optimista: lee sin bloquear y reintenta si detecta que hubo una escritura.

#include <linux/seqlock.h>

static seqlock_t reloj = __SEQLOCK_UNLOCKED(reloj);
static u64 segundos, nanos;

/* Escritor: serio, excluye a otros escritores, mueve el contador */
void actualizar_reloj(u64 s, u64 n)
{
	write_seqlock(&reloj);
	segundos = s;
	nanos = n;
	write_sequnlock(&reloj);
}

/* Lector: optimista, reintenta si el escritor pasó por en medio */
void leer_reloj(u64 *s, u64 *n)
{
	unsigned int seq;

	do {
		seq = read_seqbegin(&reloj);
		*s = segundos;
		*n = nanos;
	} while (read_seqretry(&reloj, seq));
}

El mecanismo es un contador de secuencia: par en reposo, impar mientras un escritor está dentro. read_seqbegin gira hasta ver un valor par y lo devuelve; read_seqretry devuelve verdadero (reintenta) si el contador cambió o es impar, señal de que un escritor se solapó con la lectura y los valores copiados podrían estar desgarrados. Por dentro, el contador y los datos se ordenan con smp_wmb/smp_rmb (nivel 18.3), de modo que las comprobaciones del lector encierran correctamente las lecturas de datos.

🛑
El lado lector debe ser puro

La sección de lectura puede ejecutarse varias veces y puede observar datos transitorios a medio escribir. Por eso no puede tener efectos secundarios: nada de escribir, liberar memoria ni actuar sobre lo leído hasta que read_seqretry confirme. Copias los valores y solo los usas tras salir del bucle con éxito. Un kfree o un puntero desreferenciado dentro del bucle es un bug.

Compromisos: el escritor es barato y nunca lo bloquean los lectores (ideal para un único escritor caliente como el timekeeper), pero una escritura intensa mata de hambre a los lectores, que reintentan sin fin. Solo sirve para estado pequeño y copiable, no para recorrer punteros con asignación. El seqcount_t es el núcleo sin bloqueo; el seqlock_t le añade un spinlock que serializa a los escritores. Usos reales: timekeeping (kernel/time/timekeeping.c), rename_lock sobre dentries, el recorrido de rutas en RCU de fs/namei.c, estadísticas de dispositivos de red.

ℹ️
La variante latch para lectores que no pueden reintentar

Cuando ni siquiera puedes tolerar un reintento —por ejemplo sched_clock, que debe devolver siempre un valor monótono y ya— existe seqcount_latch_t con raw_write_seqcount_latch. El escritor mantiene dos copias del dato y alterna cuál publica, de modo que el lector siempre encuentra una copia coherente sin bloquear ni reintentar. Es el seqlock llevado al extremo del camino más caliente del kernel.

flowchart TD
N[Necesito sincronizar] --> Q1[El dato se puede particionar por nucleo]
Q1 -- si --> PC[Variables per-CPU sin compartir]
Q1 -- no --> Q2[Lectura muy frecuente y dato pequeno]
Q2 -- si --> SQ[Seqlock lector optimista]
Q2 -- no --> Q3[Muchos lectores y punteros]
Q3 -- si --> RCU[RCU nivel 17]
Q3 -- no --> SL[Spinlock nivel 15]

La referencia canónica

Cuando dudes, hay una sola ley: Documentation/memory-barriers.txt, el tratado de unas tres mil líneas de Paul McKenney y otros que define el modelo de memoria de Linux, cada barrera y cada primitiva de este nivel. A su lado, tools/memory-model/ contiene el LKMM formal y los tests litmus para herd7, y Documentation/core-api/wrappers/memory-barriers.rst los envuelve para la web. Léelo entero al menos una vez en tu vida de kernel developer.

La sincronización más rápida es la que no haces

Mira el arco completo del nivel 18: de “el orden es mentira” (18.1) a las vallas de compilador (18.2), a las de CPU (18.3), a publicar y suscribir (18.4), y ahora a evitar sincronizar del todo (18.5). El principio maestro que emerge es contraintuitivo: no escalas bloqueando más rápido, escalas no compartiendo y no bloqueando al lector. Las variables per-CPU son el kernel diciendo “no coordines: particiona”. Los seqlocks dicen “no bloquees al lector: sé optimista y reintenta”. Es la misma filosofía que RCU, donde el lector no paga nada. Los kernels modernos escalan a miles de núcleos no por tener los locks más veloces del mundo, sino por haber diseñado los caminos calientes para que casi nunca haya un dato realmente compartido en disputa. Cuando internalizas eso, dejas de preguntar “qué lock pongo aquí” y empiezas a preguntar “cómo hago que aquí no haga falta ninguno”. Esa inversión de la pregunta es la cima de todo el arco de concurrencia, y el modo de pensar de quienes escriben el núcleo del núcleo.

⚔️ Particiona y sé optimista
  1. Define un contador con DEFINE_PER_CPU, increméntalo con this_cpu_inc en algún camino y agrega el total con for_each_possible_cpu.
  2. Explica por qué un acceso de varios pasos vía this_cpu_ptr necesita preempt_disable alrededor.
  3. Implementa un par de valores (por ejemplo, una coordenada) protegido por seqlock, con un escritor y un lector optimista.
  4. Razona por qué la sección de lectura no puede tener efectos secundarios ni liberar memoria.
  5. Hojea las secciones “SEQLOCKS” y “PER-CPU” de Documentation/memory-barriers.txt.