Polowanie na wycieki pamięci w JavaScript: dostępność, mechanizmy utrzymywania i oczyszczanie
Dowiedz się, dlaczego JavaScript z mechanizmem zbierania śmieci nadal powoduje wycieki pamięci, jakie codzienne wzorce utrzymują dane w pamięci oraz jak znaleźć przyczynę za pomocą zrzutów pamięci i łańcuchów utrzymywania.
Aplikacja internetowa, która działa błyskawicznie o 9 rano, ale staje się powolna o 17:00, to jeden z najczęstszych objawów wycieku pamięci: kliknięcia reagują z opóźnieniem, przewijanie traci gładkość, animacje są przerywane, zużycie pamięci RAM przekracza gigabajt, a ponowne załadowanie strony przywraca wszystko do normy. Takie wycieki nie wywołują żadnych wyjątków, nie psują testów i przechodzą bez problemów proces CI, ponieważ ujawniają się dopiero wtedy, gdy ktoś trzyma aplikację otwartą przez godziny. Ten przewodnik wyjaśnia, dlaczego języki z mechanizmem zbierania śmieci nadal powodują wycieki, omawia wzorce prowadzące do większości przypadków wycieków w praktyce i przedstawia powtarzalny proces w narzędziach Chrome DevTools do znalezienia dokładnego obiektu, który utrzymuje pamięć w użyciu.
Zbieranie śmieci uwalnia to, co jest niedostępne, a nie to, co nie jest używane
Ponieważ JavaScript nigdy nie prosi o wywołanie funkcji malloc() lub free(), łatwo jest uwierzyć, że system czasu wykonywania w pełni zarządza pamięcią. To przekonanie jest tylko częściowo prawdziwe. Zbieracz nie zwraca obiektu, gdy twój kod już go nie używa; zwraca go wtedy, gdy nic już nie może do niego dotrzeć. Jeśli jakaś zapomniana referencja nadal wskazuje na obiekt, silnik nie ma sposobu, by stwierdzić, że ten obiekt jest bezużyteczny. Z punktu widzenia systemu czasu wykonywania, cokolwiek dostępne, może być nadal potrzebne.
To rozdzielenie między „już nie używane” a „już niedostępne” jest źródłem niektórych z najtrudniejszych błędów wydajnościowych we współczesnym rozwoju stron internetowych.
Szczególny przypadek: panel sterowania, który zwalniał każde popołudnie
Załóżmy panel sterowania operacjami, który pracownicy trzymają otwarty przez całą zmianę. W środowisku testowym wszystko działa szybko: błyskawiczne załadowanie, efektywne wywołania API oraz wysokie wyniki w testach Lighthouse. Następnie przychodzą informacje z produkcji. Po pięciu lub sześciu godzinach otwieranie kart zwalnia, wykresy rysują się powoli, a nawet prosty moduł wymaga sporego czasu na otwarcie.
Typową pierwszą reakcją jest obwinianie backendu. Zespół optymalizuje zapytania do bazy danych, upewnia się, że odpowiedzi API trwają mniej niż 100 milisekund, i stwierdza niskie zużycie CPU na serwerach. Nic z tego nie tłumaczy spowolnienia, które narasta w ciągu dnia.
Rozwiązanie pochodzi z Menedżera Zadań Chrome. Karta rozpoczęła dzień przy mniej więcej 150 MB pamięci i pod koniec popołudnia osiągnęła prawie 1,4 GB. Każda nawigacja, każdy dialog, każde odświeżenie widgetu pozostawiało trochę pamięci. Każde przydzielanie pamięci było niewielkie; tysiące z nich nie. Renderowanie, opóźnienia sieciowe oraz szybkość wykonywania były w porządku. Aplikacja po prostu nigdy nie uwalniała obiektów, których już nie potrzebowała.
Jak silniki decydują, co pozostaje w pamięci
Stworzenie obiektu w JavaScript nie wymaga żadnych specjalnych procedur:
const user = {
id: 101,
name: "Emma"
};
Gdy nic już nie odwołuje się do user, silnik może go zwrócić. Interesujące jest to, jak dokonuje takiej decyzji. V8 (Chrome i Node.js), SpiderMonkey (Firefox) oraz JavaScriptCore (Safari) śledzą graf obiektów połączonych za pomocą odwołań. Korzenie, takie jak obiekt globalny, znajdują się na szczycie, a od nich zależą wszystkie elementy tworzone przez aplikację: instancja aplikacji, router, magazyn danych, drzewa komponentów oraz wszelkie zmienne globalne.
Cykl zbierania rozpoczyna się od tych korzeni i podąża za każdym możliwym odwołaniem. To, do czego dotrze, przetrwa; to, do czego nie może dotrzeć, staje się kandydatem do usunięcia. Kluczowym słowem jest kandydat, a kluczową właściwością – niedostępny. Obiekt nie jest zbierany dlatego, że jest stary, nieużywany lub zapomniany. Jest zbierany wyłącznie dlatego, że żaden szlak odwołań nie prowadzi do niego.
Załóżmy, że załadujesz dużą listę rekordów:
const employees = fetchEmployees();
Jakikolwiek czas później interfejs użytkownika już nie pokazuje tych danych, ale inny obiekt nadal na nie wskazuje:
cache.employees = employees;
Nawet jeśli nikt więcej nie odczyta cache.employees, zbieracz nie może tego założyć. Odnośnik istnieje, więc cała tablica pozostaje w pamięci. Silnik działa dokładnie tak, jak został zaprojektowany; wyciek pochodzi z kodu aplikacji.
Myslenie w kategoriach odnośników zamiast obiektów
Wycieki stają się znacznie łatwiejsze do zrozumienia, gdy przestaniesz myśleć o obiektach, a zaczniesz myśleć o odnośnikach, które na nie wskazują. Weź funkcję, która tworzy obiekt i go zwraca:
function createUser() {
const user = {
name: "Alice"
};
return user;
}
const employee = createUser();
Po jej uruchomieniu pojedynczy odnośnik łączy zmienną z obiektem:
employee
│
▼
{ name: "Alice" }
Jeśli następnie usuniesz ten odnośnik, obiekt nie ma żadnych przychodzących powiązań i następna kolekcja może go usunąć:
employee = null;
Jedna korekta, jeśli spróbujesz tego sam: w poprzednim fragmencie zmienna employee została deklarowana za pomocą const, więc jej przypisanie ponownie wywołuje błąd TypeError. Użyj let, jeśli zamierzasz później usunąć odniesienie do tej zmiennej. Zasada pozostaje ta sama: to usunięcie ostatniego odniesienia sprawia, że obiekt może zostać usunięty.
Teraz nieznacznie zmień przykład, aby funkcja przechowywała utworzone obiekty w tablicy na poziomie modułu:
const users = [];
function createUser() {
const user = {
name: "Alice"
}; users.push(user);
}
Gdy createUser() zakończy działanie, każdy obiekt użytkownika jest nadal odnoszony przez tablicę users, a sama tablica jest dostępna z najwyższego poziomu:
Window
│
▼
users
│
├── User 1
├── User 2
├── User 3
└── User 4
Dopóki users jest dostępny, dostępne są również wszystkie elementy w nim zawarte. Dlatego wycieki pamięci zazwyczaj rosną stopniowo: żaden pojedynczy obiekt nie jest duży, ale tysiące małych obiektów gromadzi się w ciągu godzin lub dni.
Wycieki pamięci kumulują się po jednej interakcji
Wyrażenie „wyciek pamięci” sprawia wrażenie ogromnego obiektu pochłaniającego setki megabajtów. W praktyce ten problem prawie zawsze ma charakter mały i powtarzalny.
Załóżmy, że zamknięcie okna ustawień pozostawia za sobą około 20 KB. Sama w sobie jest to ilość zaniedbywalna. Użytkownik zaawansowany, który otwiera i zamyka to okno 500 razy w ciągu dnia pracy, już w ten sposób stracił około 10 MB. Dodajmy pięć komponentów z podobnie małymi stratami na interakcję, osiemgodzinne sesje pracy, kilka kart otwartych jednocześnie oraz aktualizacje przychodzące co kilka sekund – wtedy te zaniedbywalne ilości zamieniają się w setki megabajtów.
Ta arytmetyka wyjaśnia również, dlaczego deweloperzy rzadko to zauważają. Podczas rozwoju strony ładuje się je na nowo co kilka minut, co całkowicie usuwa te straty. Użytkownicy natomiast nie ładują strony na nowo; kontynuują pracę.
Dlaczego aplikacje jednostronicowe odczuwają to najbardziej
Klasyczne strony wielostronicowe miały niezamierzony mechanizm bezpieczeństwa: każda nawigacja ładowała nowy dokument i usuwała całą pamięć JavaScript, w tym wszystkie wyciekłe obiekty.
Aplikacje jednostronicowe stworzone za pomocą React, Angular, Vue, Svelte lub podobnych frameworków mogą działać godzinami bez konieczności pełnego ładowania. Jest to doskonałe dla doświadczenia użytkownika i właśnie dlatego ważna jest dyscyplina w zarządzaniu pamięcią. Każda zmiana trasy, modala, komunikatu typu toast, wiadomości przez WebSocket oraz odświeżenie wykresu tworzy nowe obiekty. Jeśli nie zostaną one prawidłowo zwolnione, pozostają w pamięci tak długo, jak długo trwa strona.
Istnieje tu ironia: im lepsze doświadczenie, tym dłużej ludzie pozostają na stronie, a tym większe szanse ma małe wycieki pamięci na się kumulować.
Kolektor jest zaawansowany, a nie jasnowidzem
Współczesne silniki wykorzystują kolekcjonowanie stopniowe i generacyjne, jednoczesne oznaczanie obiektów, kompresję oraz zbieranie danych w czasie bezczynności. Te techniki sprawiają, że zarządzanie pamięcią jest szybkie i niewidoczne dla użytkownika, ale nie mogą naprawić błędów logicznych.
Zastanów się nad pożyczeniem komuś książki i nigdy nie proszeniem o jej zwrot. Nie mogą wiedzieć, że o niej zapomniałeś, więc z ich punktu widzenia nadal oczekujesz jej zwrotu kiedyś. Odwołania działają w ten sam sposób. Dopóki twoja aplikacja posiada odwołanie do obiektu, silnik zakłada, że ten ma znaczenie, niezależnie od tego, czy twój kod kiedykolwiek ponownie do niego się odezwie, czy nie. Nie potrafi odczytać intencji; śledzi jedynie połączenia w grafie.
To zmiany wpływają na sposób debugowania. Zamiast zastanawiać się, dlaczego mechanizm zwalniania pamięci nie działa, zadajesz bardziej produktywne pytanie: co nadal utrzymuje odwołanie do tego obiektu? To niemal zawsze jest miejsce, gdzie znajduje się błąd.
Wzorce stojące za większością wycieków pamięci w praktyce
Kolektor nie jest uszkodzony; wycieki powstają dlatego, że kod nadal przechowuje referencje, które powinien był usunąć. Te referencje rzadko wyglądają podejrzanie – pochodzą z zwykłego, rozsądnego kodu, a nie z egzotycznych algorytmów czy błędów przeglądarki. Poniższe wzorce obejmują wycieki, z którymi najczęściej spotykamy się w środowisku produkcyjnym.
Słuchacze zdarzeń, które nigdy nie są usuwane
Słuchacze należą do najczęstszych przyczyn wycieków, szczególnie w aplikacjach typu SPA. Ich konfiguracja jest zazwyczaj niewinna: bierze się element i dołącza do niego obsługę zdarzeń.
const button = document.getElementById("save");
button.addEventListener("click", saveDocument);
Później użytkownik przechodzi do innej strony, a przycisk znika z ekranu. Usunięcie elementu z DOM-u samo w sobie nie eliminuje wszystkich odniesień do JavaScriptu. Jeśli obsługa zdarzeń jest nadal zarejestrowana, a coś innego utrzymuje dostęp do elementu lub obsługi zdarzeń, zarówno słuchacz, jak i to, co jest przez niego referencjonowane, pozostają w pamięci. Ucieczka pamięci jest najpoważniejsza w przypadku słuchaczy przypisanych do elementów długowiecznych, takich jak window lub document, ponieważ te elementy nigdy nie znikają. Rozwiązaniem jest wyraźne odwołanie się od rejestracji obsługi zdarzeń:
button.removeEventListener("click", saveDocument);
W React, Angular lub Vue należy to zrobić w fazie unmount lub destroy cyklu życia komponentu. Przydatnym nawykiem jest traktowanie każdej wywołania addEventListener() jako zobowiązania: gdy je dodajesz, musisz wiedzieć, kiedy i gdzie zostanie ono usunięte. Przekazanie sygnału AbortController do kilku słuchaczy i jego anulowanie podczas teardown to wygodny sposób na realizację tego zobowiązania masowo.
Czasowniki, które przetrwają swój ekran
Czasowniki wyciekają równie cicho. Panel sterowania, który sprawdza aktualne dane co pięć sekund, może wyglądać w ten sposób:
const timer = setInterval(() => {
loadLatestData();
}, 5000);
Jeśli użytkownik opuści stronę, a interwał nie zostanie usunięty, funkcja powrotna będzie nadal wykonywana w tle. Zachowuje ona swoją zamykaną funkcję, a wraz z nią wszystkie funkcje, zmienne, a nawet całe instancje komponentów, do których ta zamykana funkcja odwołuje się. Kilka zapomnianych interwałów może zajmować znacznie więcej pamięci, niż się spodziewa się. Usuń je, gdy ich właściciel odejdzie:
clearInterval(timer);
Ta sama zasada obowiązuje w przypadku setTimeout() (za pomocą clearTimeout()) oraz requestAnimationFrame() (za pomocą cancelAnimationFrame()).
Węzły DOM oddzielone od dokumentu
Węzeł oddzielony od dokumentu to element, który nie jest już częścią dokumentu, ale nadal jest referowany z JavaScript. Typowym sposobem na stworzenie takiego węzła jest pobranie modalu i jego usunięcie:
const modal = document.getElementById("modal");
modal.remove();
Wydaje się, że element zniknął, ale jeśli jakaś zmienna, tablica, zamknięcie lub obiekt stanu nadal wskazuje na ten element, nie można go usunąć, podobnie jak jego potomków. Aplikacje, które dynamicznie tworzą okna modalne, podpowiedzi, spadające listy lub panele powiadomień, są szczególnie podatne na ten problem. Każdy węzeł jest niewielki, ale po setkach interakcji oddzielone poddrzewa mogą zajmować zaskakująco dużo pamięci.
Zamknięcia, które przechowują więcej danych, niż jest to konieczne
Zamknięcia są jedną z najpotężniejszych cech JavaScriptu, ale jednocześnie ułatwiają przypadkowe zajmowanie pamięci. Rozważmy fabrykę, która przydziela dużą tablicę przed zwróceniem funkcji:
function createLogger() {
const largeData = new Array(100000).fill("data");
return function () {
console.log("Logging...");
};
}
Funkcja zwracana w ogóle nie dotyka zmiennej largeData. To, czy ten tabliczka pozostanie aktywna, zależy od sposobu, w jaki silnik reprezentuje otaczający zakres. W praktyce V8 zachowuje tylko te zmienne, do których faktycznie odwołują się jakieś zamknięcia w tym zakresie, więc ten konkretny fragment kodu zazwyczaj nie przechowuje tej tabliczki. Ryzyko pojawia się, gdy drugie zamknięcie utworzone w tym samym zakresie faktycznie korzysta z largeData: zamknięcia te dzielą się jednym obiektem kontekstowym, więc długo żyjący logger również sprawia, że duża tabliczka pozostaje aktywna. Użycie eval wewnątrz tego zakresu również zmusza silnik do przechowywania wszystkiego.
Żadne z tych faktów nie czyni zamknięć funkcji czymś złym; nowoczesny JavaScript od nich zależy. Ważna lekcja polega na świadomym decydowaniu o tym, co funkcja długowieczna może widzieć. Jeśli potrzebuje tylko jednej wartości, należy ją przekazać lub skopiować zamiast utrzymywać dostęp do całego obiektu lub zbioru danych. Małe modyfikacje zakresu dostępu mogą znacząco zmniejszyć zużycie pamięci.
Bufory bez polityki usuwania
Buforowanie oszczędza czas poświęcony na powtarzalne zadania, ale bufor, który stale się rozrasta, to jedynie powolna utrata zasobów mimo dobrych intencji. Oto przykład minimalnego mechanizmu memoizacji:
const cache = {};
function getUser(id) {
if (!cache[id]) {
cache[id] = fetchUser(id);
} return cache[id];
}
Początkowo działa dobrze. Po sześciu miesiącach eksploatacji może zawierać setki tysięcy wpisów, których nikt już nie będzie potrzebował. Zamiast pozwalać buforowi rosnąć bez ograniczeń, warto rozważyć:
- Maksymalną wielkość
- Czasowe wygaszanie przestarzałych wpisów
- Strategię usuwania według zasady LRU (najrzadziej używane)
WeakMap, gdy cache jest klasyfikowany według obiektów, których czas trwania powinien decydować o czasie trwania wpisuNależy pamiętać, że WeakMap przyjmuje jako klucze jedynie obiekty (lub nierегистrowane symbole), więc nie zastępuje mechanizmu LRU w przypadku cache’ów klasyfikowanych według numerowych identyfikatorów, takich jak ten powyżej. Jeśli chcesz przypomnienie dotyczące zachowania słabych odwołań, zapoznaj się z naszym przeglądem Symboli, WeakMaps, Proxie i generatorów. Cache bez strategii usuwania w rzeczywistości nie jest cache’em; to trwałe przechowywanie.
Główne zmienné, które istnieją wiecznie
Wszystko, co jest powiązane ze zmienną globalną, istnieje tak długo, jak aplikacja. Jest to zarówno wygodne, jak i ryzykowne. Zbiór na poziomie modułu, taki jak ten:
let allUsers = [];
ciągle się powiększa za każdym razem, gdy dodawane są nowe dane:
allUsers.push(...newUsers);
Chyba że jakiś kod wyraźnie go skróci, tablica tylko się powiększa. W trakcie miesięcy rozwoju duże obiekty globalne często stają się miejscem gromadzenia danych. Gdy badasz problem wydajności, stan globalny to jedno z pierwszych miejsc, które warto sprawdzić.
WebSockets i inne długotrwałe połączenia
Funkcje w czasie rzeczywistym często opierają się na WebSockets, a ich otwarcie wymaga zaledwie jednej linii kodu:
const socket = new WebSocket(url);
Błędem jest niezamknięcie takiego połączenia. Otwarty socket nadal otrzymuje wiadomości, uruchamia funkcje zwrotne i przechowuje stan aplikacji nawet po tym, jak użytkownik przeszedł gdzie indziej. Zamknij połączenia, gdy funkcja, która je używa, przestaje istnieć:
socket.close();
Ta sama zasada dotyczy obiektów typu observable, strumieni, niestandardowych emiterów zdarzeń oraz wszelkich innych subskrypcji, które mogą przetrwać swojego konsumenta.
Wspólny mianownik
Te przykłady wyglądają różnie na pierwszy rzut oka, ale mają wspólną przyczynę: coś utrzymuje odniesienie do obiektu, który powinien stać się niedostępny. Zazwyczaj jest to jedna z następujących rzeczy:
- Zarejestrowany słuchacz
- Czekający interwał lub timeout
- Zakres zamknięcia
- Ciągle rosnąca pamięć cache
- Zmienna na poziomie modułu lub globalna zmienna
- Otwarty sokiet lub inna subskrypcja
Gdy zaczniesz myśleć w kategoriach odniesień, a nie obiektów, poszukiwanie wycieków staje się znacznie bardziej intuicyjne. Zamiast pytać, dlaczego pamięć ciągle rośnie, zapytaj, co nadal utrzymuje ten obiekt. To pytanie zazwyczaj prowadzi prosto do źródła wycieku.
Znalezienie wycieku przed twoimi użytkownikami
Znajomość przyczyn jest przydatna, ale w rzeczywistym kodzie najtrudniejszym pytaniem jest po prostu to, gdzie występuje wyciek. Duże aplikacje mogą mieć tysiące komponentów i setki słuchaczy, przy czym obiekty są tworzone co sekundę. Domyślanie się rzadko daje efekty. Narzędzia przeglądarki są doskonałe; kluczowe jest ich używanie w spójnej, uporządkowanej kolejności, a nie próba opanowania każdej funkcji DevTools.
Potwierdzenie faktycznego wycieku
Rozrastająca się ilość pamięci nie oznacza automatycznie wycieku. Silniki przypisują pamięć w miarę pracy aplikacji i zwalniają ją podczas procesu zbierania, więc zdrowa aplikacja wykazuje wzorzec przypominający ząb piły:
Memory
^
| /\ /\ /\
| / \ / \ / \
|______/____\__/____\___/____\____ Time
Użytkowanie pamięci rośnie podczas aktywności i spada po każdym procesie zbierania. Aplikacja z wyciekiem wygląda inaczej:
Memory
^
| /\ /\
| / \ / \
| / \ / \
|_______/______\__/______\________
| /
| /
| /
|___________/________________ Time
Niewielkie spadki pokazują, że proces zbierania odpadków jest aktywny, ale każdy takowy spadek występuje na wyższym poziomie niż poprzedni. Pamięć nigdy nie wraca do swojego wcześniejszego poziomu bazowego, co jest pierwszym prawdziwym znakiem utrzymywania obiektów w pamięci. Zanim wyciągniesz wnioski, ręcznie uruchom proces zbierania odpadków (ikonka kosza na panelach Pamięć i Wydajność), ponieważ poziom bazowy, który wydaje się wysoki tylko dlatego, że proces zbierania jeszcze nie został uruchomiony, nie oznacza wycieku pamięci.
Krok 1: otwórz panel Pamięć
Chrome oferuje kilka narzędzi do zarządzania pamięcią, ale nie musisz korzystać z nich wszystkich jednocześnie. Otwórz DevTools i przejdź do panelu Pamięć. W zależności od wersji Chrome zobaczysz różne typy analiz, takie jak zrzut stanu pamięci typu heap, instrumentację alokacji na osi czasu oraz próbkowanie alokacji; dokładne nazwy i opcje zmieniają się w zależności od wersji, więc sprawdź aktualną dokumentację DevTools, jeśli twoja wersja się różni.
Dla większości dochodzeń zdjęcie stanu pamięci jest odpowiednim punktem wyjścia, ponieważ pokazuje, co obecnie zajmuje pamięć.
Krok 2: zapisz stan wyjściowy
Zanim uruchomisz podejrzaną funkcję, zrób zdjęcie stanu początkowego aplikacji, czyli „zdjęcie” pamięci. Następnie powtarzaj interakcje, które podejrzewasz. Na przykład:
- Otwórz i zamknij okno modalne dziesięć razy
- Nawiguj w tę i z powrotem między stronami
- Zrób upload pliku
- Zastosuj filtry do dużej tabeli danych
- Ciągle przewijaj karty na panelu sterowania
Gdy skończysz, zrób drugie zdjęcie stanu. Teraz masz dwa stany do porównania.
Krok 3: porównaj zdjęcia stanu
Tutaj właśnie rozpoczyna się prawdziwe śledztwo. Jeśli czyszczenie zadziała poprawnie, tymczasowe obiekty utworzone podczas interakcji powinny zniknąć po ich zebraniu. Jeśli tak się nie stanie, określone typy obiektów będą się nadal mnożyć. Typowymi podejrzanymi są:
- Elementy DOM oddzielone od struktury
- Wielkie tablice
- Słuchacze zdarzeń
- Własne klasy aplikacji
- Komponenty frameworku, które powinny zostać zniszczone
Nie musisz rozumieć każdego elementu w pamięci. Użyj widoku porównawczego i szukaj typów obiektów, których liczba stale rośnie za każdym razem, gdy powtarzasz tę samą czynność. Spójność jest zazwyczaj najważniejszym wskazówką.
Elementy DOM oddzielone od struktury to najłatwiejsze do zauważenia dowody
Węzły oderwane to jeden z najprostszych rodzajów wycieków do rozpoznania. Otwórz i zamknij modał dwadzieścia razy; po każdym zamknięciu ten modał powinien zniknąć. Jeśli zrzut obrazu nadal zawiera dwadzieścia elementów modałowych, coś je utrzymuje. Możesz wpisać „Detached” do filtru klas zrzutu obrazu, aby szybko je wylistować.
Sam DOM rzadko jest prawdziwym problemem. Rzeczywista przyczyna zazwyczaj leży gdzie indziej:
- Obsługa nadal zarejestrowana na elemencie lub w celu o długim czasie życia
- Interwał lub timeout, którego funkcja zwrotna odnosi się do węzła
- Zamknięcie, które przechwyciło element
- Miejsce przechowywania, pole komponentu lub tablica, które zachowały wskaźnik do niego
Traktuj węzeł oderwany jako objaw: dowód na to, że jakaś inna referencja uniemożliwiła oczyszczenie.
Śledź łańcuch utrzymywania
Gdy znajdziesz obiekt, który wyraźnie nie powinien już istnieć, następnym pytaniem jest to, kto utrzymuje go przy życiu. Sekcja Retainers w zrzutce pamięci odpowiada dokładnie na to pytanie. Wybierz obiekt, a Chrome pokazuje łańcuch referencji łączący go z korzeniem GC. Koncepcyjnie może to wyglądać w ten sposób:
Window
│
Application
│
UserService
│
cachedUsers
│
User Object
Badanie staje się teraz proste. Zamiast zastanawiać się, dlaczego obiekt użytkownika nadal istnieje, możesz zobaczyć, że jest on referencjonowany przez kolekcję cachedUsers wewnątrz UserService. Zlokalizowanie obiektu utrzymywanego w pamięci jest pomocne; identyfikacja tego, co go utrzymuje to właśnie to, co naprawdę usuwa błąd.
Oglądaj liczniki w czasie rzeczywistym za pomocą Performance Monitor
Zrzutki są idealne do szczegółowej analizy, ale nie są jedynym narzędziem. Performance Monitor w Chrome pokazuje metryki w czasie rzeczywistym, które obejmują:
- Rozmiar pamięci JS
- Liczba węzłów DOM
- Liczba słuchaczy zdarzeń JS
- Dokumenty i ramki
Jeśli liczba węzłów DOM lub słuchaczy zdarzeń stale rośnie podczas powtarzania tej samej czynności, oczyszczanie nie odbywa się prawidłowo. Zaletą jest szybkość: nie trzeba czekać, aż aplikacja stanie się wolna, ponieważ podejrzane tendencje często stają się widoczne już po kilku minutach.
Izoluj jeden mały, powtarzalny scenariusz
Częstym błędem jest próba badania całej aplikacji naraz. Zamiast tego skup się na jednej interakcji:
- Pokaż jeden moduł, zamknij go i powtórz to dwadzieścia razy z rzędu
- Lub przełączaj się między tymi samymi dwoma trasami pięćdziesiąt razy
Sytuacje tak trudne są o wiele łatwiejsze do skwantyfikowania. Gdy jedno powtarzane działanie przynosi wzrost przy każdej iteracji, znacznie zawężasz zakres poszukiwań, a odpowiedni kod zazwyczaj jest łatwy do znalezienia w tym kontekście.
Celowo testuj długie sesje
Rozwijający aplikacje zwykle testują je przez dziesięć lub piętnaście minut, a potem przechodzą dalej. Prawdziwi użytkownicy paneli wewnętrznych, platform handlowych, narzędzi monitoringu lub portali wsparcia mogą mieć je otwarte przez cały dzień. Włącz sesje długotrwałe do swoich testów pamięci: pozostaw aplikację otwartą, interaktywnie z nią pracuj od czasu do czasu i obserwuj, jak zmienia się jej zużycie pamięci. Wiele wycieków staje się widocznych dopiero po setkach lub tysiącach interakcji.
Proces debugowania, który unika domysłów
Przeskakiwanie między narzędziami analizy marnuje czas. Lepsze efekty daje ustalona sekwencja działań:
- Zapewnij się, że zużycie pamięci stale rośnie wraz z kolejnymi operacjami.
To eliminuje konieczność domysłów w tym procesie. Zamiast zakładać, że winna jest jakaś konkretna komponenta, pozwala się dowodom doprowadzić do jej identyfikacji.
Zapobieganie wyciekom przed ich dotarciem do środowiska produkcyjnego
Rozpoznanie to tylko połowa historii; lepszym rozwiązaniem jest unikanie wycieków od samego początku. Większość wycieków nie wynika z nieporozumień programistów co do JavaScripta. Zdarzają się one, ponieważ nowoczesne aplikacje są długowieczne, wysoce interaktywne i ciągle alokują zasoby, a w takim środowisku łatwo zapomnieć, że wszystko, co stworzymy, w końcu musi zostać zniszczone. Zespoły, które rzadko borykają się z problemami pamięci, niekoniecznie piszą inteligentniejszy kod – mają takie nawyki, które sprawiają, że wycieków jest mało prawdopodobne.
Określ wyraźny termin końca życia każdego zasobu
Zawsze, gdy kod tworzy coś długowiecznego, zadaj sobie jedno pytanie: kiedy to zostanie zniszczone? Dotyczy to znacznie więcej niż samej pamięci:
- Słuchacze na elementach,
windowlubdocument - Interwały, timeouty i klatki animacji
- Sockety oraz inne połączenia sieciowe
Ustawienie ich zazwyczaj jest proste; problem pojawia się przy ich usuwaniu – w tym aspekcie aplikacje zawodzą. Prosta zasada to wyjaśnia: jeśli twój kod ma punkt startu, musi mieć też punkt zakończenia. Już sama ta mentalność zapobiega sporej liczbie wycieków zasobów.
Zadbaj o to, by komponenty same się kończyły
Frameworki oparte na komponentach zachęcają do tworzenia samodzielnych jednostek, a samodzielność powinna obejmować również możliwość ich rozwiązania. Komponent, który uruchamia timer, powinien go zatrzymać po swoim wyłączeniu. Komponent, który rejestruje słuchacze, powinien je usunąć. Nic z tego, co komponent uruchomił, nie powinno nadal działać po tym, jak znika z ekranu.
Pomyśl o wymeldowaniu się z pokoju hotelowego: nie wychodzisz, zostawiając zapalone światła, włączoną telewizję i płynącą wodę. Dobrze zaprojektowany komponent pozostawia wszystko takim, jakim to znalazł. W React oznacza to zwracanie funkcji czyszczenia z każdego useEffect, który dokonuje subskrypcji, planowania lub połączenia.
Projektuj cache z myślą o usunięciu, a nie tylko o dodawaniu
Cache powstają z dobrych intencji – aby uniknąć powtarzających się wywołań API lub kosztownych obliczeń. Po kilku miesiącach mogą zawierać tysiące obiektów, których nikt nie sprawdzał od tygodni. Projektując cache, myśl o tym, jak dane są usuwane, z taką samą starannością, z jaką są dodawane:
- Jak długo dane powinny pozostać w pamięci?
- Jaka jest maksymalna wielkość?
- Czy dane powinny wygasać automatycznie?
- Czy rzadko używane dane mogą zostać usunięte?
Jeśli na te pytania nie ma odpowiedzi, cache niemal na pewno będzie rosł z upływem czasu.
Zachowuj tylko te dane, które naprawdę potrzebujesz
Kolejnym częstym problemem jest przechowywanie całych obiektów, gdy wykorzystywana jest tylko ich niewielka część. Jeśli pobierasz duży profil tylko po to, by pokazać nazwę użytkownika, nie ma powodu, by przechowywać pełną odpowiedź bezterminowo; zapisz tylko te pola, których potrzebuje interfejs. Mniejsze obiekty zużywają mniej pamięci, są łatwiejsze do zrozumienia i rzadziej zostają przypadkowo zachowane. Czasami rozwiązaniem nie jest więcej kodu, lecz mniej przechowywanych danych.
Potraktuj małe wycieki poważnie
Kuszące jest zignorowanie wycieku kilku kilobajtów. Problem polega na tym, że użytkownicy rzadko robią coś tylko raz. Dashboard używany przez cały dzień, narzędzie administracyjne dostępne dla setek pracowników lub panel monitoringu, którego nikt nigdy nie odświeża, uruchamia te same ścieżki kodu tysiące razy. Wyciek, który dziś jest ledwo zauważalny, może stać się poważnym incydentem po kilku tygodniach normalnego użytkowania.
Dodawanie sprawdzeń pamięci do codziennej pracy nad kodem
Praca nad wydajnością skupia się zazwyczaj na czasie ładowania i opóźnieniach API, ale pamięć również wymaga uwagi. Podczas tworzenia nowej funkcjonalności poświęć kilka dodatkowych minut na sprawdzenie:
- Czy pamięć wraca do swojego początkowego poziomu po użyciu tej funkcjonalności?
- Czy liczba słuchaczy zdarzeń wzrasta w nieoczekiwany sposób?
- Czy węzły DOM znikają po usunięciu komponentów?
- Czy powtarzanie tej samej czynności spowoduje stopniowy wzrost zużycia pamięci?
Takie sprawdzenia są proste i mogą zaoszczędzić godziny na późniejszym debugowaniu.
Listwa kontrolna przy przeglądaniu kodu
Zanim połączysz zmiany, przejdź szybko przez krótką listę kwestii do sprawdzenia:
- Czy każdy dodany słuchacz zdarzeń został również usunięty?
- Czy timerzy są usuwane, gdy przestają być potrzebne?
- Czy subskrypcje są prawidłowo kończone?
- Czy ten cache może rosnąć bez ograniczeń?
Nie musisz stosować tego do każdej linii kodu, ale włączenie tego do procesu sprawdzania znacznie zmniejsza szanse na wypuszczenie produktu z błędem.
Główne wnioski
- Błędy rzadko powodują awarie od razu; stopniowo się kumulują i najpierw wpływają na najbardziej aktywnych użytkowników, co czyni je tak niebezpiecznymi.
- Są one również przewidywalne: obiekt pozostaje aktywny tylko dlatego, że nadal istnieje do niego jakaś referencja, więc każdy błąd ma swoje źródło w referencji, która powinna zostać usunięta.
- Gdy aplikacja zwalnia w ciągu kilku godzin, unikaj obwiniania przeglądarki lub silnika. Zrób zrzut pamięci, znajdź obiekty pozostawione w pamięci i prześledź łańcuch ich utrzymywania.
- Zwykła odpowiedź jest banalna: zapomniany odbiorca sygnału, niezakończony timer, nieskończona pamięć cache lub komponent, który nigdy nie zakończył procesu czyszczenia.
- Szybki JavaScript to nie tylko szybkość wykonywania kodu; chodzi tu o zarządzanie cyklem życia wszystkich zasobów, które przydzielamy, aby aplikacja pozostawała responsywna niezależnie od tego, czy ktoś używa jej przez pięć minut, czy cały dzień pracy.
Literatura pokrewna
- Wycieki pamięci w React Native: śledzenie stosu JS i właścicieli pamięci natywnej — Dowiedz się, dlaczego zbieranie śmieci nie może uratować aplikacji React Native przed wyciekami pamięci natywnej, oraz jak odkryć, co utrzymuje w życiu funkcje zwrotne, obiekty JSI i odszyfrowane obrazy.
- Cache map źródłowych Node to cichy wyciek pamięci w trybie deweloperskim — Dowiedz się, dlaczego włączenie opcji --enable-source-maps lub NODE_V8_COVERAGE może powodować nieograniczone rozrastanie się pamięci z powodu wielokrotnych wywołań eval, oraz jak diagnozować i łagodzić ten problem już dziś.