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.
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.
- Seguir el camino de
schedulea__scheduley acontext_switch. - Entender la conmutación del espacio de direcciones con
switch_mmy el TLB perezoso. - Ver cómo
switch_toguarda y restaura registros y pila en ensamblador. - Razonar el coste real: recarga de
CR3, TLB, caché y por qué existe el parámetrolast.
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.
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
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.
- Ejecuta
perf bench sched pipeyperf stat -e context-switches,cpu-migrationssobre una carga real, y relaciona los números consched_switch. - Explica, línea a línea de
__switch_to_asm, qué ocurre exactamente en el par demovqque intercambia%rspy por qué a partir de ahí ya corres sobre otra pila. - Razona por qué un cambio hacia un hilo de kernel puede no recargar
CR3mientras que uno hacia una tarea de usuario sí, y qué papel juegaactive_mm. - 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. - Argumenta por qué
rq->lockse toma enprevy se libera ennext, y qué invariante rompería soltarlo antes deswitch_to.