Le serveur net/http de Go utilise un modèle de goroutine par connexion combiné à la stratégie de planification M:N du runtime. Lorsque le serveur accepte une connexion TCP, il crée immédiatement une goroutine légère pour gérer l'ensemble du cycle de vie de cette connexion, permettant à la boucle d'acceptation principale de revenir et de recevoir immédiatement la prochaine connexion. Ces goroutines sont multiplexées sur un pool limité de threads OS par le planificateur Go, qui paralyse les goroutines effectuant des E/S bloquantes et replanifie celles prêtes à s'exécuter sur des threads disponibles. Cette architecture permet au serveur de maintenir des centaines de milliers de connexions concurrentes en utilisant seulement un petit nombre de threads du noyau, évitant ainsi la surcharge mémoire des serveurs traditionnels par thread par connexion.
Nous devions construire une passerelle de télémétrie en temps réel capable d'ingérer des données de 50 000 dispositifs IoT simultanément via des connexions HTTP/1.1 persistantes.
Description du problème : Notre prototype initial utilisant Python avec Twisted fournissait la concurrence nécessaire mais est rapidement devenu ingérable en raison de chaînes de callbacks complexes et d'un traitement d'erreur profondément imbriqué. Lorsque nous avons essayé une approche Java avec un thread par connexion pour simplifier le code, nous avons rencontré la limite de threads du système d'exploitation à environ 32 000 connexions, provoquant l'écrasement de la JVM avec OutOfMemoryError: unable to create new native thread car chaque thread consommait plus de 1 Mo de mémoire virtuelle.
Différentes solutions envisagées :
Asyncio avec machines d'état explicites : Nous avons évalué la migration vers asyncio de Python pour utiliser une seule boucle d'événements avec des coroutines. Cela réduirait considérablement l'empreinte mémoire par rapport aux threads, mais nécessiterait de réécrire toute notre logique de parsing de protocole en syntaxe async/await et introduirait le risque de bloquer accidentellement la boucle d'événements avec des opérations intensives en CPU. Déboguer des traces de pile à travers des frontières asynchrones s'est également avéré notoirement difficile pour notre équipe de développement.
Fragmentation horizontale des instances JVM : Nous avons envisagé d'exécuter dix plus petites instances Java derrière un répartiteur de charge, chaque instance gérant 5 000 threads. Cette approche a résolu la limite des threads par processus mais a introduit une complexité opérationnelle substantielle, nécessitant des ressources matérielles supplémentaires et compliquant la gestion de l'état partagé et de la fidélité des connexions au sein du cluster. Le coût opérationnel de maintien de ce micro-cluster a dépassé les avantages de rester avec Java.
Modèle goroutine par connexion de Go : Nous avons choisi de réimplémenter la passerelle en Go, en tirant parti des packages net/http et net de la bibliothèque standard. La méthode Serve du serveur crée automatiquement une goroutine légère pour chaque connexion TCP acceptée, et le planificateur du runtime Go multiplexe ces dernières sur un pool limité de threads OS. Cela nous a permis d'écrire du code E/S simple ressemblant à du synchrone qui se mettrait à l'échelle jusqu'à des centaines de milliers de connexions sans gestion manuelle des machines d'état.
Solution choisie et pourquoi : Nous avons choisi l'implémentation Go car elle offrait l'évolutivité des systèmes basés sur des événements combinée à la simplicité de la programmation avec des threads. Le runtime gère automatiquement la complexité de la planification et des E/S non bloquantes, permettant à nos développeurs de se concentrer sur la logique commerciale plutôt que sur les primitives de concurrence. De plus, la taille de pile initiale de 2 Ko des goroutines signifiait que nous pourrions théoriquement gérer des millions de connexions dans notre budget mémoire.
Résultat : Le système de production a géré avec succès 75 000 connexions persistantes simultanées sur un seul serveur à 8 cœurs, consommant moins de 4 Go de RAM. L'utilisation du CPU est restée stable à 35-40 % car le planificateur masquait efficacement la latence des E/S, et nous avons éliminé le fardeau opérationnel de la gestion d'un cluster d'instances Java fragmentées.
Comment le planificateur Go empêche-t-il un problème de troupeau rugissant lorsque des milliers de goroutines se bloquent sur la même réception de canal ?
Le planificateur Go utilise une file d'attente d'attente premier entré, premier sorti (FIFO) pour les canaux, et non un réveille-tout de style sémaphore. Lorsqu'un expéditeur écrit sur un canal, le planificateur réveille exactement une goroutine en attente de la file d'attente de réception (celle qui a attendu le plus longtemps). Cela garantit qu'une seule goroutine consomme la valeur, empêchant le problème de troupeau rugissant où plusieurs goroutines se réveillent, se disputent le verrou et retournent toutes sauf une à l'endormissement. Les candidats supposent souvent à tort que les opérations sur les canaux diffusent à tous les attenteurs comme des variables de condition.
Pourquoi augmenter GOMAXPROCS au-delà du nombre de cœurs CPU physiques peut-il dégrader la performance d'un serveur HTTP Go lié aux E/S ?
Bien que le planificateur Go soit préemptif depuis la version 1.14, avoir plus de threads OS (M) que de cœurs augmente la surcharge de commutation de contexte au niveau du noyau. Pour les serveurs liés aux E/S, des threads excessifs peuvent amener le planificateur à passer plus de temps à gérer des files d'attente d'exécution et des passes de threads qu'à exécuter du code utilisateur. De plus, chaque thread OS consomme des ressources du noyau (mémoire pour le stockage local de thread et les piles du noyau), ce qui peut mettre sous pression le système d'exploitation lorsqu'il est étendu au-delà d'un parallélisme nécessaire.
Comment le serveur net/http de Go gère-t-il la file d'attente SO_BACKLOG de TCP lorsque le taux d'acceptation des goroutines lag temporairement derrière le taux d'arrivée des connexions ?
Le serveur s'appuie sur la file d'attente de backlog d'écoute du noyau (contrôlée par la Backlog de net.ListenConfig ou les valeurs par défaut du système). Si les goroutines mettent du temps à se lancer ou si les gestionnaires mettent du temps à accepter des connexions de l'écouteur, le noyau met en file d'attente les SYNs entrants dans le backlog. Une fois le backlog rempli, le noyau rejette les nouvelles connexions via TCP RST. La boucle Accept() de Go s'exécute dans sa propre goroutine et devrait idéalement générer rapidement des goroutines de gestion. Cependant, si la génération de gestionnaires est retardée (par exemple, en raison de pauses du GC ou de contentions de mutex dans le middleware), des connexions sont perdues. Les candidats manquent souvent que Go n'implémente pas de mise en file d'attente de connexions dans l'espace utilisateur ; cela dépend entièrement du backlog du noyau, et le réglage de SOMAXCONN ou ListenConfig.Backlog est crucial pour l'absorption des pics.