Tout ce que vous vouliez savoir sur le traitement des requêtes, mais vous avez hésité à demander

Qu'est-ce qu'un service réseau? Il s'agit d'un programme qui accepte les demandes entrantes sur le réseau et les traite, renvoyant éventuellement des réponses.


Il existe de nombreux aspects dans lesquels les services réseau diffèrent les uns des autres. Dans cet article, je me concentre sur la façon de gérer les demandes entrantes.


Le choix d'une méthode de traitement des demandes a des conséquences considérables. Comment faire un service de chat avec 100 000 connexions simultanées? Quelle approche adopter pour extraire des données d'un flux de fichiers mal structurés? Un mauvais choix entraînera une perte de temps et d'énergie.


L'article aborde des approches telles qu'un pool de processus / threads, le traitement orienté événement, le modèle moitié sync / moitié async et bien d'autres. De nombreux exemples sont donnés, les avantages et les inconvénients des approches, leurs caractéristiques et leurs applications sont pris en compte.


Présentation


Le sujet des méthodes de traitement des requêtes n'est pas nouveau, voir, par exemple: un , deux . Cependant, la plupart des articles ne le considèrent que partiellement. Cet article est destiné à combler les lacunes et à fournir une présentation cohérente du problème.


Les approches suivantes seront envisagées:


  • traitement séquentiel
  • processus de demande
  • flux de demande
  • pool de processus / thread
  • traitement événementiel (configuration du réacteur)
  • modèle moitié sync / moitié asynchrone
  • traitement de convoyeur

Il est à noter qu'un service qui traite des requêtes n'est pas nécessairement un service réseau. Il peut s'agir d'un service qui reçoit de nouvelles tâches de la base de données ou de la file d'attente des tâches. Dans cet article, les services réseau sont destinés, mais vous devez comprendre que les approches considérées ont une portée plus large.


TL; DR


À la fin de l'article est une liste avec une brève description de chaque approche.


Traitement séquentiel


Une application se compose d'un seul thread dans un seul processus. Toutes les demandes ne sont traitées que séquentiellement. Il n'y a pas de parallélisme. Si plusieurs demandes arrivent au service en même temps, l'une d'elles est traitée, les autres sont mises en file d'attente.


De plus, cette approche est facile à mettre en œuvre. Il n'y a pas de verrous et de concurrence pour les ressources. L'inconvénient évident est l'incapacité à évoluer avec un grand nombre de clients.


Processus de demande


Une application se compose d'un processus principal qui accepte les demandes et les flux de travail entrants. Pour chaque nouvelle demande, le processus principal crée un flux de travail qui traite la demande. La mise à l'échelle par le nombre de demandes est simple: chaque demande obtient son propre processus.


Il n'y a rien de compliqué dans cette architecture, mais elle a les problèmes limitations :


  • Le processus consomme beaucoup de ressources.
    Essayez de créer 10 000 connexions simultanées au SGBDR PostgreSQL et regardez le résultat.
  • Les processus n'ont pas de mémoire partagée (par défaut). Si vous avez besoin d'accéder à des données partagées ou à un cache partagé, vous devrez mapper la mémoire partagée (appeler linux mmap, munmap) ou utiliser un stockage externe (memcahed, redis)

Ces problèmes ne s'arrêtent en aucun cas. Ce qui suit montrera comment ils sont gérés dans le SGBDR PostgeSQL.


Avantages de cette architecture:


  • La chute de l'un des processus n'affectera pas les autres. Par exemple, une erreur de traitement de cas rare ne supprimera pas la demande entière, seule la demande traitée souffrira
  • Différenciation des droits d'accès au niveau du système d'exploitation. Étant donné que le processus est l'essence même du système d'exploitation, vous pouvez utiliser ses mécanismes standard pour délimiter les droits d'accès aux ressources du système d'exploitation.
  • Vous pouvez modifier le processus en cours à la volée. Par exemple, si un script distinct est utilisé pour traiter une demande, puis pour remplacer l'algorithme de traitement, il suffit de changer le script. Un exemple sera considéré ci-dessous.
  • Machines multicœurs efficacement utilisées

Exemples:


  • Le SGBDR PostgreSQL crée un nouveau processus pour chaque nouvelle connexion. La mémoire partagée est utilisée pour travailler avec des données générales. PostgreSQL peut gérer la consommation élevée de ressources des processus de différentes manières. S'il y a peu de clients (un stand dédié aux analystes), alors ce problème n'existe pas. S'il existe une seule application qui accède à la base de données, vous pouvez créer un pool de connexions à la base de données au niveau de l'application. S'il existe de nombreuses applications, vous pouvez utiliser pgbouncer
  • sshd écoute les requêtes entrantes sur le port 22 et fork à chaque connexion. Chaque connexion ssh est un fork du démon sshd qui reçoit et exécute les commandes utilisateur en séquence. Grâce à cette architecture, les ressources de l'OS lui-même sont utilisées pour différencier les droits d'accès
  • Un exemple de notre propre pratique. Il existe un flux de fichiers non structurés à partir desquels vous devez obtenir des métadonnées. Le processus de service principal répartit les fichiers entre les processus de gestionnaire. Chaque processus de gestionnaire est un script qui prend un chemin de fichier comme paramètre. Le traitement des fichiers se produit dans un processus distinct.Par conséquent, en raison d'une erreur de traitement, l'ensemble du service ne se bloque pas. Pour mettre à jour l'algorithme de traitement, il suffit de changer les scripts de traitement sans arrêter le service.

En général, je dois dire que cette approche a ses avantages, qui déterminent sa portée, mais l'évolutivité est très limitée.


Flux de demande


Cette approche ressemble beaucoup à la précédente. La différence est que les threads sont utilisés à la place des processus. Cela vous permet d'utiliser la mémoire partagée prête à l'emploi. Cependant, les autres avantages de l'approche précédente ne peuvent plus être utilisés, tandis que la consommation de ressources sera également élevée.


Avantages:


  • Mémoire partagée prête à l'emploi
  • Facilité de mise en œuvre
  • Utilisation efficace des processeurs multicœurs

Inconvénients:


  • Un flux consomme beaucoup de ressources. Sur les systèmes d'exploitation de type Unix, un thread consomme presque autant de ressources qu'un processus

Un exemple d'utilisation est MySQL. Mais il convient de noter que MySQL utilise une approche mixte, donc cet exemple sera discuté dans la section suivante.


Pool de processus / threads


Les flux (processus) créent coûteux et longs. Afin de ne pas gaspiller les ressources, vous pouvez utiliser le même thread à plusieurs reprises. Ayant en outre limité le nombre maximum de threads, nous obtenons un pool de threads (processus). Maintenant, le thread principal accepte les demandes entrantes et les place dans une file d'attente. Les workflows prennent les demandes de la file d'attente et les traitent. Cette approche peut être considérée comme la mise à l'échelle naturelle du traitement séquentiel des demandes: chaque thread de travail ne peut traiter les flux que séquentiellement, leur mise en commun vous permet de traiter les demandes en parallèle. Si chaque flux peut gérer 1000 rps, alors 5 flux géreront la charge près de 5000 rps (sous réserve d'une concurrence minimale pour les ressources partagées).


Le pool peut être créé à l'avance au début de la prestation ou formé progressivement. L'utilisation d'un pool de threads est plus courante car vous permet d'appliquer la mémoire partagée.


La taille du pool de threads ne doit pas être limitée. Un service peut utiliser des threads libres du pool et, s'il n'y en a pas, créer un nouveau thread. Après avoir traité la demande, le thread rejoint le pool et attend la prochaine demande. Cette option est une combinaison d'une approche de thread sur demande et d'un pool de threads. Un exemple sera donné ci-dessous.


Avantages:


  • l'utilisation de nombreux cœurs de CPU
  • réduction des coûts de création d'un thread / processus

Inconvénients:


  • Évolutivité limitée du nombre de clients simultanés. L'utilisation du pool nous permet de réutiliser le même thread plusieurs fois sans coûts de ressources supplémentaires, cependant, cela ne résout pas le problème fondamental d'un grand nombre de ressources consommées par le thread / processus. La création d'un service de chat capable de supporter 100 000 connexions simultanées à l'aide de cette approche échouera.
  • L'évolutivité est limitée par les ressources partagées, par exemple, si les threads utilisent la mémoire partagée en ajustant l'accès à celle-ci à l'aide de sémaphores / mutex. Il s'agit d'une limitation de toutes les approches qui utilisent des ressources partagées.

Exemples:


  1. Application Python fonctionnant avec uWSGI et nginx. Le processus uWSGI principal reçoit les demandes entrantes de nginx et les répartit entre les processus Python de l'interpréteur qui traitent les demandes. L'application peut être écrite sur n'importe quel framework compatible uWSGI - Django, Flask, etc.
  2. MySQL utilise un pool de threads: chaque nouvelle connexion est traitée par l'un des threads libres du pool. S'il n'y a pas de threads libres, MySQL crée un nouveau thread. La taille du pool de threads libres et le nombre maximum de threads (connexions) sont limités par les paramètres.

C'est peut-être l'une des approches les plus courantes pour créer des services réseau, sinon la plus courante. Il vous permet de bien évoluer, atteignant de grands rps. La principale limitation de l'approche est le nombre de connexions réseau traitées simultanément. En fait, cette approche ne fonctionne bien que si les demandes sont courtes ou peu de clients.


Traitement orienté événement (modèle de réacteur)


Deux paradigmes - synchrone et asynchrone - sont des concurrents éternels l'un de l'autre. Jusqu'à présent, seules les approches synchrones ont été discutées, mais il serait faux d'ignorer l'approche asynchrone. Le traitement de demande orienté événement ou réactif est une approche dans laquelle chaque opération d'E / S est effectuée de manière asynchrone, et à la fin de l'opération, un gestionnaire est appelé. En règle générale, le traitement de chaque demande consiste en de nombreux appels asynchrones suivis de l'exécution de gestionnaires. À tout moment, une application à un seul thread exécute le code d'un seul gestionnaire, mais l'exécution des gestionnaires de diverses demandes alterne entre elles, ce qui vous permet de traiter simultanément (pseudo-parallèle) de nombreuses demandes parallèles.


Une discussion complète de cette approche dépasse le cadre de cet article. Pour un regard plus profond, vous pouvez recommander Reactor (Reactor) , Quel est le secret de la vitesse NodeJS? , À l'intérieur de NGINX . Ici, nous nous limitons à considérer les avantages et les inconvénients de cette approche.


Avantages:


  • Mise à l'échelle efficace par rps et le nombre de connexions simultanées. Un service réactif peut traiter simultanément un grand nombre de connexions (des dizaines de milliers) si la plupart des connexions attendent la fin des E / S

Inconvénients:


  • La complexité du développement. La programmation en style asynchrone est plus difficile qu'en mode synchrone. La logique du traitement des requêtes est plus complexe, le débogage est également plus difficile que dans le code synchrone.
  • Erreurs conduisant à bloquer l'ensemble du service. Si la langue ou le runtime n'est pas conçu à l'origine pour le traitement asynchrone, une seule opération synchrone peut bloquer l'ensemble du service, annulant la possibilité de mise à l'échelle.
  • Difficile de faire évoluer les cœurs de processeur. Cette approche suppose un seul thread dans un seul processus, vous ne pouvez donc pas utiliser plusieurs cœurs de processeur en même temps. Il convient de noter qu'il existe des moyens de contourner cette limitation.
  • Corollaire du paragraphe précédent: cette approche ne s'adapte pas bien aux requêtes nécessitant un processeur. Le nombre de rps pour cette approche est inversement proportionnel au nombre d'opérations CPU requises pour traiter chaque requête. Exiger des demandes de CPU annule les avantages de cette approche.

Exemples:


  1. Node.js utilise le modèle de réacteur prêt à l'emploi. Pour plus de détails, voir Quel est le secret de la vitesse NodeJS?
  2. nginx: les processus de travail de nginx utilisent le modèle de réacteur pour traiter les demandes en parallèle. Voir Inside NGINX pour plus de détails.
  3. Programme C / C ++ qui utilise directement les outils OS (epoll sur linux, IOCP sur windows, kqueue sur FreeBSD), ou utilise le framework (libev, libevent, libuv, etc.).

Mi-sync / mi-async


Le nom est tiré de POSA: Patterns for Concurrent and Networked Objects . Dans l'original, ce modèle est interprété de manière très large, mais pour les besoins de cet article, je comprendrai ce modèle un peu plus étroitement. Half sync / half async est une approche de traitement des demandes qui utilise un flux de contrôle léger (fil vert) pour chaque demande. Un programme se compose d'un ou de plusieurs threads au niveau du système d'exploitation, cependant, le système d'exécution du programme prend en charge les threads verts que le système d'exploitation ne voit pas et ne peut pas contrôler.


Quelques exemples pour rendre la considération plus précise:


  1. Service en langue Go. Le langage Go prend en charge de nombreux threads d'exécution légers - goroutine. Le programme utilise un ou plusieurs threads du système d'exploitation, mais le programmeur fonctionne avec des goroutines, qui sont réparties de manière transparente entre les threads du système d'exploitation afin d'utiliser des processeurs multicœurs.
  2. Service Python avec bibliothèque gevent. La bibliothèque gevent permet au programmeur d'utiliser des fils verts au niveau de la bibliothèque. L'ensemble du programme est exécuté dans un seul thread OS.

En substance, cette approche est conçue pour combiner les hautes performances de l'approche asynchrone avec la simplicité de programmation de code synchrone.


En utilisant cette approche, malgré l'illusion de synchronisme, le programme fonctionnera de manière asynchrone: le système d'exécution du programme contrôlera la boucle d'événements, et chaque opération "synchrone" sera en fait asynchrone. Lorsqu'une telle opération est appelée, le système d'exécution appelle l'opération asynchrone à l'aide des outils du système d'exploitation et enregistre le gestionnaire de la fin de l'opération. Une fois l'opération asynchrone terminée, le système d'exécution appellera le gestionnaire précédemment enregistré, qui continuera à exécuter le programme au point d'invocation de l'opération "synchrone".


Par conséquent, l'approche moitié sync / moitié asynchrone contient à la fois certains avantages et certains inconvénients de l'approche asynchrone. Le volume de l'article ne nous permet pas d'approfondir cette approche. Pour les personnes intéressées, je vous conseille de lire le chapitre du même nom dans le livre POSA: Patterns for Concurrent and Networked Objects .


L'approche moitié sync / moitié async elle-même introduit une nouvelle entité «green stream» - un flux de contrôle léger au niveau du système d'exécution du programme ou de la bibliothèque. Que faire avec les fils verts est le choix du programmeur. Il peut utiliser un pool de fils verts, il peut créer un nouveau fil vert pour chaque nouvelle demande. La différence par rapport aux threads / processus OS est que les threads verts sont beaucoup moins chers: ils consomment beaucoup moins de RAM et sont créés beaucoup plus rapidement. Cela vous permet de créer un grand nombre de fils verts, par exemple, des centaines de milliers dans la langue Go. Une telle quantité justifie l'utilisation de l'approche verte de flux sur demande.


Avantages:


  • Il évolue bien en rps et le nombre de connexions simultanées
  • Le code est plus facile à écrire et à déboguer que l'approche asynchrone

Inconvénients:


  • Étant donné que l'exécution des opérations est en fait asynchrone, des erreurs de programmation sont possibles lorsqu'une seule opération synchrone bloque l'ensemble du processus. Cela se ressent surtout dans les langages où cette approche est implémentée au moyen d'une bibliothèque, par exemple Python.
  • L'opacité du programme. Lors de l'utilisation de threads ou de processus OS, l'algorithme d'exécution du programme est clair: chaque thread / processus effectue des opérations dans l'ordre dans lequel elles sont écrites dans le code. En utilisant l'approche moitié sync / moitié asynchrone, les opérations qui sont écrites séquentiellement dans le code peuvent alterner de manière imprévisible avec des opérations qui traitent des demandes simultanées.
  • Ne convient pas aux systèmes en temps réel. Le traitement asynchrone des demandes complique considérablement la fourniture de garanties pour le temps de traitement de chaque demande individuelle. Ceci est une conséquence du paragraphe précédent.

Selon l'implémentation, cette approche évolue bien sur les cœurs de processeur (Golang) ou ne se modifie pas du tout (Python).
Cette approche, ainsi qu'asynchrone, vous permet de gérer un grand nombre de connexions simultanées. Mais la programmation d'un service en utilisant cette approche est plus facile, car le code est écrit dans un style synchrone.


Traitement des convoyeurs


Comme son nom l'indique, dans cette approche, les demandes sont traitées par pipeline. Le processus de traitement consiste en plusieurs threads OS disposés en chaîne. Chaque thread est un maillon de la chaîne, il effectue un certain sous-ensemble des opérations nécessaires pour traiter la demande. Chaque demande passe séquentiellement à travers tous les maillons de la chaîne, et différents maillons à chaque instant traitent différentes demandes.


Avantages:


  • Cette approche évolue bien en rps. Plus il y a de maillons dans la chaîne, plus les requêtes sont traitées par seconde.
  • L'utilisation de plusieurs threads vous permet de bien évoluer sur les cœurs de processeur.

Inconvénients:


  • Toutes les catégories de requêtes ne conviennent pas à cette approche. Par exemple, l'organisation de longs sondages à l'aide de cette approche sera difficile et peu pratique.
  • La complexité de l'implémentation et du débogage. Battre le traitement séquentiel afin que la productivité soit élevée peut être difficile. Le débogage d'un programme dans lequel chaque requête est traitée séquentiellement dans plusieurs threads parallèles est plus difficile que le traitement séquentiel.

Exemples:


  1. Un exemple intéressant de traitement des convoyeurs a été décrit dans le rapport highload 2018 L' évolution de l'architecture du système de négociation et de compensation de la Bourse de Moscou

Le pipeline est largement utilisé, mais le plus souvent, les liens sont des composants individuels dans des processus indépendants qui échangent des messages, par exemple via une file d'attente de messages ou une base de données.


Résumé


Un bref résumé des approches envisagées:


  • Traitement synchrone.
    Une approche simple, mais très limitée en termes d'évolutivité, aussi bien en RPS qu'en nombre de connexions simultanées. Il ne permet pas l'utilisation de plusieurs cœurs de processeur en même temps.
  • Un nouveau processus pour chaque demande.
    . , . . ( , ).
  • .
    , , . , .
  • /.
    /. . rps . . .
  • - (reactor ).
    rps . - , . CPU
  • Half sync/half async.
    rps . CPU (Golang) (Python). , () . reactor , , reactor .
  • .
    , . (, long polling ).

, .


: ? , ?


Les références


  1. Articles liés:
  2. - :
  3. :
  4. Half sync/half async:
  5. :
  6. :

Source: https://habr.com/ru/post/fr432630/


All Articles