Skip to main content

Utiliser Clang pour minimiser l’utilisation des variables globales

septembre

29, 2020

by RandomTruffle


Technologie

Tout programme non trivial a au moins un certain degré de bon état général, mais trop peut être une mauvaise chose. En C++ (qui constitue près de 100 % du code moteur de Roblox), cet état global est initialisé avant la fonction() et détruit après retour de la fonction(), et cela se produit dans un ordre essentiellement non déterministe. En plus d’entraîner une sémantique de démarrage et d’arrêt confuse et difficile à comprendre (ou à modifier), elle peut également conduire à une grande instabilité.

Le code de Roblox crée également un grand nombre de fils détachés (des fils qui ne sont jamais joints et qui fonctionnent jusqu’à ce qu’ils décident de s’arrêter, ce qui pourrait ne jamais arriver). Ces deux choses ensemble ont une interaction négative très sérieuse sur l’arrêt, parce que les fils qui fonctionnent depuis longtemps continuent d’accéder à l’état global qui est en train d’être détruit. Cela peut conduire à des taux d’incidents élevés, à des défaillances de la série de tests et simplement à une instabilité générale.

La première étape pour se sortir d’un tel pétrin est de comprendre l’étendue du problème. Dans ce billet, je vais donc parler d’une technique que vous pouvez utiliser pour gagner en visibilité dans votre flux de démarrage global. Je vais également discuter de la manière dont nous l’utilisons pour améliorer la stabilité de l’ensemble de la plate-forme du moteur de jeu Roblox en diminuant notre utilisation des variables globales.

Introduction -finstrument-fonctions

Rien ne me motive plus que de découvrir une nouvelle option obscure de compilateur dont je n’ai jamais eu l’utilité auparavant. J’étais donc assez heureux quand un collègue m’a indiqué cette option dans la référence de la ligne de commande Clang. Je ne l’avais jamais utilisée auparavant, mais elle avait l’air très cool. L’idée étant que si nous pouvions faire en sorte que le compilateur nous dise chaque fois qu’il entre et sort d’une fonction, nous pourrions filtrer cette information par le biais d’une sorte de « symbolizer » et générer un rapport des fonctions qui a) arrive avant la fonction (), et b) soit la toute première fonction de la call-stack (indiquant qu’il s’agit d’une globale).

Malheureusement, la documentation se contente de vous dire que l’option existe sans mentionner comment l’utiliser ou même si elle fait réellement ce qu’elle semble faire. Il y a aussi deux options différentes qui sont similaires (-finstrument-functions et -finstrument-functions-after-inlining), et je n’étais pas encore tout à fait sûr de la différence. J’ai donc décidé de faire un petit test sur godbolt pour voir ce qui se produit et que vous pouvez voir ici. Notez qu’il y a deux sorties d’assemblage pour la même liste de sources. L’un utilise la première option et l’autre la seconde, et nous pouvons comparer le rendement de l’assemblage pour comprendre les différences. Nous pouvons recueillir quelques éléments à prélever sur cet échantillon :

  1. Le compilateur injecte des demandes à __cyg_profile_func_enter et __cyg_profile_func_exit à l’intérieur de chaque fonction, en ligne ou non.
  2. La seule différence entre les deux options se situe au niveau du site d’appel d’une fonction en ligne.
  3. Dans le cas des -finstrument-functions, l’instrumentation pour la fonction « inlined » est insérée sur le lieu de l’appel, tandis que dans le cas des -finstrument-functions-after-inlining, nous ne disposons que de l’instrumentation pour la fonction sortie. Cela signifie qu’en utilisant -finstrument-functions-after-inlining, vous ne serez pas en mesure de déterminer quelles fonctions sont alignées et où.

Bien sûr, cela ressemble exactement à ce qui est indiqué dans les documents, mais il suffit parfois de regarder sous le capot pour s’en convaincre.

En d’autres termes, si nous voulons connaître les appels aux fonctions en ligne dans cette trace, nous devons utiliser des -finstrument-functions, car sinon leur instrumentation est supprimée silencieusement par le compilateur. Malheureusement, je n’ai jamais réussi à faire fonctionner les -finstrument-functionspour travailler sur un exemple réel. Je me retrouvais toujours avec des erreurs de linker dans la bibliothèque C++ standard que je n’arrivais pas à comprendre. Je pense que l’inlining est souvent une approche heuristique, ce qui peut conduire à de subtiles violations de la règle ODR (one-definition rule/règle à une définition) lorsque l’optimiseur prend des décisions différentes en matière d’inlining à partir de différentes unités de traduction. Heureusement, les constructeurs mondiaux (c’est ce qui nous intéresse) ne peuvent pas être alignés de toute façon, donc ce n’était pas un problème.

Je suppose que je devrais également mentionner que j’ai encore des tonnes d’erreurs de linker avec les fonctions -finstrument-functions-after-inlining également, mais je les ai résolues. Pour autant que je sache, cette option semble impliquer une sémantique de « linker » d’archive. La discussion sur l’archive est en dehors de la portée de ce billet, mais il suffit de dire que je l’ai corrigée en utilisant des groupes de liens (par exemple –Wl,–start-group et –Wl,–end-group) sur la ligne de commande du compilateur. J’ai été un peu surpris que nous n’ayons pas eu ces mêmes erreurs de linker sans cette option et je ne comprends toujours pas totalement pourquoi. Si vous savez pourquoi cette option changerait la sémantique des linkers, veuillez me le faire savoir dans les commentaires !

Mise en œuvre des Callback Hooks

Si vous êtes astucieux, vous vous demandez peut-être ce que peut bien signifier __cyg_profile_func_enter et __cyg_profile_func_exit et pourquoi le programme réussit même à établir une liaison dans le premier sans donner d’erreurs de référence de symboles indéfinis, puisque le compilateur essaie apparemment d’appeler une fonction que nous n’avons jamais définie. Heureusement, il existe certaines options qui nous permettent de voir à l’intérieur de l’algorithme du linker afin de savoir d’où vient ce symbole pour commencer. Plus précisement, – <symbole y> devrait nous indiquer comment le linker résout <symbole>. Nous allons d’abord l’essayer avec un programme factice et un symbole que nous avons défini nous-mêmes, puis nous allons l’essayer avec __cyg_profile_func_ente .

zturner@ubuntu:~/src/sandbox$ cat instr.cpp
int main() {}

zturner@ubuntu:~/src/sandbox$ clang++-9 -fuse-ld=lld -Wl,-y -Wl,main instr.cpp
/usr/bin/../lib/gcc/x86_64-linux-gnu/crt1.o: reference to main
/tmp/instr-5b6c60.o: definition of main

Pas de surprise jusqu’ici. La bibliothèque d’exécution C fait référence à la principale (), et notre fichier objet le définit. Voyons maintenant ce qui se passe avec __cyg_profile_func_enter et -fonctions-finstrument-after-inlining.

zturner@ubuntu:~/src/sandbox$ clang++-9 -fuse-ld=lld
-finstrument-functions-after-inlining -Wl,-y -Wl,__cyg_profile_func_enter instr.cpp
/tmp/instr-8157b3.o: reference to __cyg_profile_func_enter
/lib/x86_64-linux-gnu/libc.so.6: shared definition of __cyg_profile_func_enter

Maintenant, nous voyons que la libc fournit la définition, et notre fichier objet y fait référence. L’établissement de liens fonctionne un peu différemment sur les plates-formes Unix-y que sur Windows, mais cela signifie essentiellement que si nous définissons cette fonction nous-mêmes dans notre fichier cpp, l’éditeur de liens ne fera que la préférer automatiquement à la version de la bibliothèque partagée. Un lien godbolt fonctionnel sans sortie de runtime est ici. Vous pouvez donc maintenant voir où cela mène, mais il reste encore quelques problèmes à résoudre.

  1. Nous ne voulons pas faire cela pendant toute la durée du programme. Nous voulons nous arrêter dès que nous atteindrons la principale.
  2. Nous avons besoin d’un moyen de symboliser cette trace.

Le premier problème est facile à résoudre. Il suffit de comparer l’adresse de la fonction appelée à l’adresse de la principale, et de mettre un drapeau indiquant que nous devons désormais cesser de tracer. (Notez que prendre l’adresse de la principale est un comportement indéfini [1], mais pour nos besoins, cela fait le travail, et nous n’expédions pas ce code, donc ¯\_(ツ)_/¯). Le deuxième problème mérite probablement d’être discuté un peu plus en détail.

Symboliser les traces

Pour symboliser ces traces, nous avons besoin de deux choses. Tout d’abord, nous devons stocker la trace quelque part sur un stockage permanent. Nous ne pouvons pas nous attendre à symboliser en temps réel avec une quelconque performance raisonnable. Vous pouvez écrire du code C pour enregistrer la trace vers un nom de fichier magique, ou vous pouvez faire comme moi et l’écrire simplement dans stderr (de cette façon, vous pouvez transférer stderr vers un fichier lorsque vous l’exécutez).

Ensuite, et c’est peut-être le plus important, pour chaque adresse, nous devons indiquer le chemin complet vers le module auquel l’adresse appartient. Votre programme charge de nombreuses bibliothèques partagées, et afin de traduire une adresse en un symbole, nous devons savoir à quelle bibliothèque partagée ou à quel exécutable appartient réellement l’adresse. En outre, nous devons veiller à écrire l’adresse du symbole dans le fichier sur le disque. Lorsque votre programme est en cours d’exécution, le système d’exploitation peut l’avoir chargé n’importe où dans la mémoire. Et si nous voulons le symboliser après coup, nous devons nous assurer que nous pouvons toujours y faire référence après que les informations sur l’endroit où il a été chargé en mémoire aient été perdues. La fonction linux dladdr() nous donne les deux informations dont nous avons besoin. Un échantillon de godbolt en état de marche avec la mise en œuvre exacte de nos crochets d’instrumentation tels qu’ils apparaissent dans notre base de code peut être trouvé ici.

Tout mettre ensemble

Maintenant que nous avons un fichier dans ce format enregistré sur le disque, il ne nous reste plus qu’à symboliser les adresses. addr2line est une option, mais j’ai choisi llvm-symbolizer car je le trouve plus robuste. J’ai écrit un script Python pour analyser le fichier et symboliser chaque adresse, puis l’imprimer dans le même format hiérarchique « visuel » que le fichier de sortie original. Il existe différentes options pour filtrer la liste de symboles résultante afin que vous puissiez nettoyer la sortie pour n’inclure que les éléments qui sont intéressants pour votre cas. Par exemple, j’ai filtré tous les variables globales qui ont boost: : dans leur nom, parce que je ne peux pas exactement aller réécrire boost pour ne pas utiliser de variables globales.

Le scénario n’est pas aussi simple qu’on pourrait le croire, car le simple fait d’indexer chaque ligne et de la symboliser serait d’une lenteur inacceptable (quand j’ai essayé cela, il m’a fallu plus de 2 heures avant de tuer le processus). En effet, la même adresse peut apparaître des milliers de fois, et il n’y a aucune raison de lancer plusieurs fois llvm-symbolizer pour la même adresse. Il y a donc beaucoup d’astuces pour prétraiter la liste d’adresses et éliminer les doublons. Je ne discuterai pas plus en détail de la mise en œuvre car elle n’est pas super intéressante. Mais je vais faire encore mieux et fournir la source !

Ainsi, après tout cela, nous pouvons exécuter n’importe laquelle de nos cibles internes pour obtenir la hiérarchie d’appel, l’exécuter dans le script, puis obtenir une sortie comme celle-ci (sortie réelle d’un processus Roblox, informations du fichier source supprimées) :

excluded_symbols = [‘.*boost.*’]
excluded_modules = [‘/usr.*’]
/usr/lib/x86_64-linux-gnu/libLLVM-9.so.1: 140 unique addresses
InterestingRobloxProcess: 38928 unique addresses
/usr/lib/x86_64-linux-gnu/libstdc++.so.6: 1 unique addresses
/usr/lib/x86_64-linux-gnu/libc++.so.1: 3 unique addresses
Impression de l’arbre d’appel avec la profondeur 2 pour 29276 variables globales.
__cxx_global_var_init.5 (InterestingFile1.cpp:418:22)
RBX::InterestingRobloxClass2::InterestingRobloxClass2() (InterestingFile2.cpp.:415:0)
__cxx_global_var_init.19 (InterestingFile2.cpp:183:34)
(anonymous namespace)::InterestingRobloxClass2::InterestingRobloxClass2()
(InterestingFile2.cpp:171:0)
__cxx_global_var_init.274 (InterestingFile3.cpp:2364:33)
RBX::InterestingRobloxClass3::InterestingRobloxClass3()

Et voilà : la première moitié de la bataille est terminée. Je peux exécuter ce script sur toutes les plateformes, comparer les résultats pour comprendre dans quel ordre nos variables globales sont réellement initialisées dans la pratique, puis faire migrer lentement ce code des « initialisateurs » globaux vers les principales où il peut être déterministe et explicite.

Travaux futurs

Il m’est venu à l’esprit, quelque temps après l’avoir mis en œuvre, que nous pourrions créer un profilage à usage général qui exposerait certains symboles publics (dllexport si vous parlez Windows) et permettrait à un module d’extension de s’y connecter de manière dynamique. Ce module de plugin pouvait filtrer les adresses en utilisant n’importe quelle logique arbitraire qui l’intéressait. Un cas d’utilisation intéressant qui m’est venu à l’esprit est qu’il pourrait rechercher les informations de débogage, vérifier si l’adresse actuelle correspond au constructeur d’une fonction statique locale, et écrire l’adresse si c’est le cas. Cela nous permet effectivement de mieux comprendre l’ordre dans lequel nos statiques paresseuses sont initialisées. Les possibilités sont infinies.

Lecture complémentaires

Si vous êtes intéressé par ce genre de choses, j’ai rassemblé quelques-unes de mes références préférées sur ce sujet.

  1. Divers : The C++ Language Standard
  2. Matt Godbolt : The Bits Between the Bits: How We Get to main()
  3. Ryan O’Neill : Learning Linux Binary Analysis
  4. Linkers and Loaders : John R. Levine

  1. https://eel.is/c++draft/basic.exec#basic.start.main-3

Ni Roblox Corporation ni ce blog ne cautionnent ni ne soutiennent aucune entreprise ou service. En outre, aucune garantie ou promesse n’est faite quant à l’exactitude, la fiabilité ou l’exhaustivité des informations contenues dans ce blog.

Cet article de blog a été publié à l’origine sur Roblox Tech Blog.