Variables per-CPU: una copia por núcleo, cero sincronización
La sincronización más barata es no sincronizar. DEFINE_PER_CPU y alloc_percpu dan a cada núcleo su propia copia del dato; this_cpu_ptr, this_cpu_inc y get_cpu_var operan sobre la copia local sin locks ni barreras. Cómo se implementa con la sección .data..percpu y el registro de segmento, y por qué particionar el dato elimina la contención de raíz.
Todo el nivel 18 fue sobre negociar el orden entre núcleos con barreras y locks. Las variables per-CPU dan el paso radical: no negociar. Si cada núcleo tiene su propia copia del dato, no hay nada compartido que ordenar, ninguna línea de caché que rebote, ningún lock que adquirir. Es la técnica que sostiene los contadores de red, las freelists del asignador de slab y la propia maquinaria de RCU. Particionar en vez de sincronizar: la escalabilidad por diseño, no por candados más rápidos.
- Declarar datos per-CPU con
DEFINE_PER_CPUy asignarlos conalloc_percpu. - Acceder a la copia local con
this_cpu_*,this_cpu_ptryget_cpu_var. - Entender por qué la preferencia y la migración obligan a fijar el núcleo.
- Comprender la implementación: sección
.data..percpuy offset por CPU.
Declarar y asignar datos per-CPU
La cabecera es include/linux/percpu.h. Una variable per-CPU se declara una vez y el kernel materializa una copia física por núcleo:
#include <linux/percpu.h>
/* Estatica: una copia de 'stats' por cada CPU del sistema */
struct net_stats {
unsigned long paquetes;
unsigned long bytes;
};
static DEFINE_PER_CPU(struct net_stats, stats);
/* Dinamica: el equivalente en tiempo de ejecucion */
struct net_stats __percpu *pstats = alloc_percpu(struct net_stats);
/* ...usar... */
free_percpu(pstats);
El calificador __percpu no es decorativo: marca el puntero como “esto es un offset per-CPU, no una dirección normal”, y sparse (el analizador estático del kernel) chilla si lo desreferencias sin las macros adecuadas. Ese tipeo fuerte convierte un error de concepto en un error de compilación.
Acceder a la copia local
Hay dos familias de acceso, y la distinción es la clave de todo:
/* 1) Operaciones de un solo paso: atomicas frente a IRQ y preferencia */
this_cpu_inc(stats.paquetes); /* += 1 sobre la copia local */
this_cpu_add(stats.bytes, len);
unsigned long n = this_cpu_read(stats.paquetes);
/* 2) Acceso por puntero de varios pasos: hay que FIJAR el nucleo */
struct net_stats *s = get_cpu_ptr(&stats); /* desactiva preferencia */
s->paquetes++;
s->bytes += len;
put_cpu_ptr(&stats); /* la reactiva */
Las macros this_cpu_read/write/inc/add compilan a una sola instrucción con prefijo de segmento (en x86, %gs apunta al área per-CPU del núcleo actual): “elige mi copia y opérala” ocurre atómicamente, sin que una interrupción o una migración se cuele en medio. En cambio, this_cpu_ptr(&var) solo te da el puntero a tu copia; entre calcularlo y usarlo el kernel podría expulsarte (preempt) y migrarte a otro núcleo, dejándote escribiendo en la copia equivocada. Por eso el acceso de varios pasos exige fijar el núcleo con get_cpu_ptr/put_cpu_ptr, con get_cpu_var/put_cpu_var, o estar ya en contexto de interrupción o bajo un lock.
Con datos per-CPU no hay contención entre CPUs —cada una toca lo suyo—. El peligro es sutil y local: que tú mismo cambies de núcleo entre leer this_cpu_ptr y usar el puntero. El kernel moderno lo expresa con disciplina mediante local_lock_t (visto en 18.5): en un kernel normal es en esencia un preempt_disable con comprobaciones de lockdep; bajo PREEMPT_RT —hoy mainline— se transforma en un lock per-CPU real que puede dormir. Es la forma portable y autodocumentada de blindar un acceso per-CPU de varios pasos.
Para leer el total global se suman las copias de todos los núcleos, sabiendo que el resultado es aproximado porque los escritores siguen avanzando:
unsigned long total = 0;
int cpu;
for_each_possible_cpu(cpu)
total += per_cpu(stats.paquetes, cpu); /* copia de un CPU concreto */
this_cpu_inc / read / add
Operación de un solo paso sobre la copia local, atómica frente a interrupciones y preferencia. La forma por defecto para contar sin fijar nada.
this_cpu_ptr
Puntero a la copia local. Rapidísimo, pero solo válido con el núcleo fijado: úsalo bajo get_cpu/put_cpu, en IRQ o con un lock sostenido.
get_cpu_var / put_cpu_var
Abren y cierran un bloque de varios pasos con la preferencia desactivada. La forma clásica y explícita de fijar el núcleo.
per_cpu con cpu explícito
Accede a la copia de un núcleo concreto. Es la pieza para recorrer y agregar el total con for_each_possible_cpu.
smp_processor_id() devuelve el número del núcleo actual, pero llamarlo con la preferencia activa es un bug latente: podrías migrar justo después y quedarte con un número obsoleto. Con CONFIG_DEBUG_PREEMPT el kernel lo caza y avisa. Cuando de verdad no importa —código estadístico donde una migración es tolerable— existe raw_smp_processor_id(), que silencia la comprobación y documenta que la carrera es aceptable.
Cómo funciona por dentro
El truco de implementación es elegante. El enlazador reúne todas las variables per-CPU en una sección especial, .data..percpu, que actúa de plantilla. Al arrancar, el kernel reserva un bloque de memoria por núcleo y guarda en __per_cpu_offset[cpu] la distancia desde la plantilla hasta el bloque de ese núcleo:
/* Idea esencial de this_cpu_ptr / per_cpu_ptr */
#define per_cpu_ptr(ptr, cpu) \
((typeof(ptr))((void *)(ptr) + __per_cpu_offset[cpu]))
/* La copia local usa el offset del nucleo actual, en x86 via %gs */
#define this_cpu_ptr(ptr) per_cpu_ptr(ptr, smp_processor_id())
Acceder a la copia local es, pues, “dirección de la plantilla más mi offset”: una suma, sin indirecciones ni búsquedas. Y como el offset del núcleo actual vive en un registro de segmento, la CPU lo aplica en la propia instrucción de acceso. Ahí está el porqué de que this_cpu_inc no necesite lock: no hay dato compartido, y la elección de “mi copia” es inseparable de la operación.
flowchart TD V[Variable per-CPU declarada una vez] --> B[El kernel crea una copia por nucleo] B --> P0[Copia del nucleo 0] B --> P1[Copia del nucleo 1] B --> P2[Copia del nucleo 2] P0 --> S[Cada nucleo escribe solo en su copia] P1 --> S P2 --> S S --> R[Sin lock sin barrera sin rebote de cache] style R fill:#a6e3a1,color:#11111b
Para datos per-CPU escritos con furia conviene alinearlos a línea de caché con DEFINE_PER_CPU_SHARED_ALIGNED, y así evitar el false sharing con vecinos (26.5). Los usos reales son legión: la freelist per-CPU de SLUB, las listas de páginas per-CPU del buddy allocator (per_cpu_pageset), el estado per-CPU de RCU, las estadísticas de vmstat, los contadores de interrupciones de /proc/interrupts.
Detente en la inversión conceptual, porque es una de las más profundas de la ingeniería de sistemas. Durante todo el arco de concurrencia la pregunta fue “¿qué mecanismo uso para que varios núcleos compartan este dato de forma segura?” —spinlock, mutex, RCU, barreras—. Las variables per-CPU responden con otra pregunta: “¿y si no lo comparten?”. Si el dato se puede partir en una copia por núcleo, el problema de la sincronización no se resuelve: se disuelve. No hay línea de caché que rebote entre sockets, no hay lock que contienda, no hay orden de memoria que negociar, porque no hay nada compartido. La contención no se ha hecho más barata; ha dejado de existir. Ese es el secreto de por qué el kernel de Linux escala a miles de núcleos: no por tener los locks más veloces del planeta, sino por haber rediseñado sus caminos calientes para que casi nunca haya un dato genuinamente compartido en disputa. Cuando internalizas esto, tu instinto ante un cuello de botella de concurrencia cambia para siempre. Dejas de preguntar “¿qué lock pongo aquí?” y empiezas a preguntar “¿puedo hacer que aquí no haga falta ninguno?”. Esa segunda pregunta, la de particionar antes que sincronizar, es el modo de pensar de quien escribe el núcleo del núcleo.
- Declara un contador con
DEFINE_PER_CPU, increméntalo conthis_cpu_incen algún camino y agrega el total confor_each_possible_cpu. - Explica por qué
this_cpu_incno necesita lock pero un acceso de varios pasos víathis_cpu_ptrsí necesita fijar el núcleo. - Reescribe ese acceso de varios pasos con
get_cpu_ptr/put_cpu_ptry justifica dónde empieza y acaba la sección. - Describe qué guarda
__per_cpu_offset[cpu]y cómothis_cpu_ptrlo usa para llegar a la copia local. - Busca dos usuarios reales de datos per-CPU en el árbol (por ejemplo
mm/slub.conet/) y explica qué particionan.