wandres.dev
EL BLOCK LAYER · bio, request, blk-mq

El camino de una petición: de submit_bio al completado

El viaje completo de una E/S de bloques: cómo submit_bio entra por submit_bio_noacct y blk_mq_submit_bio, cómo el plug por-tarea agrupa varias bios para fusionarlas, cómo se asigna una struct request con su tag, cómo el planificador la ordena, cómo queue_rq la entrega al driver, y cómo la interrupción de completado dispara blk_mq_complete_request y blk_mq_end_request para liberar el tag y llamar a bio_endio.

⏱ 17 min

Ya conoces las piezas: la bio que describe una transferencia y la arquitectura de dos capas que la absorbe sin ahogarse. Falta ver la película entera, del principio al fin: qué le ocurre exactamente a una bio desde que un filesystem la entrega con submit_bio hasta que su callback anuncia “hecho”. Es una línea de montaje con estaciones precisas —agrupar en el plug, fusionar con las vecinas, vestirse de request con su tag, pasar por el planificador, entregarse al driver, ejecutarse en el hardware y volver por el camino del completado—. Seguir ese viaje instrucción a instrucción es lo que convierte el block layer de una caja negra en un mecanismo que entiendes y puedes depurar.

🎯 Al terminar esta lección sabrás
  • Seguir submit_bio hasta blk_mq_submit_bio y entender qué decide ahí.
  • Comprender el plug por-tarea como agrupador que habilita la fusión.
  • Ver cómo una bio se convierte en struct request con su tag y pasa al planificador.
  • Cerrar el ciclo con queue_rq, la interrupción y blk_mq_end_request.

submit_bio: la entrada única al block layer

Todo camino empieza en submit_bio(bio). No importa si la bio viene de ext4, del writeback del page cache o de un O_DIRECT: esta es la puerta común. Internamente cae en submit_bio_noacct, que valida la bio contra los límites del dispositivo y, si excede el máximo de hardware, la trocea con bio_split (nivel 35.2) antes de seguir. De ahí pasa a blk_mq_submit_bio, el corazón donde se decide el destino de la petición.

void submit_bio(struct bio *bio);        /* la entrega, no bloquea */
/*   -> submit_bio_noacct   valida, contabiliza y trocea si excede el limite */
/*     -> blk_mq_submit_bio decide: fusionar, o vestir de request y encolar */

submit_bio no espera: entrega la bio y regresa. El resultado llegará más tarde por el bi_end_io. Quien necesite sincronía la construye encima —submit_bio_wait duerme hasta el completado—, pero el mecanismo primitivo es asíncrono, porque la asincronía es lo que permite tener miles de transferencias en vuelo a la vez.

El plug: agrupar para fusionar

Antes de tocar ninguna cola, blk-mq intenta un truco de amortización: el plug. Un hilo que va a emitir varias bio seguidas —un writeback recorriendo páginas sucias— rodea la ráfaga con blk_start_plug y blk_finish_plug. Mientras el plug está puesto, las peticiones se acumulan en una lista privada de esa tarea, sin tocar colas compartidas, y al soltar el plug se vuelcan todas juntas.

struct blk_plug plug;

blk_start_plug(&plug);
for (i = 0; i < n; i++)
	submit_bio(bios[i]);   /* se acumulan en la lista del plug, no se despachan */
blk_finish_plug(&plug);    /* aqui se vuelcan juntas: se fusionan y despachan */

El plug logra dos cosas. Difiere el despacho para fusionar peticiones a sectores contiguos en una sola request más grande —el trabajo de amortización del nivel 35.1—, y evita tomar cerrojos de cola una vez por bio para tomarlos una vez por ráfaga. Es la misma idea del batching que atraviesa todo el kernel: reunir para pagar el coste fijo una vez.

De bio a request: tag, fusión y planificador

Cuando una bio no se fusiona con ninguna existente, se le asigna una struct request —la unidad que el driver consumirá—, y con ella un tag del sbitmap (nivel 35.3). Una request agrupa una o varias bio de sectores contiguos y lleva la contabilidad de la transferencia entera.

static blk_status_t mi_queue_rq(struct blk_mq_hw_ctx *hctx,
				const struct blk_mq_queue_data *bd)
{
	struct request *rq = bd->rq;
	struct bio_vec bv;
	struct req_iterator iter;

	blk_mq_start_request(rq);                /* arranca el reloj de timeout */

	rq_for_each_segment(bv, rq, iter) {      /* recorre todos los segmentos */
		/* ... programar el DMA de bv hacia/desde el hardware ... */
	}
	return BLK_STS_OK;   /* aceptada; BLK_STS_RESOURCE pediria reintento */
}

Entre la request y el driver puede mediar un planificador de E/S (nivel 36): mq-deadline impone plazos para que ninguna petición se muera de hambre, bfq reparte ancho de banda con justicia, kyber acota latencias, y none despacha directo —lo habitual en NVMe, donde el propio dispositivo reordena mejor que el software—. El planificador vive por hctx y decide el orden en que las request se ofrecen al driver, sin volver a ser un cerrojo global.

queue_rq y el completado: cerrar el ciclo

El despacho llama a ->queue_rq(hctx, bd), el punto donde la petición sale del block layer y entra en tu driver. El driver la programa en el hardware —escribe el descriptor en el slot indexado por el tag, toca el doorbell— y devuelve BLK_STS_OK. Después, silencio: la CPU sigue con otra cosa mientras el dispositivo trabaja.

Cuando el hardware termina, eleva una interrupción de completado (nivel 34). El manejador del driver identifica qué comando acabó —por su tag— y llama a blk_mq_complete_request(rq), que encamina el completado hacia la CPU que emitió la petición (por localidad de cache, vía IPI o softirq de bloque) y desde allí ejecuta el epílogo:

/* en el manejador de interrupcion del driver */
struct request *rq = blk_mq_tag_to_rq(tags, tag);  /* el tag localiza la request */
blk_mq_complete_request(rq);                        /* encamina a la CPU emisora */

/* y el epilogo, que el driver invoca con el resultado: */
blk_mq_end_request(rq, BLK_STS_OK);
/*   libera el tag al sbitmap, y llama a bio_endio() en cada bio de la request */

blk_mq_end_request es el cierre exacto del arco: devuelve el tag al sbitmap para que otra petición pueda usar ese slot, y llama a bio_endio en cada bio que la request agrupaba, disparando por fin los bi_end_io que el filesystem instaló al principio. El writeback marca la página como limpia; el O_DIRECT despierta al hilo que esperaba. El círculo se cierra donde empezó.

flowchart TD
A[submit_bio] --> B[submit_bio_noacct valida y trocea]
B --> C[blk_mq_submit_bio]
C --> D[Intentar fusion en el plug o el planificador]
D -->|fusiona| E[Se anade a una request existente]
D -->|no fusiona| F[Asigna struct request y un tag]
E --> G[Planificador mq-deadline o none]
F --> G
G --> H[queue_rq entrega al driver]
H --> I[El hardware ejecuta la transferencia]
I --> J[Interrupcion de completado]
J --> K[blk_mq_complete_request encamina a la CPU emisora]
K --> L[blk_mq_end_request libera el tag y llama a bio_endio]
style A fill:#89b4fa,color:#11111b
style H fill:#f9e2af,color:#11111b
style L fill:#a6e3a1,color:#11111b
⚠️
queue_rq corre en contexto atómico: no duermas ahí

->queue_rq puede ejecutarse con interrupciones deshabilitadas y sin derecho a dormir, igual que un manejador (nivel 34). Programar el hardware, sí; tomar un mutex, asignar con GFP_KERNEL o esperar por un bus lento, no. Si el driver no puede aceptar la petición ahora —sin descriptores libres—, no bloquea: devuelve BLK_STS_RESOURCE o BLK_STS_DEV_RESOURCE, y el block layer la reintentará más tarde. Bloquear en queue_rq es el mismo pecado que dormir en un manejador, y lo paga el sistema entero.

El camino de una petición es una tubería asíncrona, no una llamada a función

Lo que más cuesta interiorizar de la E/S de bloques, y lo que más lo cambia todo cuando por fin encaja, es que este viaje no es una llamada a función. Tu instinto de programador de espacio de usuario dice que read() es una función que va, trae el dato y vuelve; y en la superficie de la API lo parece. Pero por dentro, desde submit_bio, no hay pila que se hunda y regrese: hay una petición que se suelta en una tubería y viaja por ella cambiando de manos y de contexto —del hilo emisor al plug, del plug al planificador, del planificador al queue_rq, del driver al hardware, del hardware a una interrupción en quizá otra CPU, y de vuelta al completado— sin que ningún hilo se quede esperando de pie junto a ella. El hilo que llamó a submit_bio ya se fue a hacer otra cosa; el que ejecuta bio_endio puede ser un manejador de interrupción que no sabe nada del filesystem. Esta descomposición del “leer un bloque” en una cadena de estaciones asíncronas es exactamente lo que permite que un servidor tenga cien mil transferencias en vuelo con un puñado de hilos: nadie bloquea esperando el disco, todos sueltan trabajo en la tubería y recogen resultados por callback. El bi_end_io que parecía un detalle en el nivel 35.2 se revela ahora como el pilar de toda la arquitectura: es el mecanismo por el que una petición puede completarse en un tiempo y un lugar totalmente desacoplados de donde nació. Y el tag, que parecía un simple identificador, es el hilo de Ariadna que permite reencontrar la petición al volver del laberinto del hardware. Cuando dejes de leer submit_bio como “lee esto ahora” y empieces a leerlo como “inyecta esta petición en la tubería y sigue tu vida”, habrás cruzado la frontera entre usar el almacenamiento y entender cómo un sistema operativo esconde la latencia del disco detrás de la ilusión de que leer es instantáneo.

⚔️ Sigue una petición de punta a punta
  1. Traza en orden las funciones desde submit_bio hasta queue_rq, nombrando qué decide cada estación: validar, trocear, agrupar en el plug, fusionar, asignar tag, planificar.
  2. Explica qué gana el plug y por qué el writeback del page cache rodea su ráfaga con blk_start_plug y blk_finish_plug.
  3. Describe el epílogo del completado: qué hacen exactamente blk_mq_complete_request y blk_mq_end_request, y por qué el primero encamina a la CPU emisora.
  4. Razona por qué queue_rq no puede dormir y qué debe devolver un driver que no tiene descriptores libres en ese instante.
  5. Argumenta, apoyándote en bi_end_io y el tag, por qué el camino de una petición es una tubería asíncrona y no una llamada de función que bloquea.