wandres.dev
SMP, RT Y CGROUPS · balanceo, tiempo real, recursos

SMP: una cola de ejecución por núcleo y el arte de reequilibrar

Por qué un servidor de 2026 con cientos de núcleos no puede tener una sola cola de ejecución global sin que su lock se convierta en el cuello de botella del sistema, cómo Linux da a cada CPU su propia struct rq, cómo la cadena de sched_class elige la siguiente tarea, y por qué particionar obliga a un contrapeso: el balanceo de carga que migra tareas entre núcleos a través de los dominios de scheduling.

⏱ 18 min

Un servidor de 2026 apila fácilmente doscientos núcleos lógicos. Imagina una única lista global de tareas listas protegida por un solo lock: cada tick, cada despertar, cada cambio de contexto en cualquiera de esos doscientos núcleos tendría que tomar ese candado. El lock —no la CPU— sería la máquina. Linux resuelve el problema con el mismo instinto del nivel 26: particionar. Cada CPU tiene su propia cola de ejecución y su propio lock, y decide a solas a quién correr. Pero partir crea un problema nuevo y simétrico: el trabajo se acumula desigual. El balanceo de carga es el contrapeso que reparte sin destruir la localidad.

🎯 Al terminar esta lección sabrás
  • Entender por qué cada CPU tiene su propia struct rq con su propio lock.
  • Recorrer la cadena de sched_class y cómo pick_next_task elige a la siguiente tarea.
  • Ver cómo el balanceo migra tareas con load_balance sobre los dominios de scheduling.
  • Comprender el compromiso entre localidad de caché y reparto equitativo.

Una cola de ejecución por núcleo

El planificador no guarda una lista de tareas listas: guarda una por CPU. La estructura es struct rq (runqueue), y es una variable per-CPU —exactamente la técnica del nivel 26— alineada a línea de caché para que dos núcleos nunca compartan la suya:

/* kernel/sched/core.c */
DEFINE_PER_CPU_SHARED_ALIGNED(struct rq, runqueues);

/* kernel/sched/sched.h */
#define cpu_rq(cpu)   (&per_cpu(runqueues, (cpu)))
#define this_rq()     this_cpu_ptr(&runqueues)
#define task_rq(p)    cpu_rq(task_cpu(p))

Cada rq lleva su propio candado, rq->__lock, un raw_spinlock (nivel 15). La consecuencia es enorme: planificar en el núcleo 7 no toca ninguna estructura del núcleo 8, así que doscientos núcleos toman doscientas decisiones en paralelo, sin contención. El precio es que una tarea “vive” en la rq de un núcleo concreto, y moverla a otro exige tomar los dos locks en orden —double_rq_lock— y actualizar task_cpu(p). Esa migración no es gratis, y de encarecerla brota todo el resto del nivel.

El planificador es modular: la cadena de clases

Dentro de cada rq no hay un algoritmo único sino varias políticas apiladas por prioridad absoluta. Cada una es un struct sched_class, y el kernel las encadena de la más urgente a la más laxa mediante una sección del enlazador:

/* orden real de mayor a menor prioridad (kernel/sched/) */
/*   stop_sched_class   -> migracion y hotplug, por encima de todo   */
/*   dl_sched_class     -> SCHED_DEADLINE (EDF), nivel 47.3          */
/*   rt_sched_class     -> SCHED_FIFO y SCHED_RR, nivel 47.3         */
/*   fair_sched_class   -> SCHED_NORMAL, hoy EEVDF                   */
/*   idle_sched_class   -> la tarea ociosa, cuando no hay nada mas   */

static struct task_struct *__pick_next_task(struct rq *rq, ...)
{
	const struct sched_class *class;

	for_each_class(class) {                 /* de dl a idle, en orden */
		struct task_struct *p = class->pick_next_task(rq);
		if (p)
			return p;                   /* la primera clase con algo, gana */
	}
	BUG();                                  /* idle siempre devuelve la idle task */
}

for_each_class recorre las clases en orden fijo: si hay una tarea SCHED_DEADLINE lista, corre antes que cualquier SCHED_FIFO; si hay una SCHED_FIFO, corre antes que cualquier tarea normal. Las tareas corrientes —el 99% del sistema— caen en fair_sched_class, que desde el kernel 6.6 implementa EEVDF (Earliest Eligible Virtual Deadline First), sucesor de CFS: cada entidad acumula vruntime y recibe un plazo virtual que decide su turno, buscando repartir la CPU con justicia y baja latencia. La estructura de árbol y el detalle de EEVDF los abre el nivel de scheduling justo; aquí basta con retener que SCHED_NORMAL es la clase fair, y que las clases de tiempo real la pisan siempre.

ℹ️
La cola por CPU no es una lista, es un árbol por clase

Decir runqueue evoca una cola FIFO, pero cada clase organiza sus tareas como más le conviene. La clase fair usa un árbol rojinegro ordenado por plazo virtual; la clase RT, un array de listas indexado por prioridad (bitmap más struct list_head[100]); SCHED_DEADLINE, un árbol ordenado por plazo absoluto. La struct rq es solo el contenedor per-CPU que agrupa esas subestructuras y el estado común: el reloj rq->clock, la tarea en curso rq->curr y el candado.

Balanceo de carga: migrar sin desbalancear

Si cada núcleo elige de su propia cola, ¿qué impide que ocho tareas caigan en el núcleo 0 mientras el 1 duerme ocioso? Nada, salvo el balanceador de carga. Corre en dos momentos. En el despertar de una tarea, select_task_rq_fair elige de entrada un buen núcleo destino —idealmente uno ocioso y cercano en caché al que la despierta—. Y de forma periódica, el tick del planificador dispara un softirq que reequilibra:

/* kernel/sched/fair.c, esquema del camino periodico */
void sched_tick(void)               /* en cada tick de este CPU */
{
	/* ...contabilidad de la tarea en curso... */
	trigger_load_balance(rq);   /* si toca, levanta SCHED_SOFTIRQ */
}

/* el softirq acaba en: */
static int load_balance(int this_cpu, struct rq *this_rq,
			struct sched_domain *sd, ...)
{
	struct sched_group *group = find_busiest_group(&env);
	if (!group)
		return 0;                       /* ya esta equilibrado */

	struct rq *busiest = find_busiest_queue(&env, group);
	/* detach_tasks() del busiest -> attach_tasks() aqui */
	/* respetando afinidad, cache-hotness y limites de carga */
}

La clave está en cómo decide a quién robar tareas, y ahí entran los dominios de scheduling (struct sched_domain). El kernel modela la topología del hardware como una jerarquía: hilos SMT que comparten núcleo físico, núcleos que comparten caché L3, sockets, nodos NUMA (nivel 26). Balancear entre dos hilos SMT es baratísimo y se hace muy a menudo; balancear entre dos nodos NUMA es carísimo —mueve datos lejos de su memoria— y se hace rara vez y con histéresis. La medida de “cuánto pesa” una tarea no es un contador ingenuo sino PELT (Per-Entity Load Tracking), una media geométrica que estima la demanda reciente de cada tarea y cada rq.

flowchart TD
N[Nodo NUMA: balanceo caro y raro] --> L3a[Dominio LLC socket 0]
N --> L3b[Dominio LLC socket 1]
L3a --> Ca[Nucleo fisico 0]
L3a --> Cb[Nucleo fisico 1]
Cb --> T0[Hilo SMT 0: balanceo barato y frecuente]
Cb --> T1[Hilo SMT 1: balanceo barato y frecuente]
style N fill:#f38ba8,color:#11111b
style T0 fill:#a6e3a1,color:#11111b
style T1 fill:#a6e3a1,color:#11111b
🧮

Cola per-CPU

Cada núcleo decide de su propia struct rq con su rq->__lock. Cero contención en el camino caliente; el precio es que migrar cuesta dos locks.

🪜

Cadena de clases

dl sobre rt sobre fair sobre idle. pick_next_task devuelve la primera clase con trabajo: la prioridad entre políticas es absoluta.

⚖️

Balanceo de carga

load_balance migra tareas del núcleo más cargado al ocioso, guiado por los dominios de scheduling para pagar poco cuando puede.

📉

PELT

La carga no se cuenta por número de tareas sino por demanda estimada: una media que decae y refleja el uso reciente de CPU.

⚠️
Migrar enfría la caché: el equilibrio perfecto puede ser más lento

El balanceador nunca busca el reparto matemáticamente exacto, y hace bien. Una tarea que lleva rato en el núcleo 3 tiene ahí su código y sus datos calientes en L1 y L2; arrancarla al núcleo 5 por cuadrar un decimal la deja escribiendo y leyendo en cachés frías, y el coste de repoblarlas puede exceder con creces lo ganado. Por eso load_balance pondera la cache-hotness (task_hot), impone umbrales de desbalance mínimo antes de mover nada y equilibra con frecuencia decreciente según se sube en la jerarquía de dominios. Equilibrio y localidad tiran en direcciones opuestas: el arte está en el punto medio, no en el extremo.

Particionar y reequilibrar son la respiración del kernel escalable

Detente en la figura completa, porque es el patrón maestro de todo sistema que aspira a escalar a cientos de núcleos. En el nivel 26 viste la primera mitad: para no contender, parte el dato en una copia por CPU. Aquí ves que esa mitad, sola, es insuficiente y hasta peligrosa. Si cada núcleo planifica solo de su cola, el sistema gana paralelismo perfecto pero pierde la visión de conjunto: nada impide que un núcleo se ahogue con ocho tareas mientras siete duermen ociosos, y el paralelismo teórico se desperdicia en la práctica. La partición compra escalabilidad al precio de la ceguera global. El balanceo de carga es la segunda mitad, el latido complementario: cada cierto tiempo, y solo cada cierto tiempo, alguien levanta la vista sobre las colas locales y redistribuye. Fíjate en la asimetría deliberada: el camino rápido —elegir a quién correr ahora— es puramente local y sin locks compartidos, mientras que el camino lento —reequilibrar— es global, caro y poco frecuente. Esa es exactamente la misma forma que verás en memcg, en el asignador de páginas per-CPU con su devolución al buddy, en las freelists de SLUB con su reaprovisionamiento. El principio se enuncia en una frase: decide localmente casi siempre, coordina globalmente casi nunca. Cuando internalizas que escalar no es tener el lock global más rápido sino diseñar para que el camino caliente jamás lo necesite, y aceptar a cambio una corrección global perezosa, dejas de ver el planificador como un algoritmo y empiezas a verlo como lo que es: la coreografía entre autonomía local y equilibrio global que hace posible el cómputo masivamente paralelo.

⚔️ Observa las colas y la topología de tu máquina
  1. Ejecuta cat /proc/schedstat y ls /sys/kernel/debug/sched/domains/cpu0/ para ver los dominios de scheduling reales de tu CPU y sus banderas.
  2. Explica por qué struct rq se declara con DEFINE_PER_CPU_SHARED_ALIGNED y no con un simple DEFINE_PER_CPU.
  3. Recorre pick_next_task en kernel/sched/core.c y argumenta por qué idle_sched_class nunca puede devolver NULL.
  4. Con perf sched o schedstat, provoca desbalance lanzando más hilos calientes que núcleos y observa cómo se reparten; razona cuándo el balanceador decide no migrar.
  5. Justifica, con la jerarquía de dominios, por qué balancear entre dos hilos SMT es mucho más barato que entre dos nodos NUMA.