Le mur de la concurrence : Pourquoi select et poll s'effondrent à grande échelle

Les concentrateurs VPN d'entreprise et les passerelles réseau à forte densité doivent maintenir des dizaines, voire des centaines de milliers de sessions clientes simultanées sur un unique serveur physique. Chaque tunnel actif gère son propre état de socket UDP ou TCP, ses clés de session cryptographiques et ses temporisateurs d'activité (keepalive).

À l'origine, le multiplexage des entrées/sorties reposait sur les appels système historiques issus de BSD : select() et poll(). Bien que simples à mettre en œuvre, ces interfaces affichent une complexité algorithmique linéaire en $O(N)$ qui les rend inopérantes face aux exigences des passerelles modernes :

  1. Parcours linéaire systématique à chaque cycle d'événements : select() et poll() transmettent l'intégralité du tableau de descripteurs de l'espace utilisateur vers l'espace noyau à chaque appel. Le noyau doit examiner individuellement chaque descripteur surveillé, même si seulement 5 sockets sur 50 000 contiennent des trames chiffrées prêtes à être lues. Lorsque le nombre de sessions augmente, le processeur passe la majeure partie de son temps à scruter des connexions inactives.
  2. Duplication répétitive de mémoire entre utilisateur et noyau : Chaque exécution de select() ou de poll() requiert la réallocation et la copie de volumineuses structures de métadonnées à travers la frontière noyau. Pour une passerelle administrant 100 000 sessions, transférer plusieurs mégaoctets de données d'état des dizaines de fois par seconde sature le bus mémoire et évince les lignes de cache du processeur.
  3. Plafond rigide de descripteurs de fichiers : La norme POSIX impose une limite FD_SETSIZE (couramment fixée à 1024 descripteurs) pour select(), obligeant les architectures anciennes à multiplier les processus concurrents uniquement pour absorber la charge des utilisateurs.

Pour gérer une volumétrie massive avec une efficacité prédictible en $O(1)$, Linux propose son sous-système dédié : epoll.

Architecture interne de Linux epoll

Introduit avec le noyau Linux 2.6, le mécanisme epoll surmonte ce verrou d'évolutivité en séparant la surveillance en deux étapes distinctes : l'enregistrement d'état et la récupération des événements. Au lieu de transmettre la liste complète des descripteurs à chaque itération, le noyau conserve un arbre d'état persistant dans sa propre mémoire.

+-------------------------------------------------------------------------+
|                  Boucle d'événements VPN (Espace utilisateur)           |
+-------------------------------------------------------------------------+
       |                                                    ^
       | epoll_ctl(EPOLL_CTL_ADD / MOD / DEL)               | epoll_wait()
       v                                                    |
+-------------------------------------------------------------------------+
|                         Contexte epoll dans le noyau                    |
|                                                                         |
|   +-----------------------------------------------------------------+   |
|   |                Arbre Rouge-Noir de surveillance (rbr)           |   |
|   |         - Insertion, recherche et retrait en O(log N)           |   |
|   |         - Persistance des états entre les cycles de scrutation  |   |
|   +-----------------------------------------------------------------+   |
|                                    |                                    |
|                                    v [Interruption réseau / Réveil]     |
|   +-----------------------------------------------------------------+   |
|   |                  Liste des événements prêts (rdllist)           |   |
|   |         - Restitution immédiate en O(1) des sockets actives     |   |
|   +-----------------------------------------------------------------+   |
+-------------------------------------------------------------------------+
       |                                                    |
       v Trames réseau via DMA                              v Paquets distribués
+-------------------------------------------------------------------------+
|                    Interface réseau physique / Périphérique TUN/TAP     |
+-------------------------------------------------------------------------+

Les trois fonctions fondamentales

Le sous-système s'articule autour de trois appels système compacts :

  • epoll_create1(int flags) : Instancie un descripteur epoll dans l'espace noyau. L'attribut EPOLL_CLOEXEC garantit la fermeture automatique du descripteur lors des appels execve().
  • epoll_ctl(int epfd, int op, int fd, struct epoll_event *event) : Administre l'arbre rouge-noir (rbr) qui enregistre les sockets surveillées. Les opérations d'ajout (EPOLL_CTL_ADD), de modification (EPOLL_CTL_MOD) et de suppression (EPOLL_CTL_DEL) s'exécutent en $O(\log N)$, éliminant les transferts massifs de mémoire.
  • epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout) : Bloque l'exécution jusqu'à l'arrivée d'événements. Cette fonction retourne exclusivement la liste des descripteurs disposant de données prêtes, garantissant un traitement en temps constant $O(1)$ quel que soit le nombre global de clients connectés.

Déclenchement par niveau ou par front : L'apport de EPOLLET

Par défaut, epoll fonctionne en mode Level-Triggered (LT) (déclenchement par niveau). Bien qu'intuitif, le passage au mode Edge-Triggered (ET) (déclenchement par front) via l'indicateur EPOLLET est indispensable pour maximiser le débit des serveurs VPN à forte charge.

Mode déclenché par niveau (Level-Triggered - LT) :
Données présentes dans le tampon socket ───────────────────────────────────>
epoll_wait() signale l'événement         [OUI]    [OUI]    [OUI]    [OUI]   ...
(Signale continuellement tant que le tampon contient au moins un octet)

Mode déclenché par front (Edge-Triggered - ET - EPOLLET) :
Arrivée de données (Transition d'état : Vide -> Non-vide) ─────────────────>
epoll_wait() signale l'événement         [OUI]    [NON]    [NON]    [NON]   ...
(Ne signale qu'une seule fois lors du changement d'état ; lecture totale requise)

Les inconvénients du mode Level-Triggered en multi-thread

En mode Level-Triggered, epoll_wait() continue d'émettre des alertes tant que le tampon de la socket n'est pas entièrement vidé. Si un thread de travail ne lit qu'une fraction d'une trame chiffrée, les appels ultérieurs réveillent à nouveau les processus en attente. Dans une architecture multi-threadée, cette répétition engendre des basculements de contexte excessifs et de vives contentions sur les verrous.

Maîtriser le mode Edge-Triggered (EPOLLET)

Lorsque l'indicateur EPOLLET est activé, epoll ne transmet une notification que lors d'un changement d'état du descripteur (par exemple, lors de la transition d'un tampon vide à l'arrivée d'un nouveau paquet). Cette approche diminue de plus de 60 % le volume d'interruptions générées par le noyau.

Cependant, le mode Edge-Triggered impose deux règles de développement rigoureuses :

  1. Sockets non bloquantes obligatoires : Tous les descripteurs surveillés doivent être configurés avec O_NONBLOCK via fcntl(). Si un thread se retrouve bloqué sur un appel synchrone read() ou recv(), l'ensemble de la boucle d'événements est paralysé.
  2. Vidage exhaustif du tampon jusqu'à EAGAIN : Comme epoll ne renverra pas d'alerte avant l'arrivée de la prochaine salve de paquets, le thread doit exécuter recv() ou recvmsg() en boucle jusqu'à obtenir l'erreur EAGAIN ou EWOULDBLOCK, confirmant que la file d'attente est totalement vide.
// Schéma de lecture complète en mode Edge-Triggered
while (1) {
    ssize_t count = recv(client_fd, buffer, sizeof(buffer), 0);
    if (count == -1) {
        if (errno == EAGAIN || errno == EWOULDBLOCK) {
            // Tampon entièrement vidé ; retour sécurisé vers epoll_wait
            break;
        }
        // Traitement d'une rupture inattendue de connexion
        close(client_fd);
        break;
    } else if (count == 0) {
        // Clôture normale de la session par le client
        close(client_fd);
        break;
    }
    // Transmission des données brutes vers le déchiffrement VPN
    process_vpn_packet(buffer, count);
}

Neutraliser les aléas de concurrence avec EPOLLONESHOT

Dans les passerelles VPN à haut rendement, les architectures modernes exploitent des pools de threads partageant un même descripteur epoll. Cette conception soulève deux risques opérationnels majeurs : l'effet de Thundering Herd (troupeau en furie) et les conflits d'accès concurrents (Race Conditions).

Le risque de désynchronisation entre threads

Imaginons que le Thread A et le Thread B attendent des événements sur une même instance epoll. Une salve de paquets UDP arrive sur la socket 4. Le Thread A se réveille et commence à traiter le flux. Pendant qu'il procède au déchiffrement, un nouveau paquet atteint la socket 4. Sans mécanisme de protection, epoll_wait() réveille aussitôt le Thread B pour cette même socket.

Le Thread A et le Thread B lisent alors simultanément sur la socket 4, provoquant un désordre dans la séquence des paquets, invalidant la fenêtre anti-rejeu (anti-replay window) et corrompant l'état de la session de sécurité.

La solution : EPOLLONESHOT

Pour écarter ce risque sans recourir à des verrous mutex applicatifs coûteux, Linux met à disposition le drapeau EPOLLONESHOT.

1. epoll_ctl(epfd, EPOLL_CTL_ADD, sock_fd, EPOLLIN | EPOLLET | EPOLLONESHOT)
   - La socket est enregistrée avec la protection oneshot.

2. Arrivée des paquets -> Le Thread A est réveillé par epoll_wait().
   - Le noyau désactive automatiquement toute notification future sur sock_fd.

3. Nouveaux paquets reçus pendant le travail du Thread A.
   - Le Thread B reste en sommeil ; sock_fd ne réveille aucun autre processus.

4. Le Thread A termine la lecture et vide la socket.
   - Le Thread A réarme la socket : epoll_ctl(epfd, EPOLL_CTL_MOD, sock_fd, ... | EPOLLONESHOT)
   - La socket est prête à recevoir de nouvelles alertes en toute sécurité.

Avec EPOLLONESHOT, le système garantit qu'un descripteur donné n'est traité que par un seul thread à la fois, éliminant les conflits d'ordonnancement tout en préservant le parallélisme sur l'ensemble des cœurs processeur.

Déploiement multi-threadé au sein de l'architecture RiceGuard

Dans l'infrastructure distribuée de VPNRice, le moteur RiceGuard tire parti des atouts combinés d' EPOLLET et d' EPOLLONESHOT pour offrir une réactivité sans verrou sur des clusters de serveurs à très haute densité.

+-------------------------------------------------------------------+
|                     Passerelle de production VPNRice              |
|                                                                   |
|   +-----------------------------------------------------------+   |
|   |         Plus de 100 000 sessions clientes actives (UDP/TCP)|  |
|   +-----------------------------------------------------------+   |
|                                 |                                 |
|                                 v                                 |
|   +-----------------------------------------------------------+   |
|   |          Répartiteur d'événements epoll (Arbre rbr)       |   |
|   |            Configuration conjointe EPOLLET + EPOLLONESHOT |   |
|   +-----------------------------------------------------------+   |
|         |                      |                      |           |
|         v                      v                      v           |
|  +-------------+        +-------------+        +-------------+    |
|  | Thread 0    |        | Thread 1    |        | Thread 2    |    |
|  | Cœur CPU 0  |        | Cœur CPU 1  |        | Cœur CPU 2  |    |
|  | Déchiffrement|       | Déchiffrement|       | Déchiffrement|   |
|  | ChaCha20    |        | ChaCha20    |        | ChaCha20    |    |
|  +-------------+        +-------------+        +-------------+    |
|         |                      |                      |           |
|         v Réarmement           v Réarmement           v Réarmement|
|   epoll_ctl(MOD)         epoll_ctl(MOD)         epoll_ctl(MOD)    |
+-------------------------------------------------------------------+

1. Répartition de charge sans verrou global

RiceGuard associe les sessions clientes à des threads de travail assignés à des cœurs CPU dédiés (CPU pinning). Grâce à l'emploi de contextes epoll partitionnés ou à l'usage d'EPOLLONESHOT, les routines cryptographiques traitent les paquets sans solliciter de spinlocks généraux. La gestion des poignées de main, la vérification des liaisons et le déchiffrement s'effectuent avec une latence de traitement inférieure à la milliseconde.

2. Réception groupée haute performance avec recvmmsg

Afin de minimiser le coût des basculements de contexte en période de pointe, RiceGuard associe les notifications par front à l'appel système recvmmsg(). Dès qu' epoll_wait() valide la présence de trames, le thread de travail peut récupérer jusqu'à 64 paquets en une seule transition, transmettant ces vecteurs aux instructions vectorielles SIMD (AVX-512 / NEON) pour un déchiffrement ChaCha20-Poly1305 accéléré.

3. Cloisonnement par espaces de noms et étanchéité

Chaque environnement d'abonnés au sein du réseau RiceGuard est confiné dans des espaces de noms réseau Linux (netns) étanches. Le routage orchestré par epoll s'opère strictement à l'intérieur des limites désignées, interdisant toute fuite de données en clair vers les interfaces externes. Associée à des résolveurs DNS chiffrés locaux, cette architecture pilotée par les événements garantit la confidentialité, la stabilité et l'endurance nécessaires aux déploiements d'entreprise.