open atlas
↑ К треку
Внутренности движка JavaScript JSE · 05 · 02

Что на самом деле удерживает замыкание

Замыкание — это функция плюс указатель на Context. Этот Context удерживает каждую context-allocated переменную — включая те, что замыкание не читает — потому что соседние замыкания делят один Context. Каноническая утечка замыкания и как её разорвать.

JSE Middle ◷ 14 min
Уровень
ОсновыJuniorMiddleSenior

Куча вашего сервера растёт на 40 МБ в час. Профайлер указывает на крошечный обработчик события — три строки, никаких аллокаций, ничего большого не захватывает. Вы перечитали его десять раз — он выглядит невинно. Утечка не в этом замыкании; она в соседней функции, созданной в той же области, и в буфере на 50 МБ, который замыкание держит в заложниках, даже не подозревая об этом. Две функции, один Context, один удержанный мир.

Замыкание — это функция плюс указатель на Context

Когда вы пишете функцию, захватывающую внешнюю переменную, вы не платите ни за какую магию — вы просто даёте функции указатель, по которому она идёт каждый раз, когда нужна эта переменная. В V8 объект-функция (JSFunction) несёт две важные здесь вещи: указатель на свой SharedFunctionInfo (код и ScopeInfo, общие для всех экземпляров этой функции) и указатель на свой Context (контекст — рантайм-объект области, в которой функция создана). Этот второй указатель и есть замыкание. «Замыкание» — не особый вид объекта; это обычный факт, что функция держит живую ссылку на свой определяющий Context.

Поскольку функция держит Context, Context достижим, пока достижима функция. А сборщик мусора освобождает только недостижимые объекты. Поэтому время жизни каждой переменной в этом Context привязано ко времени жизни замыкания — даже тех переменных, которые тело замыкания ни разу не упоминает.

Ловушка: соседи делят один Context

Вот часть, которая удивляет людей. Компилятор не создаёт отдельный Context на каждое замыкание. Все функции, определённые в одной области, захватывают один и тот же объект Context — тот, что для этой области. Если две внутренние функции живут в одной области, а у области есть context-allocated переменные a и b, то обе функции указывают на один и тот же Context, держащий и a, и b. Удержание любой из функций живой удерживает живыми a и b.

function makeHandlers() {
  const big = new Array(5_000_000).fill(0); // ~40 МБ, context-allocated
  const small = () => 42;                    // захватывает ТОТ ЖЕ Context, что и ниже
  const reportSize = () => big.length;       // вот эта реально использует 'big'
  return small;                              // возвращаем только 'small'...
}
const keep = makeHandlers();
// 'big' ВСЁ ЕЩЁ жив: 'small' делит Context, держащий 'big', потому что
// 'reportSize' (определённая в той же области) вынудила 'big' стать
// context-allocated. 'small' никогда не читает 'big' — и всё равно его держит.

Мы вернули small, функцию, вычисляющую 42. И всё же big нельзя собрать. Почему? reportSize ссылается на big, поэтому компилятор помечает big как context-allocated и кладёт его в единственный Context области. small захватывает ровно этот Context. Пока keep (возвращённый small) достижим, его Context достижим, и big внутри него достижим. 40 МБ остаются.

Как читать retainer в DevTools

Именно это показывает снимок кучи. Снимите snapshot в Chrome DevTools (или node --inspect), найдите массив big и посмотрите его путь Retainers. Вы увидите, что он удерживается объектом system / Context, который удерживается функцией-замыканием. Context — это улика: когда retainer объекта — это неожиданный для вас Context, значит, замыкание держит область.

Удержание замыканием, в числах
Context'ов на область (не на замыкание)
1
Что пришпиливает замыкание
весь Context
Переменных, которые замыкание держит, не читая
всех захвач. соседей
Цепочка retainer в DevTools
obj → Context → fn
JSFunction делит между экземплярами
SharedFunctionInfo
Цена одного забытого долгоживущего обработчика
вся куча области

Три способа разорвать удержание

Фикс всегда — разорвать путь closure → Context → big:

  1. Обнулить. После последнего реального использования присвойте big = null. Слот Context теперь держит null; 40 МБ становятся недостижимы и собираемы, хотя Context (и small) живут дальше.
  2. Не делать его context-allocated. Перенесите big в более узкую область (например, IIFE или отдельную функцию), чтобы он не попал в Context, который захватывает долгоживущее замыкание. Если ни одно выжившее замыкание не захватывает big, он умирает со своим кадром.
  3. Разделить области. Создайте долгоживущее замыкание в области, которая попросту не содержит большую переменную. Постройте small в одной функции, а big — в другой; тогда они захватят разные Context.

Все три разрыва работают на одном принципе: если ни одна живая ссылка не ведёт из Context достижимого замыкания к большому объекту, GC вправе его собрать. Без хотя бы одного из этих действий даже big = undefined где-то в стороне ничего не даёт — слот Context и есть удерживающая ссылка.

function makeHandlers() {
  const small = () => 42;            // своя область, big нигде не видно
  (function setup() {
    const big = new Array(5_000_000).fill(0);
    process(big);                    // используется здесь, ничем долгоживущим не захвачен
  })();                              // 'big' умирает, когда setup() возвращается
  return small;                      // удерживает только свой (крошечный) Context
}
Викторина

Две стрелки определены в одной области; одна читает массив на 40 МБ, другая ничего большого не читает. Вы возвращаете только маленькую, а другую отбрасываете. Что станет с массивом?

Викторина

Какое изменение надёжно позволяет GC освободить большой массив, сохранив маленькое долгоживущее замыкание?

Расставь шаги по порядку

Расставьте по порядку путь достижимости, который обходит GC и который удерживает большой массив живым через возвращённое маленькое замыкание.

  1. 1 GC-корень ссылается на возвращённое замыкание (например, переменная уровня модуля)
  2. 2 Замыкание держит указатель на свой определяющий Context
  3. 3 Этот общий Context держит слот, указывающий на большой массив
  4. 4 GC помечает большой массив достижимым и не освобождает его
Частая ошибка

Тонкая версия из реального мира: DOM-обработчик события замыкается над областью, которая также содержит ссылку на большое отсоединённое поддерево или данные предыдущего рендера. Обработчик крошечный и «очевидно нормальный», но он зарегистрирован на долгоживущем элементе, поэтому его Context — и всё context-allocated в этой области — переживает каждый рендер. Класс утечек detached-DOM очень часто оказывается утечкой общего Context в костюме DOM.

Вспомните перед уходом
  1. 01
    Что именно удерживает замыкание и почему «только то, что использует» — неверно?
  2. 02
    Разберите каноническую утечку общего Context.
  3. 03
    Каковы три надёжных фикса и что каждый делает механически?
Итог

Замыкание в V8 — ничего экзотического: это JSFunction, держащий указатель на Context той области, где он создан, рядом со своим общим SharedFunctionInfo. Поскольку сборщик мусора сохраняет достижимые объекты, замыкание держит свой Context живым, а Context держит живой каждую context-allocated переменную в этой области. Решающий и удивительный факт — компилятор аллоцирует один Context на область, общий для всех функций, определённых в ней — поэтому два соседних замыкания делят единственный Context, и крошечное возвращённое замыкание пришпиливает каждую переменную, которую любой сосед вынудил попасть в этот Context, включая буфер в несколько мегабайт, который оно никогда не читает. Это каноническая утечка замыкания, и снимок кучи вскрывает её как объект, удержанный через system / Context, удержанный замыканием. Все фиксы разрезают путь closure → Context → большой объект: обнулить переменную после использования, перенести её в область, которую не захватывает ни одно выжившее замыкание, или построить долгоживущее замыкание в отдельной области. Этот же паттерн лежит в основе многих утечек detached-DOM и обработчиков на каждый запрос — поэтому это напрямую связано с уроком об утечках памяти в юните о сборке мусора. Теперь, когда снимок кучи покажет крупный буфер, удержанный через неожиданный system / Context, — ищите соседние замыкания, делящие эту область: одно из них держит дверь открытой.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено
Связанные уроки
встречается в184

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.