Si vous devez poser cette question, vous n'êtes probablement pas familier avec ce que font la plupart des applications / services Web. Vous pensez probablement que tous les logiciels font cela:
user do an action
│
v
application start processing action
└──> loop ...
└──> busy processing
end loop
└──> send result to user
Cependant, ce n'est pas ainsi que fonctionnent les applications Web, ou en fait n'importe quelle application avec une base de données comme back-end. Les applications Web font ceci:
user do an action
│
v
application start processing action
└──> make database request
└──> do nothing until request completes
request complete
└──> send result to user
Dans ce scénario, le logiciel passe la majeure partie de son temps d'exécution à utiliser 0% de temps processeur en attendant le retour de la base de données.
Application réseau multithread:
Les applications réseau multithread gèrent la charge de travail ci-dessus comme ceci:
request ──> spawn thread
└──> wait for database request
└──> answer request
request ──> spawn thread
└──> wait for database request
└──> answer request
request ──> spawn thread
└──> wait for database request
└──> answer request
Ainsi, le thread passe la plupart de son temps à utiliser 0% de CPU en attendant que la base de données renvoie des données. Ce faisant, ils ont dû allouer la mémoire requise pour un thread qui comprend une pile de programme complètement distincte pour chaque thread, etc. pas cher.
Boucle d'événement à filetage unique
Puisque nous passons la plupart de notre temps à utiliser 0% de CPU, pourquoi ne pas exécuter du code lorsque nous n'utilisons pas de CPU? De cette façon, chaque demande aura toujours le même temps CPU que les applications multithread mais nous n'avons pas besoin de démarrer un thread. Nous faisons donc ceci:
request ──> make database request
request ──> make database request
request ──> make database request
database request complete ──> send response
database request complete ──> send response
database request complete ──> send response
En pratique, les deux approches retournent des données avec à peu près la même latence car c'est le temps de réponse de la base de données qui domine le traitement.
Le principal avantage ici est que nous n'avons pas besoin de générer un nouveau thread donc nous n'avons pas besoin de faire beaucoup, beaucoup de malloc qui nous ralentiraient.
Filetage magique et invisible
La chose apparemment mystérieuse est de savoir comment les deux approches ci-dessus parviennent à exécuter la charge de travail en "parallèle"? La réponse est que la base de données est filetée. Notre application à un seul thread exploite donc le comportement à plusieurs threads d'un autre processus: la base de données.
Là où l'approche à fil unique échoue
Une application à un seul fil tombe en panne si vous devez faire beaucoup de calculs CPU avant de renvoyer les données. Maintenant, je ne parle pas d'une boucle for traitant le résultat de la base de données. C'est encore principalement O (n). Ce que je veux dire, c'est des choses comme la transformation de Fourier (encodage mp3 par exemple), le lancer de rayons (rendu 3D), etc.
Un autre écueil des applications à filetage unique est qu'elles n'utiliseront qu'un seul cœur de processeur. Donc, si vous avez un serveur quadricœur (ce qui n'est pas rare de nos jours), vous n'utilisez pas les 3 autres cœurs.
Lorsque l'approche multithread échoue
Une application multithread échoue fortement si vous devez allouer beaucoup de RAM par thread. Tout d'abord, l'utilisation de la RAM elle-même signifie que vous ne pouvez pas gérer autant de demandes qu'une application à filetage unique. Pire encore, malloc est lent. L'allocation de lots et de lots d'objets (ce qui est courant pour les frameworks Web modernes) signifie que nous pouvons potentiellement être plus lents que les applications à filetage unique. C'est là que node.js gagne généralement.
Un cas d'utilisation qui finit par empirer le multithread est lorsque vous devez exécuter un autre langage de script dans votre thread. D'abord, vous devez généralement malloc l'intégralité du runtime pour ce langage, puis vous devez malloc les variables utilisées par votre script.
Donc, si vous écrivez des applications réseau en C ou go ou java, la surcharge de thread n'est généralement pas trop mauvaise. Si vous écrivez un serveur Web C pour servir PHP ou Ruby, il est très facile d'écrire un serveur plus rapide en javascript ou Ruby ou Python.
Approche hybride
Certains serveurs Web utilisent une approche hybride. Nginx et Apache2 implémentent par exemple leur code de traitement réseau en tant que pool de threads de boucles d'événements. Chaque thread exécute une boucle d'événements en traitant simultanément les requêtes à thread unique, mais les requêtes sont équilibrées en charge entre plusieurs threads.
Certaines architectures monothread utilisent également une approche hybride. Au lieu de lancer plusieurs threads à partir d'un seul processus, vous pouvez lancer plusieurs applications - par exemple, 4 serveurs node.js sur une machine à quatre cœurs. Vous utilisez ensuite un équilibreur de charge pour répartir la charge de travail entre les processus.
En effet, les deux approches sont des images miroir techniquement identiques l'une de l'autre.