Maîtrise d’io_uring sous Linux : des fondements à l’implémentation

Pourquoi io_uring est-il devenu indispensable ?

Avant io_uring, l’asynchrone sous Linux était un champ de ruines fragmenté :

  • AIO : ne supporte que les entrées-sorties directes, pas les fichiers bufferisés ni les sockets ; interface horrible et bugs chroniques.
  • epoll : il vous dit « ce descripteur est prêt », mais ne lit ni n’écrit – les opérations restent synchrones.
  • libaio : exige O_DIRECT avec alignement, usage très limité.

io_uring, apparu dans le noyau 5.1, a mis fin à ce chaos avec une interface unique, efficace et simple. Il gère fichiers ordinaires, sockets, tubes, temporisateurs, et minimise le coût des appels système grâce à des files circulaires en mémoire partagée.

1. Architecture fondamentale : deux files, une vie

1.1 Vue d’ensemble

io_uring repose sur deux tampons circulaires mappés en mémoire :

         Espace utilisateur                     Noyau
   ┌──────────────────┐                 ┌──────────────────┐
   │   File SQ         │                 │   File SQ         │
   │  (Soumission)     │  ── écriture ─▶ │  (lue par noyau)  │
   │                   │                 │                   │
   │  SQE | SQE | SQE  │                 │  SQE | SQE | SQE  │
   └──────────────────┘                 └──────────────────┘
   
   ┌──────────────────┐                 ┌──────────────────┐
   │   File CQ         │                 │   File CQ         │
   │  (Achèvement)     │  ◀── lecture ─  │  (écrite par noyau)│
   │                   │                 │                   │
   │  CQE | CQE | CQE  │                 │  CQE | CQE | CQE  │
   └──────────────────┘                 └──────────────────┘

Le principe fondamental : les files SQ et CQ sont des mémoires partagées entre noyau et utilisateur ; on remplace les appels système par des écritures en mémoire.

1.2 SQE et CQE : fiches de tâche et bulletins de résultat unifiés

Beaucoup croient que io_uring_prep_read, io_uring_prep_write, … créent des structures différentes : c’est faux. Toutes ces fonctions remplissent les champs d’un même conteneur de 64 octets :

struct io_uring_sqe {
    __u8    opcode;          // IORING_OP_READ / WRITE / ACCEPT / TIMEOUT …
    __u8    flags;
    __u16   ioprio;
    __s32   fd;
    __u64   off;             // décalage pour lecture/écriture
    __u64   addr;            // adresse tampon (lecture/écriture) ou pointeur sockaddr (accept)
    __u32   len;             // longueur du tampon
    __u32   accept_flags;    // spécifique à accept
    __u64   user_data;       // ★ données utilisateur, restituées intactes dans le CQE
    // … autres champs en union
};

struct io_uring_cqe {
    __u64   user_data;       // ★ provient du SQE
    __s32   res;             // résultat (octets lus ou nouvel fd, ou -errno en cas d'échec)
    __u32   flags;
};

Ainsi, io_uring_prep_read(sqe, fd, buf, nr, offset) ne fait qu’assigner les champs :

// pseudo-code illustratif
void io_uring_prep_read(struct io_uring_sqe *sqe, int fd, void *buf,
                         unsigned nr, __u64 offset) {
    sqe->opcode = IORING_OP_READ;
    sqe->fd     = fd;
    sqe->off    = offset;
    sqe->addr   = (__u64)buf;
    sqe->len    = nr;
}

Il ne s’agit pas de « créer une requête de lecture », mais de remplir la même fiche générique avec les paramètres adaptés à l’opération « lire ».

De même, le CQE est unique : que ce soit une lecture qui a retourné 42 octets ou un accept qui a donné le fd 15, la même structure est utilisée. La différence réside dans l’interprétation du champ res (nombre d’octets vs nouveau fd).

1.3 user_data : votre carte d’identité

sqe->user_data = (__u64)(uintptr_t)&mon_contexte;

user_data est un champ opaque de 8 octets. Vous y inscrivez une valeur, elle vous est rendue dans le CQE sans modification. C’est le canal d’identification entre io_uring et votre application.

Usages typiques :

  • Pointeur vers votre structure de contexte (8 octets sur 64 bits, parfait).
  • Valeur composite : fd dans les 32 bits bas, type d’opération dans les 32 bits hauts.
  • Index dans un tableau de connexions.

user_data n’est pas une structure que vous uploadez. C’est une poche de 8 octets : vous y mettez ce que vous voulez, le CQE vous le rend.

2. Flux d’utilisation générique : un patron en 6 étapes

Qu’il s’agisse de lire un fichier, de recevoir des données réseau ou de déclencher un temporisateur, la séquence est identique :

┌────────────────────────────────────────────────┐
│ ① Initialiser l'instance io_uring              │
│    io_uring_queue_init(QD, &ring, 0)           │
│                                                │
│ ② Obtenir un SQE libre                         │
│    sqe = io_uring_get_sqe(&ring)               │
│    if (!sqe) → SQ pleine, soumettre puis réessayer│
│                                                │
│ ③ Remplir le SQE                               │
│    io_uring_prep_xxx(sqe, ...)                 │
│    sqe->user_data = votre marqueur              │
│                                                │
│ ④ (optionnel) Obtenir plus de SQE → lot      │
│                                                │
│ ⑤ Soumettre tous les SQE au noyau et attendre │
│    io_uring_submit_and_wait(&ring, wait_nr)    │
│                                                │
│ ⑥ Récolter les CQE et traiter les résultats   │
│    io_uring_peek_batch_cqe(&ring, cqes, N)    │
│    pour chaque CQE : traiter selon user_data   │
│    io_uring_cq_advance(&ring, nready)          │
│    retour en ②                                 │
│                                                │
│ ⑦ Nettoyage à la fin                            │
│    io_uring_queue_exit(&ring)                  │
└────────────────────────────────────────────────┘

Exemple minimal : lecture asynchrone d’un fichier

#include <liburing.h>
#include <fcntl.h>
#include <stdio.h>

int main() {
    struct io_uring ring;
    io_uring_queue_init(8, &ring, 0);

    char buf[4096];
    int fd = open("/etc/os-release", O_RDONLY);
    
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_read(sqe, fd, buf, sizeof(buf), 0);
    sqe->user_data = 1001;   // identifiant quelconque
    
    io_uring_submit_and_wait(&ring, 1);
    
    struct io_uring_cqe *cqe;
    io_uring_wait_cqe(&ring, &cqe);   // méthode simple pour un seul CQE
    
    if (cqe->res > 0) {
        printf("Lu %d octets : %.*s\n", cqe->res, cqe->res, buf);
    }
    printf("user_data retourné : %llu\n", cqe->user_data);
    
    io_uring_cqe_seen(&ring, cqe);
    close(fd);
    io_uring_queue_exit(&ring);
    return 0;
}

Compilation : gcc -o lecture_async lecture_async.c -luring

3. Deux modes d’initialisation et leur choix

Mode simple (recommandé 90% des cas)

struct io_uring ring;
io_uring_queue_init(1024, &ring, 0);

Le paramètre flags à 0 signifie « aucune fonction avancée activée ». C’est le mode le plus stable, adapté à : fichiers, réseau, temporisateurs, tous les mélanges, développement et apprentissage.

Mode avancé (optimisation extrême)

struct io_uring ring;
struct io_uring_params params;
memset(&params, 0, sizeof(params));   // ★ obligatoire !
params.flags = IORING_SETUP_SQPOLL;    // thread de polling noyau
params.sq_thread_idle = 2000;          // veille après 2 s d'inactivité
io_uring_queue_init_params(1024, &ring, &params);

io_uring_params offre un contrôle fin :

Champ Rôle
params.flags Active les modes avancés (SQPOLL, IOPOLL…)
params.sq_thread_cpu Affinité du thread SQPOLL sur un cœur
params.sq_thread_idle Temps d'inactivité avant mise en veille du thread (ms)
params.cq_entries Taille personnalisée de la file CQ (≥ file SQ)
params.features Rempli par le noyau après init : fonctionnalités supportées

Pourquoi ne pas toujours utiliser init_params ? Parce que la simplicité suffit la plupart du temps. init n'est qu'un wrapper vers init_params.

4. SQPOLL vs IOPOLL : deux politiques de polling totalement différentes

4.1 SQPOLL : le noyau scrute la file de soumission (logicielle)

Sans SQPOLL :
   Utilisateur                    Noyau
      │                             │
      │ écrit SQE dans SQ (RAM)    │
      │ io_uring_enter()          │  ★ appel système : « nouvelles tâches ! »
      │                             │  noyau traite les SQE
      │← retour io_uring_enter ────│
      │                             │  noyau exécute les E/S
      │                             │  écrit CQE dans CQ
      │← lecture CQ (RAM) ────────│

Avec SQPOLL :
   Utilisateur                    Noyau
      │                             │
      │ écrit SQE dans SQ (RAM)    │  ★ pas d'appel système !
      │                             │  thread noyau scrute SQ en boucle
      │                             │  ┌──────────────────┐
      │                             │  │ thread SQPOLL    │
      │                             │  │ while(1) {       │
      │                             │  │   if (nouveau SQE)│
      │                             │  │     traiter       │
      │                             │  │ }                │
      │                             │  └──────────────────┘
      │                             │  noyau exécute E/S
      │                             │  écrit CQE
      │← lecture CQ (RAM) ────────│

Coût : un cœur CPU noyau à 100 % en scrutation active (peut être mis en veille configurable). Économise les appels système de soumission.

Usage : services réseau à très haut débit (milliers de requêtes/s). Nécessite memlock dans /etc/security/limits.conf.

4.2 IOPOLL : le noyau scrute le matériel

Sans IOPOLL :
   Noyau envoie requête au matériel
      │
      ▼
   Matériel traite…
      │
      ▼
   Interruption matérielle → noyau → écrit CQE
   (latence d'interruption : microsecondes)

Avec IOPOLL :
   Noyau envoie requête
      │
      ▼
   Noyau scrute la file d'achèvement du matériel  ← pas d'interruption
      │
      ▼
   Détecte achèvement → écrit CQE immédiatement
   (latence inférieure à la microseconde)

Coût : CPU noyau en scrutation 100 %. Exige O_DIRECT (E/S directes), pas de bufferisation.

Usage : SSD NVMe ultra-rapides, cartes réseau 100 Gbps. Inutile sur disques SATA classiques.

4.3 Comparaison

Aspect SQPOLL IOPOLL
Objet scruté File SQ (logicielle) File d'achèvement matérielle
Élimine appel système io_uring_enter latence d'interruption matérielle
Coût noyau thread noyau en scrutation CPU noyau scrute matériel
Condition stockage aucune O_DIRECT obligatoire
Périphériques tous (fichiers, sockets…) NVMe haut de gamme, cartes réseau
Combinaison SQPOLL | IOPOLL possible (objets différents, non conflictuels)

Cas typique combiné : serveur de stockage haute performance dédié, un cœur noyau lié en mode SQPOLL | IOPOLL, les threads utilisateur n'exécutent aucun appel système pour les E/S.

5. io_uring vs epoll : du Reactor au Proactor

5.1 Reactor (epoll)

epoll_wait(fds) ──▶ retourne les fd prêts
   │
   └── pour chaque fd prêt :
         recv(fd, buf, len, 0)   ← synchrone, vous faites l'E/S
         process(buf)
         send(fd, resp, len, 0)   ← synchrone, vous faites l'E/S

Vous demandez « quels fd sont prêts ? », le noyau donne une liste. Puis vous lisez/écrivez. Chaque recv/send est un appel système synchrone.

5.2 Proactor (io_uring)

io_uring_prep_recv(sqe, fd, buf, len)  ← soumission « je veux lire »
io_uring_prep_send(sqe, fd, buf, len)  ← soumission « je veux écrire »
io_uring_submit(&ring)                 ← tout en un appel système

... noyau exécute les E/S de manière asynchrone, puis écrit les CQE ...

pour chaque CQE :
    si CQE de recv :
        process(buf)
        soumettre nouveau send
    si CQE de send :
        soumettre nouveau recv

Vous dites « noyau, lis ce fd pour moi ». Le noyau répond « c’est fait, voilà le résultat ». Vous n’exécutez pas l’E/S vous-même.

5.3 Différence fondamentale

epoll (Reactor) :
  Perception synchrone : epoll indique « prêt » → vous appelez recv/send
  Déplacement des données : tampon utilisateur ← noyau via recv/send
  Piloté par événements : les notifications du noyau font évoluer votre machine à états

io_uring (Proactor) :
  Opérations asynchrones : vous soumettez les E/S voulues → noyau notifie « fini »
  Déplacement des données : noyau écrit directement dans votre tampon (DMA asynchrone / copie mémoire)
  Piloté par intentions : vous déclarez ce que vous voulez faire ensuite

5.4 Coût en appels système

Exemple : recevoir 100 octets et répondre 10 octets :

epoll (au moins 3 appels système) :
  epoll_wait(epfd, events, max, timeout)  → 1
  recv(fd, buf, 100, 0)                   → 1
  send(fd, resp, 10, 0)                   → 1
  Total : 3 (peut être optimisé par batch)

io_uring (1 appel système) :
  io_uring_submit_and_wait(ring, 1)       → 1
    ↳ soumission recv + submit
    ↳ attente CQE recv
    ↳ traitement utilisateur + soumission send (dans la file SQ, pas d'appel système)
    ↳ attente CQE send
  Total : 1 (amorti si lots)

La source de la performence n’est pas magique, elle vient de deux décisions :

  1. Mémoire partagée : les files SQ/CQ sont mmapées, la soumission et la récolte se font sans appel système la plupart du temps.
  2. Soumission par lots : plusieurs SQE peuvent être soumis en un seul submit, et un seul parcours de CQE peut traiter toutes les terminaisons.

epoll est-il encore pertinent ?

Oui, absolument. epoll reste excellent dans :

  • Simplicité : modèle mental simple, peu de bugs.
  • Compatibilité : Linux ≥ 2.6, alors qu’io_uring nécessite ≥ 5.1.
  • Débogage : pas de sémantique complexe de mémoire partagée, strace montre tout.

Les avantages d’io_uring deviennent nets pour les débits élevés. Pour un nombre de connexions modéré (quelques centaines) et des requêtes modestes (quelques milliers par seconde), epoll suffit largement.

6. Autres opérations importantes

Temporisateur

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
struct __kernel_timespec ts = { .tv_sec = 1, .tv_nsec = 0 };
io_uring_prep_timeout(sqe, &ts, 0, 0);
sqe->user_data = TAG_TIMEOUT;
io_uring_submit(&ring);

Accept d’une connexion TCP

struct sockaddr_in client_addr;
socklen_t addr_len = sizeof(client_addr);
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_accept(sqe, serveur_fd, (struct sockaddr*)&client_addr,
                      &addr_len, SOCK_NONBLOCK);
sqe->user_data = TAG_ACCEPT;

SQE chaînés (Linked SQE)

Créer une dépendance : le second SQE n’est exécuté qu’après la réussite du premier.

struct io_uring_sqe *sqe1 = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe1, fd, buf, 4096, 0);
sqe1->flags |= IOSQE_IO_LINK;
sqe1->user_data = 1;

struct io_uring_sqe *sqe2 = io_uring_get_sqe(&ring);
io_uring_prep_write(sqe2, fd, buf, 4096, 4096);
sqe2->user_data = 2;
// sqe2 ne s'exécutera qu'après le succès de sqe1

Récolte par lots des CQE

struct io_uring_cqe *cqes[128];
int n = io_uring_peek_batch_cqe(&ring, cqes, 128);
for (int i = 0; i < n; i++) {
    // traiter cqes[i]
}
io_uring_cq_advance(&ring, n);

7. Guide anti‑pièges pour débutants

Piège 1 : params non initialisé

// Erreur
struct io_uring_params params;
params.flags = IORING_SETUP_SQPOLL;  // autres champs = déchets !
io_uring_queue_init_params(1024, &ring, &params);

// Correct
struct io_uring_params params;
memset(&params, 0, sizeof(params));
params.flags = IORING_SETUP_SQPOLL;
io_uring_queue_init_params(1024, &ring, &params);

Le noyau utilise les champs non initialisés (ex. sq_thread_cpu) comme paramètres, des valeurs aléatoires peuvent causer un comportement imprévisible.

Piège 2 : user_data dépasse 8 octets

// Dangereux
struct mon_gros_contexte ctx;
sqe->user_data = (__u64)&ctx;      // OK sur 64 bits
// Mais :
sqe->user_data = ctx.fd | (ctx.event << 32); // OK, deux int = 8 octets

// Erreur
struct infos_connexion { int fd; int event; void *ptr; }; // 16 octets
memcpy(&sqe->user_data, &info, sizeof(info)); // ★ débordement !

user_data fait __u64 soit exactement 8 octets. Toute structure emballée doit tenir dans 8 octets ; au‑delà, les champs adjacents du SQE sont écrasés.

Solutions : soit une structure ≤ 8 octets, soit un pointeur (8 octets sur 64 bits), soit un index.

Piège 3 : profondeur de file inadaptée

// Trop petite
io_uring_queue_init(4, &ring, 0);   // 4 SQE, blocage fréquent en haut débit

// Trop grande
io_uring_queue_init(32768, &ring, 0);  // mémoire verrouillée énorme, risque d'échec

Choix raisonnable :

  • Fichiers : 64–256
  • Réseau : 512–2048
  • Mixte : 1024 (valeur sûre par défaut)

Piège 4 : droits memlock insuffisants

# Symptôme
io_uring_queue_init: Cannot allocate memory

# Vérifier
ulimit -l

# Corriger dans /etc/security/limits.conf
username  hard  memlock  65536
username  soft  memlock  65536

io_uring utilise de la mémoire verrouillée (non swappable). La limite memlock doit dépasser entries × (taille SQE + taille CQE + registres). Au moins entries × 1 Ko.

Piège 5 : règles d’héritage des flags SQE

// Dans une chaîne IOSQE_IO_LINK, si un SQE échoue,
// les suivants sont annulés, mais les SQE non chaînés déjà soumis ne sont pas affectés.
// Comprendre la sémantique LINK + gestion d'erreur.

Piège 6 : obtenir un SQE alors que la file SQ est pleine

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
if (!sqe) {
    // SQ pleine ! Soumettre d'abord, puis réessayer
    io_uring_submit(&ring);
    sqe = io_uring_get_sqe(&ring);
}

io_uring_get_sqe retourne NULL quand il n’y a plus de place dans la file SQ. Cela signifie que vous soumettez trop vite (bien plus vite que le noyau ne consomme). Il faut soumetrte un lot pour libérer des emplacements.

8. Aide au choix technique

Que voulez‑vous faire ?
│
├─ Lire/écrire un gros fichier unique (plusieurs Mo)
│   → E/S synchrones suffisent, io_uring n'apporte pas grand‑chose
│
├─ Lire/écrire beaucoup de petits fichiers (logs, base de données)
│   → io_uring, mode par défaut, profondeur 256
│
├─ Serveur TCP, milliers de requêtes par seconde
│   → io_uring ou epoll au choix, epoll plus simple
│
├─ Serveur TCP, dizaines de milliers de req/s
│   → io_uring + SQPOLL, profondeur 1024+
│
├─ Serveur de stockage NVMe ultra‑rapide (latence < 10 μs)
│   → io_uring + SQPOLL + IOPOLL, affinité CPU
│
├─ Besoin de gérer fichiers ET réseau dans le même modèle
│   → io_uring (modèle unifié), epoll ne gère pas les fichiers classiques
│
├─ Noyau < 5.1 (ancien Linux / conteneur)
│   → epoll, io_uring indisponible
│
├─ Portabilité hors Linux (macOS, BSD)
│   → epoll non plus. Utilisez libuv ou boost.asio
│
├─ Prototypage rapide, débogage simple
│   → epoll, strace montre tout clairement
│
└─ Apprentissage de l'asynchrone, recherche de performances extrêmes
    → io_uring, solution unique à tous les besoins d'E/S asynchrones

Étiquettes: io_uring Linux asynchronous I/O liburing SQE

Publié le 24 juillet à 06h45