Перейти к основному содержимому

Слабые ссылки и финализаторы

· 9 мин. чтения
Сатья Гунасекаран ([@_gsathya](https://twitter.com/_gsathya)), Матиас Биненс ([@mathias](https://twitter.com/mathias)), Шу-ю Го ([@_shu](https://twitter.com/_shu)) и Лешек Свирски ([@leszekswirski](https://twitter.com/leszekswirski))

В общем случае ссылки на объекты в JavaScript являются сильно удерживаемыми, что означает, что пока у вас есть ссылка на объект, он не будет удален сборщиком мусора.

const ref = { x: 42, y: 51 };
// Пока у вас есть доступ к `ref` (или любой другой ссылке на
// тот же объект), объект не будет удален сборщиком мусора.

На данный момент WeakMap и WeakSet являются единственными способами частично слабой ссылки на объект в JavaScript: добавление объекта в качестве ключа в WeakMap или WeakSet не предотвращает его удаление сборщиком мусора.

const wm = new WeakMap();
{
const ref = {};
const metaData = 'foo';
wm.set(ref, metaData);
wm.get(ref);
// → metaData
}
// У нас больше нет ссылки на `ref` в этой области видимости блока, поэтому он
// может быть удален сборщиком мусора, даже если он является ключом в `wm`, к
// которому у нас все еще есть доступ.

<!--truncate-->
const ws = new WeakSet();
{
const ref = {};
ws.add(ref);
ws.has(ref);
// → true
}
// У нас больше нет ссылки на `ref` в этой области видимости блока, поэтому он
// может быть удален сборщиком мусора, даже если он является ключом в `ws`, к
// которому у нас все еще есть доступ.
примечание

Примечание: Вы можете рассматривать WeakMap.prototype.set(ref, metaData) как добавление свойства со значением metaData к объекту ref: пока у вас есть ссылка на объект, вы можете получить метаданные. Как только у вас больше нет ссылки на объект, сборщик мусора может удалить его, даже если у вас все еще есть ссылка на WeakMap, в который он был добавлен. Аналогично, вы можете рассматривать WeakSet как частный случай WeakMap, где все значения являются булевыми.

JavaScript WeakMap на самом деле не является слабым: он фактически сильно ссылается на содержимое до тех пор, пока ключ жив. WeakMap начинает слабо ссылаться на содержимое только после удаления ключа сборщиком мусора. Более точное название данного типа отношений — эфемерон.

WeakRef — это более продвинутый API, который предоставляет настоящие слабые ссылки, позволяя определять время жизни объекта. Давайте рассмотрим пример.

В качестве примера предположим, что мы работаем над веб-приложением для чата, которое использует веб-сокеты для общения с сервером. Вообразим класс MovingAvg, который для диагностической оценки производительности сохраняет набор событий из веб-сокета для вычисления простого скользящего среднего времени задержки.

class MovingAvg {
constructor(socket) {
this.events = [];
this.socket = socket;
this.listener = (ev) => { this.events.push(ev); };
socket.addEventListener('message', this.listener);
}

compute(n) {
// Вычислить простое скользящее среднее для последних n событий.
// …
}
}

Он используется классом MovingAvgComponent, который позволяет вам контролировать, когда начинать и заканчивать мониторинг простого скользящего среднего времени задержки.

class MovingAvgComponent {
constructor(socket) {
this.socket = socket;
}

start() {
this.movingAvg = new MovingAvg(this.socket);
}

stop() {
// Позволить сборщику мусора освободить память.
this.movingAvg = null;
}

render() {
// Выполнить рендеринг.
// …
}
}

Мы знаем, что сохранение всех серверных сообщений внутри экземпляра MovingAvg использует много памяти, поэтому мы заботимся о том, чтобы установить this.movingAvg в null при остановке мониторинга, чтобы позволить сборщику мусора освободить память.

Однако после проверки панели памяти в DevTools мы обнаружили, что память вовсе не освобождается! Опытный веб-разработчик, возможно, уже заметил ошибку: слушатели событий являются сильными ссылками и должны быть явно удалены.

Давайте сделаем это явным с помощью диаграмм достижимости. После вызова start() наш граф объектов выглядит следующим образом, где сплошная стрелка обозначает сильную ссылку. Все, что доступно через сплошные стрелки от экземпляра MovingAvgComponent, не может быть удалено сборщиком мусора.

После вызова stop() мы удалили сильную ссылку от экземпляра MovingAvgComponent к экземпляру MovingAvg, но не через слушатель сокета.

Таким образом, слушатель в экземплярах MovingAvg, ссылаясь на this, удерживает весь экземпляр живым, пока слушатель событий не будет удален.

До сих пор решение заключается в ручной отмене регистрации обработчика событий с помощью метода dispose.

class MovingAvg {
constructor(socket) {
this.events = [];
this.socket = socket;
this.listener = (ev) => { this.events.push(ev); };
socket.addEventListener('message', this.listener);
}

dispose() {
this.socket.removeEventListener('message', this.listener);
}

// …
}

Недостаток такого подхода заключается в том, что это ручное управление памятью. MovingAvgComponent и все другие пользователи класса MovingAvg должны помнить о вызове dispose, чтобы избежать утечек памяти. Более того, ручное управление памятью имеет каскадный характер: пользователи MovingAvgComponent должны помнить о вызове stop, чтобы избежать утечек памяти, и так далее. Поведение приложения не зависит от обработчика событий этого диагностического класса, а обработчик потребляет много памяти, но не вычислительных ресурсов. Нам действительно нужно, чтобы продолжительность жизни обработчика событий была логически связана с экземпляром MovingAvg, чтобы MovingAvg можно было использовать как любой другой объект JavaScript, память которого автоматически освобождается сборщиком мусора.

WeakRef позволяет разрешить дилемму, создавая слабую ссылку на фактический обработчик событий, а затем оборачивая этот WeakRef в внешний обработчик событий. Таким образом, сборщик мусора может освободить фактический обработчик событий и память, которую он удерживает, например, экземпляр MovingAvg и его массив events.

function addWeakListener(socket, listener) {
const weakRef = new WeakRef(listener);
const wrapper = (ev) => { weakRef.deref()?.(ev); };
socket.addEventListener('message', wrapper);
}

class MovingAvg {
constructor(socket) {
this.events = [];
this.listener = (ev) => { this.events.push(ev); };
addWeakListener(socket, this.listener);
}
}
примечание

Примечание: Слабые ссылки WeakRef на функции должны использоваться с осторожностью. Функции JavaScript являются замыканиями и сильно ссылаются на внешние окружения, которые содержат значения свободных переменных, используемых внутри функций. Эти внешние окружения могут содержать переменные, которые также используются другими замыканиями. То есть, при работе с замыканиями их память часто может быть сильно ссылаемой другими замыканиями в тонких случаях. Именно поэтому addWeakListener является отдельной функцией, и wrapper не является локальным для конструктора MovingAvg. В V8, если бы wrapper был локальным для конструктора MovingAvg и делил лексическую область с обработчиком, который обернут в WeakRef, то экземпляр MovingAvg и все его свойства стали бы доступны через разделяемое окружение от обработчика-обертки, делая экземпляр недоступным для сборщика мусора. Помните об этом, разрабатывая код.

Сначала мы создаем обработчик событий и присваиваем его this.listener, чтобы он был сильно связан с экземпляром MovingAvg. Другими словами, пока экземпляр MovingAvg существует, обработчик событий тоже существует.

Затем, в addWeakListener, мы создаем WeakRef, целевым объектом которого является фактический обработчик событий. Внутри wrapper мы выполняем deref. Поскольку WeakRef не предотвращает сбор мусора для своих целевых объектов, если у них нет других сильных ссылок, мы должны вручную разыменовывать их, чтобы получить целевой объект. Если целевой объект был собран сборщиком мусора за это время, deref возвращает undefined. В противном случае возвращается изначальная цель, то есть функция listener, которую мы затем вызываем, используя опциональную цепочку.

Поскольку обработчик событий обернут в WeakRef, единственная сильная ссылка на него — это свойство listener в экземпляре MovingAvg. То есть, нам удалось связать продолжительность жизни обработчика событий с продолжительностью жизни экземпляра MovingAvg.

Возвращаясь к диаграммам достижимости, наш граф объектов выглядит следующим образом после вызова start() с реализацией на основе WeakRef, где пунктирная стрелка обозначает слабую ссылку.

После вызова stop() мы удаляем единственную сильную ссылку на обработчик:

Со временем, после выполнения сбора мусора, экземпляр MovingAvg и обработчик будут собраны:

Но здесь остается проблема: мы добавили уровень косвенности к listener, обернув его в WeakRef, но wrapper в addWeakListener все еще создает утечку по той же причине, по которой изначально создавал утечку listener. Допустим, это меньшая утечка, поскольку утечка состоит только из обертки, а не целого экземпляра MovingAvg, но это все равно утечка. Решением этой проблемы является сопряженная функция для WeakRef, а именно FinalizationRegistry. С новым API FinalizationRegistry мы можем зарегистрировать обратный вызов, который будет выполнен, когда сборщик мусора уничтожит зарегистрированный объект. Такие обратные вызовы называются финализаторами.

примечание

Примечание: Колбек финализации не запускается сразу после сборки мусора для слушателя событий, поэтому не используйте его для важной логики или метрик. Время сборки мусора и вызовов колбеков финализации не определено. На самом деле, движок, который никогда не запускает сборку мусора, будет полностью совместим. Однако можно предположить, что движки будут собирать мусор, и колбеки финализации будут вызываться позднее, если только среда не будет уничтожена (например, закрытие вкладки или завершение работы worker'а). Учитывайте эту неопределенность при написании кода.

Мы можем зарегистрировать колбек с помощью FinalizationRegistry, чтобы удалить wrapper из сокета, когда внутренний слушатель событий будет собран сборщиком мусора. Наш окончательный вариант реализации выглядит следующим образом:

const gListenersRegistry = new FinalizationRegistry(({ socket, wrapper }) => {
socket.removeEventListener('message', wrapper); // 6
});

function addWeakListener(socket, listener) {
const weakRef = new WeakRef(listener); // 2
const wrapper = (ev) => { weakRef.deref()?.(ev); }; // 3
gListenersRegistry.register(listener, { socket, wrapper }); // 4
socket.addEventListener('message', wrapper); // 5
}

class MovingAvg {
constructor(socket) {
this.events = [];
this.listener = (ev) => { this.events.push(ev); }; // 1
addWeakListener(socket, this.listener);
}
}
примечание

Примечание: gListenersRegistry — это глобальная переменная для обеспечения выполнения финализаторов. Объект FinalizationRegistry не сохраняется сам по себе объектами, зарегистрированными в нем. Если сам реестр будет собран сборщиком мусора, его финализатор может не запуститься.

Мы создаем слушатель событий и присваиваем его this.listener, чтобы он имел сильную ссылку на экземпляр MovingAvg (1). Затем мы оборачиваем слушатель событий, который выполняет работу, в WeakRef, чтобы его можно было собрать сборщиком мусора, и чтобы он не предотвращал сборку MovingAvg из-за ссылки через this (2). Мы создаем обертку, которая вызывает deref для проверки, все еще ли доступен объект, и если да, то вызывает его (3). Мы регистрируем внутренний слушатель в FinalizationRegistry, передавая удерживающее значение { socket, wrapper } для регистрации (4). Затем мы добавляем возвращенную обертку в качестве слушателя событий на socket (5). Через некоторое время после того, как экземпляр MovingAvg и внутренний слушатель будут собраны сборщиком мусора, финализатор может быть вызван с переданным ему удерживающим значением. Внутри финализатора мы также удаляем обертку, обеспечивая сборку всех связанных с использованием экземпляра MovingAvg данных сборщиком мусора (6).

С учетом всего вышесказанного, наша первоначальная реализация MovingAvgComponent больше не приводит к утечкам памяти и не требует ручной утилизации.

Не переусердствуйте

После ознакомления с этими новыми возможностями может возникнуть соблазн применить WeakRef ко всем элементам™. Однако, это, вероятно, не лучшая идея. Некоторые случаи явно не подходят для использования WeakRef и финализаторов.

В общем, избегайте написания кода, полагающегося на то, что сборщик мусора очистит WeakRef или вызовет финализатор в предсказуемое время — это невозможно! Более того, возможность сборки объекта мусором может зависеть от деталей реализации, таких как представление замыканий, которые сложны и могут различаться между JavaScript-движками и даже между их версиями. В частности, вызовы колбеков финализатора:

  • Могут не происходить сразу после сборки мусора.
  • Могут происходить не в том же порядке, что и сама сборка мусора.
  • Могут не происходить вовсе, например, если окно браузера закрыто.

Поэтому не размещайте важную логику в траектории выполнения финализатора. Они полезны для выполнения очистки в ответ на сборку мусора, но вы не можете использовать их надежно, например, для записи значимых метрик использования памяти. Для этого случая используйте performance.measureUserAgentSpecificMemory.

WeakRef и финализаторы могут помочь сэкономить память, и работают лучше, когда используются умеренно, как способ постепенного улучшения. Поскольку это функции для опытных пользователей, мы ожидаем, что большинство их применений будет происходить внутри фреймворков или библиотек.

Поддержка WeakRef