Efektywne zarządzanie pamięcią na dużą skalę w Gmailu

John McCutchan
John McCutchan
Loreena Lee
Loreena Lee

Wprowadzenie

Chociaż JavaScript używa zbierania elementów do automatycznego zarządzania pamięcią, nie zastępuje to skutecznego zarządzania pamięcią w aplikacjach. Aplikacje JavaScript mają te same problemy z pamięcią co natywne aplikacje, np. wycieki pamięci i zaśmiecanie pamięci, ale muszą też radzić sobie z przerwami w zbieraniu pamięci podręcznej. Duże aplikacje, takie jak Gmail, mają te same problemy co mniejsze aplikacje. Czytaj dalej, aby dowiedzieć się, jak zespół Gmaila używał Narzędzi programistycznych Chrome do identyfikowania, izolowania i rozwiązywania problemów z pamięcią.

Sesja z Google I/O 2013

Materiał ten został zaprezentowany na konferencji Google I/O 2013. Obejrzyj film poniżej:

Gmail, mamy problem…

Zespół Gmaila miał poważny problem. Coraz częściej słyszeliśmy o tym, że karty Gmaila zużywają wiele gigabajtów pamięci na laptopach i komputerach stacjonarnych o ograniczonej ilości zasobów, co często kończyło się zawieszaniem całej przeglądarki. historie o procesorach przypinanych na poziomie 100%, aplikacjach, które przestają odpowiadać, i kartach Chrome, które stają się smutne („Jest martwy, Jimie”). Zespół nie wiedział, jak zacząć diagnozować problem, nie mówiąc już o jego rozwiązaniu. Nie mieli pojęcia, jak rozpowszechniony jest ten problem, a dostępne narzędzia nie nadawały się do dużych aplikacji. Współpracując z zespołami Chrome, opracowali nowe techniki diagnozowania problemów z pamięcią, ulepszali istniejące narzędzia oraz umożliwili zbieranie danych o pamięci z pola. Zanim jednak przejdziemy do narzędzi, omówimy podstawy zarządzania pamięcią w JavaScript.

Podstawy zarządzania pamięcią

Zanim zaczniesz efektywnie zarządzać pamięcią w JavaScript, musisz poznać podstawy. W tej sekcji omówimy typy proste, wykres obiektów oraz definicje ogólnego zaśmiecania pamięci i wycieku pamięci w JavaScript. Pamięć w JavaScript może być postrzegana jako graf, dlatego teoria grafów odgrywa pewną rolę w zarządzaniu pamięcią w JavaScript i w profilowaniu sterty.

Typy elementów podstawowych

JavaScript ma 3 typy prymitywne:

  1. Liczba (np. 4, 3,14159)
  2. Wartość logiczna (prawda lub fałsz)
  3. Ciąg znaków („Hello World”)

Te typy prymitywne nie mogą odwoływać się do żadnych innych wartości. W grafu obiektów te wartości są zawsze węzłami końcowymi lub węzłami końcowymi, co oznacza, że nigdy nie mają krawędzi wychodzącej.

Istnieje tylko jeden typ kontenera: obiekt. W JavaScript obiekt jest tablicą asocjacyjną. Niepusty obiekt to wewnętrzny węzeł z krawędziami wychodzącymi do innych wartości (węzłów).

Co z tablicami?

Tablica w JavaScript jest w istocie obiektem z kluczami numerycznymi. Jest to uproszczenie, ponieważ środowisko uruchomieniowe JavaScript będzie optymalizować obiekty podobne do tablic i reprezentować je jako tablice.

Terminologia

  1. Wartość – instancja typu prymitywnego, obiektu, tablicy itp.
  2. Zmienna – nazwa odwołująca się do wartości.
  3. Właściwość – nazwa w obiekcie, która odwołuje się do wartości.

Obiektowa sieć semantyczna

Wszystkie wartości w JavaScript są częścią grafu obiektów. Graf zaczyna się od korzeni, np. obiektu okna. Nie masz kontroli nad czasem trwania korzeni GC, ponieważ są one tworzone przez przeglądarkę i usuwane po usunięciu strony. Zmienne globalne to w istocie właściwości okna.

Graf obiektów

Kiedy wartość staje się śmieciem?

Wartość staje się nieużyteczna, gdy nie ma ścieżki od korzenia do tej wartości. Innymi słowy, zaczynając od źródeł i wyczerpująco przeszukując wszystkie właściwości obiektu i zmiennych, które są aktywne w ramce stosu, nie można uzyskać wartości, ponieważ stała się ona odpadem.

Wykres dotyczący generowania śmieci

Czym jest wyciek pamięci w JavaScript?

Wyciek pamięci w JavaScript najczęściej występuje, gdy istnieją węzły DOM, do których nie można dotrzeć z drzewa DOM strony, ale są one nadal dostępne dla obiektu JavaScript. Chociaż nowoczesne przeglądarki utrudniają przypadkowe wycieki danych, są one nadal łatwiejsze do wywołania, niż mogłoby się wydawać. Załóżmy, że dołączasz element do drzewa DOM w ten sposób:

email.message = document.createElement("div");
displayList.appendChild(email.message);

A później usuwasz element z listy wyświetlania:

displayList.removeAllChildren();

Dopóki element email istnieje, element DOM, do którego odwołuje się wiadomość, nie zostanie usunięty, nawet jeśli jest teraz odłączony od drzewa DOM strony.

Co to jest bloat?

Gdy strona jest zaśmiecona, zużywa więcej pamięci niż jest to konieczne do zapewnienia optymalnej szybkości. Ucieknięcia pamięci również pośrednio powodują nadmierne rozrastanie się aplikacji, ale nie jest to zamierzone. Pamięć podręczna aplikacji, która nie ma żadnych ograniczeń rozmiaru, jest częstym źródłem nadmiernego zużycia pamięci. Strona może też być za duża z powodu danych hosta, np. danych pikselowych załadowanych z obrazów.

Czym jest czyszczenie pamięci?

Czyszczenie pamięci to sposób odzyskiwania pamięci w JavaScript. O tym, kiedy to nastąpi, decyduje przeglądarka. Podczas zbioru wszystkie skrypty na stronie są zawieszone, a wartości na żywo są wykrywane przez przeszukiwanie grafu obiektów, zaczynając od źródeł zbioru. Wszystkie wartości, które nie są dostępne, są klasyfikowane jako śmieci. Pamięć dla wartości nieużytecznych jest odzyskiwana przez menedżera pamięci.

Szczegóły dotyczące zbieracza śmieci V8

Aby lepiej zrozumieć, jak działa zbieranie elementów, przyjrzyjmy się bliżej zbieraczowi elementów V8. V8 używa kolekcjonera generacyjnego. Pamięć jest podzielona na 2 pokolenia: młodsze i starsze. Przydzielanie i zbieranie danych w przypadku młodego pokolenia odbywa się szybko i często. Przydzielanie i gromadzenie danych w ramach starszej generacji jest wolniejsze i rzadziej występujące.

Generational Collector

V8 używa kolektora generującego 2 pokolenia. Wiek wartości jest zdefiniowany jako liczba bajtów przypisanych od momentu przypisania. W praktyce wiek wartości jest często przybliżony na podstawie liczby kolekcji z młodszego pokolenia, które przetrwały. Gdy wartość jest wystarczająco stara, staje się częścią starszej generacji.

W praktyce świeżo przydzielone wartości nie są długotrwałe. Badanie programów Smalltalk wykazało, że po zebraniu danych przez młodsze pokolenie zachowało się tylko 7% wartości. Z badań przeprowadzonych na różnych platformach wynika, że średnio od 90% do 70% świeżo przydzielonych wartości nigdy nie jest przenoszonych do starszej generacji.

Young Generation

Stos młodej generacji w V8 jest podzielony na 2 przestrzenie o nazwach from i to. Pamięć jest przydzielana z przestrzeni to. Przydzielanie jest bardzo szybkie, dopóki miejsce nie zostanie zapełnione. Wtedy uruchamiana jest kolekcja młodego pokolenia. Kolekcja młodszej generacji najpierw zamienia pola „From” (od) i „To” (do). Stare pole „To” (teraz pole „From”) jest skanowane, a wszystkie aktualne wartości są kopiowane do pola „To” lub przenoszone do starszej generacji. Typowa kolekcja młodego pokolenia zajmie około 10 milisekund (ms).

Zwyczajnie rozumiesz, że każde przydzielenie zasobów przez Twoją aplikację przybliża Cię do wyczerpania przestrzeni i zatrzymania GC. Uwaga deweloperzy gier: aby zapewnić czas wyświetlania klatki wynoszący 16 ms (wymagany do osiągnięcia 60 klatek na sekundę), aplikacja musi przydzielać zerowe alokacje, ponieważ pojedyncza kolekcja młodej generacji pochłania większość czasu przeznaczonego na wyświetlanie klatki.

Stos pamięci młodej generacji

Stara generacja

Kopie zapasowe starej generacji w V8 są zbierane przy użyciu algorytmu znakowania i skompresowania. Przypisania starej generacji występują, gdy wartość jest przenoszona z młodej generacji do starej. Gdy występuje kolekcja starej generacji, tworzona jest też kolekcja nowej generacji. Aplikacja zostanie wstrzymana na kilka sekund. W praktyce jest to dopuszczalne, ponieważ kolekcje starszej generacji są rzadkie.

V8 GC Summary

Automatyczne zarządzanie pamięcią z użyciem mechanizmu zbierania elementów do usunięcia jest bardzo korzystne dla wydajności programistów, ale za każdym razem, gdy przydzielasz wartość, zbliżasz się do przerwy w zbieraniu elementów do usunięcia. Pauzy w zbieraniu pamięci podręcznej mogą zepsuć odbiór aplikacji, powodując jej zacinanie. Teraz, gdy już wiesz, jak JavaScript zarządza pamięcią, możesz dokonać odpowiednich wyborów w przypadku swojej aplikacji.

Rozwiązywanie problemów z Gmailem

W ciągu ostatniego roku do DevTools Chrome dodano wiele funkcji i poprawek, dzięki którym narzędzia te stały się jeszcze bardziej wydajne. Ponadto sama przeglądarka wprowadziła kluczową zmianę w interfejsie performance.memory API, która umożliwia Gmailowi i innym aplikacjom zbieranie statystyk pamięci z pola. Dzięki tym wspaniałym narzędziom to, co kiedyś wydawało się niemożliwe, stało się ekscytującą zabawą w śledzenie za winowajcami.

Narzędzia i techniki

Zgromadzone dane i interfejs performance.memory API

Od wersji 22 przeglądarki Chrome interfejs performance.memory API jest domyślnie włączony. W przypadku aplikacji długo działających, takich jak Gmail, dane od rzeczywistych użytkowników są bezcenne. Te informacje pozwalają nam odróżnić użytkowników zaawansowanych (korzystających z Gmaila przez 8–16 godzin dziennie i otrzymujących setki wiadomości dziennie) od użytkowników przeciętnych, którzy spędzają w Gmailu kilka minut dziennie i otrzymują około tuzina wiadomości tygodniowo.

Ten interfejs API zwraca 3 elementy danych:

  1. jsHeapSizeLimit – ilość pamięci (w bajtach), do której ograniczony jest stos JavaScript.
  2. totalJSHeapSize – ilość pamięci (w bajtach) przydzielona stosowi JavaScript, w tym wolne miejsce.
  3. usedJSHeapSize – ilość pamięci (w bajtach), która jest obecnie używana.

Pamiętaj, że interfejs API zwraca wartości pamięci dla całego procesu Chrome. Chociaż nie jest to domyślny tryb, w pewnych okolicznościach Chrome może otworzyć wiele kart w tym samym procesie renderowania. Oznacza to, że wartości zwracane przez performance.memory mogą zawierać informacje o zajętej pamięci przez inne karty przeglądarki oprócz tej, na której znajduje się Twoja aplikacja.

Pomiar pamięci na dużą skalę

Gmail zmodyfikował swój kod JavaScript, aby używać interfejsu performance.memory API do zbierania informacji o pamięci około raz na 30 minut. Ponieważ wielu użytkowników Gmaila pozostawia aplikację otwartą przez wiele dni, zespół mógł śledzić wzrost wykorzystania pamięci na przestrzeni czasu, a także ogólne statystyki dotyczące wykorzystania pamięci. W ciągu kilku dni od instrumentowania Gmaila w celu zbierania informacji o pamięci od losowo wybranych użytkowników zespół zebrał wystarczającą ilość danych, aby zrozumieć, jak powszechne są problemy z pamięcią wśród przeciętnych użytkowników. Użytkownicy określili punkt odniesienia i wykorzystywali strumień danych wejściowych do śledzenia postępów w realizacji celu polegającego na zmniejszeniu wykorzystania pamięci. Ostatecznie te dane będą również wykorzystywane do wykrywania regresji pamięci.

Poza śledzeniem pomiary w warunkach rzeczywistych dają też wgląd w korelację między zapotrzebowaniem na pamięć a wydajnością aplikacji. Wbrew powszechnemu przekonaniu, że „więcej pamięci to lepsza wydajność”, zespół Gmaila odkrył, że im większy ślad pamięci, tym dłuższy czas oczekiwania na wykonanie typowych działań w Gmailu. Dzięki temu odkryciu byli bardziej zmotywowani niż kiedykolwiek do ograniczenia zużycia pamięci.

Pomiar pamięci na dużą skalę

Wykrywanie problemów z pamięcią za pomocą osi czasu w Narzędziach deweloperskich

Pierwszym krokiem w rozwiązywaniu problemu ze skutecznością jest udowodnienie, że problem istnieje, utworzenie testu, który można powtórzyć, i przeprowadzenie pomiaru bazowego. Bez programu, który można odtworzyć, nie można wiarygodnie zmierzyć problemu. Bez pomiaru podstawowego nie wiesz, o ile wzrosła skuteczność.

Panel osi czasu w Narzędziach deweloperskich jest idealnym miejscem do udowodnienia, że problem istnieje. Zawiera ona pełny przegląd tego, ile czasu zajmuje wczytywanie aplikacji internetowej i interakcji z nią. Na osi czasu są nanoszone wszystkie zdarzenia, od wczytywania zasobów po analizowanie kodu JavaScript, obliczanie stylów, przerwy w zbieraniu elementów do usunięcia i odświeżanie. W celu zbadania problemów z pamięcią panel Czas trwania ma też tryb Pamięć, który śledzi łączną przydzieloną pamięć, liczbę węzłów DOM, liczbę obiektów okna i liczbę przypisanych odbiorników zdarzeń.

Udowadnianie, że problem istnieje

Na początek zidentyfikuj sekwencję działań, które Twoim zdaniem powodują wyciek pamięci. Zacznij nagrywać oś czasu i wykonaj sekwencję działań. Aby wymusić pełne usunięcie elementów z kosza, kliknij przycisk kosza u dołu ekranu. Jeśli po kilku iteracjach zobaczysz wykres w kształcie piły, oznacza to, że przydzielasz wiele obiektów o krótkim czasie życia. Jeśli jednak sekwencja działań nie powinna powodować zatrzymania pamięci, a liczba węzłów DOM nie spada do wartości początkowej, masz podstawy, aby podejrzewać wyciek pamięci.

Wykres w kształcie piły

Gdy potwierdzisz, że problem istnieje, możesz zidentyfikować jego źródło za pomocą narzędzia Heap Profiler w Narzędziach deweloperskich.

Wykrywanie wycieków pamięci za pomocą narzędzia Heap Profiler w DevTools

Panel Profiler zawiera profilowanie procesora i profilowanie sterty. Profilowanie stosu polega na tworzeniu zrzutu pamięci z wykresem grafu obiektów. Zanim zostanie wykonany zrzut, zarówno starsze, jak i młodsze generacje są zbierane. Innymi słowy, zobaczysz tylko wartości, które były aktywne w momencie wykonania zrzutu ekranu.

W tym artykule nie da się wyczerpująco omówić wszystkich funkcji profilatora Heap, ale szczegółową dokumentację znajdziesz na stronie dla deweloperów Chrome. Skupimy się tutaj na programie profilującym alokację sterty.

Korzystanie z profilowania alokacji sterty

Profilowanie alokacji Heap łączy szczegółowe informacje o migawkach z profilowania Heap z ciągłym aktualizowaniem i śledzeniem w panelu osi czasu. Otwórz panel Profile, uruchom profil Rejestruj alokacje sterty, wykonaj sekwencję działań, a potem zatrzymaj nagrywanie na potrzeby analizy. Profilator alokacji okresowo tworzy migawki stosu (tak często jak co 50 ms!) i jedną końcową migawkę na końcu nagrywania.

Program profilujący alokacji sterty

Paski u góry wskazują, kiedy w steku znajdują się nowe obiekty. Wysokość każdego słupka odpowiada rozmiarowi niedawno przydzielonych obiektów, a kolor słupków wskazuje, czy te obiekty są nadal aktywne w końcowym zrzucie stosu: niebieskie słupki wskazują obiekty, które są nadal aktywne na końcu osi czasu, a szare słupki wskazują obiekty, które zostały przydzielone w trakcie osi czasu, ale zostały już zebrane przez mechanizm garbage collection.

W przykładzie powyżej działanie zostało wykonane 10 razy. Przykładowy program przechowuje w pamięci podręcznej 5 obiektów, więc spodziewane są 5 ostatnich niebieskich pasków. Jednak niebieski pasek z największą wartością po lewej stronie wskazuje na potencjalny problem. Następnie możesz użyć suwaków na osi czasu powyżej, aby powiększyć ten konkretny zrzut ekranu i zobaczyć obiekty, które zostały niedawno przydzielone w tym miejscu. Kliknięcie konkretnego obiektu w stosie spowoduje wyświetlenie w dolnej części zrzutu obrazu stosu drzewa utrzymującego. Przeanalizowanie ścieżki przechowywania obiektu powinno dostarczyć wystarczającej ilości informacji, aby zrozumieć, dlaczego obiekt nie został zebrany. Możesz wprowadzić niezbędne zmiany w kodzie, aby usunąć niepotrzebne odwołanie.

Rozwiązywanie problemu z pamięcią w Gmailu

Korzystając z omawianych wyżej narzędzi i technik, zespół Gmaila zidentyfikował kilka kategorii błędów: nieograniczone pamięci podręczne, nieskończenie rosnące tablice funkcji wywołujących, które czekają na coś, co nigdy się nie wydarzy, oraz odbiorców zdarzeń, którzy nieumyślnie przechowują swoje cele. Dzięki rozwiązaniu tych problemów udało się znacznie zmniejszyć ogólne wykorzystanie pamięci przez Gmaila. Użytkownicy w 99% przypadków zużywali o 80% mniej pamięci niż wcześniej, a zużycie pamięci przez użytkowników w średniej spadło o prawie 50%.

Wykorzystanie pamięci przez Gmaila

Ponieważ Gmail zużywa mniej pamięci, czas oczekiwania na pauzę w GC został skrócony, co poprawiło ogólny komfort użytkowników.

Ponadto zespół Gmaila, który zbierał statystyki dotyczące wykorzystania pamięci, odkrył regresję w Chrome związaną z odzyskiwaniem pamięci. W szczególności wykryto 2 błędy powodujące podział, gdy dane dotyczące pamięci w Gmailu zaczęły wykazywać znaczny wzrost różnicy między łączną pamięcią przydzieloną a pamięcią aktywną.

Wezwanie do działania

Zadaj sobie te pytania:

  1. Ile pamięci używa moja aplikacja? Możliwe, że używasz zbyt dużo pamięci, co wbrew powszechnemu przekonaniu ma negatywny wpływ na ogólną wydajność aplikacji. Trudno jest określić, jaka liczba jest odpowiednia, ale pamiętaj, aby sprawdzić, czy dodatkowe buforowanie używane przez Twoją stronę ma wymierny wpływ na wydajność.
  2. Czy moja strona jest wolna od wycieków? Jeśli na Twojej stronie występuje wyciek pamięci, może to wpływać nie tylko na jej wydajność, ale też na inne karty. Użyj lokalizatora obiektów, aby zlokalizować wycieki.
  3. Jak często moja strona jest indeksowana przez Google? Każdą przerwę w GC możesz sprawdzić w panelu osi czasuNarzędziach dla deweloperów w Chrome. Jeśli Twoja strona często wykonuje GC, prawdopodobnie zbyt często przydzielasz pamięć, co powoduje częste odzyskiwanie pamięci z młodej generacji.

Podsumowanie

Zaczęliśmy od kryzysu. Omówiono podstawy zarządzania pamięcią w JavaScriptzie, a w szczególności w V8. Dowiedli się, jak korzystać z tych narzędzi, w tym z nowej funkcji śledzenia obiektów dostępnej w najnowszych wersjach Chrome. Dzięki tym informacjom zespół Gmaila rozwiązał problem z wykorzystaniem pamięci i poprawił wydajność. To samo możesz zrobić z aplikacją internetową.