Guide des modèles de serveur de réseau multitraitement
Publié: 2022-03-11En tant que personne qui écrit du code réseau haute performance depuis plusieurs années maintenant (ma thèse de doctorat portait sur le sujet d'un serveur de cache pour les applications distribuées adaptées aux systèmes multicœurs), je vois de nombreux tutoriels sur le sujet qui manquent complètement ou omettent toute discussion des principes fondamentaux des modèles de serveur de réseau. Cet article est donc conçu comme une vue d'ensemble et une comparaison, espérons-le, utiles des modèles de serveurs de réseau, dans le but de lever une partie du mystère de l'écriture de code de réseau haute performance.
Cet article est destiné aux "programmeurs système", c'est-à-dire aux développeurs back-end qui travailleront avec les détails de bas niveau de leurs applications, en implémentant le code du serveur réseau. Cela se fera généralement en C++ ou C, bien qu'aujourd'hui la plupart des langages et frameworks modernes offrent des fonctionnalités de bas niveau décentes, avec différents niveaux d'efficacité.
Je considérerai comme une connaissance commune que, puisqu'il est plus facile de faire évoluer les processeurs en ajoutant des cœurs, il est naturel d'adapter le logiciel pour utiliser au mieux ces cœurs. Ainsi, la question devient de savoir comment partitionner le logiciel entre les threads (ou processus) qui peuvent être exécutés en parallèle sur plusieurs processeurs.
Je prendrai également pour acquis que le lecteur est conscient que la "concurrence" signifie essentiellement "multitâche", c'est-à-dire plusieurs instances de code (qu'elles soient identiques ou différentes, peu importe), qui sont actives en même temps. La simultanéité peut être obtenue sur un seul processeur, et avant l'ère moderne, c'était généralement le cas. Plus précisément, la simultanéité peut être obtenue en basculant rapidement entre plusieurs processus ou threads sur un seul processeur. C'est ainsi que les anciens systèmes à processeur unique réussissaient à exécuter de nombreuses applications en même temps, d'une manière que l'utilisateur percevrait comme des applications exécutées simultanément, bien qu'elles ne le soient pas vraiment. Le parallélisme, en revanche, signifie spécifiquement que le code est exécuté en même temps, littéralement, par plusieurs processeurs ou cœurs de processeur.
Partitionnement d'une application (en plusieurs processus ou threads)
Aux fins de cette discussion, il n'est en grande partie pas pertinent de parler de threads ou de processus complets. Les systèmes d'exploitation modernes (à l'exception notable de Windows) traitent les processus presque aussi légers que les threads (ou dans certains cas, inversement, les threads ont acquis des fonctionnalités qui les rendent aussi lourds que les processus). De nos jours, la principale différence entre les processus et les threads réside dans les capacités de communication et de partage de données entre processus ou entre threads. Là où la distinction entre les processus et les threads est importante, je ferai une note appropriée, sinon, il est prudent de considérer que les mots «thread» et «process» dans cette section sont interchangeables.
Tâches d'application réseau courantes et modèles de serveur de réseau
Cet article traite spécifiquement du code du serveur réseau, qui implémente nécessairement les trois tâches suivantes :
- Tâche n° 1 : établissement (et suppression) des connexions réseau
- Tâche 2 : communication réseau (IO)
- Tâche #3 : Travail utile ; c'est-à-dire la charge utile ou la raison pour laquelle l'application existe
Il existe plusieurs modèles généraux de serveur de réseau pour partitionner ces tâches entre les processus ; à savoir:
- MP : Multi-Processus
- SPED : processus unique, piloté par les événements
- SEDA : architecture pilotée par les événements par étapes
- AMPED : événementiel multi-processus asymétrique
- SYMPED : SYmmetric Multi-Processus événementiel
Ce sont les noms de modèles de serveurs de réseau utilisés dans la communauté universitaire, et je me souviens d'avoir trouvé des synonymes "dans la nature" pour au moins certains d'entre eux. (Les noms eux-mêmes sont, bien sûr, moins importants - la vraie valeur réside dans la façon de raisonner sur ce qui se passe dans le code.)
Chacun de ces modèles de serveur de réseau est décrit plus en détail dans les sections qui suivent.
Le modèle multi-processus (MP)
Le modèle de serveur de réseau MP est celui que tout le monde apprenait en premier, en particulier lors de l'apprentissage du multithreading. Dans le modèle MP, il existe un processus "maître" qui accepte les connexions (Tâche #1). Une fois qu'une connexion est établie, le processus maître crée un nouveau processus et lui passe le socket de connexion, il y a donc un processus par connexion. Ce nouveau processus fonctionne alors généralement avec la connexion d'une manière simple, séquentielle et par étapes : il lit quelque chose à partir de celle-ci (Tâche #2), puis effectue des calculs (Tâche #3), puis y écrit quelque chose (Tâche #2 de nouveau).
Le modèle MP est très simple à mettre en œuvre et fonctionne en fait extrêmement bien tant que le nombre total de processus reste assez faible. Faible à quel point? La réponse dépend vraiment de ce que les tâches #2 et #3 impliquent. En règle générale, disons que le nombre de processus ou de threads ne doit pas dépasser environ le double du nombre de cœurs de processeur. Une fois qu'il y a trop de processus actifs en même temps, le système d'exploitation a tendance à passer beaucoup trop de temps à battre (c'est-à-dire à jongler avec les processus ou les threads sur les cœurs de processeur disponibles) et ces applications finissent généralement par dépenser presque tout leur processeur. temps dans le code "sys" (ou noyau), faisant peu de travail réellement utile.
Pour : Très simple à mettre en œuvre, fonctionne très bien tant que le nombre de connexions est faible.
Inconvénients : a tendance à surcharger le système d'exploitation si le nombre de processus devient trop important et peut avoir une instabilité de latence lorsque les E/S réseau attendent la fin de la phase de charge utile (calcul).
Le modèle SPED (Single Process Event Driven)
Le modèle de serveur de réseau SPED a été rendu célèbre par certaines applications de serveur de réseau de haut niveau relativement récentes, telles que Nginx. Fondamentalement, il effectue les trois tâches dans le même processus, en les multiplexant. Pour être efficace, il nécessite des fonctionnalités assez avancées du noyau comme epoll et kqueue. Dans ce modèle, le code est piloté par les connexions entrantes et les « événements » de données, et implémente une « boucle d'événements » qui ressemble à ceci :
- Demandez au système d'exploitation s'il y a de nouveaux "événements" réseau (tels que de nouvelles connexions ou des données entrantes)
- S'il y a de nouvelles connexions disponibles, établissez-les (Tâche #1)
- Si des données sont disponibles, lisez-les (Tâche #2) et agissez en conséquence (Tâche #3)
- Répétez jusqu'à ce que le serveur se ferme
Tout cela est fait en un seul processus, et cela peut être fait de manière extrêmement efficace car cela évite complètement le changement de contexte entre les processus, ce qui tue généralement les performances dans le modèle MP. Les seuls changements de contexte ici proviennent des appels système, et ceux-ci sont minimisés en agissant uniquement sur les connexions spécifiques auxquelles sont attachés des événements. Ce modèle peut gérer des dizaines de milliers de connexions simultanément, tant que le travail de charge utile (tâche n° 3) n'est pas trop compliqué ou gourmand en ressources.
Il y a cependant deux inconvénients majeurs à cette approche :
- Étant donné que les trois tâches sont effectuées séquentiellement dans une seule itération de boucle, le travail de charge utile (tâche n ° 3) est effectué de manière synchrone avec tout le reste, ce qui signifie que s'il faut beaucoup de temps pour calculer une réponse aux données reçues par le client, tout le reste s'arrête pendant que cela se fait, introduisant des fluctuations potentiellement énormes de la latence.
- Un seul cœur de processeur est utilisé. Cela a l'avantage, encore une fois, de limiter absolument le nombre de changements de contexte requis par le système d'exploitation, ce qui augmente les performances globales, mais présente l'inconvénient important que tous les autres cœurs de processeur disponibles ne font rien du tout.
C'est pour ces raisons que des modèles plus avancés sont nécessaires.

Avantages : Peut être très performant et facile sur le système d'exploitation (c'est-à-dire, nécessite une intervention minimale du système d'exploitation). Ne nécessite qu'un seul cœur de processeur.
Inconvénients : n'utilise qu'un seul processeur (quel que soit le nombre disponible). Si le travail de charge utile n'est pas uniforme, il en résulte une latence non uniforme des réponses.
Le modèle d'architecture pilotée par les événements par étapes (SEDA)
Le modèle de serveur de réseau SEDA est un peu complexe. Il décompose une application complexe pilotée par des événements en un ensemble d'étapes reliées par des files d'attente. S'il n'est pas mis en œuvre avec soin, ses performances peuvent souffrir du même problème que le boîtier MP. Cela fonctionne comme ceci :
- Le travail de charge utile (tâche n° 3) est divisé en autant d'étapes ou de modules que possible. Chaque module implémente une seule fonction spécifique (pensez aux « microservices » ou « micro-noyaux ») qui réside dans son propre processus séparé, et ces modules communiquent entre eux via des files d'attente de messages. Cette architecture peut être représentée sous la forme d'un graphe de nœuds, où chaque nœud est un processus et les bords sont des files d'attente de messages.
- Un seul processus exécute la tâche n° 1 (suivant généralement le modèle SPED), qui décharge les nouvelles connexions vers des nœuds de point d'entrée spécifiques. Ces nœuds peuvent être soit des nœuds de réseau purs (tâche n° 2) qui transmettent les données à d'autres nœuds pour le calcul, soit peuvent également implémenter le traitement de la charge utile (tâche n° 3). Il n'y a généralement pas de processus « maître » (par exemple, un processus qui collecte et agrège les réponses et les renvoie sur la connexion) puisque chaque nœud peut répondre par lui-même.
En théorie, ce modèle peut être arbitrairement complexe, le graphe de nœuds pouvant avoir des boucles, des connexions à d'autres applications similaires, ou où les nœuds s'exécutent réellement sur des systèmes distants. En pratique, cependant, même avec des messages bien définis et des files d'attente efficaces, il peut devenir difficile de penser et de raisonner sur le comportement du système dans son ensemble. Le surcoût de transmission de messages peut détruire les performances de ce modèle, par rapport au modèle SPED, si le travail effectué à chaque nœud est court. L'efficacité de ce modèle est nettement inférieure à celle du modèle SPED, et il est donc généralement utilisé dans des situations où le travail de charge utile est complexe et prend du temps.
Avantages : Le rêve ultime de l'architecte logiciel : tout est séparé en modules indépendants et soignés.
Inconvénients : la complexité peut exploser uniquement à cause du nombre de modules, et la mise en file d'attente des messages est toujours beaucoup plus lente que le partage direct de la mémoire.
Le modèle AMPED (Asymmetric Multi-Process Event-Driven)
Le serveur de réseau AMPED est une version plus simple et plus facile à modéliser de SEDA. Il n'y a pas autant de modules et de processus différents, et moins de files d'attente de messages. Voici comment cela fonctionne:
- Implémentez les tâches #1 et #2 dans un seul processus "maître", dans le style SPED. C'est le seul processus qui effectue des E/S réseau.
- Implémentez la tâche n° 3 dans un processus « travailleur » séparé (éventuellement démarré dans plusieurs instances), connecté au processus maître avec une file d'attente (une file d'attente par processus).
- Lorsque les données sont reçues dans le processus "maître", recherchez un processus de travail sous-utilisé (ou inactif) et transmettez les données à sa file d'attente de messages. Le processus maître reçoit un message du processus lorsqu'une réponse est prête, auquel cas il transmet la réponse à la connexion.
L'important ici est que le travail de charge utile soit effectué dans un nombre fixe (généralement configurable) de processus, qui est indépendant du nombre de connexions. Les avantages ici sont que la charge utile peut être arbitrairement complexe et qu'elle n'affectera pas les E/S du réseau (ce qui est bon pour la latence). Il existe également une possibilité d'augmenter la sécurité, car un seul processus effectue les E/S réseau.
Avantages : Séparation très nette des E/S réseau et du travail de charge utile.
Inconvénients : utilise une file d'attente de messages pour transmettre les données entre les processus, ce qui, selon la nature du protocole, peut devenir un goulot d'étranglement.
Le modèle SYmmetric Multi-Process Event-Driven (SYMPED)
Le modèle de serveur de réseau SYMPED est à bien des égards le « Saint Graal » des modèles de serveur de réseau, car c'est comme avoir plusieurs instances de processus de « travail » SPED indépendants. Il est implémenté en ayant un seul processus acceptant les connexions dans une boucle, puis en les transmettant aux processus de travail, chacun ayant une boucle d'événement de type SPED. Cela a des conséquences très favorables :
- Les processeurs sont chargés exactement pour le nombre de processus générés, qui à chaque instant effectuent soit des E/S réseau, soit un traitement de charge utile. Il n'existe aucun moyen d'augmenter davantage l'utilisation du processeur.
- Si les connexions sont indépendantes (comme avec HTTP), il n'y a pas de communication interprocessus entre les processus de travail.
C'est en fait ce que font les nouvelles versions de Nginx ; ils génèrent un petit nombre de processus de travail, chacun exécutant une boucle d'événements. Pour rendre les choses encore meilleures, la plupart des systèmes d'exploitation fournissent une fonction grâce à laquelle plusieurs processus peuvent écouter indépendamment les connexions entrantes sur un port TCP, éliminant ainsi le besoin d'un processus spécifique dédié au travail avec les connexions réseau. Si l'application sur laquelle vous travaillez peut être implémentée de cette manière, je vous recommande de le faire.
Avantages : Plafond d'utilisation du processeur supérieur strict, avec un nombre contrôlable de boucles de type SPED.
Inconvénients : étant donné que chacun des processus a une boucle de type SPED, si le travail de charge utile n'est pas uniforme, la latence peut à nouveau varier, tout comme avec le modèle SPED normal.
Quelques astuces de bas niveau
En plus de sélectionner le meilleur modèle architectural pour votre application, il existe quelques astuces de bas niveau qui peuvent être utilisées pour augmenter encore les performances du code réseau. Voici une brève liste de certains des plus efficaces :
- Évitez l'allocation de mémoire dynamique. Comme explication, regardez simplement le code des allocateurs de mémoire populaires - ils utilisent des structures de données complexes, des mutex, et il y a tellement de code dedans (jemalloc, par exemple, fait environ 450 KiB de code C !). La plupart des modèles ci-dessus peuvent être implémentés avec un réseau et/ou des tampons complètement statiques (ou pré-alloués) qui ne changent de propriété entre les threads que si nécessaire.
- Utilisez le maximum que le système d'exploitation peut fournir. La plupart des systèmes d'exploitation permettent à plusieurs processus d'écouter sur un seul socket et implémentent des fonctionnalités où une connexion ne sera pas acceptée tant que le premier octet (ou même une première requête complète !) n'aura pas été reçu sur le socket. Utilisez sendfile() si vous le pouvez.
- Comprenez le protocole réseau que vous utilisez ! Par exemple, il est généralement judicieux de désactiver l'algorithme de Nagle, et il peut être judicieux de désactiver la persistance si le taux de (re)connexion est élevé. Découvrez les algorithmes de contrôle de congestion TCP et voyez s'il est judicieux d'essayer l'un des plus récents.
Je pourrais en parler davantage, ainsi que des techniques et astuces supplémentaires à utiliser, dans un futur article de blog. Mais pour l'instant, cela fournit, espérons-le, une base utile et informative concernant les choix architecturaux pour l'écriture de code réseau haute performance, ainsi que leurs avantages et inconvénients relatifs.
