Odpowiedź na pytanie
Linker Go przeprowadza eliminację martwego kodu poprzez algorytm analizy osiągalności, który konstruuje graf zależności, zaczynając od punktów wejścia programu: main.main i wszystkich funkcji init pakietów. Przechodzi przez graf wywołań, zaznaczając każdą funkcję i zmienną globalną, które są statycznie referencjonowane, a następnie odrzuca niezaznaczone symbole przed zapisaniem ostatecznego pliku binarnego. Proces ten jest konserwatywny; jeśli adres funkcji jest brany i przechowywany w interfejsie, przekazywany do reflect.Value.Call lub referencjonowany za pomocą kodu asemblera albo dyrektywy //go:linkname, linker musi go zachować, ponieważ nie może udowodnić, że funkcja nie zostanie wywołana w czasie wykonywania. Dodatkowo, funkcje eksportowane z CGO i metody zarejestrowane do dekodowania opartego na refleksji (takie jak json.Unmarshal do interface{}, które dynamicznie przełącza się na konkretne typy) mogą wymusić zatrzymanie nieosiągalnych ścieżek kodu. Optymalizacja jest włączona domyślnie i działa w ramach pakietów, co oznacza, że niewykorzystany kod w zależnościach zewnętrznych może być usunięty, jeśli nie ma odniesień z osiągalnego kodu aplikacji.
Sytuacja z życia
Zespół platformy zauważył, że ich narzędzie CLI zwiększyło się do 47MB po wprowadzeniu kompleksowej biblioteki obserwowalności, która obsługiwała wiele backendów telemetrycznych (Jaeger, Zipkin, Prometheus), mimo że usługa eksportowała tylko metryki Prometheus. Problem wynikał z monolitycznej architektury biblioteki, w której importowanie pakietu inicjowało globalne rejestry dla wszystkich backendów, wciągając drogie zależności, takie jak klienci Kafka i biblioteki gPRC dla Zipkin, które nigdy nie były faktycznie używane.
Pierwszym rozważanym rozwiązaniem było ręczne utrzymywanie forków biblioteki z usuniętymi nieużywanymi backendami. Choć zapewniałoby to eliminację martwego kodu, stworzyłoby to nieakceptowalne obciążenie konserwacyjne, wymagające ręcznych łatek bezpieczeństwa i rozwiązywania konfliktów scalania z gałęzią główną.
Drugie podejście, które przetestowano, polegało na zastosowaniu kompresji UPX do pliku binarnego, co zmniejszyło rozmiar do 13MB. Jednak wprowadziło to znaczne opóźnienie przy uruchamianiu z powodu rozpakowywania w czasie wykonywania i spowodowało fałszywe alarmy w skanerach antywirusowych dla firm, co czyniło je nieodpowiednimi do wdrożenia produkcyjnego.
Trzecia opcja polegała na użyciu ldflags="-s -w" do usunięcia informacji debugowania i tabel symboli. To przyniosło jedynie redukcję o 3MB, nie rozwiązując rzeczywistego problemu powiększania kodu maszynowego, ponieważ nieużywane implementacje backendów pozostały w binarnym pliku.
Zespół zdecydował się przebudować swój kod, aby uniknąć problematycznego importu. Zdefiniowali minimalny interfejs metryczny w głównej aplikacji, a następnie przenieśli konkretne wdrożenie Prometheus do podpakietu importowanego tylko przez main. To zapewniło, że nieużywane ścieżki kodu Zipkin i Jaeger nie były referencjonowane przez żaden symbol osiągalny z main.main lub funkcji init. Przeprowadzili również audyt, aby sprawdzić, czy nie ma żadnych wyszukiwań metod reflect.Type, które mogłyby przypadkowo zatrzymać konstruktorów backendów. Ta zmiana architektoniczna umożliwiła linkerowi Go agresywne optymalizowanie drzewa.
Wynik był taki, że rozmiar zmniejszył się do 9MB bez zewnętrznej kompresji, co przyspieszyło przesyłanie artefaktów CI i zmniejszyło czasy uruchamiania kontenerów, jednocześnie zachowując możliwość aktualizacji biblioteki obserwowalności bez łatania.
Co często umyka kandydatom
Dlaczego linker zachowuje funkcje, które są tylko referencjonowane wewnątrz bloków kodu chronionych przez fałszywe warunki czasów kompilacji, takie jak if false?
Linker Go działa na poziomie zależności symboli, a nie na poziomie bloku podstawowego w funkcjach. Choć duża optymalizacja kompilatora SSA (Static Single Assignment) może eliminować martwe gałęzie jak if false, jeśli sama funkcja zawierająca gałąź jest osiągalna, każda funkcja, którą wywołuje bezpośrednio (nie przez logikę warunkową), tworzy krawędź referencyjną w pliku obiektowym. Co ważniejsze, jeśli pakiet jest importowany, jego funkcja init jest bezwarunkowo uznawana za korzeń grafu osiągalności. Dlatego każda funkcja wywoływana przez funkcję init jest zatrzymywana, niezależnie od tego, czy publiczne API pakietu jest kiedykolwiek używane przez aplikację. Programiści często zakładają, że nieużywane importy są nieszkodliwe, ale mogą istotnie zwiększać rozmiar binariów, jeśli te importy przeprowadzają kosztowną inicjalizację.
Jak wzięcie adresu funkcji z &fn wpływa na eliminację martwego kodu w porównaniu do jej bezpośredniego wywołania i dlaczego może to powodować niespodziewane zwiększenie rozmiaru binarnego w rejestrach zwrotnych?
Gdy adres funkcji jest brany i przechowywany w zmiennej globalnej lub strukturze danych w czasie inicjalizacji pakietu (np. var defaultHandler = &unusedFunction), linker musi oznaczyć unusedFunction jako osiągalną, ponieważ przypisanie tworzy statyczną referencję danych, której linker nie może odróżnić od dynamicznego użycia. W przeciwieństwie do bezpośrednich wywołań funkcji, które mogą być eliminowane, gdy sama funkcja wywołująca staje się nieosiągalna, wzięcie adresu tworzy trwałą referencję w sekcji danych binariów. To często zaskakuje programistów implementujących systemy wtyczek lub rejestry handlerów HTTP, które używają zmiennych poziomu pakietu map[string]func(), ponieważ każda funkcja dodana do mapy przetrwa eliminację martwego kodu, nawet jeśli mapa nigdy nie jest wykorzystywana.
Co odróżnia wpływ dyrektywy //go:linkname na zatrzymanie symboli w porównaniu do standardowych funkcji eksportowanych i dlaczego linkowanie do wewnętrznej funkcji standardowej biblioteki może uniemożliwić eliminację całego pakietu?
Dyrektywa //go:linkname pozwala pakietowi A na odniesienie do symbolu z pakietu B przy użyciu nazwy symbolu linkera, a nie mechanizmu eksportu języka. Gdy symbol jest celem dyrektywy //go:linkname z dowolnego pakietu w budowie, linker traktuje go jako korzeń grafu osiągalności, podobnie jak main.main. Jest tak, ponieważ dyrektywa jest często używana przez runtime i standardową bibliotekę do uzyskiwania dostępu do nieeksportowanych funkcji przez granice pakietów (np. runtime wywołujące wewnętrzne syscall). W przeciwieństwie do zwykłych funkcji eksportowanych, które są zatrzymywane tylko wtedy, gdy istnieje przejrzysta ścieżka wywołania z main lub init, cele linkname przetrwają, nawet jeśli pakiet zawierający dyrektywę nigdy nie jest importowany przez aplikację. W konsekwencji, kod użytkownika, który łączy się z wewnętrznymi symbolami standardowej biblioteki, może niechcący zmusić linker do zatrzymania dużych części pakietów runtime lub syscall, które w przeciwnym razie byłyby eliminowane.