wandres.dev
INTERRUPCIONES A FONDO · threaded IRQ, softirq, workqueue

Top half y bottom half: el arte de diferir

Por qué el manejador de interrupción debe ser cortísimo, cómo cada microsegundo que pasa dentro roba latencia a todo el sistema, y el patrón universal de dividir el trabajo en una mitad superior urgente que reconoce el hardware y una mitad inferior que hace el trabajo pesado con las interrupciones ya reabiertas.

⏱ 15 min

Un manejador de interrupción no es un lugar donde trabajar: es un lugar del que huir cuanto antes. Mientras corre, la línea que lo disparó está enmascarada y, en muchos casos, otras interrupciones esperan detrás de él. Cada instrucción que ejecutas dentro es latencia que le robas al reloj del sistema, al siguiente paquete, al temporizador que debía disparar. La solución que el kernel adoptó hace décadas y sigue perfeccionando es partir el trabajo en dos: una mitad superior brevísima que hace lo imprescindible ahora, y una mitad inferior que hace lo pesado después, con las puertas ya reabiertas.

🎯 Al terminar esta lección sabrás
  • Distinguir la mitad superior (top half) de la inferior (bottom half).
  • Entender por qué el top half debe ser mínimo: bloquea la atención del sistema.
  • Aplicar el patrón de diferir: reconocer arriba, procesar abajo.
  • Situar el abanico de mecanismos de bottom half y qué decide entre ellos.

El coste oculto de un manejador lento

Mientras tu manejador dura ejecuta, la CPU está en contexto de interrupción con esa línea enmascarada. Si el manejador tarda cincuenta microsegundos copiando un buffer o esperando a un chip lento, durante esos cincuenta microsegundos ese dispositivo no puede volver a avisar, el temporizador de alta resolución acumula retraso, y otro dispositivo que comparta la línea o la prioridad espera su turno. La latencia de interrupción —el tiempo desde que el hardware la eleva hasta que se atiende de verdad— es una métrica de calidad de todo el sistema, y el peor manejador de cualquier driver cargado fija su límite inferior.

/* ANTIPATRÓN: un manejador que hace el trabajo entero dentro */
static irqreturn_t mal_handler(int irq, void *dev_id)
{
	struct mi_nic *nic = dev_id;
	int i;

	for (i = 0; i < nic->rx_pendientes; i++) {
		struct sk_buff *skb = construir_skb(nic, i); /* asigna, copia... */
		netif_rx(skb);                               /* procesa la pila entera */
	}
	return IRQ_HANDLED;   /* y mientras tanto NADIE más ha podido interrumpir */
}

Ese bucle puede recorrer cientos de paquetes con las interrupciones cerradas. Es correcto y es un desastre: convierte una ráfaga de red en una pausa ciega para el resto de la máquina.

Partir en dos: reconocer arriba, procesar abajo

La disciplina es milenaria en la escala del kernel: la mitad superior (top half) es el manejador dura y hace solo lo que no admite espera —reconocer la interrupción en el hardware, rescatar el dato que se perdería si tardas, apuntar qué queda por hacer y programar la mitad inferior—. La mitad inferior (bottom half) corre después, con las interrupciones ya reabiertas, y hace el trabajo caro. El término “bottom half” nació con un mecanismo concreto de los años noventa; hoy es genérico y designa a cualquiera de sus herederos.

/* PATRÓN: la mitad superior hace lo mínimo y difiere el resto */
static irqreturn_t buen_handler(int irq, void *dev_id)
{
	struct mi_nic *nic = dev_id;
	u32 status = readl(nic->regs + REG_INT_STATUS);

	if (!(status & INT_RX))
		return IRQ_NONE;

	/* 1. reconocer y enmascarar en el dispositivo: no vuelvas a interrumpir */
	writel(INT_RX, nic->regs + REG_INT_MASK_SET);
	/* 2. programar la mitad inferior; ella drenará los paquetes con calma */
	napi_schedule(&nic->napi);

	return IRQ_HANDLED;   /* salgo en nanosegundos, no en microsegundos */
}

Fíjate en el gesto clave: la mitad superior enmascara su propia fuente antes de irse. Así, mientras la mitad inferior drena el anillo de recepción, el dispositivo no vuelve a interrumpir por cada paquete; una sola interrupción paga el procesamiento de muchos. Es el corazón de NAPI, el modelo de recepción de red del kernel, y el ejemplo canónico de por qué diferir no solo mejora la latencia sino también el rendimiento bruto.

flowchart LR
HW[Dispositivo eleva IRQ] --> TH[Mitad superior]
subgraph Contexto de interrupcion atomico
  TH --> A1[Reconoce y enmascara el hardware]
  A1 --> A2[Rescata lo urgente]
  A2 --> A3[Programa la mitad inferior]
end
A3 --> BH[Mitad inferior]
subgraph Interrupciones ya reabiertas
  BH --> B1[Procesa el lote entero]
  B1 --> B2[Despierta a quien esperaba]
end
style TH fill:#f38ba8,color:#11111b
style BH fill:#a6e3a1,color:#11111b

El abanico de mitades inferiores

“Diferir” no es un único mecanismo sino una familia, y su eje de decisión es una sola pregunta: ¿el trabajo diferido necesita dormir? Si no puede dormir pero debe correr cuanto antes, vive en contexto atómico (softirq, tasklet). Si puede y quiere dormir —tomar un mutex, hablar por un bus lento, asignar con GFP_KERNEL— necesita contexto de proceso (hilo de IRQ, workqueue).

softirq

Contexto atómico, por CPU, altísima prioridad. No duerme. Es el mecanismo de los caminos más calientes: red y bloque. No lo defines tú (nivel 34.4).

🧩

tasklet

Azúcar sobre softirq, serializado consigo mismo, atómico. Cómodo pero en retirada activa del kernel; no lo uses en código nuevo (nivel 34.4).

🧵

threaded IRQ

La mitad inferior corre en un hilo del kernel que sí puede dormir. Integrado en el registro de la IRQ. La opción por defecto hoy (nivel 34.3).

🗓️

workqueue

Contexto de proceso desacoplado de la IRQ: puede dormir, admite retardo y prioridad propia. Para trabajo que no va uno a uno con la interrupción (nivel 34.5).

Cada uno de los cuatro compra una libertad a cambio de latencia. El softirq apenas cuesta un retorno de interrupción pero no puede dormir; el workqueue puede hacer cualquier cosa pero paga un cambio de contexto. Elegir bien es situar cada trabajo en el punto justo de ese compromiso, y los tres niveles siguientes los recorren uno a uno.

Cuándo un manejador puede hacerlo todo

No toda interrupción necesita mitad inferior. Si el manejador solo tiene que leer un registro, apuntar un valor y despertar a un durmiente, eso cabe entero en la mitad superior y partir sería un coste sin premio. El criterio es cuantitativo: si tu manejador termina en un puñado de instrucciones y jamás toca nada que pueda bloquear, déjalo entero. En cuanto aparezca un bucle no acotado, una asignación grande, una espera por hardware o una copia a usuario, esa parte se va abajo. La pregunta no es “¿debo dividir?” sino “¿cuánto de esto no admite estar en contexto de interrupción?”.

/* mitad superior autosuficiente: nada aquí puede bloquear, no hay mitad inferior */
static irqreturn_t boton_handler(int irq, void *dev_id)
{
	struct boton *b = dev_id;
	u32 estado = readl(b->regs + REG_GPIO);    /* lectura MMIO: no duerme */

	writel(estado, b->regs + REG_GPIO_ACK);    /* reconocer la fuente */
	input_report_key(b->input, KEY_POWER, !!(estado & PIN_BOTON));
	input_sync(b->input);                      /* despierta a los lectores sin dormir */
	return IRQ_HANDLED;
}

Un botón GPIO es el caso puro: leer el pin, reconocer, notificar y salir son cuatro accesos que jamás bloquean. Meterle una mitad inferior sería añadir un cambio de contexto para no ganar nada. El arte de este nivel no es diferir siempre, sino diferir solo lo que no cabe en el instante atómico.

⚠️
Enmascarar la fuente no es opcional en líneas por nivel

Si difieres el trabajo pero olvidas enmascarar o reconocer la interrupción en el hardware, en una línea disparada por nivel el dispositivo la mantiene alta y reingresas al manejador de inmediato, en bucle, antes de que la mitad inferior llegue a correr. La máquina se cuelga procesando la misma interrupción para siempre. Diferir y silenciar la fuente son un solo gesto, no dos.

Atender una interrupción es un problema de planificación disfrazado

Lo que parece un detalle de eficiencia —“haz el manejador corto”— es en realidad el kernel resolviendo un problema de planificación en un lugar donde el planificador no existe. Recuerda del nivel anterior por qué el contexto de interrupción es atómico: no hay proceso, y sin proceso no hay a quién planificar ni a quién dormir. La mitad superior está atrapada en ese vacío. Toda la técnica de partir en dos consiste en pasar la mayor cantidad posible de trabajo desde el reino sin planificador hacia el reino con planificador, donde el kernel recupera sus herramientas: prioridades, preempción, capacidad de dormir, contabilidad justa. La mitad superior es un peaje que se paga en la moneda más cara del sistema —tiempo no planificable, no interrumpible, que congela una porción de la máquina— y por eso se paga lo mínimo. La mitad inferior es la misma tarea, pero denominada en la moneda barata del tiempo planificable, que el kernel sabe repartir, priorizar y posponer sin dañar a nadie. Cuando interiorices que “top half” y “bottom half” no son dos fases de un algoritmo sino dos economías temporales distintas —una de emergencia, sin planificador, y otra ordinaria, con él— entenderás por qué la historia entera de las interrupciones en Linux es un movimiento constante en una sola dirección: sacar trabajo de la costura atómica y devolverlo al mundo donde volver a tener derechos. Los softirqs, los hilos de IRQ y las workqueues no son tres inventos sueltos, sino tres estaciones de ese mismo éxodo.

⚔️ Mide el coste de no diferir
  1. Escribe una mitad superior que solo lea un registro de estado, apunte un valor en una estructura y programe una mitad inferior; devuelve IRQ_HANDLED en cuatro líneas.
  2. Añade la lógica que enmascara la fuente en el hardware antes de salir y explica qué tormenta evita en una línea por nivel.
  3. Toma el mal_handler de este nivel y enumera cada operación que no debería estar en contexto de interrupción y por qué.
  4. Para un dispositivo que solo incrementa un contador por interrupción, argumenta por qué partir en dos sería un coste sin beneficio.
  5. Dibuja, para tu driver imaginado, la frontera exacta entre lo urgente y lo diferible, y justifica dónde la trazaste.