wandres.dev
I/O SCHEDULERS Y BLOCK DRIVERS · mq-deadline, un block driver

NVMe y el futuro: colas paralelas, blk-mq y el camino a io_uring

Por qué la vieja capa de bloques de cola única se ahogó en su propio lock al llegar los SSD, cómo NVMe nació con miles de colas paralelas por hardware, por qué blk-mq encaja como un guante con ese diseño y el scheduler none es su compañero natural, y cómo la misma idea de anillos emparejados y sin lock sube hasta el espacio de usuario en io_uring.

⏱ 16 min

Durante cuarenta años el software de E/S asumió que el disco era lentísimo y la CPU barata, y gastó ciclos sin reparo con tal de ahorrar un seek. El SSD invirtió el axioma: el medio se volvió tan veloz que el propio software —el lock de la cola, el reorden, la ceremonia de cada petición— pasó a ser el cuello de botella. NVMe es el hardware que nació ya dentro de esa nueva verdad, con miles de colas paralelas en lugar de una, y blk-mq es la capa de bloques rediseñada para hablar con él sin ahogarse. Cerramos el nivel entendiendo por qué encajan, por qué su planificador es none, y cómo la misma forma —anillos emparejados y sin lock, uno por núcleo— trepa hasta el espacio de usuario en io_uring.

🎯 Al terminar esta lección sabrás
  • Entender por qué la capa de bloques de cola única se ahogó al llegar los SSD.
  • Ver la arquitectura de NVMe: pares de colas de envío y finalización, uno por CPU.
  • Explicar por qué blk-mq encaja con NVMe y por qué none es su planificador natural.
  • Conectar el modelo de anillos emparejados con io_uring, que lo lleva a espacio de usuario (nivel 40).

El lock que se ahogó

La vieja capa de bloques tenía una cola por dispositivo protegida por un solo queue_lock. Con un HDD que daba doscientas operaciones por segundo, ese lock jamás era el problema: el disco era tan lento que las CPU sobraban. Pero un NVMe moderno entrega millones de operaciones por segundo, y de pronto todas las CPU del sistema chocaban contra el mismo lock para meter peticiones en la misma cola. La contención de ese cerrojo único fijaba un techo de rendimiento muy por debajo de lo que el hardware podía dar: el software se había convertido en el disco lento.

La respuesta fue blk-mq (multi-queue), un rediseño en dos niveles de colas que elimina el punto único de contención.

/* include/linux/blk-mq.h — dos niveles de colas, sin lock global */
struct blk_mq_ctx {			/* cola de SOFTWARE, una por CPU */
	struct list_head	rq_lists[HCTX_MAX_TYPES];
	unsigned int		cpu;
	/* cada CPU encola en la suya: cero contencion entre nucleos */
};

struct blk_mq_hw_ctx {			/* cola de HARDWARE, una por cola del dispositivo */
	struct blk_mq_tags	*tags;	/* los tags de las peticiones en vuelo */
	unsigned int		queue_num;
	/* blk-mq mapea las colas de software a estas */
};

Cada CPU encola en su propia cola de software sin tocar la de las demás; luego blk-mq las mapea a las colas de hardware que el dispositivo expone. El lock global desapareció porque desapareció la cola global.

🐌

Era de cola única

Una request_queue por dispositivo, un queue_lock global. Suficiente para un HDD de cientos de IOPS, letal para un SSD de millones: todas las CPU chocando contra el mismo cerrojo. El software era el eslabón lento.

🚀

Era multi-cola (blk-mq)

Colas de software por CPU y colas de hardware mapeadas al dispositivo. Sin estado compartido entre núcleos en el camino caliente. El techo lo pone de nuevo el silicio, no el cerrojo.

NVMe: colas paralelas hasta el hardware

NVMe fue diseñado desde cero para esa realidad. En lugar de una cola, define hasta 65535 pares de colas, y cada par son dos anillos en memoria: una submission queue donde el software deposita comandos y una completion queue donde el dispositivo deposita resultados. La idea maestra: dar a cada CPU su propio par, con su propio vector de interrupción MSI-X, de modo que un núcleo envía y recibe E/S sin sincronizarse jamás con otro.

/* drivers/nvme/host/pci.c — una cola NVMe es un par de anillos SQ/CQ */
struct nvme_queue {
	struct nvme_dev		*dev;
	struct nvme_command	*sq_cmds;	/* anillo de envio: comandos */
	struct nvme_completion	*cqes;		/* anillo de fin: resultados */
	dma_addr_t		sq_dma_addr;
	dma_addr_t		cq_dma_addr;
	u32 __iomem		*q_db;		/* doorbell: avisa al dispositivo */
	u16			q_depth;
	u16			qid;
	u16			sq_tail;	/* donde escribe el software */
	u16			cq_head;	/* donde lee el software */
	u8			cq_phase;
};

El protocolo es un baile de índices sin bloqueo: el software escribe un comando en sq_tail, avanza el índice y toca el doorbell; el dispositivo lo consume, ejecuta y deposita el resultado en la completion queue; una interrupción avisa, y el software drena cq_head. No hay lock porque cada anillo tiene un solo productor y un solo consumidor.

Por qué blk-mq encaja y none es su compañero

El acoplamiento es casi perfecto. Las colas de hardware de blk-mq se corresponden una a una con los pares de colas de NVMe: la petición que una CPU encoló en su cola de software se despacha por la cola de hardware mapeada a esa misma CPU, que es el par SQ/CQ que NVMe le dio a ese núcleo. El camino de una lectura, de la CPU al plato de flash y de vuelta, no cruza ni un solo lock compartido con otro núcleo.

# una cola hardware por CPU, calcada de los pares SQ/CQ de la NVMe
$ ls /sys/block/nvme0n1/mq/
0  1  2  3  4  5  6  7

# y por eso el planificador por defecto es none
$ cat /sys/block/nvme0n1/queue/scheduler
[none] mq-deadline kyber bfq

none es aquí la elección correcta, no la perezosa. Un SSD no tiene cabeza que mover: leer el sector 5 y el 5 000 000 cuesta lo mismo, así que no hay seeks que reordenar y toda la razón de ser del ascensor se ha evaporado. Peor: interponer un planificador central con su cola y su lock reintroduciría exactamente la contención que blk-mq y NVMe se diseñaron para eliminar. En el hardware más rápido, el mejor planificador es el que no planifica.

flowchart TD
subgraph Espacio de usuario
  U[Aplicacion]
end
subgraph Nucleo blk-mq
  SW0[Cola software CPU0]
  SW1[Cola software CPU1]
  HW0[Cola hardware 0]
  HW1[Cola hardware 1]
end
subgraph Dispositivo NVMe
  Q0[Par SQ CQ 0]
  Q1[Par SQ CQ 1]
end
U --> SW0
U --> SW1
SW0 --> HW0
SW1 --> HW1
HW0 --> Q0
HW1 --> Q1
ℹ️
Poll queues: cuando la interrupción también estorba

En las cargas de menor latencia hasta la interrupción de fin cuesta demasiado: el cambio de contexto y la entrada en la IRQ añaden microsegundos. Por eso NVMe reserva poll queues que no interrumpen; el software pregunta activamente por la completitud con blk_poll en vez de esperar el aviso. Es un consumo de CPU deliberado a cambio de latencia mínima y determinista, y es la base sobre la que io_uring ofrece su modo de sondeo. Cuando el dispositivo es más rápido que el coste de que te avisen, dejas de esperar y preguntas.

La misma forma, tres veces: el anillo emparejado sin lock

Da un paso atrás y mira la silueta que se repite en las tres capas de la pila, porque es la lección que corona el nivel y anticipa los que vienen. En el fondo, el hardware NVMe habla con el kernel mediante un par de anillos en memoria compartida —uno para pedir, otro para recibir— con un solo productor y un solo consumidor cada uno, de modo que nunca hace falta un lock. Sube una capa: blk-mq organiza su cara interna con colas por CPU que evitan el estado compartido entre núcleos, la misma huida de la contención con la misma técnica. Sube otra capa más, hasta la frontera con el espacio de usuario, y encontrarás io_uring: dos anillos en memoria compartida entre la aplicación y el kernel —una submission queue de peticiones y una completion queue de resultados— con un solo productor y un solo consumidor cada uno, de modo que un programa puede lanzar y cosechar miles de operaciones sin una sola llamada al sistema por operación. Es literalmente la arquitectura de NVMe reflejada hacia arriba, cruzando la membrana entre usuario y núcleo igual que NVMe cruza la que separa al núcleo del hardware. Esto no es una coincidencia estética: es una convergencia obligada. Cuando el dispositivo dejó de ser el elemento lento, el cuello de botella se desplazó hacia todo lo que rodeaba a cada operación —el lock, el reorden, la interrupción, la llamada al sistema— y la única forma de aniquilar ese coste, en cualquier capa donde apareciera, resultó ser siempre la misma: anillos emparejados, un productor y un consumidor, memoria compartida, cero locks, uno por núcleo. Aprende a reconocer esa forma, porque una vez que la ves en NVMe la ves en blk-mq, y una vez que la ves en blk-mq la reconocerás de inmediato en io_uring, y entenderás que la historia moderna de la E/S no es una sucesión de inventos sino un solo patrón propagándose, capa a capa, desde el flash hasta tu programa.

⚔️ Sigue el camino de una E/S sin locks
  1. Cuenta con ls /sys/block/nvme0n1/mq/ cuántas colas hardware tiene tu NVMe y relaciónalo con el número de CPU de tu máquina.
  2. Cambia el planificador de tu NVMe a mq-deadline, mide IOPS con fio en lecturas aleatorias de 4 KiB y compáralo con none; explica el resultado.
  3. Describe el baile de índices de un par SQ/CQ y justifica por qué no necesita ningún lock aunque productor y consumidor sean entidades distintas.
  4. Explica por qué el ascensor del nivel 36.1 no aporta nada en un SSD, apelando a la ausencia de seeks.
  5. Dibuja el paralelismo entre el par SQ/CQ de NVMe y los anillos de submission y completion de io_uring, e identifica qué membrana cruza cada uno.