Pourquoi Discord passe de Go à Rust



La rouille devient une langue de premier ordre dans un large éventail de domaines. Chez Discord, nous l'utilisons avec succès à la fois côté serveur et côté client. Par exemple, du côté client dans le pipeline d'encodage vidéo pour Go Live, et du côté serveur pour les fonctions Elixir NIF (Native Implemented Functions).

Nous avons récemment considérablement amélioré les performances d'un service unique, en le réécrivant de Go to Rust. Cet article explique pourquoi il était logique pour nous de réécrire le service, comment nous l'avons fait et combien la productivité s'est améliorée.

Service de suivi de l'état de lecture (États de lecture)


Notre entreprise est construite autour d'un seul produit, alors commençons par un certain contexte, ce que nous avons exactement transféré de Go à Rust. Il s'agit d'un service Read States. Sa seule tâche est de garder une trace des canaux et des messages que vous lisez. Les états de lecture sont accessibles chaque fois que vous vous connectez à Discord, chaque fois que vous envoyez un message et chaque fois que vous lisez le message. En bref, les états sont lus en continu et sont sur un «chemin chaud». Nous voulons nous assurer que Discord est toujours rapide, donc la vérification de l'état doit être rapide.

La mise en œuvre du service sur Go ne répondait pas à toutes les exigences. La plupart du temps, cela fonctionnait rapidement, mais toutes les quelques minutes, il y avait de forts retards, visibles par les utilisateurs. Après avoir examiné la situation, nous avons déterminé que les retards étaient dus aux principales caractéristiques de Go: son modèle de mémoire et le garbage collector (GC).

Pourquoi Go ne répond pas à nos objectifs de performance


Pour expliquer pourquoi Go n'atteint pas nos objectifs de performances, nous devons d'abord discuter des structures de données, de l'échelle, des modèles d'accès et de l'architecture des services.

Pour stocker des informations d'état, nous utilisons une structure de données, appelée: État de lecture. Il y en a des milliards dans Discord: un état pour chaque utilisateur par canal. Chaque état possède plusieurs compteurs, qui doivent être mis à jour atomiquement et souvent remis à zéro. Par exemple, l'un des compteurs est le numéro @mentiondu canal.

Pour mettre à jour rapidement le compteur atomique, chaque serveur Read States possède un cache LRU (Least Latest Used). Chaque cache a des millions d'utilisateurs et des dizaines de millions d'états. Le cache est mis à jour des centaines de milliers de fois par seconde.

Pour des raisons de sécurité, le cache est synchronisé avec le cluster de bases de données Cassandra. Lorsqu'une clé est poussée hors du cache, nous entrons les états de cet utilisateur dans la base de données. À l'avenir, nous prévoyons de mettre à jour la base de données dans les 30 secondes à chaque mise à jour d'état. Il s'agit de dizaines de milliers d'enregistrements dans la base de données chaque seconde.

Le graphique ci-dessous montre le temps de réponse et la charge CPU à l'intervalle de temps de pointe pour le service Go 1. On peut voir que les retards et les salves de charge sur le processeur se produisent environ toutes les deux minutes.



D'où vient donc l'augmentation des retards toutes les deux minutes?


Dans Go, la mémoire n'est pas libérée immédiatement lorsqu'une clé est poussée hors du cache. Au lieu de cela, le garbage collector s'exécute périodiquement et recherche les parties inutilisées de la mémoire. C'est beaucoup de travail qui peut ralentir un programme.

Il est très probable que des ralentissements périodiques de notre service soient associés à la collecte des ordures. Mais nous avons écrit un code Go très efficace avec une quantité minimale d'allocation de mémoire. Il ne devrait plus y avoir beaucoup de déchets. Que se passe-t-il?

En fouillant dans le code source de Go, nous avons appris que Go démarre de force la récupération de place au moins toutes les deux minutes . Quelle que soit la taille du segment de mémoire, si le GC n'a pas démarré pendant deux minutes, Go le forcera à démarrer.

Nous avons décidé que si vous exécutez GC plus souvent, vous pouvez éviter ces pics avec des retards importants, nous avons donc défini un point de terminaison dans le service pour modifier la valeur GC Percent à la volée . Malheureusement, la configuration de GC Percent n'a rien affecté. Comment cela pourrait-il arriver? Il s'avère que GC n'a pas voulu démarrer plus souvent, car nous n'avons pas alloué suffisamment de mémoire.

Nous avons commencé à creuser plus loin. Il s'est avéré que de tels retards importants ne se produisent pas en raison de l'énorme quantité de mémoire libérée, mais parce que le garbage collector scanne l'intégralité du cache LRU pour vérifier toute la mémoire. Ensuite, nous avons décidé que si nous diminuions le cache LRU, le volume d'analyse diminuerait. Par conséquent, nous avons ajouté un paramètre supplémentaire au service pour modifier la taille du cache LRU et changé l'architecture, divisant le LRU en plusieurs caches distincts sur chaque serveur.

Et c'est arrivé. Avec des caches plus petits, les retards de pointe sont réduits.

Malheureusement, le compromis avec la diminution du cache LRU a augmenté le 99e centile (c'est-à-dire que la valeur moyenne pour un échantillon de 99% des retards a augmenté, à l'exclusion des pics). En effet, la diminution du cache réduit la probabilité que l'état de lecture de l'utilisateur soit dans le cache. S'il n'est pas là, alors nous devons nous tourner vers la base de données.

Après un grand nombre de tests de charge sur différentes tailles de cache, nous avons en quelque sorte trouvé un paramètre acceptable. Bien que ce ne soit pas idéal, c'était une solution satisfaisante, nous avons donc quitté le service pendant longtemps pour travailler comme ça.

Dans le même temps, nous avons mis en œuvre Rust avec beaucoup de succès dans d'autres systèmes Discord et, par conséquent, nous avons pris la décision collective d'écrire des cadres et des bibliothèques pour de nouveaux services uniquement dans Rust. Et ce service semblait être un excellent candidat pour le portage vers Rust: il est petit et autonome, et nous espérions que Rust corrigerait ces explosions avec des retards et rendrait finalement le service plus agréable pour les utilisateurs 2.

Gestion de la mémoire à Rust


Rust est incroyablement rapide et efficace avec la mémoire: en l'absence d'un environnement d'exécution et d'un garbage collector, il convient aux services haute performance, aux applications embarquées et s'intègre facilement avec d'autres langages. 3

Rust n'a pas de collecteur d'ordures, nous avons donc décidé qu'il n'y aurait pas de tels retards, comme Go.

Dans la gestion de la mémoire, il utilise une approche assez unique avec l'idée de "posséder" la mémoire. En bref, Rust garde une trace de qui a le droit de lire et d'écrire dans la mémoire. Il sait quand un programme utilise de la mémoire et la libère immédiatement dès que la mémoire n'est plus nécessaire. Rust applique les règles de mémoire au moment de la compilation, ce qui élimine pratiquement la possibilité d'erreurs de mémoire au moment de l'exécution. 4Vous n'avez pas besoin de suivre manuellement la mémoire. Le compilateur s'en chargera.

Ainsi, dans la version Rust, lorsque l'état de lecture est exclu du cache LRU, la mémoire est libérée immédiatement. Cette mémoire ne reste pas et n'attend pas le garbage collector. Rust sait qu'il n'est plus utilisé et le libère immédiatement. Il n'y a aucun processus en cours d'exécution pour analyser la mémoire à libérer.

Rouille asynchrone


Mais il y avait un problème avec l'écosystème de Rust. Au moment de la mise en œuvre de notre service, il n'y avait pas de fonctions asynchrones décentes dans la branche stable de Rust. Pour un service réseau, la programmation asynchrone est un must. La communauté a développé plusieurs bibliothèques, mais avec une connexion non triviale et des messages d'erreur très stupides.

Heureusement, l'équipe de Rust a travaillé dur pour simplifier la programmation asynchrone, et elle était déjà disponible sur le canal instable (Nightly).

Discord n'a jamais eu peur d'apprendre de nouvelles technologies prometteuses. Par exemple, nous avons été l'un des premiers utilisateurs d'Elixir, React, React Native et Scylla. Si une technologie semble prometteuse et nous donne un avantage, nous sommes prêts à affronter l'inévitable difficulté de mise en œuvre et l'instabilité des outils avancés. C'est l'une des raisons pour lesquelles nous avons atteint si rapidement un public de 250 millions d'utilisateurs avec moins de 50 programmeurs en l'état.

L'introduction de nouvelles fonctions asynchrones à partir du canal instable de Rust est un autre exemple de notre volonté d'adopter une nouvelle technologie prometteuse. L'équipe d'ingénierie a décidé de mettre en œuvre les fonctions nécessaires sans attendre leur support dans la version stable. Avec d'autres représentants de la communauté, nous avons surmonté tous les problèmes qui se sont posés, et maintenant la rouille asynchronemaintenu dans une branche stable. Notre tarif a payé.

Mise en œuvre, tests de résistance et lancement


Réécrire le code était simple. Nous avons commencé par une diffusion approximative, puis l'avons réduite à des endroits où cela avait du sens. Par exemple, Rust a un excellent système de type avec un support étendu pour les génériques (pour travailler avec des données de tout type), nous avons donc discrètement jeté le code Go, qui compensait le manque de génériques. De plus, le modèle de mémoire Rust prend en compte la sécurité de la mémoire dans différents threads, nous avons donc jeté les goroutines de protection.

Les tests de charge ont immédiatement montré un excellent résultat. Les performances du service sur Rust se sont avérées aussi élevées que celles de la version Go, mais sans ces éclats de retard accru !

En règle générale, nous n'avons pratiquement pas optimisé la version Rust. Mais même avec les optimisations les plus simples, Rust a pu surpasser une version soigneusement réglée de Go.Ceci est une preuve éloquente de la facilité d'écriture de programmes Rust efficaces par rapport à un approfondissement de Go.

Mais nous n'avons pas satisfait le simple quo de performance. Après un peu de profilage et d'optimisation, nous avons dépassé Go à tous égards . Retard, CPU et mémoire - tout s'est amélioré dans la version Rust.

Les optimisations des performances de la rouille comprenaient:

  1. Passer à BTreeMap au lieu de HashMap dans le cache LRU pour optimiser l'utilisation de la mémoire.
  2. Remplacement de la bibliothèque de métriques d'origine par une version prenant en charge la concurrence simultanée moderne Rust.
  3. Diminuez le nombre de copies en mémoire.

Satisfait, nous avons décidé de déployer le service.

Le lancement s'est déroulé sans heurts, car nous avons effectué des tests de résistance. Nous avons connecté le service à un nœud de test, découvert et corrigé plusieurs cas limites. Peu de temps après, ils ont déployé une nouvelle version sur l'ensemble du parc de serveurs.

Les résultats sont montrés plus bas.

Le graphique violet est Go, le graphique bleu est Rust.



Augmentez la taille du cache


Lorsque le service a fonctionné avec succès pendant plusieurs jours, nous avons décidé d'augmenter à nouveau le cache LRU. Comme mentionné ci-dessus, dans la version Go, cela n'a pas pu être fait, car le temps de collecte des ordures a augmenté. Étant donné que nous ne faisons plus de récupération de place, vous pouvez augmenter le comptage du cache sur une augmentation encore plus importante des performances. Nous avons donc augmenté la mémoire sur les serveurs, optimisé la structure des données pour une utilisation moindre de la mémoire (pour le plaisir) et augmenté la taille du cache à 8 millions d'états d'état de lecture.

Les résultats ci-dessous parlent d'eux-mêmes. Notez que le temps moyen est maintenant mesuré en microsecondes et le retard maximum @mentionest mesuré en millisecondes.



Développement de l'écosystème


Enfin, Rust possède un merveilleux écosystème qui se développe rapidement. Par exemple, récemment, une nouvelle version du runtime asynchrone que nous utilisons est Tokio 0.2. Nous avons mis à jour, et sans aucun effort de notre part, réduit automatiquement la charge sur le CPU. Dans le graphique ci-dessous, vous pouvez voir comment la charge a diminué depuis le 16 janvier environ.



Dernières pensées


Discord utilise actuellement Rust dans de nombreuses parties de la pile logicielle: pour GameSDK, la capture et l'encodage de vidéos dans Go Live, Elixir NIF , plusieurs services backend et bien d'autres.

Lors du démarrage d'un nouveau projet ou composant logiciel, nous envisageons certainement d'utiliser Rust. Bien sûr, seulement là où cela a du sens.

En plus des performances, Rust offre aux développeurs de nombreux autres avantages. Par exemple, son type vérificateur de sécurité et d'emprunt (vérificateur d'emprunt) simplifie considérablement la refactorisation lorsque vous modifiez les exigences du produit ou introduisez de nouvelles fonctionnalités linguistiques. L'écosystème et les outils sont excellents et se développent rapidement.

Fait amusant: l'équipe de Rust utilise également Discord pour se coordonner. Il y a même un très utileServeur communautaire de Rust , où nous discutons parfois.



Notes de bas de page


  1. Graphiques tirés de Go version 1.9.2. Nous avons essayé les versions 1.8, 1.9 et 1.10 sans aucune amélioration. La migration initiale de Go to Rust s'est terminée en mai 2019. [rendre]
  2. Pour plus de clarté, nous ne recommandons pas de tout réécrire dans Rust sans raison. [rendre]
  3. Citation du site officiel. [rendre]
  4. Bien sûr, jusqu'à ce que vous utilisiez dangereux . [rendre]

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


All Articles