Treści na tej stronie zostały przetłumaczone przy użyciu sztucznej inteligencji (AI) lub technologii tłumaczenia maszynowego i mogą zawierać błędy.

Skip to content

Wykorzystanie Clang do zminimalizowania użycia zmiennych globalnych

Każdy niebanalny program ma przynajmniej pewną ilość stanu globalnego, ale jego nadmiar może być szkodliwy. W języku C++ (który stanowi prawie 100% kodu silnika Roblox) ten stan globalny jest inicjowany przed funkcją main() i niszczony po powrocie z funkcji main(), a dzieje się to w większości w nieokreślonej kolejności. Oprócz tego, że prowadzi to do mylącej semantyki uruchamiania i wyłączania, która jest trudna do zrozumienia (lub zmiany), może to również prowadzić do poważnej niestabilności.

Kod Roblox tworzy również wiele długo działających wątków odłączonych (wątków, które nigdy nie są łączone i po prostu działają, dopóki nie zdecydują się zatrzymać, co może nigdy nie nastąpić). Te dwie rzeczy razem mają bardzo poważny negatywny wpływ na wyłączanie, ponieważ długo działające wątki nadal mają dostęp do stanu globalnego, który jest niszczony. Może to prowadzić do zwiększonej liczby awarii, niestabilności zestawu testów i ogólnej niestabilności.

Pierwszym krokiem do wydostania się z takiego bałaganu jest zrozumienie zakresu problemu, więc w tym poście omówię jedną technikę, której można użyć, aby uzyskać wgląd w globalny przepływ uruchamiania. Omówię również, w jaki sposób wykorzystujemy to do poprawy stabilności całej platformy silnika gry Roblox poprzez zmniejszenie użycia zmiennych globalnych.

Przedstawiamy -finstrument-functions

Nic nie ekscytuje mnie bardziej niż poznanie nowej, mało znanej opcji kompilatora, której nigdy wcześniej nie wykorzystywałem, więc byłem bardzo zadowolony, gdy kolega wskazał mi tę opcję w Clang Command Line Reference. Nigdy wcześniej jej nie używałem, ale brzmiała bardzo fajnie. Pomysł polegał na tym, że gdybyśmy mogli sprawić, by kompilator informował nas za każdym razem, gdy wchodzi do funkcji i z niej wychodzi, moglibyśmy przefiltrować te informacje przez jakiś symbolizer i wygenerować raport funkcji, które a) występują przed main() oraz b) są pierwszą funkcją w stosie wywołań (co wskazuje, że są globalne).

Niestety, dokumentacja zasadniczo informuje tylko o istnieniu tej opcji, nie wspominając o tym, jak z niej korzystać ani czy faktycznie działa tak, jak sugeruje jej nazwa. Istnieją również dwie różne opcje, które brzmią podobnie (-finstrument-functions i -finstrument-functions-after-inlining), a ja nadal nie byłem do końca pewien, na czym polega różnica. Postanowiłem więc wrzucić szybki przykład na godbolt, żeby zobaczyć, co się stanie. Można go obejrzeć tutaj. Zwróć uwagę, że dla tego samego kodu źródłowego są dwa wyniki w asemblerze. Jeden używa pierwszej opcji, a drugi drugiej, i możemy porównać te wyniki, żeby zrozumieć różnice. Z tego przykładu możemy wyciągnąć kilka wniosków:

  1. Kompilator wstawia wywołania funkcji __cyg_profile_func_enter i __cyg_profile_func_exit wewnątrz każdej funkcji, niezależnie od tego, czy jest ona wbudowana, czy nie.
  2. Jedyna różnica między tymi dwiema opcjami występuje w miejscu wywołania funkcji wbudowanej.
  3. W przypadku opcji -finstrument-functions instrumentacja funkcji wbudowanej jest wstawiana w miejscu wywołania, natomiast w przypadku opcji -finstrument-functions-after-inlining mamy do czynienia wyłącznie z instrumentacją funkcji zewnętrznej. Oznacza to, że podczas korzystania z opcji -finstrument-functions-after-inlining nie będzie można określić, które funkcje są wbudowane i gdzie.

Oczywiście brzmi to dokładnie tak, jak opisano w dokumentacji, ale czasami trzeba po prostu zajrzeć pod maskę, aby się o tym przekonać.

Innymi słowy, jeśli chcemy uzyskać informacje o wywołaniach funkcji wbudowanych w tym śladzie, musimy użyć opcji -finstrument-functions, ponieważ w przeciwnym razie ich instrumentacja zostanie po cichu usunięta przez kompilator. Niestety, nigdy nie udało mi się uruchomić opcji -finstrument-functions na prawdziwym przykładzie. Zawsze kończyło się to błędami linkera głęboko w bibliotece Standard C++, których nie byłem w stanie rozgryźć. Moim zdaniem inlining jest często heurystyczny, co może w jakiś sposób prowadzić do subtelnych naruszeń ODR (zasady jednej definicji), gdy optymalizator podejmuje różne decyzje dotyczące inliningu w różnych jednostkach tłumaczenia. Na szczęście konstruktory globalne (które nas interesują) i tak nie mogą być w żadnym wypadku wstawiane, więc nie stanowiło to problemu.

Powinienem też wspomnieć, że nadal pojawiało się mnóstwo błędów linkera przy opcji -finstrument-functions-after-inlining, ale udało mi się je rozgryźć. O ile dobrze rozumiem, ta opcja wydaje się sugerować semantykę linkera --whole-archive. Omówienie opcji --whole-archive wykracza poza zakres tego wpisu na blogu, ale wystarczy powiedzieć, że naprawiłem to, używając grup linkera (np. -Wl,--start-group i -Wl,--end-group) w wierszu poleceń kompilatora. Byłem nieco zaskoczony, że bez tej opcji nie pojawiały się te same błędy linkera i nadal nie do końca rozumiem, dlaczego tak się stało. Jeśli wiesz, dlaczego ta opcja zmienia semantykę linkera, daj mi znać w komentarzach!

Wdrażanie haków wywołania zwrotnego

Jeśli jesteś bystry, możesz się zastanawiać, czym właściwie są __cyg_profile_func_enter i __cyg_profile_func_exit oraz dlaczego program w ogóle łączy się pomyślnie w pierwszym przypadku bez wyświetlania błędów odwołania do niezdefiniowanego symbolu, skoro kompilator najwyraźniej próbuje wywołać jakąś funkcję, której nigdy nie zdefiniowaliśmy. Na szczęście istnieją opcje, które pozwalają nam zajrzeć do algorytmu linkera, dzięki czemu możemy dowiedzieć się, skąd w ogóle bierze ten symbol. W szczególności opcja -y <symbol> powinna nam powiedzieć, w jaki sposób linker rozpoznaje <symbol>. Wypróbujemy to najpierw na programie testowym i symbolu, który sami zdefiniowaliśmy, a następnie spróbujemy z __cyg_profile_func_enter.

  zturner@ubuntu:~/src/sandbox$ cat instr.cpp<br>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<br>/tmp/instr-5b6c60.o: definition of main

Nie ma tu żadnych niespodzianek. Biblioteka uruchomieniowa C odwołuje się do main(), a nasz plik obiektowy definiuje tę funkcję. Zobaczmy teraz, co się dzieje z __cyg_profile_func_enter i -finstrument-functions-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

Widzimy, że libc dostarcza definicję, a nasz plik obiektowy odwołuje się do niej. Łączenie działa nieco inaczej na platformach typu Unix niż w systemie Windows, ale zasadniczo oznacza to, że jeśli sami zdefiniujemy tę funkcję w naszym pliku cpp, linker automatycznie preferuje ją zamiast wersji z biblioteki współdzielonej. Działający link godbolt bez wyjścia środowiska uruchomieniowego znajduje się tutaj. Więc teraz już wiesz mniej więcej, do czego to zmierza, jednak pozostało jeszcze kilka problemów do rozwiązania.

  1. Nie chcemy tego robić dla pełnego przebiegu programu. Chcemy zatrzymać się, gdy tylko dotrzemy do main.
  2. Potrzebujemy sposobu, aby symbolicznie przedstawić ten ślad.

Pierwszy problem jest łatwy do rozwiązania. Wystarczy porównać adres wywoływanej funkcji z adresem main i ustawić flagę wskazującą, że od tego momentu powinniśmy zaprzestać śledzenia. (Należy zauważyć, że pobieranie adresu main jest zachowaniem niezdefiniowanym[1], ale w naszym przypadku spełnia to swoje zadanie, a my nie udostępniamy tego kodu, więc ¯\_(ツ)_/¯). Drugi problem zasługuje jednak prawdopodobnie na nieco szersze omówienie.

Symbolizacja śladów

Aby symbolizować te ślady, potrzebujemy dwóch rzeczy. Po pierwsze, musimy zapisać ślad gdzieś w pamięci trwałej. Nie możemy oczekiwać, że symbolizacja w czasie rzeczywistym będzie przebiegać z rozsądną wydajnością. Można napisać kod w C, aby zapisać ślad pod jakąś magiczną nazwą pliku, lub można zrobić to, co ja, i po prostu zapisać go do stderr (w ten sposób można przekierować stderr do pliku podczas uruchamiania).

Po drugie, i być może ważniejsze, dla każdego adresu musimy zapisać pełną ścieżkę do modułu, do którego adres ten należy. Twój program ładuje wiele bibliotek współdzielonych i aby przetłumaczyć adres na symbol, musimy wiedzieć, do której biblioteki współdzielonej lub pliku wykonywalnego adres ten faktycznie należy. Ponadto musimy uważać, aby zapisać adres symbolu w pliku na dysku. Podczas działania programu system operacyjny mógł załadować go w dowolnym miejscu w pamięci. A jeśli zamierzamy symbolizować go po fakcie, musimy upewnić się, że nadal możemy do niego odwołać się po utracie informacji o tym, gdzie został załadowany w pamięci. Funkcja dladdr() w systemie Linux dostarcza nam obu potrzebnych informacji. Działający przykład godbolt z dokładną implementacją naszych haków instrumentacyjnych, tak jak pojawiają się one w naszym kodzie źródłowym, można znaleźć tutaj.

Łączenie wszystkiego w całość

Teraz, gdy mamy plik w tym formacie zapisany na dysku, wystarczy tylko przypisać symbole do adresów. Jedną z opcji jest addr2line, ale ja wybrałem llvm-symbolizer, bo wydaje mi się bardziej niezawodny. Napisałem skrypt w języku Python, który analizuje plik i nadaje symbole każdemu adresowi, a następnie wyświetla je w tym samym „wizualnym” formacie hierarchicznym, w jakim znajduje się oryginalny plik wyjściowy. Istnieją różne opcje filtrowania wynikowej listy symboli, dzięki czemu można oczyścić wynik, aby zawierał tylko te elementy, które są interesujące w danym przypadku. Na przykład odfiltrowałem wszystkie zmienne globalne, które mają w nazwie boost::, ponieważ nie mogę po prostu przepisać biblioteki boost tak, aby nie używała zmiennych globalnych.

Skrypt nie jest tak prosty, jak mogłoby się wydawać, ponieważ zwykłe przeszukiwanie każdej linii i symbolizacja byłyby niedopuszczalnie powolne (kiedy tego próbowałem, zajęło to ponad 2 godziny, zanim w końcu zabiłem proces). Dzieje się tak, ponieważ ten sam adres może pojawić się tysiące razy, a nie ma powodu, aby uruchamiać llvm-symbolizer wielokrotnie dla tego samego adresu. W skryptcie jest więc sporo sprytnych rozwiązań, które pozwalają wstępnie przetworzyć listę adresów i wyeliminować duplikaty. Nie będę omawiać implementacji bardziej szczegółowo, bo nie jest to zbyt interesujące. Ale zrobię coś jeszcze lepszego i udostępnię kod źródłowy!

Po tym wszystkim możemy uruchomić dowolny z naszych wewnętrznych celów, aby uzyskać drzewo wywołań, przepuścić je przez skrypt, a następnie uzyskać wynik taki jak ten (rzeczywisty wynik z procesu Roblox, informacje o pliku źródłowym zostały usunięte):

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<br>/usr/lib/x86_64-linux-gnu/libc++.so.1: 3 unique addresses<br>Printing call tree with depth 2 for 29276 global variables.
  __cxx_global_var_init.5 (InterestingFile1.cpp:418:22)
  RBX::InterestingRobloxClass2::InterestingRobloxClass2() (InterestingFile2.cpp.:415:0)
  &gt;__cxx_global_var_init.19 (InterestingFile2.cpp:183:34)
  (anonymous namespace)::InterestingRobloxClass2::InterestingRobloxClass2()<br>(InterestingFile2.cpp:171:0)
  __cxx_global_var_init.274 (InterestingFile3.cpp:2364:33)
  RBX::InterestingRobloxClass3::InterestingRobloxClass3()

I oto mamy to: pierwsza połowa bitwy za nami. Mogę uruchomić ten skrypt na każdej platformie, porównać wyniki, aby zrozumieć, w jakiej kolejności nasze zmienne globalne są faktycznie inicjowane w praktyce, a następnie powoli przenieść ten kod z globalnych inicjalizatorów do funkcji main, gdzie może być deterministyczny i jawny.

Przyszłe prace

Jakiś czas po wdrożeniu tego rozwiązania przyszło mi do głowy, że moglibyśmy stworzyć uniwersalny hak profilujący, który udostępniałby pewne symbole publiczne (dllexport’ed, jeśli mówisz językiem Windows) i pozwalał modułowi wtyczki na dynamiczne podłączenie się do tego. Ten moduł wtyczki mógłby filtrować adresy przy użyciu dowolnej logiki, która go interesuje. Jednym z interesujących przypadków użycia, które wymyśliłem, jest wyszukiwanie informacji debugowania, sprawdzanie, czy bieżący adres odpowiada konstruktorowi lokalnej zmiennej statycznej funkcji, a jeśli tak, to zapisywanie tego adresu. Pozwala to nam skutecznie uzyskać głębsze zrozumienie kolejności, w jakiej inicjowane są nasze leniwe zmienne statyczne. Możliwości są tu nieograniczone.

Więcej informacji

Jeśli interesuje Cię ten temat, zebrałem kilka moich ulubionych źródeł dotyczących tego zagadnienia.

  1. Różne: Standard języka C++
  2. Matt Godbolt: Bity między bitami: jak dochodzimy do main()
  3. Ryan O’Neill: Nauka analizy binarnej w systemie Linux
  4. Linkery i ładowarki: John R. Levine
  5. https://eel.is/c++draft/basic.exec#basic.start.main-3

Ani firma Roblox Corporation, ani niniejszy blog nie promują ani nie wspierają żadnej firmy ani usługi. Ponadto nie udziela się żadnych gwarancji ani obietnic dotyczących dokładności, wiarygodności lub kompletności informacji zawartych w niniejszym blogu.

Ten wpis na blogu został pierwotnie opublikowany na blogu technicznym Roblox.