2 points par GN⁺ 2024-05-10 | 1 commentaires | Partager sur WhatsApp
  • Les problèmes de latence dans les systèmes distribués se résolvent de façon répétée simplement en activant TCP_NODELAY, ce qui suggère que le comportement par défaut de TCP peut être en décalage avec les charges de travail modernes
  • L’algorithme de Nagle a été conçu en 1984 dans la RFC896 pour réduire le coût des en-têtes des petits paquets TCP, en empêchant l’envoi d’un nouveau segment avant la réception d’un ACK
  • Utilisé avec le delayed ACK, il peut introduire de la latence : un côté attend l’ACK tandis que l’autre attend soit des données de réponse soit l’expiration d’un minuteur, ce qui pénalise les applications en pipeline sensibles à la latence
  • Même avec un RTT intra-datacenter d’environ 500μs, les serveurs modernes peuvent accomplir beaucoup de travail pendant ce laps de temps, ce qui rend discutable l’intérêt de retarder une transmission d’un RTT complet
  • Dans les systèmes distribués modernes, avec TLS, l’encodage, la sérialisation et la taille des messages applicatifs, le problème des paquets d’un seul octet est moins fréquent, et dans les environnements sensibles à la latence il est plus naturel de désactiver l’algorithme de Nagle

Le réglage regardé en premier lors du débogage de la latence

  • Lorsqu’un problème de latence apparaît dans un système distribué, on commence souvent par vérifier si TCP_NODELAY est activé
  • De nombreux développeurs de systèmes distribués ont perdu du temps sur des problèmes qui se résolvaient avec cette simple option de socket
  • Cette répétition laisse penser que le comportement par défaut de TCP ne convient pas aux systèmes distribués actuels, ou que l’algorithme de Nagle lui-même est devenu obsolète

Le problème que l’algorithme de Nagle cherchait à résoudre

  • RFC896 est un document de 1984 qui traite du problème des petits paquets
  • À l’époque, lorsqu’on envoyait via TCP des données arrivant caractère par caractère, comme des frappes clavier, on se retrouvait avec 40 octets d’en-tête pour 1 octet de données
    • Cela représentait 4000 % d’overhead avec 40 octets d’en-tête pour 1 octet utile
    • Supportable à faible charge, mais défavorable au débit réseau
  • L’objectif de l’algorithme de Nagle était de mieux amortir le coût des en-têtes TCP afin d’augmenter le débit
    • Les petits paquets provenaient surtout d’applications interactives humaines comme les shells, ou d’implémentations transmettant les données au noyau petit à petit via plusieurs appels à write
  • Son fonctionnement essentiel consiste à ne pas envoyer immédiatement de nouvelles données dans un segment TCP distinct tant que les données précédemment envoyées n’ont pas encore été acquittées
  • On présente souvent l’algorithme de Nagle avec un minuteur, mais la RFC896 elle-même n’utilise pas de minuteur distinct en dehors du temps d’aller-retour (RTT) du réseau

La latence introduite lorsqu’il est combiné au delayed ACK

  • Le delayed ACK consiste à ne pas envoyer immédiatement l’accusé de réception d’un paquet, mais à attendre soit des données à renvoyer, soit l’expiration d’un minuteur
  • RFC813 est un document précoce de 1982 proposant le retard d’ACK, en expliquant que dans certaines situations le récepteur peut différer l’envoi d’un ACK et programmer un minuteur pour l’envoyer plus tard
  • RFC1122 formalise davantage le delayed ACK
  • Les deux mécanismes sont raisonnables séparément, mais peuvent créer de la latence lorsqu’ils sont utilisés ensemble
    • L’algorithme de Nagle attend la réception d’un ACK avant d’envoyer davantage de données
    • Le delayed ACK retarde l’envoi de l’ACK jusqu’à ce que des données de réponse soient prêtes ou qu’un minuteur expire
    • Cela aide à remplir davantage les paquets, mais convient mal aux applications en pipeline sensibles à la latence
  • Un commentaire de John Nagle sur Hacker News attribue lui aussi le problème non pas à la prévention des tinygrams, mais à la combinaison du retard d’ACK et d’un minuteur fixe
  • C’est un exemple où deux fonctions de protocole raisonnables se combinent pour produire un comportement indésirable, ce qui illustre la difficulté de concevoir des protocoles

Les points de décalage avec les systèmes distribués modernes

  • Même sans delayed ACK, le comportement de l’algorithme de Nagle peut ne pas correspondre à ce que recherchent les systèmes distribués modernes
  • Dans l’environnement actuel, le RTT lui-même représente un coût qu’on ne peut pas ignorer
    • Un RTT unique à l’intérieur d’un datacenter est généralement d’environ 500μs
    • Entre datacenters d’une même région, le RTT se chiffre en quelques ms
    • Sur des trajets mondiaux, il peut atteindre plusieurs centaines de ms
  • Les serveurs modernes peuvent effectuer beaucoup de travail même en quelques centaines de μs, ce qui rend peu évident le bénéfice d’un choix consistant à retarder l’envoi des données d’un RTT complet
  • La justification initiale de l’algorithme de Nagle était de réduire l’overhead d’en-tête multiplié par 40 associé aux paquets d’un seul octet
  • Les bases de données distribuées et systèmes distribués modernes n’envoient généralement pas de paquets d’un seul octet
    • Les données envoyées par l’application sont elles-mêmes plus volumineuses
    • Des overheads de protocole supplémentaires s’ajoutent, comme TLS
    • Il faut aussi compter l’overhead de l’encodage et de la sérialisation
  • Le problème consistant à éviter les petits messages reste important, mais cette responsabilité s’est en pratique déplacée vers la couche applicative
  • Envoyer des données encapsulées en JSON octet par octet n’est pas efficace, indépendamment de l’algorithme de Nagle

Pourquoi considérer TCP_NODELAY comme le choix par défaut

  • Si vous construisez un système distribué sensible à la latence sur du matériel moderne de type datacenter, vous pouvez activer TCP_NODELAY afin de désactiver l’algorithme de Nagle
  • Compte tenu du trafic des systèmes modernes, de la structure des applications et des performances matérielles, l’algorithme de Nagle n’est peut-être plus nécessaire
  • On peut même défendre l’idée que TCP_NODELAY devrait être la valeur par défaut
  • Un code qui appelle write pour chaque octet peut devenir plus lent avec TCP_NODELAY comme comportement par défaut
  • Si l’efficacité est importante, il vaut mieux corriger ce code au niveau de l’application plutôt que de compter sur l’algorithme de Nagle

TCP_QUICKACK reste plutôt un choix secondaire

  • TCP_QUICKACK peut être évoqué comme alternative, mais son manque de portabilité et sa sémantique particulière en font un mauvais premier choix
  • Il faut vérifier directement sa signification dans la page de manuel Linux sur tcp
  • Plus fondamentalement, TCP_QUICKACK ne résout pas le problème de fond : le noyau peut garder les données plus longtemps que ne le souhaite le programme
  • Si le programme appelle write(), on s’attend à ce que write() soit effectivement exécuté

1 commentaires

 
GN⁺ 2024-05-10
Avis sur Hacker News
  • Au cours de ma carrière, j’ai corrigé plusieurs fois des problèmes de latence causés par l’algorithme de Nagle, et c’est désormais la première chose que je soupçonne
    La logique elle-même se tient, mais elle ne convient pas à certaines charges de travail ; à mon avis, il faudrait que l’ingénieur fasse un choix explicite lors de la création d’un socket, plutôt que de s’en remettre au réglage par défaut du système d’exploitation
    Le problème n’est pas de savoir si c’est une bonne ou une mauvaise option, mais qu’il existe un réglage qui modifie assez agressivement la manière dont les données sont envoyées, et que beaucoup de gens ignorent son existence

    • C’est un peu pareil pour moi : chaque fois que je vois un nouveau framework RPC, j’ai pour hobby d’ouvrir une issue GitHub demandant « avez-vous pensé à TCP_NODELAY, ou bien ce framework est-il limité à 20 appels par seconde ? »
      Jusqu’ici, j’ai trouvé un bug à chaque fois
      Exemples : https://cloud-haskell.atlassian.net/browse/DP-108 ou https://github.com/agentm/curryer/issues/3
      En revanche, je ne suis pas d’accord avec l’idée que ce ne serait « ni une bonne ni une mauvaise option »
      C’est une heuristique côté noyau destinée à « corriger magiquement » des applications mal écrites et, comme le dit l’article, une application normale ne fait pas d’appel système réseau write() d’un octet
      Ce genre de logiciel doit être corrigé
      Le seul cas où cette fonctionnalité a du sens, selon moi, c’est celui d’un administrateur système noyau dans une situation rare où il ne peut pas corriger le logiciel qui tourne sur la machine, par exemple pour des raisons de politique d’équipe
      Dans tous les autres cas, elle rend les logiciels normaux plus complexes
      Cela veut dire qu’il faut désactiver explicitement une magie étrange ajoutée pour augmenter légèrement le débit de logiciels mal écrits, et qui crée des latences importantes et surprenantes dans les logiciels bien écrits
      Dans le fil lié ici, John Nagle dit que les ACK différés sont pires, et je suis d’accord avec lui
      Mais le schéma Send/Send/Receive que l’algorithme de Nagle aggrave est un cas d’usage parfaitement valable et courant, et correspond à tout ce qui fait du RPC en pipeline au-dessus de TCP
      À mon avis, les ACK différés comme l’algorithme de Nagle devraient être désactivés par défaut
      Le nom devrait plutôt être quelque chose comme TCP_DELAY, et on ne devrait l’activer que lorsqu’on ne veut pas implémenter un buffering élémentaire en espace utilisateur
      Les gens ne devraient pas avoir besoin de connaître ce genre de choses, et le comportement par défaut devrait être celui qui ne surprend pas
    • Si l’objectif est surtout de corriger des applications qui ont un mauvais comportement de write, alors une option pour activer TCP_DELAY devient assez bizarre
      Cela suppose un ingénieur logiciel assez malin pour connaître cette option, mais pas assez pour découper correctement ses appels à write ou implémenter lui-même un meilleur buffering à la Nagle adapté à son application
    • D’accord. Dans le trading haute fréquence / faible latence, désactiver l’algorithme de Nagle est bien connu depuis assez longtemps, probablement plus de 15 ans, et c’est aussi l’une des premières choses que je vérifie
    • Ce qu’on veut vraiment, c’est fixer le délai à n microsecondes, mais il n’y a pas de bonne méthode autre que de mettre soi-même du buffering en espace utilisateur devant l’appel système
      À moins d’avoir quelque chose comme io_uring pour compenser le coût des appels système, l’approche côté espace utilisateur fonctionne mieux
    • Cette logique visait à l’origine des choses comme les sessions Telnet
      Si je me souviens bien, c’était la motivation principale
  • La conclusion est un peu étrange. L’algorithme de Nagle était clairement une tentative de regrouper les écritures, et indépendamment du matériel, du réseau, de l’application ou du cas d’usage, il y a des situations où regrouper les écritures est préférable
    Aujourd’hui encore, beaucoup de calculs utilisent le regroupement des écritures, et les applications réseau en bénéficient aussi
    Des protocoles de plus haut niveau plus récents comme QUIC regroupent les écritures et déplacent en pratique les connexions indépendantes et la gestion des erreurs de TCP vers l’espace utilisateur, afin que le protocole pousse les données vers l’application aussi vite que possible, tandis que la gestion des connexions et des erreurs des flux individuels revient à l’application, et non à la pile TCP/IP de l’hôte ou aux routeurs
    Si les réseaux redeviennent saturés comme autrefois, l’algorithme de Nagle reviendra sous une forme modifiée pour QUIC, probablement plus profondément dans le code applicatif, en attendant que certains seuils soient atteints avant d’envoyer des paquets QUIC
    Tout, en technologie, est réinventé lorsque le matériel ou le logiciel atteint un goulot d’étranglement. Comme les performances des deux ne progressent pas au même rythme, cela finit toujours par arriver
    Au-delà de la bande passante, l’algorithme de Nagle est utile quand de petits paquets saturent le nombre de paquets par seconde

    • La différence entre QUIC et TCP tient au péché originel de TCP et de ses prédécesseurs : avoir imité une connexion de port série asynchrone sans couche de messages visible
      Cela permettait certes de se connecter à un service avec un téléscripteur physique, mais TCP s’est retrouvé incapable de connaître les frontières de messages ; aujourd’hui, on peut injecter une partie de cette connaissance, mais les premiers logiciels ne le pouvaient pas
      À l’inverse, de nombreux protocoles non TCP comme QUIC, SCTP ou TP4 fournissent explicitement des frontières de messages
      L’interface avec le système n’est pas un port série émulé, mais quelque chose fondé sur des messages, tout au plus réassemblés
    • C’est vrai, mais cette implémentation précise reposait sur une heuristique pour décider comment faire le regroupement, et ses hypothèses ne semblent pas s’être vérifiées
    • Le regroupement doit être contrôlé par l’application, pas par le protocole
      Le protocole n’a pas assez de contexte pour regrouper correctement
  • À l’inverse, que penser de la désactivation des ACK retardés ?
    Le problème vient du comportement pathologique qui apparaît quand la prévention des petits paquets interagit avec les ACK retardés.
    L’option exposée pour désactiver la prévention des petits paquets est TCP_NODELAY, mais comment désactiver les ACK retardés ?
    C’est le cas quand on veut benchmarker les quatre combinaisons pour voir laquelle convient le mieux.
    En cherchant un peu, Linux dispose de l’option de socket TCP_QUICKACK, mais il faut la définir à chaque réception.
    Il y a aussi /proc/sys/net/ipv4/tcp_delack_min et /proc/sys/net/ipv4/tcp_ato_min.
    FreeBSD a net.inet.tcp.delayed_ack et net.inet.tcp.delacktime.

    • TCP_QUICKACK corrige la pire forme du problème, mais ne le résout pas entièrement.
      L’algorithme de Nagle peut toujours attendre jusqu’à un aller-retour complet avant d’envoyer les données, et, si l’on suit la RFC, cela ajoute surtout de la latence pour presque aucun gain.
    • Exact. Avec TCP_QUICKACK, devoir le définir à chaque réception… à quoi pensaient-ils ?
      Pourquoi voudrait-on ne le désactiver que par moments ?
    • Sur CentOS/RedHat, on peut ajouter quickack 1 à la fin de la route pour désactiver les ACK retardés sur ce chemin.
  • Dans un monde où la bande passante était limitée, où la taille minimale d’un paquet était de 64 octets et où il fallait en plus un intervalle inter-trame, envoyer un paquet TCP pour chaque octet était un énorme gaspillage de bande passante.
    Sur la plupart des réseaux Ethernet, la taille minimale reste la même aujourd’hui, et c’est aussi vrai pour l’envoi d’ACK vides.
    Mais ma position de base est la suivante : ce n’est pas TCP_NODELAY, c’est simplement TCP.

    • Ce serait bien d’avoir un protocole avec un mécanisme intégré pour détecter que le tuyau d’en face s’est rompu, quelle qu’en soit la raison.
    • QUIC (https://en.wikipedia.org/wiki/QUIC) n’est-il pas censé résoudre les problèmes de TCP comme la latence ?
  • Je ne suis pas vraiment convaincu par l’argument selon lequel Nagle ne serait plus nécessaire.
    Telnet n’est plus important aujourd’hui, mais il doit encore exister beaucoup d’applications qui font des choses du genre :
    write(fd, "Host: "), write(fd, hostname), write(fd, "\r\n"), write(fd, "Content-type: "), etc.
    Même si ce n’est pas un surcoût d’un facteur 40, cela peut facilement être autour de 5 fois.

    • Il suffit de corriger l’application.
      On n’écrit pas dans un fichier de cette façon en s’attendant à des performances magiques. Même si le système d’exploitation a aussi ses propres buffers.
      Il n’y a aucune raison d’attendre autre chose seulement parce qu’on écrit dans un socket, et Nagle ne vous épargne de toute façon pas le surcoût des appels système.
    • En voyant la mention de Telnet, je me suis demandé ce que faisait OpenSSH : il définit TCP_NODELAY sur toutes les connexions, y compris les sessions interactives.
      Je l’ai vérifié à la fois en lisant le code et en observant son comportement avec strace.
    • Si l’on part du principe qu’on fait des entrées-sorties asynchrones, mettre en buffer au lieu de bloquer sur chaque petit write(2) est la seule approche vraiment sensée ; ce type de pattern ne me paraît donc plus si courant.
      Côté serveur, les entrées-sorties asynchrones sont généralement nécessaires pour bien passer à l’échelle, et côté client, être bloqué par un appel réseau donne une mauvaise expérience.
      C’est encore plus vrai dans l’environnement actuel, où les changements de réseau sont fréquents et où l’on sort souvent de la zone de couverture.
    • Tout Internet ne devrait pas être puni parce que certains développeurs écrivent du mauvais code.
    • Il ne faut tout simplement pas faire ça dès le départ.
      Même en dehors de l’aspect réseau, les appels système sont assez coûteux, donc c’est mauvais pour les performances.
  • Je me demande s’il existe une bonne façon d’activer TCP_NODELAY sur un socket quand on n’a pas accès au code source de l’application.
    Je n’ai pas trouvé de paramètre noyau pour l’appliquer de façon permanente, ni de commande pour le changer après coup.
    J’ai pu désactiver les ACK retardés en mettant quickack 1 dans la table de routage, mais activer TCP_NODELAY depuis l’extérieur de l’application semble particulièrement difficile.
    J’ai récemment rencontré exactement le problème décrit ici entre une application dont je suis propriétaire et une application source fermée avec laquelle elle interagit.

    • Une interception via LD_PRELOAD de socket(2) ne fonctionnerait-elle pas ?
      L’idée serait d’appeler la vraie fonction, puis de faire un setsockopt ou équivalent, et de retourner le socket modifié.
    • Selon la situation précise, il serait peut-être possible d’insérer socat au milieu.
      Si c’était initialement your_app —> server, cela deviendrait your_app -> localhost_socat -> server.
      socat a une option en ligne de commande pour définir tcp_nodelay.
      Il faut toutefois convaincre l’application source fermée de se connecter à localhost.
      Si elle fait une résolution DNS, une entrée dans /etc/hosts pourrait éventuellement la faire se connecter à localhost.
      Comme l’application communique avec socat via un socket local, le tcp_nodelay côté application n’a pas d’effet.
    • On pourrait peut-être attacher un débogueur et appeler setsockopt via ptrace.
    • Ouvrir /proc//fd/ et définir les options du socket pourrait peut-être fonctionner. Je n’ai pas testé.
    • LD_PRELOAD
  • Il y a environ 15 ans, je jouais à un MMO très temps réel, et toutes les communications passaient par TCP.
    Quand on cliquait sur un bouton, mon action n’apparaissait même pas à l’écran tant que le paquet de réponse n’était pas revenu.
    Au final, les gamins qui jouaient à ce jeu, moi compris, ont tous découvert qu’activer TCP_NODELAY rendait le jeu beaucoup plus fluide.
    L’effet était particulièrement marqué pour les joueurs de Californie, proches des serveurs du jeu.

    • Je ne sais pas si tu parles de WoW, mais vers cette période une mise à jour du jeu a justement fait ce changement, et probablement d’autres aussi.
      Un effet secondaire intéressant était qu’avant le changement, quand le flux TCP se bloquait, le jeu se figeait brièvement puis rejouait très vite les événements reçus en retard.
      En général, cet événement était ma mort.
      Après le changement, à la place, la connexion était simplement coupée.
  • Épisode connexe du podcast Oxide and Friends : https://www.youtube.com/watch?v=mqvVmYhclAg

    • C’était un excellent épisode, et il montrait vraiment très fortement l’importance de la visualisation.
  • Cela ne s’applique pas si vous utilisez un langage moderne qui active TCP_NODELAY par défaut, comme Go :-)

  • Ce n’est pas toujours ça. Parfois, c’est DNS

    • Une fois, une carte de ligne défectueuse dans un routeur mettait à 0 le dernier bit des adresses IPv4, ce qui a donné un ticket du genre « seules les adresses IPv4 paires sont accessibles »
    • Dans mon cas, une fois, c’était du verre sale
      De la poussière, près d’un chantier, s’était déposée dans l’interstice entre le laser et la fibre optique d’un routeur, atténuant suffisamment le signal ; on observait 40 à 50 % de perte de paquets
      Après avoir localisé le point de perte, le NOC a envoyé un e-mail à l’opérateur de transport concerné, et le lendemain le technicien dépêché sur place a répondu en racontant cette histoire
    • Une fois tous les 50 ans, à 2 milliards de km de distance, ça peut être une puce mémoire défectueuse
      Mais comme on peut généralement la contourner par un patch, ce n’est pas si grave
    • Il ne faut pas non plus oublier BGP, ni les disques qui se remplissent sans alerte
    • Si ça échoue, c’est DNS ; si ça cesse simplement d’avancer, c’est TCP_NODELAY ou le buffering de flux
      Le Web, système vraiment complexe, échoue aussi à cause des caches