Event listeners и garbage collection
Оригинальная статья: Jake Archibald — Event listeners and garbage collection
В статье на примерах проверяется утверждение, что при удаление какого либо объекта garbage collection удаляет так же все события повешенные на этот объект.
Давайте рассмотрим следующий пример кода:
async function showImageSize(url) { const blob = await fetch(url).then((r) => r.blob()); const img = await createImageBitmap(blob); updateUISomehow(img.width, img.height); } btn1.onclick = () => showImageSize(url1);
В этом коде создается race condition (условие гонки). Если пользователь нажимает btn1, а затем btn2, то возможно, что ответ от сервера для url2 будет получен раньше, чем url1. Но это не тот порядок, в котором пользователь нажимал на кнопки, поэтому пользователь может получить не ожидаемый результат.
Иногда лучший способ решить это — создать очередь в два действия, или еще лучше «прервать» предыдущую операцию showImageSize, что бы новая операция заменила ее. fetch поддерживает прерывание запросов, но, к сожалению, createImageBitmap — нет. Тем не менее, вы можете по крайней мере проигнорировать результат. Я написал маленькую функцию для этого:
async function abortable(signal, promise) { if (signal.aborted) throw new DOMException('AbortError', 'AbortError'); return Promise.race([ promise, new Promise((_, reject) => { signal.addEventListener('abort', () => { reject(new DOMException('AbortError', 'AbortError')); }); }), ]); }
И вот как это можно использовать:
let controller; async function showImageSize(url) { // Прервать любой предыдущий экземпляр this if (controller) controller.abort(); try { const { signal } = (controller = new AbortController()); const blob = await fetch(url, { signal }).then((r) => r.blob()); const img = await abortable(signal, createImageBitmap(blob)); updateUISomehow(img.width, img.height); } catch (err) { if (err.name === 'AbortError') return; throw err; } } btn1.onclick = () => showImageSize(url1); btn2.onclick = () => showImageSize(url2);
Задача решена! Я написал об этом в твиттере и получил ответ:
Вы разве не пропустили { once: true }, чтобы не порождать ситуацию с утечкой памяти через listener? — Felix Becker (@felixfbecker)
И это хороший вопрос!
В чем проблема?
Давайте сделаем более «экстремальную» версию:
async function abortable(signal, promise) { if (signal.aborted) throw new DOMException('AbortError', 'AbortError'); // Выделим 100 Мб памяти const lotsOfMemory = new Uint8Array(1000 * 1000 * 100); return Promise.race([ promise, new Promise((_, reject) => { signal.addEventListener('abort', () => { // выводим часть памяти console.log('async task aborted', lotsOfMemory[0]); reject(new DOMException('AbortError', 'AbortError')); }); }), ]); }
В этой версии я выделяю 100 Мб памяти в массиве Uint8Array. На этот объект ссылается слушатель ‘abort‘, поэтому он должен оставаться в памяти. Но как долго он будет оставаться в памяти?
‘abort‘ может никогда не сработать, но также ‘abort‘ может срабатывать несколько раз.
Если вы вызываете controller.abort() несколько раз, браузер вызовет событие ‘abort‘ только один раз. Но это обычное событие DOM, так что ничто не мешает кому-то делать что-то странное, типа этого:
signal.dispatchEvent(new Event('abort')); signal.dispatchEvent(new Event('abort')); signal.dispatchEvent(new Event('abort'));
Итак, каждый вызов abortable вызывают утечку в 100 МБ памяти? Конечно, оригинальная версия abortable не выделяла 100 МБ, но все равно добавляет listener событий к объекту. Это вызывает утечку?
Это на самом деле проблема?
Давайте проверим это, создав 10 асинхронных задач, которые просто ждут:
const resolvers = []; async function asyncTask() { const controller = new AbortController(); await abortable( controller.signal, new Promise((resolve) => { resolvers.push(resolve); }), ); console.log('async task complete'); } for (let i = 0; i < 10; i++) asyncTask();
И давайте попробуем увидеть это с помощью Chrome DevTools:
И да, наши большие объекты торчат в памяти. И это понятно, потому что асинхронная задача еще не завершена. Давайте завершим их:
while (resolvers[0]) { const resolve = resolvers.shift(); resolve(); }
И посмотрим, есть ли изменения:
Да! Все наши объекты были убраны garbage collected. Итак, ответ: нет, abortable не создает утечку памяти.
Вот демо, которое я создал, что вы могли попробовать это сами.
Но почему?
Вот еще один пример с меньшим количеством кода:
async function abortable(signal, promise) { if (signal.aborted) throw new DOMException('AbortError', 'AbortError'); return Promise.race([ promise, new Promise((_, reject) => { signal.addEventListener('abort', () => { reject(new DOMException('AbortError', 'AbortError')); }); }), ]); } async function demo() { const controller = new AbortController(); const { signal } = controller; const img = await abortable(signal, someAsyncAPI()); } demo();
Слушатель события (event listener), добавленный к signal, и все, к чему имеет доступ слушатель, должно оставаться в памяти до тех пор, пока не сработает событие «abort». Есть два способа поведения:
- Что-то вызывает signal.dispatchEvent(new Event(‘abort’)) .
- Браузер решает отправить событие ‘abort‘ по событию signal, которое происходит только при первом вызове controller.abort().
Пока мы ожидаем разрешения функции someAsyncAPI(), в demo() существуют ссылки на signal и controller.
Но как только someAsyncAPI() разрешается, demo() удаляется из стека Он больше не содержит ссылки на signal или controller. После этого браузер понимает, что signal больше не может принимать события, и этот слушиватель событий никогда не будет вызываться, поэтому его можно удалить garbage collected вместе со всем, на что он ссылается.
И это все!
Браузеры, как правило, довольно умны, когда дело доходит до обратных вызовов:
fetch(url).then( () => console.log('It worketh!'), () => console.log('It didnth!'), );
В этом случае у вас есть два обратных вызова, но только один вызывается. Браузер знает, что может удалить оба через GC, как только обещание разрешается. То же самое касается и этого:
function demo() { const xhr = new XMLHttpRequest(); xhr.addEventListener('load', () => console.log('It worketh!')); xhr.addEventListener('error', () => console.log('It didnth!')); xhr.open('GET', url); xhr.send(); }
Когда срабатывает либо «load», либо «error», браузер устанавливает флаг для экземпляра xhr, чтобы сказать «Я больше не буду запускать других событий для этого объекта», и поскольку у вас больше нет ссылки на xhr, вы не можете больше запускать для него события и соотвественно они могут быть удалены GC.
Такое поведение скорее оптимизация браузера, чем специфическое поведение языка.
И так если вы не уверены, в чем то конкретном, проверьте это сами!