Jak konteksty zamknięcia V8 przechowują pamięć poza tym, co wykorzystuje dana funkcja
V8 tworzy jeden wspólny kontekst na każde wezwanie dla każdej zmiennych zewnętrznych, o których wspomina jakakolwiek funkcja wewnętrzna — dzięki temu nieużywane elementy i stałe słuchacze mogą przechowywać duże wartości.
Źródła: Interviewer: You have 5 years of experience and still don’t know how closures work internally
Podczas rozmowy kwalifikacyjnej w Swiggy na tablicy umieszczono krótki fragment DOM. Kandydat miał lata doświadczenia w JavaScript i odpowiedział na pierwsze pytanie przy użyciu typowej historii. Następne pytanie rozbiło tę historię w taki sam sposób, w jaki to się dzieje z większością praktykujących programistów.
Oto początkowy fragment:
function update() {
const elem = document.getElementById("root");
const allH1s = document.querySelectorAll("h1");
function handleClick() {
console.dir(handleClick);
}
elem.addEventListener("click", handleClick);
}
update();
Po zakończeniu wywołania update() słuchacz kliknięć pozostaje przy #root. Czy handleClick utrzymuje dostęp do allH1s, dzięki czemu cała lista NodeList pozostaje aktywna?
Standardowa odpowiedź brzmi „tak”: zamykanie funkcji zachowuje jej środowisko leksykalne, więc wszystko, co znajduje się w zakresie dostępu update – włączając elem i allH1s – przetrwa, dopóki słuchacz jest przyłączony. Ta odpowiedź opisuje raczej język specyfikacji niż to, co faktycznie zachowuje Chrome. Przesłuchujący poprosił o otwarcie DevTools i sprawdzenie.
Każdy obiekt funkcji w Chrome udostępnia wewnętrzną listę [[Scopes]] zawierającą zakresy, które zamykanie funkcji nadal utrzymuje. Można to zobaczyć za pomocą console.dir. Funkcja obsługująca kliknięcia zapisuje informacje o sobie przy każdym kliknięciu, więc można:
- Otworzyć stronę zawierającą
<h1 id="root">(lub taką stworzyć), wkleić dostarczony fragment kodu i go uruchomić.
console.dir(handleClick) wyświetlił tę funkcję.[[Scopes]], a następnie Closure (update).To, co się pojawi, to ani allH1s, ani elem. Jedynym zewnętrznym powiązaniem, które pozostało, jest sama funkcja handleClick, i to tylko dlatego, że ciało funkcji odwołuje się do tego imienia w console.dir(handleClick). Zmień ciało funkcji na console.log("clicked"), a pozycja Closure (update) zniknie; [[Scopes]] pokaże tylko Global.
Zatem lista węzłów nie jest przechowywana przez obsługę zdarzenia. Gdy update() zakończy się, nic już jej nie odwołuje, więc kolektor może ją przywrócić. Sam widok inspektora nie jest dowodem na zachowanie pamięci — potwierdza to późniejsze wymuszone sprzątanie pamięci — ale już teraz obala twierdzenie, że „cały zakres jest przechwycony”.
Kandydat przeszedł następnie do innego powszechnego modelu: zamykania przechowują tylko te zmienne, które są w nich wymienione, a handleClick nigdy nie wymienia allH1s. Pracodawca poprosił o dodanie jednej linijki bez edytowania handleClick.
Jedna linijka, która nigdy się nie uruchamia
function update() {
const elem = document.getElementById("root");
const allH1s = document.querySelectorAll("h1");
const printH1 = () => allH1s; // never called
function handleClick() {
console.dir(handleClick); // unchanged
}
elem.addEventListener("click", handleClick);
}
update();
printH1 to nieużywana funkcja strzałkowa, która odczytuje wartość allH1s. Nic jej nie wywołuje, nie zwraca ani nie przechowuje. Wykonaj te same kroki inspekcji po jej dodaniu.
handleClick pozostała niezmieniona, litera po literze, jednak teraz pokazuje NodeList, którego nigdy nie odczytuje. Sama funkcja strzałkowa nie występuje w [[Scopes]] — nic nie przechwyciło printH1, więc to powiązanie zniknęło wraz z wywołaniem — ale zmienna, o której mowa, przetrwała w ramach wspólnego kontekstu.
Metoda „Cały zakres” nie odpowiada pierwszemu przypadkowi, a „Tylko to, co używa ta funkcja” — drugiemu. Zasada V8 dotyczy wywołania, a nie poszczególnych funkcji osobno.
Zasada, której faktycznie przestrzega V8
Gdy V8 kompiluje update, przeszukuje każdą funkcję wewnętrzną i łączy zmienne zewnętrzne, do których odnoszą się te funkcje. Ta łączna struktura staje się jednym obiektem kontekstu alokowanym na pamięci heap, który jest tworzony raz na każde wezwanie. Zmienne, o których nie wspomina żadna funkcja wewnętrzna, pozostają na stosie i znikają po zakończeniu wykonywania update. Każda zamykana funkcja powstała podczas tego wezwania wskazuje na ten sam kontekst. Nie istnieje żaden prywatny zbiór „moich zmiennych” dla każdej funkcji.
Zastosuj regułę:
- Przypadek 1: wśród funkcji wewnętrznych istnieje tylko
handleClick; on odnosi się do samego siebie. Kontekst ={ handleClick }.elemiallH1spozostają lokalne dla stosu i znikają.
printH1 i handleClick. Zbiór łączny obejmuje allH1s oraz handleClick. Kontekst to { allH1s, handleClick }. Nic nie odwołuje się do printH1, więc to powiązanie zostaje zniszczone – jednak slot kontekstowy, który został przez nie wymuszony, pozostaje zajęty tym samym obiektem, który handleClick utrzymuje przy życiu.Dwie konsekwencje tej zasady powodują rzeczywiste wycieki.
Decyzja zapada poprzez przeczytanie kodu, zanim cokolwiek zostanie uruchomione
Kształt kontekstu jest ustalony już podczas analizy kodu. Silnik nie czeka, by sprawdzić, czy printH1 kiedykolwiek zostanie utworzony. Nawet jeśli umieści się go w niemożliwej gałęzi, allH1s nadal zostanie uwzględniony:
if (false) {
const printH1 = () => allH1s; // never even instantiated
}
// [[Scopes]] of handleClick: Closure (update) { allH1s: NodeList(3), handleClick }
Uruchomienie tej wersji w Chrome 153 za pomocą protokołu DevTools oraz wymuszenie czyszczenia pamięci później nadal sprawia, że NodeList pozostaje aktywny. Przypisanie printH1 = null po jego utworzeniu nic nie zmienia: sam helper nigdy nie blokował pamięci – to wspólny kontekst był takim blokerem.
Zachowanie polega na najszybszym zakończeniu życia najdłużej istniejącego closure
Dlatego pytanie rozmówcy dotyczyło pamięci. Prześledź elementy, które blokują pamięć:
[ <h1 id="root"> ] (still in the document)
↓ event listener list holds
[ handleClick ] (the function object)
↓ [[Context]] points to the ONE context for this call
[ { allH1s, handleClick } ] (the shared context)
↓ allH1s slot holds
[ NodeList(3) [h1#root, h1, h1] ]
meanwhile
[ printH1 ] <- gone, it was on the stack
printH1 już nie istnieje. Promocja, którą spowodowało – allH1s znajdujące się w tym kontekście – przetrwa je. handleClick fiksuje kontekst; lista słuchaczy elementu fiksuje handleClick; dokument fiksuje element. Dopóki węzeł nie zostanie usunięty lub słuchacz nie zostanie odłączony, NodeList oraz węzły h1, do których odnoszą się, pozostają dostępne.
Dowód, że to pamięć, a nie artefakt inspektora
[[Scopes]] pokazuje odniesienia. Tylko zbieranie śmieci pokazuje, czy dane są przechowywane. Chrome może wymusić zbieranie śmieci; WeakRef obserwuje obiekt bez przechowywania go. Wklej:
let ref;
function update() {
const elem = document.getElementById("root");
const allH1s = document.querySelectorAll("h1");
ref = new WeakRef(allH1s); // observe, don't retain
const printH1 = () => allH1s; // the one line
function handleClick() {
console.dir(handleClick);
}
elem.addEventListener("click", handleClick);
}
update();
Otwórz panel Pamięci, kliknij kilka razy „Zbieraj śmieci”, a następnie sprawdź konsolę:
> ref.deref()
▶ NodeList(3) [h1#root, h1, h1]
// still here after two forced GCs
Usuń wiersz printH1, załaduj ponownie i powtórz:
> ref.deref()
undefined
// collected — nothing was holding it
Te dwa scenariusze różnią się jedną niewykorzystaną strzałką: albo NodeList zostaje zniszczony po zwróceniu się funkcji update, albo przetrwa tak długo, jak długo istnieje obsługa kliknięcia.
DevTools mogą również zatrzymać obiekty, które zapisałeś. Jeśli kliknąłeś przed wymuszeniem GC, wykonała się instrukcja console.dir(handleClick) i konsola zachowała wydrukowaną funkcję — a więc i kontekst — nawet po odłączeniu się słuchacza { once: true }. Czyste usuwanie treści konsoli (lub unikanie zapisywania podejrzanych obiektów) przed zaufaniem do zrzutu pamięci jest istotne podczas poszukiwania rzeczywistych wycieków pamięci.
Gdzie to ma wpływ w rzeczywistym kodzie
Trzy nagłówki to żart. Zastąp allH1s dużym obciążeniem danych, przetworzoną książką kalkulacyjną lub tablicą z milionem wierszy. Zastąp printH1 narzędziem do debugowania, nieużywanym formatownikiem lub funkcją zwrotną „do późniejszego użycia”. Dołącz słuchacz o długim czasie życia – resize dla window, obsługę wiadomości przez sokiet lub funkcję setInterval.
function setup() {
const rows = new Array(1e6).fill(0).map((_, i) => ({ i })); // the big thing
const debug = () => rows.length; // mentions it, never called
function onResize() {
console.dir(onResize); // never touches rows
}
window.addEventListener("resize", onResize);
}
setup();
Po zakończeniu wykonywania setup() i przeprowadzeniu czyszczenia pamięci, [[Scopes]] z funkcji onResize nadal pokazuje tablicę zawierającą milion elementów, a WeakRef odnoszący się do niej nadal działa poprawnie. Obsługa zmiany rozmiaru zablokowuje pamięć, której nigdy nie nazwał. Analizy pomijają ten wzorzec, ponieważ funkcja wydaje się nieszkodliwa, a element powodujący promocję mógł zostać dodany znacznie później.
Rozwiązanie
Każda z poniższych opcji została sprawdzona w ten sam sposób: [[Scopes]], wymuszone czyszczenie pamięci, a następnie WeakRef.deref().
1. Unikaj funkcji nawiasowych, które nadają nazwę dużej wartości zewnętrznej
Zachwyty:
const printH1 = () => allH1s;
Nie zawiera:
const printH1 = (list) => list;
Przekazanie wartości jako parametru unika zewnętrznego odniesienia, więc nie pojawia się żadna pola kontekstu. Usunięcie niewykorzystywanych pomocników jest jeszcze lepsze.
2. Odłącz słuchacz, gdy skończysz
Kontekst przetrwa tylko tak długo, jak długo istnieje handleClick. Przerwij to połączenie:
// any one of these frees the NodeList on the next GC
elem.removeEventListener("click", handleClick);
elem.addEventListener("click", handleClick, { once: true });
elem.addEventListener("click", handleClick, { signal: controller.signal });
controller.abort();
Usunięcie #root z dokumentu również go uwalnia. Ten typ wycieku preferuje trwałe cele, takie jak window, document, gniazda sieciowe oraz długo istniejące elementy korzeniowe, ponieważ nic regularnie nie usuwa słuchacza.
3. Gdy element współbrzmiący musi zachować dostęp, ustaw później zewnętrzne powiązanie na null
let allH1s = document.querySelectorAll("h1");
const printH1 = () => allH1s;
// ...use it...
allH1s = null; // the context slot still exists, but now holds null
Po ustawieniu wartości na null, [[Scopes]] nadal wyświetla allH1s, ale wartość ta wynosi null i można pobrać NodeList. Jest to mniej elegancka opcja, jednak przydatna, gdy restrukturyzacja elementów współbrzmiących nie jest możliwa w danym momencie.
Czy to specyfikacja, czy V8?
V8. ECMAScript stanowi, że zamknięcie przechowuje swój zapis środowiska i nie mówi nic o strategii GC. Silnik zgodny ze specyfikacją może przechowywać wszystkie związki z danego zakresu przez cały czas trwania zamknięcia; inny może być bardziej rygorystyczny niż V8. „Tylko zmienne, o których wspomina dana funkcja wewnętrzna” to optymalizacja V8. Dlatego „cały zakres” odpowiada sformułowaniu w specyfikacji, a nie temu, co w praktyce przechowują Chrome, Edge, Node czy Deno.
Nie polegaj na żadnym z tych zachowań. Przechowuj duże wartości w wąskich zakresach, pisz pomocniki, które przyjmują argumenty zamiast obejmować zewnętrzne zmienne, oraz zapewnij każdemu długo żyjącemu słuchaczowi udokumentowany punkt odłączenia.
Co sprawdzał rozmówca
To nie była definicja z słownika dotycząca zamknięć. Ważne było rozpoznanie, że mechanizm przechwytywania zmiennych odnosi się do konkretnej wywołania, a nie do każdej funkcji wewnętrznej: elementy powstałe podczas jednego wywołania dzielą jeden kontekst, którego struktura jest ustalona podczas analizy, zmiennych przechowywanych jest tyle, ile najdłużej istniejących elementów tego kontekstu, a doświadczony inżynier używa narzędzi deweloperskich zamiast cytować diagramy.
Główne wnioski
- Zamknięcia nie przechowują całych kontekstów ani tylko tych nazw, do których mają dostęp. Dla każdego wywołania dzielą jeden kontekst zawierający wszystkie zmiennye zewnętrzne, o których wspomina jakakolwiek funkcja wewnętrzna.
- Ten zestaw zmiennych jest wybierany podczas analizy kodu. Funkcje wewnętrzne, które nie są używane, oraz pomocniki o wartości null nadal wpływają na dostępność nazw, o których mówią.
- Kontekst trwa tak długo, jak najdłużej istniejące zamknięcie w nim zawarte. Trwały słuchacz sprawia, że cały kontekst staje się trwały.
console.dir(fn) → [[Scopes]], a utrzymywanie obiektów za pomocą WeakRef w połączeniu z opcją Memory → Collect garbage. Najpierw wytrzyj konsolę, aby DevTools nie pełnił funkcji retainera.Ciągłe rozmowy rekrutacyjne nagradzają osoby, które potrafią powiązać zachowanie silnika z identyfikacją wycieków pamięci. Umiejętność opisywania zapisów środowiska jest mniej przydatna niż znajomość tego, który panel otworzyć, który ikona uruchamia zbieranie śmieci, oraz to, jak sam logging w konsoli może utrzymać podejrzany grafak przy życiu. Traktuj [[Scopes]] jako mapę odniesień, a WeakRef w połączeniu z przymusowym GC jako dowód na utrzymywanie obiektów, a następnie przeprojektuj rozwiązanie tak, aby odpowiedź nie zależała już od szczegółów optymalizatora.
Literatura pokrewna
- Gdzie napisana jest funkcja określa, co ona widzi: leksykalny zakres w JavaScript — Zrozum, jak JavaScript rozwiązuje problem nazw zmiennych poprzez środowiska leksykalne, dlaczego miejsce wywołania nigdy nie ma znaczenia przy wyszukiwaniu oraz jak to wpływa na obsługiwanie zdarzeń w React.
- Poza rozmiarem pakietu: znalezienie tego, co naprawdę spowalnia twoją aplikację webową — Dlaczego redukcja liczby kilobajtów rzadko naprawia wolną aplikację oraz jak śledzić rzeczywisty czas oczekiwania na serwerach, w procesach typu waterfall, podczas hydratacji, w skryptach i obrazach od dostawców zewnętrznych.