wandres.dev
EL SCHEDULER (EEVDF) · task_struct, context switch

El context switch: schedule, context_switch y su coste

Cómo el kernel cambia una CPU de una tarea a otra: schedule y __schedule eligen a la siguiente, context_switch conmuta el espacio de direcciones con switch_mm, switch_to guarda y restaura registros y pila en ensamblador, y finish_task_switch cierra la operación. Por qué un cambio de contexto no es gratis: recarga de CR3, TLB, contaminación de caché y el papel del parámetro last.

⏱ 17 min

Elegir a la siguiente tarea es la mitad barata del trabajo; la otra mitad es materializar el cambio. Conmutar una CPU de la tarea que corría a la que va a correr significa guardar todo el estado visible de la primera —registros, puntero de pila, espacio de direcciones— y restaurar el de la segunda, de modo que cada una, cuando vuelva, no note que estuvo suspendida. Ese acto se llama context switch, ocurre miles de veces por segundo y por núcleo, y su coste real no está en las instrucciones que ejecuta sino en la resaca microarquitectónica que deja detrás.

🎯 Al terminar esta lección sabrás
  • Seguir el camino de schedule a __schedule y a context_switch.
  • Entender la conmutación del espacio de direcciones con switch_mm y el TLB perezoso.
  • Ver cómo switch_to guarda y restaura registros y pila en ensamblador.
  • Razonar el coste real: recarga de CR3, TLB, caché y por qué existe el parámetro last.

schedule y __schedule: el corazón

schedule() es la puerta pública; el trabajo lo hace __schedule() en kernel/sched/core.c, siempre con la preempción desactivada:

asmlinkage __visible void __sched schedule(void)
{
	struct task_struct *tsk = current;

	sched_submit_work(tsk);        /* vaciar plugs de E/S antes de dormir */
	do {
		preempt_disable();
		__schedule(SM_NONE);
		sched_preempt_enable_no_resched();
	} while (need_resched());      /* reintentar si volvio a marcarse */
}

El núcleo de __schedule toma el rq->lock, decide si la tarea saliente se va a dormir (y en tal caso la saca de la runqueue con deactivate_task), pide a las clases la siguiente tarea y, si difiere de la actual, ejecuta el cambio:

static void __sched notrace __schedule(int sched_mode)
{
	struct task_struct *prev, *next;
	struct rq_flags rf;
	struct rq *rq;
	int cpu;

	cpu  = smp_processor_id();
	rq   = cpu_rq(cpu);
	prev = rq->curr;

	rq_lock(rq, &rf);
	update_rq_clock(rq);

	/* Si prev se bloquea y no hay senal pendiente, sacarla de la cola */
	if (!(sched_mode & SM_MASK_PREEMPT) && prev_state) {
		if (signal_pending_state(prev_state, prev))
			WRITE_ONCE(prev->__state, TASK_RUNNING);
		else
			deactivate_task(rq, prev, DEQUEUE_SLEEP | DEQUEUE_NOCLOCK);
	}

	next = pick_next_task(rq, prev, &rf);   /* recorre las clases */
	clear_tsk_need_resched(prev);

	if (likely(prev != next)) {
		rq->curr = next;
		/* Aqui ocurre el cambio real; al 'volver' ya somos otra tarea */
		rq = context_switch(rq, prev, next, &rf);
	} else {
		rq_unlock_irq(rq, &rf);             /* nada que cambiar */
	}
}

Nótese que rq->lock se toma aquí pero no se suelta en la rama del cambio: viaja con la tarea y lo libera finish_task_switch ya en el contexto de next. Es una de las pocas veces en el kernel donde un lock se adquiere en una tarea y se libera en otra.

context_switch: cambiar de mundo

context_switch hace dos conmutaciones consecutivas: primero el espacio de direcciones, luego los registros y la pila.

static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
	       struct task_struct *next, struct rq_flags *rf)
{
	prepare_task_switch(rq, prev, next);

	/*
	 * Conmutar el espacio de direcciones. Un hilo de kernel (mm == NULL)
	 * toma prestado el active_mm del anterior: TLB perezoso, sin recargar
	 * las tablas de paginas ni pagar el coste del cambio de CR3.
	 */
	if (!next->mm) {                                  /* -> hilo de kernel */
		enter_lazy_tlb(prev->active_mm, next);
		next->active_mm = prev->active_mm;
		if (prev->mm)
			mmgrab_lazy_tlb(prev->active_mm);
	} else {                                          /* -> tarea de usuario */
		membarrier_switch_mm(rq, prev->active_mm, next->mm);
		switch_mm_irqs_off(prev->active_mm, next->mm, next); /* recarga CR3 */
	}

	/* El intercambio de registros y pila; al retornar ya corre 'next' */
	switch_to(prev, next, prev);
	barrier();

	return finish_task_switch(prev);   /* cierra: libera lock, mmdrop... */
}

La optimización del TLB perezoso es central: los hilos de kernel no tienen espacio de usuario propio, así que en lugar de recargar CR3 (el registro que apunta a la raíz de las tablas de páginas en x86) simplemente heredan el active_mm de la tarea anterior. Cambiar de una tarea de usuario a un kthread y de vuelta puede así no pagar ni una sola invalidación de TLB. switch_mm_irqs_off es quien, cuando sí hace falta, carga las tablas nuevas; en CPUs con PCID escribe CR3 etiquetando las entradas para no vaciar el TLB de golpe.

switch_to: registros y pila

El corazón atómico del cambio está en ensamblador, porque hay que reescribir el propio puntero de pila a media función. En x86-64 la macro switch_to llama a __switch_to_asm:

/* arch/x86/include/asm/switch_to.h */
#define switch_to(prev, next, last)					\
do {									\
	((last) = __switch_to_asm((prev), (next)));			\
} while (0)
/* arch/x86/entry/entry_64.S (condensado) */
SYM_FUNC_START(__switch_to_asm)
	/* 1. Guardar los registros callee-saved en la pila de prev */
	pushq	%rbp
	pushq	%rbx
	pushq	%r12
	pushq	%r13
	pushq	%r14
	pushq	%r15

	/* 2. El cambio: intercambiar el puntero de pila */
	movq	%rsp, TASK_threadsp(%rdi)   /* prev->thread.sp = rsp */
	movq	TASK_threadsp(%rsi), %rsp   /* rsp = next->thread.sp */

	/* 3. Restaurar los callee-saved desde la pila de next */
	popq	%r15
	popq	%r14
	popq	%r13
	popq	%r12
	popq	%rbx
	popq	%rbp

	jmp	__switch_to                 /* el resto en C */
SYM_FUNC_END(__switch_to_asm)

El instante decisivo es el par de movq que intercambia %rsp: en cuanto la segunda instrucción se ejecuta, la CPU está sobre la pila de next, y todos los popq siguientes desapilan su estado, no el de prev. La función __switch_to en C remata lo que el ensamblador no cubre: cambia la base de FS/GS (el almacenamiento por hilo), el sp0 de la TSS, el estado de la FPU y el mapa de bits de puertos de E/S. Al saltar de vuelta, next reanuda su ejecución exactamente donde la dejó, en su propia llamada a schedule de hace quizá minutos.

ℹ️
El estado callee-saved basta porque schedule es una función normal

Solo se guardan los seis registros callee-saved, no los dieciséis. El resto (los caller-saved) ya los preservó el compilador al llamar a schedule, siguiendo la ABI. El cambio de contexto es, para el código que lo invoca, una llamada a función que tarda mucho en volver: por eso encaja en las reglas normales de la ABI y no necesita salvar el banco entero como haría un manejador de interrupción.

El coste: por qué un cambio no es gratis

Las instrucciones de __switch_to_asm son pocas y rápidas. El coste real es indirecto y llega después:

🧠

TLB y CR3

Cambiar de espacio de usuario recarga CR3. Sin PCID eso vacía el TLB entero y las siguientes traducciones de dirección fallan hasta recalentarlo. Con PCID se etiquetan las entradas y muchas sobreviven.

💾

Contaminación de caché

La tarea entrante encuentra las cachés L1 y L2 llenas del conjunto de trabajo de la saliente. Sus primeros accesos son fallos que hay que servir desde niveles lentos: el verdadero peaje del cambio.

🪶

Kthreads: gratis a medias

Un hilo de kernel no conmuta mm: hereda el active_mm anterior con TLB perezoso. Ir a un kthread y volver puede no costar ni una invalidación.

📊

Cómo medirlo

El tracepoint sched_switch, perf sched latency y los contadores *_ctxt_switches de /proc/PID/status cuantifican cuántos cambios hay y de qué tipo.

De ahí varias consecuencias de diseño: reducir el número de cambios (colas de trabajo por lotes, io_uring frente a un hilo por conexión), preferir despertar en la misma CPU para reaprovechar cachés calientes (afinidad y wake affine), y evitar rebotar una tarea entre núcleos sin necesidad. Un cambio voluntario —la tarea se durmió— es sano; una avalancha de cambios involuntarios por preempción suele delatar contención o sobresuscripción de CPU.

sequenceDiagram
participant P as Tarea prev
participant S as __schedule
participant CS as context_switch
participant N as Tarea next
P->>S: llama a schedule y cede la CPU
S->>S: pick_next_task elige next
S->>CS: entrega prev y next
CS->>CS: switch_mm conmuta el espacio de direcciones
CS->>CS: switch_to intercambia registros y pila
CS->>N: reanuda next donde durmio
N->>N: finish_task_switch libera el rq lock
La costura donde una tarea se duerme y despierta siendo otra

Vuelve a mirar la línea switch_to(prev, next, prev) y deja que su rareza te desasosiegue, porque encierra una de las ideas más vertiginosas del kernel. Esa llamada la ejecuta prev: es prev quien entra en la función. Pero cuando la función retorna, la CPU ya no está ejecutando a prev —está ejecutando a next, sobre la pila de next, reanudando la llamada a switch_to que next hizo la última vez que fue expulsada, hace un microsegundo o hace una hora. Una sola línea de C donde te duermes siendo una tarea y despiertas siendo otra. Y aquí aparece el tercer argumento, last, cuya necesidad solo se entiende desde esta perspectiva: cuando prev por fin resucite —quizá en otra CPU, tras pasar por manos de terceras tareas—, necesitará recuperar quién era antes de dormirse, porque la pila sobre la que ahora corre fue cambiada por un context_switch distinto del que la durmió. El parámetro last es el hilo de Ariadna que devuelve a cada tarea su identidad al salir del laberinto. Comprender esto reordena tu modelo del kernel: el flujo de ejecución no pertenece a las tareas, las atraviesa. Una CPU es un único carril de tiempo por el que las tareas entran y salen, y el context switch es la costura invisible donde una cede el carril a la siguiente sin que ninguna de las dos perciba discontinuidad. Toda la ilusión de multitarea del nivel anterior se paga aquí, en estas pocas instrucciones que reescriben el presente de un procesador.

⚔️ Cronometra y observa el cambio de contexto
  1. Ejecuta perf bench sched pipe y perf stat -e context-switches,cpu-migrations sobre una carga real, y relaciona los números con sched_switch.
  2. Explica, línea a línea de __switch_to_asm, qué ocurre exactamente en el par de movq que intercambia %rsp y por qué a partir de ahí ya corres sobre otra pila.
  3. Razona por qué un cambio hacia un hilo de kernel puede no recargar CR3 mientras que uno hacia una tarea de usuario sí, y qué papel juega active_mm.
  4. Con dos procesos fijados a la misma CPU (taskset) frente a dos en CPUs distintas, mide la diferencia y atribúyela a caché y TLB.
  5. Argumenta por qué rq->lock se toma en prev y se libera en next, y qué invariante rompería soltarlo antes de switch_to.