softirqs y tasklets: diferir sin dormir
El trabajo diferido clásico que corre en contexto atómico y no puede dormir: los softirqs estáticos por CPU que sostienen los caminos calientes de red y bloque, cómo ksoftirqd absorbe las avalanchas para no matar de hambre al espacio de usuario, y por qué los tasklets, azúcar sobre softirq, están siendo retirados del kernel.
No todo trabajo diferido puede permitirse el lujo de un cambio de contexto. El camino de recepción de red procesa millones de paquetes por segundo; si cada uno esperara a que se planificara un hilo, la latencia lo hundiría. Para esos caminos ultracalientes el kernel guarda un mecanismo que corre casi de inmediato, en cuanto se sale del manejador de interrupción, sin dormir ni ceder la CPU: el softirq. Es rápido y peligroso a partes iguales, porque sigue siendo contexto atómico, y sobre él se construyó el tasklet, hoy en franca retirada.
- Entender el softirq: trabajo diferido atómico, por CPU y de alta prioridad.
- Conocer los vectores estáticos
NET_RX,NET_TX,BLOCK,TIMER,TASKLETyRCU. - Ver cómo
ksoftirqdabsorbe las avalanchas sin matar de hambre al espacio de usuario. - Saber qué es un tasklet, por qué se apoya en softirq y por qué está en desuso.
El softirq: estático, por CPU y atómico
Un softirq no se crea al vuelo: es uno de un conjunto fijo, definido en tiempo de compilación en un enumerado de linux/interrupt.h. Solo los subsistemas del núcleo registran su vector con open_softirq, una vez al arrancar; un driver jamás inventa softirqs nuevos, solo puede activar los existentes. Corren al salir de una interrupción dura —en irq_exit— con las IRQs de hardware reabiertas pero la preempción y las mitades inferiores deshabilitadas: por eso siguen siendo atómicos y no pueden dormir. Y a diferencia de todo lo demás en este nivel, un mismo softirq puede correr en paralelo en varias CPUs, así que sus datos han de ser por CPU o estar protegidos por spinlocks.
/* un subsistema del núcleo declara su vector una sola vez, al arrancar */
open_softirq(NET_RX_SOFTIRQ, net_rx_action);
/* el driver, desde su mitad superior, solo lo marca pendiente en esta CPU */
__raise_softirq_irqoff(NET_RX_SOFTIRQ);
Marcar un softirq como pendiente es poner un bit en una máscara por CPU. Al salir de la interrupción, __do_softirq recorre esa máscara y ejecuta los manejadores pendientes en orden de prioridad. Todo ocurre sin construir ni destruir ningún hilo: esa es la razón de su velocidad y de su rigidez.
Esa reentrancia paralela tiene una consecuencia práctica ineludible: como el mismo manejador puede correr a la vez en varias CPUs, su estado no puede ser una variable global desnuda. O es por CPU —y entonces cada instancia toca solo lo suyo, sin locks— o está protegido por spinlocks. Los caminos calientes eligen casi siempre lo primero:
/* estado por CPU: cada instancia del softirq toca solo su propia cola */
static DEFINE_PER_CPU(struct cola_rx, colas_rx);
static void net_rx_action(struct softirq_action *h)
{
struct cola_rx *cola = this_cpu_ptr(&colas_rx); /* sin locks: es MÍA */
int presupuesto = 64; /* techo de trabajo */
while (presupuesto-- && !cola_vacia(cola))
procesar_uno(cola); /* nadie más toca la cola de esta CPU */
}
Ese presupuesto acotado es la otra mitad de la disciplina: ningún paso del softirq monopoliza la CPU, porque al agotarlo cede y deja que el bucle de __do_softirq decida si continuar o delegar el resto en ksoftirqd.
Los vectores y sus dueños: red y bloque
El conjunto es corto y cada entrada tiene dueño fijo, ordenada por prioridad descendente: HI, TIMER, NET_TX, NET_RX, BLOCK, IRQ_POLL, TASKLET, SCHED, HRTIMER y RCU. No es casualidad que los dos caminos más intensos del kernel —la red y el almacenamiento de bloque— tengan vector propio: son precisamente los que no toleran el coste de un cambio de contexto por operación.
/* NAPI: la mitad superior de la NIC solo programa el softirq de recepción */
static irqreturn_t nic_irq(int irq, void *dev_id)
{
struct mi_nic *nic = dev_id;
writel(INT_RX, nic->regs + REG_INT_MASK_SET); /* enmascara la fuente */
napi_schedule(&nic->napi); /* eleva NET_RX_SOFTIRQ */
return IRQ_HANDLED;
}
/* net_rx_action (el manejador de NET_RX) llamará luego a nic->poll para */
/* drenar el anillo en contexto de softirq, sin dormir, cientos de paquetes */
cat /proc/softirqs # contadores por vector y por CPU
El patrón es siempre el mismo: la interrupción enmascara su fuente y eleva el softirq; el softirq drena el lote con las IRQs abiertas. Una sola interrupción amortiza el proceso de una ráfaga entera.
ksoftirqd: la válvula contra la inanición
Aquí surge una tensión hermosa. Los softirqs corren al volver de cada interrupción, con prioridad sobre el espacio de usuario. ¿Qué pasa si llegan más rápido de lo que se procesan —una tormenta de paquetes— y cada softirq, al correr, genera más trabajo que vuelve a elevarlo? Sin freno, la máquina procesaría softirqs para siempre y ningún proceso de usuario avanzaría: inanición total. La válvula es doble: __do_softirq acota su trabajo por tiempo y por número de reintentos (MAX_SOFTIRQ_RESTART) y, si aún quedan pendientes, despierta a ksoftirqd/N, un hilo por CPU que termina el resto como una tarea planificable normal.
ps -eo pid,comm | grep ksoftirqd # un hilo por CPU: ksoftirqd/0, /1...
top # bajo tormenta de red, sube el %si (softirq)
El truco es sutil: ksoftirqd sigue ejecutando los mismos manejadores de softirq, pero ahora dentro de un hilo sujeto al planificador, que compite en igualdad con el espacio de usuario en lugar de aplastarlo. Así una avalancha de red degrada el rendimiento pero no congela la sesión. Es el kernel eligiendo, bajo presión, la equidad sobre la latencia.
Tasklets: azúcar sobre softirq, hoy en retirada
Un tasklet es una función diferida que un driver sí puede crear al vuelo, construida sobre el softirq TASKLET. Ofrece dos garantías cómodas: un tasklet dado nunca corre a la vez en dos CPUs —está serializado consigo mismo, a diferencia de un softirq crudo— y corre en contexto atómico, así que tampoco puede dormir.
/* forma moderna (transitoria); el kernel quiere eliminar los tasklets */
static void mi_tasklet(struct tasklet_struct *t)
{
struct mi_dispositivo *dev = from_tasklet(dev, t, tl);
procesar_lote(dev); /* contexto atómico: NADA que duerma aquí */
}
/* en probe: */ tasklet_setup(&dev->tl, mi_tasklet);
/* desde la mitad superior: */ tasklet_schedule(&dev->tl);
Y sin embargo, el kernel lleva años retirándolos. Las razones se acumulan: corren a prioridad de softirq, de modo que un tasklet largo daña la latencia de red y bloque de toda la máquina; no pueden dormir, lo que los hace inútiles para el driver típico que necesita hablar por un bus; su API vieja pasaba un unsigned long que forzaba conversiones de puntero inseguras (por eso apareció tasklet_setup, un parche transitorio); y no aportan nada que un threaded IRQ o una workqueue no hagan mejor. La consigna es explícita: no introduzcas tasklets en código nuevo, y si mantienes un driver que los usa, el destino es migrarlos a un thread_fn que pueda dormir o a una cola de trabajo. La migración suele ser casi mecánica: el cuerpo del tasklet pasa a un thread_fn, y de regalo gana el derecho a dormir que antes le faltaba.
/* antes: tasklet atómico. después: el mismo lote en un hilo que puede dormir */
static irqreturn_t mi_thread(int irq, void *dev_id)
{
struct mi_dispositivo *dev = dev_id;
procesar_lote(dev); /* ahora sí: mutex, I2C, kmalloc(GFP_KERNEL)... */
return IRQ_HANDLED;
}
/* el registro con request_threaded_irq sustituye por completo a */
/* tasklet_setup + tasklet_schedule, y borra la mitad superior manual */
flowchart TD
IRQ[Mitad superior] --> R[Eleva el softirq pendiente en esta CPU]
R --> X[Al salir de la interrupcion corre __do_softirq]
X --> D{Queda mucho pendiente}
D -->|No| F[Termina de inmediato, minima latencia]
D -->|Si, avalancha| K[Despierta a ksoftirqd de esta CPU]
K --> P[Procesa el resto como tarea planificable]
style F fill:#a6e3a1,color:#11111b
style K fill:#f9e2af,color:#11111bNo hay red de seguridad aquí. Un mutex_lock con contención, un kmalloc(GFP_KERNEL) que espera reclaim o un msleep dentro de un softirq o un tasklet intentan planificar en contexto atómico: el resultado es scheduling while atomic y, a menudo, un cuelgue. Si tu mitad inferior necesita dormir, no es un softirq lo que buscas: es un threaded IRQ o una workqueue.
Los dos niveles anteriores trazaron un éxodo: sacar el trabajo del contexto atómico hacia el reino planificable, donde se recupera el derecho a dormir. El softirq es la excepción deliberada a ese éxodo, y entenderla completa el mapa. Hay caminos —la recepción de red, la finalización de E/S de bloque— donde el volumen es tan brutal y la latencia tan crítica que el propio cambio de contexto, esa moneda “barata” que celebrábamos, se vuelve carísima cuando hay que pagarla millones de veces por segundo. Para ellos el kernel conserva un mecanismo que corre en la costura misma, al volver de la interrupción, sin construir ningún hilo. El precio de esa velocidad es renunciar a todo lo que da el planificador: no puedes dormir, no puedes priorizar por dispositivo, y debes tolerar que el mismo código corra en paralelo en varias CPUs. Y fíjate en la genialidad de ksoftirqd: cuando esa renuncia amenaza con matar de hambre al espacio de usuario, el kernel no abandona el softirq, sino que lo rehúsa dentro de un hilo planificable, recuperando la equidad justo en el punto de ruptura. Es una máquina que corre en modo atómico mientras puede permitírselo y cae con gracia al modo planificado cuando la presión lo exige. Los tasklets, en cambio, son la lección opuesta: azúcar que hizo cómodo lo atómico sin resolver ninguno de sus problemas, y por eso el kernel los está borrando. La moraleja del nivel es doble y afilada: usa el contexto atómico solo cuando la física del rendimiento no te deje otra, y cuando lo uses, que sea la maquinaria seria del subsistema, no un atajo que arrastra sus defectos sin sus virtudes.
- Vuelca
/proc/softirqsen reposo y bajoping -fa tu máquina; identifica qué vector se dispara y en qué CPU. - Explica por qué un softirq puede correr a la vez en dos CPUs y qué exige eso a sus estructuras de datos.
- Provoca carga de red y observa
ksoftirqdentop; describe qué frontera cruzó el sistema para despertarlo. - Toma un driver con un tasklet y diseña su migración a un
thread_fn: qué gana, qué operación ahora sí puede permitirse. - Argumenta por qué un
mutex_lockcon contención dentro de un tasklet es unBUGy no solo una mala práctica.