Journal Des vieilles bases d'unix à la hype reactive actuelle

Posté par . Licence CC by-sa
Tags : aucun
31
6
mar.
2018

Sommaire

Les systèmes unix/linux sont souvent vus comme vieux et le fait de barbus. Actuellement il y a une grande hype autour de « système réactifs ». Je me propose de faire le lien entre les 2 et d'expliquer comment c'est grâce à des bases unix très vieilles que l'on peut aujourd'hui construire des systèmes réactifs.

Tout est fichier

tout est fichier

Dans unix tout est fichier. Cela se représente facilement quand on voit son système de fichier et par exemple /dev qui montre sous forme de fichier tous les périphériques de votre machine. On peut le voir autrement dans le développement système. En effet toutes les io de vos programmes sont représentés par des descripteurs de fichier. Ils sont donc manipulables comme des fichiers et vous n'avez pas besoin de savoir qu'il s'agit d'une socket, d'un pipe ou d'un fichier pour lire ou écrire dedans.

Au sein des opérations sur les descripteurs de fichier, il y en a une sur laquelle je voudrais attirer votre attention. select(2) est un appel système qui va permettre d'attendre sur plusieurs descripteurs de fichier en même temps. Par attendre, il est entendu pouvoir lire ou écrire sur ces descripteurs. Il s'agit donc d'un appel bloquant. Quand il revient il vous indique le nombre de descripteurs qui ont l'opération attendue de possible. Cela permet a un processus de manipuler plusieurs descripteurs. Pour information ça ne vient pas de sortir, d'après mon man cet appel est apparu avec 4.2BSD.

Le problème de select(2) c'est qu'il est limité dans le nombre de descripteurs qu'il scrute (actuellement la limite sur linux est fixée à 1024). Plus récemment poll(2) est apparu (normalisé pour la première fois avec POSIX.1-2001). Ce dernier prend un tableau et non plus un set de taille fixée, il n'a donc plus de limite dans le nombre de descripteurs de fichier. Il y a un super article (comme d'habitude) de Christophe Blaess sur le fonctionnement et l'implémentation dans le noyau linux.

Il existe aussi une solution plus moderne appelée epoll(4), mais elle est spécifique à linux. Une autre solution préconisée par FreeBSD, NetBSD et OpenBSD est d'utiliser les kqueue(2) Certains (comme le développeur de curl) conseillent donc d'utiliser des bibliothèques de plus haut niveau qui vont fournir une API de plus haut niveau et qui vont pouvoir choisir en fonction du système l'appel le plus intéressant.

Le C10K problem

Le C10K problem est un problème relativement général qui constate que les serveurs n'arrivent pas à dépasser une limite de 10 000 connexions simultanées quel que soit le matériel qui est dessous (une page bien détaillée bien qu'un peu vieille). Suite à l'identification de ce problème, différentes approches ont été proposées. Dont l'une qui m'intéresse plus que les autres ici : le pattern réacteur.

Il s'agit d'un pattern d'architecture. Son objectif est de démultiplexer des requêtes pour ensuite les traiter de manière synchrones et sérialisées. L'idée est simple : on a une boucle d'événements qui va attendre des requêtes et lorsqu'elle en reçoit elle va l'envoyer à l'handler approprié. Le tout de manière totalement synchrone. Ce fonctionnement est utilisé dans nginx.

pattern reactor

Nginx lance un processus master. Master lance un processus par cœur : ce sont les workers. Les workers écoutent la même socket (par exemple le port tcp 443 sur votre interface loopback). Lorsque l'on reçoit une requête, un processus va être réveillé et le faire travailler. Cette technique est devenue très efficace depuis l'introduction de l'option SO_REUSEPORT dans le noyau 3.9. On peut trouver une description plus complète sur quora.

Mais pourquoi ?

Les méthodes classiques consistent à associer une requête à un thread (voir un processus). Cette solution génère plus de fils d'exécution que de CPU. Cela entraîne une forte pression sur l'ordonnancement du noyau. Le contexte de chacun de ses fils est aussi assez coûteux. Enfin, même s'il existe des techniques pour améliorer cela la création/libération des threads est assez coûteuse. Tout cela contribue à faire exploser les serveurs avant la limite des 10k connexions.

Dans la vie quotidienne, ça augmente les ressources utilisées et la latence. Or aujourd'hui pour beaucoup de cas d'usage, c'est la latence qui est critique (l'exécution pure est fortement optimisée et on n'a pas un volume de données important). Une partie importante des évolutions de HTTP2 par exemple vont dans ce sens. On peut aussi voir une grosse part du travail fait par google sur les couches réseau du noyau linux.

Un article intéressant sur le sujet écrit par Philippe Prados : Multitâche ou réactif ?.
Pour HTTP2 l'interview en 2 parties de Dridi Boukelmoune est intéressante et dans la seconde partie il explique clairement que l'objectif, c'est la latence (partie 1 et partie 2)

La hype !

hype reactive

On peut encore aller plus loin… Tout est fichier ! On peut généraliser ce comportement ! On peut aller jusqu'à envoyer toutes les requêtes sur la même boucle d'événement. Ainsi à chaque IO, on donne éventuellement à une autre requête (ou une autre IO) la possibilité de s'exécuter. On minimise encore le temps d'attente des fils d'exécution. Cette démarche permet de tirer parti des infrastructures du noyau et de tenter de maximiser le temps d'exécution utile du CPU. Et s'adapte particulièrement bien à des logiciels qui font beaucoup d'IO (accès à des fichiers, à une ou plusieurs bases de données, à un serveur d'authentification,…) par rapport à leur temps d'exécution réel (pas d'algorithme particulièrement compliqué).

Il y a différentes implémentation de ça :

  • Node.js est la plus connue. Je pense que le fait de voir du js se hisser à ce niveau de performance a poussé à avoir des explications.
  • celle que je connais le mieux : Vert.x : une suite de bibliothèques qui fournit une boucle d'évènements, des API pour un peu tout (faire du réseau, de la base de données, etc) qui sont asynchrones et s'interfacent avec la boucle d'évènements
  • POE pour perl. Ici il n'est pas question de faire des appels asynchrones classiques à base d'handler, de future ou de promesses, mais plutôt avec des yields. POE permet de changer la boucle d'évènement ce qui peut être assez pratique.
  • twisted pour python est très orienté réseau.
  • # Typo dans le titre

    Posté par (page perso) . Évalué à 4. Dernière modification le 06/03/18 à 16:28.

    Je pense que ce journal n'a rien à voir avec la musique ancienne et que donc les vielles sont vieilles elles aussi.

    Sed fugit interea, fugit inreparabile tempus, singula dum capti circumvectamur amore

  • # Vieille solution

    Posté par . Évalué à 3.

    La méthode de remplacer un thread par travail à faire par une fonction cyclique, existe depuis longtemps dans la gestion des interruptions : cf https://en.wikipedia.org/wiki/Interrupt_coalescing

    D'ailleurs, dans le domaine de l'embarqué, pour avoir des boucles ultra courtes, il s'agit simplement de fonction lancé par un timer à intervalle régulier sans gestion de la mémoire (tout est pré-alloué). Je ne sais pas si une personne a déjà essayer de faire un serveur qui pré-alloue les ressources pour gérer 11 000 connections à la fois.

    "La première sécurité est la liberté"

    • [^] # Re: Vieille solution

      Posté par . Évalué à 2.

      Je n'ai pas était assez clair, mais l'idée général n'est pas de dire que c'est particulièrement nouveau. Le développement de POE par exemple a commencé il y a 20 ans.

      Par contre si je comprends bien l'interrupt coalescing. Il s'agit de remplacer les interruptions hardware pour les envoyer des une file. Pour réduire les interruptions et avoir un meilleur débit en augmentant la latence, là où la programmation réactive a pour objectif de réduire la latence quitte à limiter le débit.

      • [^] # Re: Vieille solution

        Posté par . Évalué à 3.

        "Par contre si je comprends bien l'interrupt coalescing. Il s'agit de remplacer les interruptions hardware pour les envoyer des une file. Pour réduire les interruptions et avoir un meilleur débit en augmentant la latence, là où la programmation réactive a pour objectif de réduire la latence quitte à limiter le débit."

        De mémoire, c'est revenu à la mode, quand les cartes ethernet 1000mbits sont arrivé : les PC n'arrivaient pas à utiliser la moitié de la bande passante disponible. Les OS passaient leur temps dans la gestion d'interruption très bas niveau. Le but est de remplacer des cycles de gestion par des cycles vraiment utiles. Donc, oui, cela augmente le débit. Il n'y a pas de gestion logiciel de file, c'est le hardware qui gère.

        Concernant la latence, il est faux de dire qu'une latence est basse quand il y a n threads pour n taches. En cas de forte charge, on a aucune idée de la latence maximale possible. Une fonction cyclique va fournir la même latence quelle que soit la charge.

        Pour résumer, un système uniquement réactif aura une latence faible si il y a peu d’événements, dés que cela monte, une fonction cyclique offrira bien plus de garantie.

        "La première sécurité est la liberté"

        • [^] # Re: Vieille solution

          Posté par . Évalué à 1.

          Qu'entends-tu par fonction cyclique ?
          Si tu regarde le lien que j'ai donné d'octo, il explique probablement mieux que moi. Si ton nombre de threads est lié au nombre de requêtes que tu reçois, tu augmente la latence avec l'augmentation du nombre de requêtes.

          • [^] # Re: Vieille solution

            Posté par . Évalué à 3.

            Une fonction cyclique est une fonction qui est exécutée toutes les millisecondes par exemple.

            Si ton nombre de threads est lié au nombre de requêtes que tu reçois, tu augmente la latence avec l'augmentation du nombre de requêtes.

            A moins d'avoir un sacré bon scheduler, l'augmentation ne sera pas linéaire du tout. Une fois que n est bien plus grand que le nombre de cpu, "l'overhead" de gestion grandit avec le nombre de changements de contexte, etc…

            "La première sécurité est la liberté"

  • # Retour vers le passé

    Posté par (page perso) . Évalué à 10.

    J'ai l'impression de revenir dans le passé, d'une bonne dizaine d'années, avec ce journal. poll, epoll et kqueue doivent tous exister depuis au moins 15 ans, ça me fait bizarre d'en entendre parler avec "plus récemment".

    Le C10K problem est un problème relativement général qui constate que les serveurs n'arrivent pas à dépasser une limite de 10 000 connexions simultanées quelque soit le matériel qui est dessous

    Pareil, ce problème est vieux. Actuellement, même une Raspberry Pi doit arriver à surmonter ça sans problème. De nos jours, la limite pour un serveur doit plutôt être dans les centaines de milliers de connexions dans la majorité des cas, et quelques millions pour les implémentations un peu plus poussées. J'ai par exemple en tête le travail sur Phoenix, un framework web écrit en Elixir et qui tourne donc sur la machine virtuelle d'Erlang, pour atteindre les 2 millions de websockets sur un seul serveur (2015). En cherchant rapidement, j'ai trouvé un truc en Java qui tient les 10 millions de connexions. Aucun de ces deux exemples n'utilisent de la programmation événementielle.

    Je me rappelle qu'il y a quelques années, vers 2010-2012, je m'intéressais à la programmation événementielle, d'abord avec EventMachine en Ruby, puis avec Node.js. Mais, ça donnait du code compliqué et les autres langages ont fini par faire mieux (Go notamment). D'ailleurs, je trouve intéressant l'entretien avec Ryan Dahl, créateur de Node.js, où il explique que Go a un meilleur modèle d'IO que nodejs pour les serveurs. Voici une citation rapide, mais il y a plus de détails dans l'entretien complet :

    That said, I think Node is not the best system to build a massive server web. I would use Go for that. And honestly, that’s the reason why I left Node. It was the realization that: oh, actually, this is not the best server-side system ever.

    • [^] # Re: Retour vers le passé

      Posté par . Évalué à 7.

      J'ai l'impression de revenir dans le passé

      L'objectif était justement de faire le lien entre tout ce qu'on peut lire aujourd'hui sur le réactif et le fait que ça prend ses racines dans des choses qui ne sont pas particulièrement récent (j'ai bien décris à quel moment chacun de ses appels est apparu justement).

      Aucun de ces deux exemples n'utilisent de la programmation événementielle.

      Elixir par construction se base sur le système à acteurs d'erlang qui est une manière évènementielle de coder.
      Pour MigratoryData, c'est basé sur du publish-subscribe qui est classé dans l'event driven programming sur wikipedia.

      Après je n'ai pas caché qu'il y a d'autres solutions, j'ai dis explicitement que je m'intéressais à l'une d'entre elles.

      Je me rappelle qu'il y a quelques années, vers 2010-2012, je m'intéressais à la programmation événementielle, d'abord avec EventMachine en Ruby, puis avec Node.js. Mais, ça donnait du code compliqué[…]

      Ça peut devenir plus compliqué, je suis d'accord, mais les API comme Rx sont très populaires aujourd'hui et je trouve que les Functional Reactive Programming sont plutôt lisible (voir elm par exemple, même si ça n'est pas pour du serveur).

      D'ailleurs, je trouve intéressant l'entretien avec Ryan Dahl, créateur de Node.js, où il explique que Go a un meilleur modèle d'IO que nodejs pour les serveurs. Voici une citation rapide, mais il y a plus de détails dans l'entretien complet :

      That said, I think Node is not the best system to build a massive server web. I would use Go for that. And honestly, that’s the reason why I left Node. It was the realization that: oh, actually, this is not the best server-side system ever.

      Tout ce que je vois dans l'article c'est de dire que les coroutines c'est mieux que les callback-hell, mais il y a bien d'autres API sur de l'asynchrones (les futures, les promesses, les observables,…). Il dit lui même que l'introduction du mot clef "async" doit avoir changer ce problème.

      • [^] # Re: Retour vers le passé

        Posté par (page perso) . Évalué à 5. Dernière modification le 06/03/18 à 18:51.

        Elixir par construction se base sur le système à acteurs d'erlang qui est une manière évènementielle de coder.

        Je suis un peu perdu, mais pour des serveurs, si les systèmes à base d'acteurs/goroutines/green threads sont des manières événementielles de coder, qu'est-ce qui ne l'est pas ?

        De mon point de vue, il y a d'un côté la programmation événementielle, avec un seul thread, une boucle événementielle et des callbacks (potentiellement abstraits via des promises, futures, observables). Et de l'autre côté, on a des modèles qui s'exécutent sur plusieurs threads et du parallélisme. Nodejs est dans la première catégorie, Elixir et Go dans la seconde.

        Pour les serveurs où les performances (et notamment la latence) sont importantes, j'ai l'impression que le premier modèle est nettement en déclin (la hype, c'était plutôt vers 2010-2013), avec notamment beaucoup de Go. Nodejs a plutôt l'air de survivre auprès des développeurs Front (que ce soit pour des outils à la webpack, browserify, babel, etc. ou pour avoir le même code Angular/React/Vue qui tourne côté client et côté serveur).

        Ça peut devenir plus compliqué, je suis d'accord, mais les API comme Rx sont très populaires aujourd'hui et je trouve que les Functional Reactive Programming sont plutôt lisible (voir elm par exemple, même si ça n'est pas pour du serveur).

        Par contre, pour ce qui passe dans une interface graphique ou dans le navigateur, oui, là, je trouve que le modèle de programmation événementielle colle bien. Et ça a l'air plus vivant. Mais les raisons ne sont pas les mêmes et l'historique non plus (JavaScript en 1995, mais je crois que les débuts datent des bibliothèques graphiques de Smalltalk dans les années 80).

        Il dit lui même que l'introduction du mot clef "async" doit avoir changer ce problème.

        Non, il dit que JavaScript a fait des progrès, notamment avec le mot clef async, mais que le modèle du JavaScript reste inférieur pour les serveurs à celui de Go.

        • [^] # Re: Retour vers le passé

          Posté par . Évalué à 4.

          De mon point de vue, il y a d'un côté la programmation événementielle, avec un seul thread, une boucle événementielle et des callbacks (potentiellement abstraits via des promises, futures, observables). Et de l'autre côté, on a des modèles qui s'exécutent sur plusieurs threads et du parallélisme. Nodejs est dans la première catégorie, Elixir et Go dans la seconde.

          Je pense justement qu'Erlang est un mix des 2 : Erlang est basé sur des processus, de la même façon que d'autres langages sont basés sur des objets. Donc pour Erlang tu as un tas de processus (cas 2) et pour chaque processus tu as une boucle evenementielle et des callbacks. Donc dire qu'Erlang est 1 ou qu'Erlang est 2 est vrai, tout dépend à quel niveau tu regardes.

        • [^] # Re: Retour vers le passé

          Posté par . Évalué à 4.

          De mon point de vue, il y a d'un côté la programmation événementielle, avec un seul thread, une boucle événementielle et des callbacks (potentiellement abstraits via des promises, futures, observables).

          Là tu décris le paterne reactor. C'est une façon de faire de l'évènementiel. On doit pouvoir dire qu'il s'agit de tout ce qui est réactif.

          Et de l'autre côté, on a des modèles qui s'exécutent sur plusieurs threads et du parallélisme.

          Il y a un tas de façons de gérer ça donc à part dire que c'est de la programmation parallèle, je ne vois pas bien quoi en dire de plus.

          Pour les serveurs où les performances (et notamment la latence) sont importantes, j'ai l'impression que le premier modèle est nettement en déclin (la hype, c'était plutôt vers 2010-2013), avec notamment beaucoup de Go.

          Je n'ai pas réussi à trouver l'architecture interne de haproxy ou de sozu […] je viens de trouver une conférence d'un développeur de sozu. Je te l'ai mis au timecode qui parle de ça, ils font de l'eventloop monothread. Pour ceux qui ne connaissent pas sozu est un reverse proxy sorti l'an dernier (et open source) dont l'objectif est la performance et la capacité à n'avoir aucune downtime y compris lors de ses mises à jour et des changements de configuration. Ils l'ont écris en rust et sont d'abord aller voir ce qu'il y avait sur le marché. Je pense qu'on peut dire qu'ils ont une bonne vision de l'état de l'art du domaine (et au passage il explique que haproxy (comme nginx) est aussi une eventloop monothread).

          Je connais mal les green threads comment ils se comportent par rapport aux contexte switch ? Je veux dire :

          • comment le runtime d'un langage est plus à même de faire des choix que CFS ?
          • comment se comporte le contexte swicth ? c'est juste qu'on reste dans l'espace utilisateur ?
          • comment cela se passe par rapport aux caches CPU ?

          Tu aurais un exemple de service qui traite un paquet de requêtes en go par exemple ? J'aimerais bien regarder le modèle de threading utilisé.

          Nodejs a plutôt l'air de survivre auprès des développeurs Front (que ce soit pour des outils à la webpack, browserify, babel, etc. ou pour avoir le même code Angular/React/Vue qui tourne côté client et côté serveur).

          J'ai volontairement pas parlé du front parce que le sujet est très différent.

          Non, il dit que JavaScript a fait des progrès, notamment avec le mot clef async, mais que le modèle du JavaScript reste inférieur pour les serveurs à celui de Go.

          Je n'ai probablement pas lu avec assez d'attention je n'ai pas trouvé ce passage.

          • [^] # Re: Retour vers le passé

            Posté par (page perso) . Évalué à 5.

            Là tu décris le paterne reactor. C'est une façon de faire de l'évènementiel. On doit pouvoir dire qu'il s'agit de tout ce qui est réactif.

            Désolé, je ne comprends toujours pas. Ça veut dire quoi « être réactif » ? Qu'est-ce qui est du domaine de la programmation événementielle si ce n'est l'utilisation du patern reactor ? Si la programmation événementielle, c'est réagir à des événements, j'ai l'impression que la définition englobe toutes les implémentations côté serveur, je ne vois pas comment un serveur pourrait ne pas « réagir » à des requêtes réseau.

            je viens de trouver une conférence d'un développeur de sozu. Je te l'ai mis au timecode qui parle de ça, ils font de l'eventloop monothread.

            Merci, je vais regarder ça, ça m'intéresse beaucoup !

            et au passage il explique que haproxy (comme nginx) est aussi une eventloop monothread

            C'est de moins en moins vrai pour le côté mono-thread. Le multithread arrive pour haproxy et il me semble que nginx a des pools de threads de nos jours.

            Je connais mal les green threads comment ils se comportent par rapport aux contexte switch ?

            https://dave.cheney.net/2015/08/08/performance-without-the-event-loop est une introduction aux goroutines de Go de ce point de vue. En gros, chaque goroutine est très légère, on peut facilement en avoir des dizaines de milliers ou plus. Le runtime de Go lance plusieurs threads, un par coeur (en fait, c'est configurable via une variable d'environnement mais c'est la valeur par défaut) qui vont servir à exécuter ces goroutines. Et il lance également des threads pour les opérations bloquantes d'I/O. Il fait ensuite en sorte que chaque thread d'exécution exécute une goroutine pendant un laps de temps assez court, puis passe à une autre. Si une goroutine est en attente, que ce soit parce qu'elle va faire une opération d'I/O ou qu'elle attende d'envoyer ou recevoir une valeur sur un channel, elle n'est plus éligible à être exécuté sur ces threads.

            comment le runtime d'un langage est plus à même de faire des choix que CFS ?

            Je ne pense pas que le runtime du langage fasse de meilleurs choix que CFS, par contre, il peut faire ce choix plus rapidement. Et un autre avantage est qu'une goroutine (ou plus généralement un green thread) coûte beaucoup moins cher en RAM. Le problème C10K était notamment hostile pour les programmes qui utilisaient un thread par connexion car la RAM était rapidement saturée.

            comment se comporte le contexte swicth ? c'est juste qu'on reste dans l'espace utilisateur ?

            Oui, on reste dans l'espace utilisateur. En général, les green threads marche en mode coopératif : ce n'est pas un scheduler qui vient interrompre l'exécution, mais le green thread qui vérifie régulièrement s'il doit laisser la main à un autre green thread (par exemple, lors du lancement du garbage collector). Enfin, les opérations à faire pour passer d'un green thread à un autre sont, il me semble, moins couteuses que pour passer d'un thread à un autre.

            comment cela se passe par rapport aux caches CPU ?

            Je ne sais pas.

            Tu aurais un exemple de service qui traite un paquet de requêtes en go par exemple ?

            • [^] # Re: Retour vers le passé

              Posté par . Évalué à 2.

              Désolé, je ne comprends toujours pas. Ça veut dire quoi « être réactif » ?

              C'est le mot à la mode pour dire qu'on utilise un pattern reactor (AM*H*A).

              Qu'est-ce qui est du domaine de la programmation événementielle si ce n'est l'utilisation du patern reactor ? Si la programmation événementielle, c'est réagir à des événements, j'ai l'impression que la définition englobe toutes les implémentations côté serveur, je ne vois pas comment un serveur pourrait ne pas « réagir » à des requêtes réseau.

              Pour moi une distinction importante consiste dans le fait de recevoir un évènement et que cet évènement contient toute l'information utile à mettre en rapport avec le fait de tout envoyer dans le contexte d'un fil d'exécution. Quand tu spaw un thread par requête et que ton framework te positionne dans le contexte de ton thread toute l'information utile (la requête, l'authentification, etc), tu peux difficilement dire que tu es évènementiel. C'est typiquement ce que fais Spring par exemple. Il utilise intensivement le thread local storage.

              https://dave.cheney.net/2015/08/08/performance-without-the-event-loop […]

              Merci pour le lien. De ce que j'ai l'impression il remplace les appels asynchrones par des appels synchrones, mais qui indique qu'il faut les préempter et c'est la différence avec CFS. Par contre je ne vois pas comment ça pourrait bien se passer en terme de cache. Le context switch est drastiquement plus léger, mais il est toujours là, on invalide forcément tous les caches, etc.

              Merci pour les exemples. J'ai l'impression que les green threads sont là pour être plus simple qu'un monde juste asynchrone. Mais je n'arrive pas à voir cela comme véritablement plus efficace que d'autres solutions pour pallier aux callback hell.

            • [^] # Re: Retour vers le passé

              Posté par . Évalué à 1.

              dl.google.com est écrit en Go qui sert pour tous les téléchargements de Google (dont les mises à jour de Chrome)

              C'est intéressant. En fait il n'ont fait presque qu'un code glue autour de groupcache et les API étant bien faites, il n'a pas besoin de faire de copies intermédiaires entre groupcache et la réponse HTTP. Ce qui joue nettement sur le débit ce qui est leur priorité. J'ai pas vu d'endroit où il parlait de la manière dont il charge les données depuis VCS.

              C'est marrant, il parle d'utiliser un système de fichiers distribué, mais n'en utilise pas.

              Pour ce qui est des event loop :

              • il parle du fait que l'event loop était bloquée. Si tu utilise un paradigme et que tu le viole tu va avoir des problèmes. Si dans 2 ans, ils commencent à faire des recopies en usespace de données, leur débit va en pâtir
              • il parle du débit limité par l'event loop, la présentation de sozu y fait écho en expliquant qu'ils ne font pas non plus de passage par le userspace grâce à splice(2)

              Træfik, un autre reverser proxy

              Sympa, au final ils ont une perf inférieure à nginx. J'aime bien les conclusions du bench qu'ils mettent en avant :

              • utiliser le reuse dont je parle dans le journal
              • revoir la gestion des threads pour diminuer le context switch (note qu'utiliser le reuse va déjà pas mal diminuer le context switch)

              Mais c'est un reverse proxy qui a l'air super sympa (plus que sozu pour mon usage perso), je vais l'essayer.

  • # Sympa ton journal

    Posté par (page perso) . Évalué à 8. Dernière modification le 07/03/18 à 00:32.

    Le premier point que je voudrais ajouter, c'est que NodeJS utilise libuv qui a été développé pour et est utilisée pour plein d'autres langages. En plus de langages relativement classiques on la trouve pour OCaml et Lisp ou la version de haut niveau par exemple.

    Le gros problème de l'approche de NodeJS est qu'elle propose une approche de la programmation événementielle basée sur des callbacks et qu'on finit invariablement par avoir du code spaghetti assez indigeste, pas super facile à maintenir – et pas non plus super facile à mettre au point. Un des gros problèmes de la programmation par callbacks est qu'elle inverse l'ordre de composition des fonctions. Au lieu d'écrire Applique f au résultat de g on écrit Fait g puis donne son résultat à f ce qui lorsqu'on empile les appels de fonctions anonymes pour produire la pyramide du destin où le code se déplace plus rapidement vers la droite que vers la complétion de sa tâche. ;)

    // La pyramide du destin (merci à kriskoval)
    step1(function (value1) {
        step2(value1, function(value2) {
            step3(value2, function(value3) {
                step4(value3, function(value4) {
                    // Do something with value4
                });
            });
        });
    });
    

    En pratique le step1 est toujours un truc un peu banal du type “print” ou “exit” et le biscuit (les variables intéressantes et les appels de fonction intéressants) sont enterrés dans les niveaux inférieurs – même avec de l'habitude c'est assez malheureux et on a du mal à suivre le code ni à retrouver les parties intéressantes.

    Pour retomber sur ses pattes il y a la programmation par monades qui permet de recomposer les traitements “dans le bon sens” – que l'on retrouve sous plusieurs formes de façon parfois un peu cachée comme les promises.

    • [^] # Re: Sympa ton journal

      Posté par . Évalué à 6. Dernière modification le 07/03/18 à 08:44.

      Le gros problème de l'approche de NodeJS est qu'elle propose une approche de la programmation événementielle basée sur des callbacks et qu'on finit invariablement par avoir du code spaghetti assez indigeste, pas super facile à maintenir – et pas non plus super facile à mettre au point. Un des gros problèmes de la programmation par callbacks est qu'elle inverse l'ordre de composition des fonctions

      En JS moderne tu as await pour éviter ça

    • [^] # Re: Sympa ton journal

      Posté par . Évalué à 2.

      Pour retomber sur ses pattes il y a la programmation par monades qui permet de recomposer les traitements “dans le bon sens” – que l'on retrouve sous plusieurs formes de façon parfois un peu cachée comme les promises.

      C'est une autre façon de présenter les promesses, les futures et les observables oui :)

      Je suis d'accord que c'est totalement infernal de travailler à base de callback. Ça rend très complexe le moindre refactoring aussi et ça empêche de voir certain partern dans le code.

    • [^] # Re: Sympa ton journal

      Posté par (page perso) . Évalué à 3.

      Pour retomber sur ses pattes il y a la programmation par monades qui permet de recomposer les traitements “dans le bon sens” – que l'on retrouve sous plusieurs formes de façon parfois un peu cachée comme les promises.

      Mouais. Tu peux aussi utiliser un langage avec des vrais macros… Et ne pense pas que "la programmation par monades" veuille dire grand-chose. Tu penses (probablement) à une monade spécifique qui fait de la composition de fonctions. En fait, le code que tu montres, on dirait du code en CPS…

      • [^] # Re: Sympa ton journal

        Posté par . Évalué à 4.

        Tu penses (probablement) à une monade spécifique qui fait de la composition de fonctions.

        Bah, c'est la monade identité, qui n'est pas spécialement la plus utile.

        Par contre, je comprends pas trop où veut en venir Michaël : avec les monades et leur bind, on les utilise plutôt dans le sens inverse de la composition (à la manière du pipe du shell).

                  f                 g
         A   ----------->  B   ----------->  C
         |                 |                 |
         |                 |                 |
         V      map f      V       map g     V
        F(A) -----------> F(B) -----------> F(C)
        

        Là j'ai trois types A, B et C et deux fonctions f : A -> B et g : B -> C. Si je les compose, j'obtiens une fonction de A vers C. On peut le dire en mode direct (à la mode mathématique) : applique g au résultat de f; ou en mode inverse : passe le résultat de f à g. La conception à la manière d'une pipeline, c'est de le voire comme sur le dessin en mode inverse : on écrit les traitements dans l'ordre où ils sont effectués (f puis g).

        Après une monade, c'est un type paramétrique (ici F sur le dessin) avec certaines bonnes propriétés. Tout d'abord on doit pouvoir faire un map dessus : à partir de f : A -> B, on peut construire map f : F(A) -> F(B) qui respecte la composition. Si je compose f et g, puis que j'applique map, ou que j'applique map puis que je compose, on doit obtenir le même résultat.

        Ensuite, pour que ce type paramétrique soit une monade, il faut pouvoir combiner une fonction A -> F(B) avec un autre B -> F(C) : ce sont les diagonales que l'on cherche à assembler. Mais pour ce faire, on utilise plutôt l'opérateur bind (ou >>= en notation infixe) qui prend une valeur de type F(A), une fonction de type A -> F(B) et renvoie une valeur de type F(B). C'est l'équivalent du pipe pour les monades : le pipe est à la composition de fonctions, ce que le bind est à la composition d'opérateurs monadiques.

        (* on va de A vers C en composant f et g *)
        x |> f |> g
        
        (* 
           on va de F(A) vers F(C) via la monade
           f' et g' sont les fonctions en diagonales
        *)
        mx >>= f' >>= g'

        Sapere aude ! Aie le courage de te servir de ton propre entendement. Voilà la devise des Lumières.

        • [^] # Re: Sympa ton journal

          Posté par (page perso) . Évalué à 2.

          Effectivement je me suis un peu emmêlé les pinceaux, l‘habitude du OCaml et de la composition »contravariante«. Mais effectivement l‘important est que l‘ordre du texte corresponde à celui du traitement.

          Les langages avec des vraies macros , comme Collin Lisp que je cite permettent de tout programmer de façon assez agréable. Par exemple avec cl-async, on peut presque réutiliser son code synchrone avec des promises, il suffit presque de changer les let en alet;)

          • [^] # Re: Sympa ton journal

            Posté par . Évalué à 2.

            Les langages avec des vraies macros , comme Collin Lisp que je cite permettent de tout programmer de façon assez agréable. Par exemple avec cl-async, on peut presque réutiliser son code synchrone avec des promises, il suffit presque de changer les let en alet;)

            Avec l'extension de syntaxe de Lwt aussi ;-).

            Dans utop, il suffit de faire :

            #require "lwt";;
            #require "lwt.ppx";;

            Pour une version avec bind, proche de la syntaxe NodeJS, où on passe la promesse à une fonction anonyme :

            let main () =
              let open Lwt in
              Lwt_io.print "Entrer votre nom: " >>= fun () ->
              Lwt_io.(read_line stdin) >>= fun nom ->
              Lwt_io.printlf "Votre nom est: %s" nom
            ;;
            val main : unit -> unit Lwt.t = <fun>
            
            let () = Lwt_main.run (main ());;
            Entrer votre nom: kantien
            Votre nom est: kantien

            La même, mais avec l'extension de syntaxe : on retrouve une forme proche de celle d'un code synchrone (les let x = e in sont remplacés par des let%lwt x = e in) :

            let main () =
              let%lwt () = Lwt_io.print "Entrer votre nom: " in
              let%lwt nom = Lwt_io.(read_line stdin) in
              Lwt_io.printlf "Votre nom est: %s" nom
            ;;
            val main : unit -> unit Lwt.t = <fun>
            
            let () = Lwt_main.run (main ());;
            Entrer votre nom: kantien
            Votre nom est: kantien

            Il y a une forme (à base de %lwt à rajouter) pour presque toutes les constructions syntaxiques du langage.

            Sapere aude ! Aie le courage de te servir de ton propre entendement. Voilà la devise des Lumières.

      • [^] # Re: Sympa ton journal

        Posté par (page perso) . Évalué à 3.

        Tu penses (probablement) à une monade spécifique qui fait de la composition de fonctions

        Pour être très précis je pense en l‘espèce à la monade utilisée dans Lwt (OCaml) Comme le dit kantien, c‘est la monade qui enferme chaque valeur dans thread (pas nécessairement un Thread système) calculant cette valeur. La monade définit de plus une finction spéciale qui me permet de passer la valeur à l‘ordonnanceur.

        En fait, le code que tu montres, on dirait du code en CPS…

        C‘en est non? Peut-être pas de façon parfaite »selon la définition du livre« mais on pourrait le déformer légèrement pour qu‘il s‘y plie.

  • # Intéressant

    Posté par . Évalué à -5.

    ça fait un moment que je ne m'étais pas arrêter sur une page linuxfr, me contentant de marquer comme lu sur mon flux rss.

    Beau journal.

    Arf sérieux, l'éditeur est toujours aussi pourrit et peu pratique. On est en 2018, sortez-vous enfin les doigts.

  • # Petit joueur

    Posté par . Évalué à 7.

    On peut encore aller le loin… Tout est fichier ! On peut généraliser ce comportement ! On peut aller jusqu'à envoyer toutes les requêtes sur la même boucle d'événement.

    Et cette « boucle d'évènement » unique, elle s'appelle… ton noyau. Démultiplexer des « requêtes » selon un sélecteur donné, c'est ce que fait le noyau quand il répartit les paquets reçus du réseau selon l'adresse et le port de destination du paquet (oui, je me suis placé à un niveau « en dessous »). Forcément, quand tu as oublié qu'il existait autre chose que le Web et le port 80, ça peut faire bizarre comme sensation.

    Ainsi à chaque IO, on donne éventuellement à une autre requête (ou une autre IO) la possibilité de s'exécuter. On minimise encore le temps d'attente des fils d'exécution. Cette démarche permet de tirer parti des infrastructures du noyau et de tenter de maximiser le temps d'exécution utile du CPU.

    Moui… tu viens de décrire l’ordonnanceur du noyau, quoi, qui répartit les tâches entre différents processus représentant différentes applications qui écoutent sur des ports différents, non ?

    En fait, la manie du tout-Web a amené de gros anciens SPOF sous forme dispatchers de requêtes HTTP.

    Alors imagine quand ton sélecteur est à un offset donné dans ton paquet (= port de destination), facile à parser, qu'en plus en cas de problème de charge tu veuilles pouvoir répartir les requêtes avec l'assistance du client (= adresse IP de destination, obtenue par la base géante clé-valeur qui s'appelle DNS parmi un ensemble façon round-robin avec priorités, autrement appelés enregistrements SRV), ou alors si tu gardes une seule IP mais que tu veux pouvoir faire de la répartition de charge y compris sur ton architecture réseau amont (= flow ID ; seulement en IPv6), tu auras alors l'architecture ultime, distribuée, et qui en plus n'aura pas d'état incrusté dedans (= connexion HTTP ; la connexion n'a de sens que pour nœuds aux extrémités) et qui scalera à mort. Et tu pourras l'appeler « Internet ».

    Sans parler de comment tu gères ta file quand elle est pleine, parce que le tail-drop ça fait plus de 20 ans qu'on sait que c'est mauvais.

    • [^] # Re: Petit joueur

      Posté par . Évalué à 2. Dernière modification le 07/03/18 à 16:02.

      Et cette « boucle d'évènement » unique, elle s'appelle… ton noyau. Démultiplexer des « requêtes » selon un sélecteur donné, c'est ce que fait le noyau quand il répartit les paquets reçus du réseau selon l'adresse et le port de destination du paquet (oui, je me suis placé à un niveau « en dessous »). orcément, quand tu as oublié qu'il existait autre chose que le Web et le port 80, ça peut faire bizarre comme sensation.

      Je décris justement que les systèmes d'exploitation de type unix ont déjà implémenté pour toi tout ça (c'est la première partie de mon billet). L'article de Christophe Blaess est vraiment bien sur le sujet.

      Il n'y a pas que le monde tcp/udp (comme les fichiers par exemple) et ça tombe bien ce que les SE implémente permet aussi de les gérer (cf: titre de la première partie ;) ).

      Moui… tu viens de décrire l’ordonnanceur du noyau, quoi, qui répartit les tâches entre différents processus représentant différentes applications qui écoutent sur des ports différents, non ?

      Non pas vraiment. Il s'agit ici d'avoir une granularité assez fine pour faire un switch non pas sur les threads (ce qui est plutôt lourd), mais au sein d'un même thread.

      En fait, la manie du tout-Web a amené de gros anciens SPOF sous forme dispatchers de requêtes HTTP.

      Et c'est toi qui parle que de TCP ? ;) L'accès aux fichiers ou à certains périphériques entre très bien dans ce modèle (je parle d'IO tout le long pas de HTTP).

      Sans parler de comment tu gères ta file quand elle est pleine, parce que le tail-drop ça fait plus de 20 ans qu'on sait que c'est mauvais.

      J'ai pas retrouvé rapidement de sources, mais je sais que vertx fait très gaffe à ça. Il fait attention à s'appuyer sur le SE pour gérer la congestion TCP par exemple. Tu peux aussi, pour d'autres besoins, utiliser Rx et sa gestion de la backpressure.

      • [^] # Re: Petit joueur

        Posté par (page perso) . Évalué à 4. Dernière modification le 08/03/18 à 00:20.

        Non pas vraiment. Il s'agit ici d'avoir une granularité assez fine pour faire un switch non pas sur les threads (ce qui est plutôt lourd), mais au sein d'un même thread.

        Oui, le modèle de gestion du multitâche popularisé par NodeJS a plusieurs intérêts. Le premier c'est qu'en gros il fournit un cadre assez simple de programmation multi-thread en proposant de prendre en charge complètement un thread dédié aux appels systèmes et un autre qui s'occupe de traiter les évènements correspondant à ces appels. Comme programmeur, je sais qu'a tout moment je n'ai qu'un fil d'éxécution et donc je peux laisser tomber les mutexes, j'ai des programmes moins difficiles à déboguer que le “multi-thread” total. Le second effet, qui n'est pas négligeable, est que si j'ai besoin d'ajuster la capacité de mon application, je n'ai qu'un seule variable à considérer: le nombre de processus que je veux faire tourner sur une machine. Dans le monde “complètement multi thread” j'ai un espace beaucoup compliqué à explorer: pour mon serveur apache, est-ce qu'il faut mieux 16 processus avec chacun 2 thread, 8 processus avec chacun 4 threads, etc.? Quelle stratégie utiliser pour allouer les threads dans mon application? Avoir un modèle simple pour décrire la capacité de son application est appréciable – quitte à potentiellement sacrifier un peu en efficacité en acceptant de ne pas explorer certaines formes d'organisation.

        • [^] # Re: Petit joueur

          Posté par . Évalué à 4.

          Le premier c'est qu'en gros il fournit un cadre assez simple de programmation multi-thread en proposant de prendre en charge complètement un thread dédié aux appels systèmes et un autre qui s'occupe de traiter les évènements correspondant à ces appels.

          Je suis loin d’etre convaincu que les promises soient un modèle simple,surtout en JavaScript.

          Comme programmeur, je sais qu'a tout moment je n'ai qu'un fil d'éxécution et donc je peux laisser tomber les mutexes

          !!!
          Juste parce que t’as un seul thread ne veut pas dire que tes parties critiques ne vont pas s’executer “entrelacées”. Si t’avais besoin de mutex avant, t’as toujours besoin de mutexes après.

          Linuxfr, le portail francais du logiciel libre et du neo nazisme.

          • [^] # Re: Petit joueur

            Posté par (page perso) . Évalué à 2.

            Juste parce que t’as un seul thread ne veut pas dire que tes parties critiques ne vont pas s’executer “entrelacées”. Si t’avais besoin de mutex avant, t’as toujours besoin de mutexes après.

            C'est vrai, mais ce que j'ai oublié d'ajouter est qu'on a affaire à un modèle de programmation collaboratif et non préemptif – ce qui signifie que je suis sûr que personne ne touche à ma variable en même temps que je la manipule. Et comme les mutexes sont là pour rendre atomiques du point de vue de l'ordonnanceur des opérations qui en réalité ne le sont pas on n'en a plus besoin.

            • [^] # Re: Petit joueur

              Posté par . Évalué à 2.

              Jusqu’au jour où tu fais un appel de fonction un temps soit peu lourd en cpu qui va rendre la main à ton event loop et paf le bug.

              Linuxfr, le portail francais du logiciel libre et du neo nazisme.

              • [^] # Re: Petit joueur

                Posté par (page perso) . Évalué à 2.

                Non c'est justement ce qui est garanti de ne jamais arriver: en NodeJs par exemple il n'y a à tout moment qu'au plus un fil d'exécution qui execute du JavaScript.

                • [^] # Re: Petit joueur

                  Posté par . Évalué à 2. Dernière modification le 09/03/18 à 08:21.

                  Ca n’a aucun sens, c’est comme si je te disait qu’il est impossible d’avoir des problèmes de concurrence sur un pentium 4 parce qu’il est monocoeur.
                  Ta section critique que t’es garantie qu’elle ne sera jamais interrompue, ça ne marche que si elle fait un traitement synchrone, i.e. qui bloque ton event loop. Bloquer une event loop, c’est mal, ça tue la latence, du coup on limite ça à des opérations très légères. Les opérations plus lourdes font un bout de processing, et rendent la main à l’event Loop, pour continuer leur processing à la boucle suivante. J’imagine que je ne t’apprends rien en disant ça.

                  Il suffit d’introduire un seul appel de fonction un tant soit peu costaud en cpu, genre un hash crypto, ou que sais je encore, et t’es force de repasser en asynchrone. Ta garantie que ta section critique ne sera pas interrompue vient de partir en fumée, et tu te retrouves avec du code méchamment bugge.

                  Dit autrement, ton approche marchotte tant que ton appli est triviale et/ou ne prend aucune charge (ce qui en pratique est la réalité de beaucoup d’appli node), et se ramasse/se bloque dès que tu monte en charge (ce qui est aussi en pratique la réalité de pas mal d’appli node), mais je pourrais dire exactement la même chose de n’importe quel modèle de programmation.

                  En pratique, le modèle “collaboratif”, il te force à rendre la main en permanence, et t’es pas mieux loti qu’avec du préemptif. Ou alors, c’est que ton service n’est pas utilisé. C’est sur que n’avoir aucune charge aide a ne pas avoir de bugs, m’enfin, ça me paraît pas être flatteur, ni pertinent.

                  Linuxfr, le portail francais du logiciel libre et du neo nazisme.

        • [^] # Re: Petit joueur

          Posté par (page perso) . Évalué à 3.

          Un des gros inconvénients du modèle de Nodejs est d'avoir un seul fil d'exécution en collaboratif. Cela veut dire que si ce fil d'exécution est bloqué pendant 100ms pour calculer le hash d'un mot de passe avec une fonction moderne de hashage comme scrypt, il n'y a aucune autre requête HTTP qui va être traitée pendant ce temps et la latence va augmenter de 100ms pour toutes les requêtes de ce processus.

          Je préfère le modèle de multi-tâche de Go. En tant que développeur, je peux créer plein de goroutines (des fils d'exécution très légers) et le runtime de Go les place dans des threads (en préemptif). On a également la distinction entre les threads dédiées aux appels système et les autre threads dédiés aux traitements. Le nombre de threads dédiés aux traitements est la variable d'ajustement pour la montée en puissance, il est limité par la variable d'environnement GOMAXPROCS. Mais, par défaut, c'est le nombre de cœurs de la machine et c'est une très bonne valeur par défaut. J'apprécie beaucoup d'avoir par défaut la version la plus efficace, alors que pour Nodejs, ça demande souvent encore un peu de boulot de passer d'un seul à plusieurs processus : écrire le petit bout de code pour cluster, faire attention aux variables globales qui ne le sont plus (cache, pools de connexions à la base de données), etc.

          • [^] # Re: Petit joueur

            Posté par . Évalué à 1.

            Un des gros inconvénients du modèle de Nodejs est d'avoir un seul fil d'exécution en collaboratif. Cela veut dire que si ce fil d'exécution est bloqué pendant 100ms pour calculer le hash d'un mot de passe avec une fonction moderne de hashage comme scrypt, il n'y a aucune autre requête HTTP qui va être traitée pendant ce temps et la latence va augmenter de 100ms pour toutes les requêtes de ce processus.

            Effectivement c'est un traitement lourd qui doit être fait hors de l'event loop. Je ne connais pas Node, mais vertx permet ça sans problème (soit pour un appel simple et ponctuel un enrobage, soit pour un truc plus sophistiquer utiliser une forme de worker (avec le quel tu communique via un bus d'évènement qui passe par l'event loop)).

            En tant que développeur, je peux créer plein de goroutines (des fils d'exécution très légers) et le runtime de Go les place dans des threads (en préemptif).

            Le lien que tu m'a donné plus haut parle de stack de 2Kio (pour Go 1.5 ça a peut être évolué) soit si tu en lance un par connexion pour le C10K, si je ne me suis pas trompé dans mes comptes on est dans les 20Tio de mémoire consommée.

            J'apprécie beaucoup d'avoir par défaut la version la plus efficace, alors que pour Nodejs, ça demande souvent encore un peu de boulot de passer d'un seul à plusieurs processus : écrire le petit bout de code pour cluster, faire attention aux variables globales qui ne le sont plus (cache, pools de connexions à la base de données), etc.

            Il y a un gros mélange entre une implémentation (Node) et un modèle théorique (reactor), avec vertx c'est une ligne.

            • [^] # Re: Petit joueur

              Posté par . Évalué à 4.

              Le lien que tu m'a donné plus haut parle de stack de 2Kio (pour Go 1.5 ça a peut être évolué) soit si tu en lance un par connexion pour le C10K, si je ne me suis pas trompé dans mes comptes on est dans les 20Tio de mémoire consommée.

              Heu 2Kio * 10 000 çà doit être plus proche des 20 Mio que des 20 Tio non ? :)

          • [^] # Re: Petit joueur

            Posté par (page perso) . Évalué à 4.

            Un des gros inconvénients du modèle de Nodejs est d'avoir un seul fil d'exécution en collaboratif. Cela veut dire que si ce fil d'exécution est bloqué pendant 100ms pour calculer le hash d'un mot de passe avec une fonction moderne de hashage comme scrypt, il n'y a aucune autre requête HTTP qui va être traitée pendant ce temps et la latence va augmenter de 100ms pour toutes les requêtes de ce processus.

            Ce serait effectivement une manière assez plus maladroite de faire! ;) Dans ce paradigme on n'attend que les “threads” collaborent entre eux: de temps en temps il faut donc rendre la main à la boucle. Plutôt qu'un long discours, un petit exemple:

            function fiboLoop(n, k, a1, a2, callback) {
                var collaborate_p = (k % 20 === 0);
            
                if(n == k) {
                    callback(a2);
                } else {
                    if(collaborate_p) {
                        setTimeout(() => {
                            fiboLoop(n, k + 1,  a2, combine(a1, a2), callback);
                        }, 1);
                    } else {
                        fiboLoop(n, k + 1,  a2, combine(a1, a2), callback);
                    }
                }
            }

            (À tout hasard je précise que je suis loin d'être un spécialiste ou un amoureux de JavaScript, moi mon truc c'est plutôt OCaml, Lisp, SH et TeX. Donc mon code est très certainement assez médiocre. ;) )

            Ma fonction combine déduit un hash des valeurs de a1 et a2, et j'utilise un “schéma de Fibonacci” pour faire une boucle, qui toutes les 20 itérations repasse la main à l'ordonnanceur pour collaborer un peu avec ses petits copains: il suffit de se retenir de faire le calcul et de demander à la boucle de continuer quand bon lui semble.

            Voici un code complet qui met ça dans un serveur HTTP

            const http = require('http');
            const process = require('process');
            const crypto = require('crypto');
            const url = require('url');
            
            const server_port = 8080;
            const server_iterations = 400000;
            
            const fibo_initialize_a1 = "I don't like secrets.";
            const fibo_initialize_a2 = "God is my co-pilot.";
            
            function combine(a1, a2) {
                return crypto.createHmac('sha256', a1)
                    .update(a2)
                    .digest('hex');
            }
            
            function fiboLoop(n, k, a1, a2, callback) {
                var collaborate_p = (k % 20 === 0);
            
                if(n == k) {
                    callback(a2);
                } else {
                    if(collaborate_p) {
                        setTimeout(() => {
                            fiboLoop(n, k + 1,  a2, combine(a1, a2), callback);
                        }, 1);
                    } else {
                        fiboLoop(n, k + 1,  a2, combine(a1, a2), callback);
                    }
                }
            }
            
            function fibo(n, callback) {
                fiboLoop(n, 0, fibo_initialize_a1, fibo_initialize_a2, callback);
            }
            
            function safeParseInt(text, _defaultValue) {
                var parsedInt = parseInt(text);
                var defaultValue = _defaultValue || 0;
                return isNaN(parsedInt) ? defaultValue : parsedInt;
            };
            
            http.createServer(function (req, res) {
            
                var parts = url.parse(req.url, true);
                var query = parts.query;
                var iterations =
                    ('n' in query)
                    ? safeParseInt(query['n'])
                    : server_iterations;
            
                console.log('Receive fibo ' + iterations);
            
                fibo(iterations, (ax) => {
                    res.writeHead(200, {'Content-Type': 'text/plain'});
                    res.write('Hello World! ' + ax);
                    res.end();
                    console.log('Done fibo ' + iterations);
                });
            }).listen(server_port);

            On peut tester avec quelques commandes (while true; do curl 'localhost:8080?n=10'; done) (while true; do curl 'localhost:8080?n=100'; done) et (while true; do curl 'localhost:8080?n=10000'; done) qui tournent dans des terminaux.

            On a également la distinction entre les threads dédiées aux appels système et les autre threads dédiés aux traitements. Le nombre de threads dédiés aux traitements est la variable d'ajustement pour la montée en puissance, il est limité par la variable d'environnement GOMAXPROCS.

            C'est sympa cette distinction. En pratique ça se passe comment? Il faut annoter ses routines ou bien le compilateur s'en sort tout seul? Aussi c'est un peu bizarre de penser qu'une fonction change de catégorie si on la “pepper-printf” pour déboguer.

            alors que pour Nodejs, ça demande souvent encore un peu de boulot de passer d'un seul à plusieurs processus : écrire le petit bout de code pour cluster, faire attention aux variables globales qui ne le sont plus (cache, pools de connexions à la base de données), etc.

            Si on cherche à faire facilement du redimensionnement de capacité, la bonne stratégie dépend du contexte. Si par exemple on travaille avec des ressources de calcul élastiques avec des instances de 2-4 cœurs qu'on ajuste à la capacité demandée on peut se passer complètement de la couche cluster et compter sur un reverse-proxy type haproxy ou bien par exemple le répartiteur de charge de docker ou bien le consul de hashicorp par exemple – ou tout autre composant qui va répartir le flux sur plusieurs processus qui tournent sur des serveurs différents. Et même si on a une machine grassouillette avec 192 cœurs dont on veut tirer parti, ce n'est pas forcément aberrant de procéder de la sorte aussi.

            • [^] # Re: Petit joueur

              Posté par (page perso) . Évalué à 4.

              Ce serait effectivement une manière assez plus maladroite de faire! ;)

              Oui, mais pourtant, je l'ai déjà vu plusieurs fois sur des projets nodejs en production. Ce n'est pas toujours facile de voir à l'avance où ça va se produire. Et même quand on le sait, ce n'est pas facile à transformer. Ça demande d'une part de modifier du code synchrone en code asynchrone (normalement, ce n'est pas très compliqué, c'est juste du travail de bucheron) et, d'autre part, il faut réussir à introduire le découpage qui va bien (là, c'est plus compliqué).

              Si on prend ton exemple, et que l'on ajoute la version bloquante pour comparer :

              const http = require('http');
              const process = require('process');
              const crypto = require('crypto');
              const url = require('url');
              
              const server_port = 8080;
              const server_iterations = 400000;
              
              const fibo_initialize_a1 = "I don't like secrets.";
              const fibo_initialize_a2 = "God is my co-pilot.";
              
              function combine(a1, a2) {
                  return crypto.createHmac('sha256', a1)
                      .update(a2)
                      .digest('hex');
              }
              
              function fiboLoop(n, k, a1, a2, callback) {
                  var collaborate_p = (k % 20 === 0);
              
                  if(n == k) {
                      callback(a2);
                  } else {
                      if(collaborate_p) {
                          setTimeout(() => {
                              fiboLoop(n, k + 1,  a2, combine(a1, a2), callback);
                          }, 1);
                      } else {
                          fiboLoop(n, k + 1,  a2, combine(a1, a2), callback);
                      }
                  }
              }
              
              function fibo(n, callback) {
                  fiboLoop(n, 0, fibo_initialize_a1, fibo_initialize_a2, callback);
              }
              
              function blockingFibo(n) {
                  var a1 = fibo_initialize_a1, a2 = fibo_initialize_a2, tmp;
                  for (var i = 1; i <= n; i++) {
                      tmp = a2
                      a2 = combine(a1, a2);
                      a1 = tmp;
                  }
                  return a2;
              }
              
              function safeParseInt(text, _defaultValue) {
                  var parsedInt = parseInt(text);
                  var defaultValue = _defaultValue || 0;
                  return isNaN(parsedInt) ? defaultValue : parsedInt;
              };
              
              http.createServer(function (req, res) {
                  var parts = url.parse(req.url, true);
                  var query = parts.query;
                  var iterations =
                      ('n' in query)
                      ? safeParseInt(query['n'])
                      : server_iterations;
              
                  console.log('Receive fibo ' + iterations);
              
                  if ('blocking' in query) {
                      var f = blockingFibo(iterations);
                          res.writeHead(200, {'Content-Type': 'text/plain'});
                          res.write('Hello World! ' + f);
                          res.end();
                          console.log('Done blocking fibo ' + iterations);
                  } else {
                      fibo(iterations, (ax) => {
                          res.writeHead(200, {'Content-Type': 'text/plain'});
                          res.write('Hello World! ' + ax);
                          res.end();
                          console.log('Done fibo ' + iterations);
                      });
                  }
              }).listen(server_port);

              On se rend compte que la version coopérative est beaucoup plus lente :

              $ time curl 'http://localhost:8080?n=100000'
              Hello World! b04e954ac754919c16edf386311af04c0a41ef30f99c2239e479c42941572397curl 'http://localhost:8080?n=100000'  0,01s user 0,00s system 0% cpu 6,727 total
              
              $ time curl 'http://localhost:8080?n=100000&blocking'
              Hello World! b04e954ac754919c16edf386311af04c0a41ef30f99c2239e479c42941572397curl 'http://localhost:8080?n=100000&blocking'  0,01s user 0,00s system 2% cpu 0,411 total

              Les utilisateurs ne seront pas très contents s'ils doivent attendre 7 secondes à chaque fois qu'ils s'authentifient. Et il y a d'autres solutions, comme déporter ces calculs dans d'autres processus. Mon expérience de projets nodejs en production montre que ce n'est pas évident de trouver les parties de code qui vont poser problème (surtout qu'ils se situent en général pas dans le code que l'on écrit soi-même, mais souvent dans une des centaines de bibliothèques que l'on importe via npm) et, qu'en pratique, la latence n'est pas géniale.

              C'est sympa cette distinction. En pratique ça se passe comment? Il faut annoter ses routines ou bien le compilateur s'en sort tout seul? Aussi c'est un peu bizarre de penser qu'une fonction change de catégorie si on la “pepper-printf” pour déboguer.

              Ça se passe comme en nodejs : le développeur n'a pas besoin d'annoter ça, le runtime du langage va faire en sorte que les calculs se passent dans le thread principal (pour nodejs) ou les threads principaux (pour go), et quand il y a une opération bloquant d'I/O, on passe ce contexte d'exécution sur un thread dédié aux I/O. Et quand l'opération d'I/O est finie, le contexte d'exécution est de nouveau dans un état où le scheduler peut l'exécuter sur un des threads principaux. La grosse différence, c'est qu'un processus nodejs n'utilise qu'un seul cœur (en gros) alors qu'un processus go peut utiliser tous les cœurs.

              Si on cherche à faire facilement du redimensionnement de capacité, la bonne stratégie dépend du contexte.

              Oui, bien sûr, quand on fait des projets un peu conséquents, avec des besoins de performances ou de haute disponibilité, le langage est moins importante que l'architecture globale. Mais le fait qu'un processus nodejs ne sache utiliser qu'un seul cœur reste une bonne épine dans le pied quand on conçoit ces architectures.

              • [^] # Re: Petit joueur

                Posté par . Évalué à 1.

                Ça se passe comme en nodejs : le développeur n'a pas besoin d'annoter ça, le runtime du langage va faire en sorte que les calculs se passent dans le thread principal (pour nodejs) ou les threads principaux (pour go), et quand il y a une opération bloquant d'I/O, on passe ce contexte d'exécution sur un thread dédié aux I/O.

                Il y a une stratégie de localisation pour ça ? Genre mettre toutes les goroutines "système" dans un même thread système ? Je crois qu'il y a des ordonnanceurs du noyau qui ont des euristiques pour détecter des motifs d'utilisation d'appels système. Je crois que quand j'avais lu ça c'était pour pondérer la priorité, du coup ça permettrait à un green thread qui ferait énormément d'appels systèmes de ne pas trop influencer négativement le thread système sur le quel il est).

        • [^] # Re: Petit joueur

          Posté par . Évalué à 2.

          Avoir un modèle simple pour décrire la capacité de son application est appréciable – quitte à potentiellement sacrifier un peu en efficacité en acceptant de ne pas explorer certaines formes d'organisation.

          C'est marrant je vois plutôt l'inverse dans ce qui décris les green threads. C'est compliqué de gérer de l'asynchrone donc on veut rester sur un modèle à thread et que l'on me cache le fait que derrière c'est asynchrone (le runtime est obligé de faire un switch synchrone/asynchrone parce que bloquer un thread système qui celui-ci embarque un paquet de green thread).

Suivre le flux des commentaires

Note : les commentaires appartiennent à ceux qui les ont postés. Nous n'en sommes pas responsables.