ADR-022 — Architecture du module io_uring
Statut : Accepté. Document fondateur de la phase 0.
Catégorie : Architecture (couche 0).
Contexte
io_uring est le mécanisme d’I/O asynchrone moderne de Linux, introduit en kernel 5.1 et considérablement étendu dans les versions suivantes. Il offre des performances supérieures à epoll pour les workloads I/O-bound et permet l’expression d’opérations qui n’étaient pas possibles avec les API précédentes (linked operations, multishot, registered buffers).
io_uring est central dans l’architecture d’Air : il est le mécanisme principal d’I/O asynchrone, intégré directement à la couche 0 et consommé par le runtime async (ADR-023) en couche 1. La qualité du wrapper io_uring détermine en grande partie la performance et l’ergonomie du stack Air.
La conception du wrapper soulève plusieurs questions structurantes :
- Quel niveau d’abstraction adopter ? Bas niveau (très proche du kernel) ou haut niveau (encapsulant la complexité) ?
- Comment coexister avec les syscalls synchrones équivalents ?
- Comment gérer les buffers (qui doivent rester valides pendant la durée de l’opération) ?
- Comment exposer les fonctionnalités avancées (multishot, linked, registered) sans alourdir l’API basique ?
Cet ADR consigne les 10 décisions structurantes prises pour le module air-sys-syscall::io_uring.
Décisions
Décision 1 : Niveau d’abstraction 2 (soumission/complétion typée)
Le wrapper io_uring d’Air opère au niveau d’abstraction 2 : soumission d’opérations typées et récupération de complétions typées, sans exposer les ring buffers kernel directement.
#![allow(unused)]
fn main() {
// Niveau 2 (Air par défaut) :
let token = ring.submit_read(fd, buffer, offset)?;
let completion = ring.wait_completion()?;
let bytes_read = completion.bytes_read()?;
}
Le niveau 1 (manipulation directe des SQE/CQE) est exposé dans un sous-module air-sys-syscall::io_uring::raw pour les cas avancés.
Justification. Le niveau 2 offre l’ergonomie et la sûreté nécessaires pour 95% des usages. Le niveau 1 reste accessible pour les optimisations extrêmes et les usages que l’API typée ne couvre pas.
Décision 2 : Coexistence avec les syscalls synchrones, types partagés
Les opérations qui ont un équivalent synchrone (read, write, openat2, accept, connect, send, recv, etc.) sont exposées à la fois dans le module synchrone (air-sys-syscall::fs, ::net, etc.) et dans le module io_uring.
Les types utilisés sont partagés : SocketAddr, MessageFlags, OpenHow, etc. sont les mêmes types dans les deux mondes. Un développeur qui apprend l’API synchrone retrouve les mêmes types en io_uring.
Justification. Permet de choisir le mode selon le contexte (script de démarrage en synchrone, hot path en io_uring) sans réapprendre l’API. Les couches supérieures peuvent migrer progressivement entre les deux mondes.
Décision 3 : Trois mécanismes de buffers, ownership transfert par défaut
Pour les opérations io_uring qui lisent ou écrivent des données, le buffer doit rester valide pendant toute la durée de l’opération (jusqu’à la complétion). Trois mécanismes sont exposés :
Mécanisme 1 : Transfert d’ownership (défaut). L’opération prend le buffer en argument et le retourne dans la complétion. Le buffer est physiquement détenu par le ring entre la soumission et la complétion.
#![allow(unused)]
fn main() {
let buffer = vec![0u8; 1024];
let token = ring.submit_read(fd, buffer, 0)?;
let completion = ring.wait_completion()?;
let (buffer, bytes_read) = completion.into_read_result()?;
}
Mécanisme 2 : Registered buffers (performance). Les buffers sont enregistrés à l’avance via register_buffers, puis référencés par index dans les opérations. Évite la traduction d’adresses virtuelles à chaque opération.
Mécanisme 3 : Raw unsafe (cas extrême). Le développeur passe un pointeur brut et garantit la validité du buffer. Réservé aux optimisations qui ne peuvent pas s’exprimer autrement.
Justification. Le transfert d’ownership est sûr par construction (le compilateur empêche d’utiliser le buffer pendant que le ring le détient). Les modes avancés sont disponibles pour les cas où la performance brute prime sur l’ergonomie.
Décision 4 : Registration explicite, pas d’automatisation
L’enregistrement de FDs ou de buffers (register_files, register_buffers) est une décision explicite de l’application. Le wrapper Air ne tente pas d’enregistrer automatiquement.
Justification. L’automatisation cacherait des coûts et compliquerait le modèle mental. Le développeur qui veut le bénéfice de la registration le demande explicitement.
Décision 5 : Multishot et linked dans sous-modules dédiés
Les opérations multishot (accept multishot, poll multishot) et les chaînes d’opérations liées (linked operations) ont des sémantiques distinctes des opérations one-shot classiques. Elles sont exposées dans des sous-modules dédiés :
air-sys-syscall::io_uring::multishotpour les opérations qui produisent plusieurs complétions.air-sys-syscall::io_uring::linkedpour les chaînes d’opérations.
Justification. Séparation conceptuelle claire. L’API de base reste simple, les API avancées sont disponibles quand on en a besoin.
Décision 6 : IoUring Send mais pas Sync, SharedIoUring séparé
Le type principal IoUring est Send (peut être déplacé entre threads) mais pas Sync (ne peut pas être partagé par référence entre threads). Pour les usages multi-thread, un type séparé LockedIoUring ou SharedIoUring est exposé dans le sous-module air-sys-syscall::io_uring::shared.
Justification. La majorité des usages sont mono-thread (un reactor par thread, thread-per-core). Imposer Sync sur le type principal aurait un coût (lock interne) pour la majorité des cas. Les patterns multi-thread sont disponibles explicitement.
Décision 7 : Opérations sans équivalent syscall exposées individuellement
Certaines opérations io_uring n’ont pas d’équivalent syscall direct (par exemple, IORING_OP_NOP, IORING_OP_TIMEOUT, IORING_OP_LINK_TIMEOUT, IORING_OP_FILES_UPDATE). Elles sont exposées comme méthodes du IoUring.
Justification. Ces opérations sont spécifiques à io_uring et n’ont pas vocation à apparaître dans une famille de syscalls classique. Leur place est dans le module io_uring.
Décision 8 : Détection runtime via IORING_REGISTER_PROBE
io_uring évolue rapidement : chaque version kernel ajoute des opérations. Le wrapper Air utilise IORING_REGISTER_PROBE au démarrage pour détecter ce que le kernel courant supporte.
#![allow(unused)]
fn main() {
let ring = IoUring::new(256)?;
if ring.supports_op(IoUringOpcode::OpenAt2) {
// utiliser openat2 via io_uring
} else {
// fallback sur syscall synchrone
}
}
Justification. Air doit fonctionner sur Linux 5.15 LTS (kernel cible de référence pour Debian stable, Ubuntu LTS) tout en permettant l’usage des features plus récentes quand disponibles. La détection runtime est le mécanisme propre.
Décision 9 : Type Completion unique, méthodes d’interprétation typées
Les complétions io_uring contiennent un result (entier signé) dont la sémantique dépend de l’opération qui a produit la complétion. Plutôt qu’exposer un enum de toutes les complétions possibles (qui serait énorme), Air expose un type Completion unique avec des méthodes d’interprétation typées :
#![allow(unused)]
fn main() {
let completion = ring.wait_completion()?;
// Selon le type d'opération qu'on attend :
let bytes_read = completion.bytes_read()?; // pour read
let fd = completion.accepted_fd()?; // pour accept
let _ = completion.completed()?; // pour close
}
Le développeur sait quelle opération il a soumise et appelle la méthode appropriée. Si l’opération a échoué, la méthode retourne Err(Errno).
Justification. Évite l’explosion combinatoire d’un enum de complétions. L’API reste typée et claire, le développeur garde le contrôle.
Décision 10 : Gestion gracieuse de la disponibilité d’io_uring
Si le kernel ne supporte pas io_uring (très ancien) ou si l’environnement l’a désactivé (sandbox, container), IoUring::new() retourne une erreur explicite (Errno::ENOSYS ou équivalent).
L’application peut alors choisir de basculer sur les syscalls synchrones, ou de refuser de démarrer.
Justification. Pas d’échec silencieux. Pas de tentative cachée de fallback automatique. L’application décide.
Architecture résultante
Le module air-sys-syscall::io_uring est organisé en plusieurs sous-modules :
air-sys-syscall::io_uring::
├── (racine) -- API niveau 2 principale
├── ::registration -- register_files, register_buffers
├── ::linked -- chaînes d'opérations liées
├── ::multishot -- opérations multishot
├── ::shared -- variantes thread-safe (LockedIoUring, etc.)
└── ::raw -- accès niveau 1 (SQE/CQE bruts)
Cette organisation permet de découvrir progressivement les fonctionnalités : un développeur démarre avec l’API racine, et explore les sous-modules quand il a un besoin spécifique.
Phasage de spécification
La spécification du module io_uring est découpée en 4 Temps successifs :
- Temps 1 : cœur API (
IoUring,Completion, soumission/complétion de base). - Temps 2a : opérations filesystem (21 opérations : read, write, openat2, etc.).
- Temps 2b : opérations réseau (8 opérations : accept, connect, send, recv, etc.).
- Temps 2c : opérations async-spécifiques (9 opérations : nop, timeout, etc.).
- Temps 3a : registration (FdPool, RegisteredBuffer, ProvidedBuffers).
- Temps 3b : linked (LinkedChainBuilder).
- Temps 3c : multishot (accept/poll/recv multishot).
- Temps 3d : shared (LockedIoUring, RingPool, SqpollIoUring).
- Temps 4 : raw (RawSubmissionQueueEntry, RawCompletionQueueEntry, accès direct ring buffers).
Cette spec en 4 Temps est cohérente avec le phasage bottom-up de l’ADR-011.
Note (postérieure, non normative — n’altère aucune des 10 décisions). Les noms de types cités ci-dessus à titre illustratif (
FdPool,RegisteredBuffer,ProvidedBuffers…) datent d’avant la spécification détaillée. Les noms définitifs, alignés sur ADR-029 (nommage explicite), figurent dans les specs par Temps et le document maître :FixedFdTable,RegisteredBuffers,ProvidedBufferRing, etc. De même, le découpage et les comptes d’opérations (2a, 2b, 2c…) ont été révisés et étendus (ajout des Temps 2d, 3b, 3e, 3f) dans le document maître../specs/layer-0/io-uring-0-inventaire.md, qui fait foi pour l’inventaire. Les soundness S1/S2/S3 sont gravées en ADR-028.
Statut futur
ADR immuable dans ses 10 décisions. Des extensions au module io_uring (nouvelles opérations kernel) sont ajoutées normalement sans amendement, tant qu’elles respectent les conventions établies.
Licence du document : MPL 2.0 Statut : Document fondateur immuable.