- PVSM.RU - https://www.pvsm.ru -

В JavaScript есть тысячи способов выделить память, но разработчики языка лишили нас права её освобождать. Этим занимается сборщик мусора (Garbage collector, GC), функций управления которым также нет. В большинстве случаев он неплохо справляется со своей работой, однако когда в программе непрерывно освобождаются большие объёмы данных, порядка мегабайта в секунду, сборщик мусора может тупить, из-за чего процесс браузера разрастается в памяти до невменяемых размеров. В этой статье я покажу пару грязных трюков, с помощью которых можно ускорить освобождение памяти.
В качестве примера будет выступать расширение для Chrome [1] и Firefox [2], которое показывает видео — прямые трансляции — непрерывно загружая из сети, обрабатывая и освобождая массивы двоичных данных размером в несколько мегабайт. Взгляните на потребление памяти (working set) процессом браузера, в котором работает расширение. Зелёный цвет — Chrome 57, красный — Firefox 52. Сам график был любезно предоставлен виндовой оснасткой perfmon2.msc.
Если в Firefox сборщик мусора достаточно неплохо справляется, то в Chrome он явно отлынивает от работы и напрашивается на увольнение. Забавно, что год назад картина была противоположной! Браузеры, и алгоритмы работы сборщика мусора в частности, постоянно изменяются, причем не всегда в лучшую сторону. И что, нам переписывать код после выхода новой версии браузера?
Мне могут возразить, что в наше время половина гигабайта — это семечки, даже в смартфонах памяти больше. Во-первых, я предпочитаю, чтобы свободная память (если она есть) использовалась для хранения не заведомо ненужного мусора, а полезных вещей, таких как кэш операционной системы. Во-вторых, большинство браузеров по прежнему 32-битные, а значит их адресное пространство заметно меньше 4-х гигабайт. Несколько запущенных копий расширения довольно быстро его исчерпают и приведут либо к «падению» процесса, либо к проблемам воспроизведения видео.
Данные хранятся в ArrayBuffer [5]. Этот объект был специально создан для хранения и работы с большими объёмами двоичных данных. Однако у него нет функции, которая освобождает память, отведённую под буфер, или хотя бы меняющую размер буфера. В 2014 году компания Mozilla предложила добавить метод ArrayBuffer.transfer() [6], который в том числе позволял освободить память, оставляя объект в detached-состоянии. Несмотря на несложную реализацию функции, разработчики других браузеров отказались от её добавления. Счастье было так близко…
ArrayBuffer.transfer() был предложен в первую очередь для работы в паре с asm.js [7]. Я проверил как обстоят дела с управлением памятью в текущей версии потомка asm.js, WebAssembly [8]. Да никак, управлением памятью только в планах.
Как я сказал выше, после освобождения отведённой под объект памяти, этот объект переводится в detached-состояние. Как это выглядит на практике? Сишники наверное сразу подумали, что он заменяется на null. Нет, в качестве замены выступает «объект-пустышка», у которого свойство byteLength равно 0, а попытка доступа к содержимому буфера кидает (в Firefox) исключение TypeError: attempting to access detached ArrayBuffer. Такие пустышки занимают мало памяти, поэтому сборщик мусора хорошо справляется с их утилизацией.
У всех современных браузеров есть функция postMessage(), которая способна переводить ArrayBuffer в detached-состояние. Правда, она не освобождает буфер, а передаёт его в другой контекст (например, iframe или рабочий поток). Поэтому для освобождения памяти нужны дополнительные действия. Далее я покажу два трюка, которые по-разному вызывают postMessage().
MessageChannel [9] предназначен для передачи данных между контекстами. У него есть два порта: в один данные посылаем [10], из другого принимаем. Интересной особенностью является возможность закрыть [11] принимающий порт. Что в этом случае произойдёт с посылаемыми данными? Есть два варианта:
Первый вариант нам определённо подходит. Правда, слово «передать» может означать не «освободить немедленно», а лишь «пометить как никому не нужные». В последнем случае освобождение произойдёт во время очередного цикла работы сборщика мусора, который неизвестно когда начнётся.
На практике имеем разброд и шатание. В Chrome 55- и Firefox 50- освобождение памяти ускоряется. В Firefox 51+ память сразу освобождается. В Chrome 56 этот трюк применять нельзя, потому что данные застревают [13] в канале.
Вот исходный код трюка:
// HACK Firefox 49: Нельзя выбрасывать буфер, который используется в asm.js
// в качестве кучи, чтобы не вызвать исключение out of memory.
const м_Помойка = (function()
{
var _оПомойка = null;
function Выбросить(оБарахло)
{
if (typeof оБарахло !== 'object' || оБарахло === null)
{
return;
}
if (оБарахло.buffer)
{
оБарахло = оБарахло.buffer;
}
if (оБарахло.byteLength)
{
console.log(`[Помойка] Выбрасываю ${оБарахло.byteLength} байтов`);
if (!_оПомойка)
{
_оПомойка = new MessageChannel();
_оПомойка.port2.close();
}
// Посылка transferable буфера в disentangled порт.
_оПомойка.port1.postMessage(оБарахло, [оБарахло]);
}
}
return {Выбросить};
})();
И его использование:
// Создаём буфер.
// Можно использовать тот, что возвращает XMLHttpRequest, fetch и т.д.
var буфДанные = new ArrayBuffer(1e6);
// Здесь идет работа с данными в буфере...
// Буфер больше не нужен.
// Удаляем все ссылки на буфер и выбрасываем его в помойку.
м_Помойка.Выбросить(буфДанные);
буфДанные = null;
Смотрим, как влияет трюк на работу расширения. Сравните с красным графиком в начале статьи.
Максимальное потребление памяти упало на 100 МБ. Неплохая прибавка к пенсии. Плюс имеем гарантию того, что потребление памяти не будет бесконтрольно рости, например, из-за увеличения битрейта видео или частоты скачивания файлов.
Мне этот трюк не нравится из-за вышеописанных проблем с совместимостью. Тем не менее некоторое время он использовался в расширении.
Рабочий поток (Worker [15]) — это код JavaScript, выполняемый параллельно с кодом страницы (главным потоком). Буферы в рабочий поток перемещает метод Worker.postMessage() [16]. Однако одного перемещения недостаточно. Буферы будут валяться в рабочем потоке и ждать, когда у сборщика мусора дойдут до них руки. Скорее всего станет только хуже, потому что по моим наблюдениям сборщик мусора в рабочем потоке более ленивый [17], чем на странице.
Чтобы получить профит, нужно завершить выполнение рабочего потока [18]. Во время этой процедуры браузер достаточно быстро освободит всю выделенную потоку память. Не знаю, прописано ли это в стандарте. Я не проверял работоспособность трюка в относительно старых версиях Chrome, но не жду от них никаких неприятных сюрпризов.
На создание и завершение рабочего потока тратится процессорное время, поэтому в целях оптимизации нужно завершать поток только после того, как в нём накопится достаточно много данных, в моём случае примерно 10 мегабайт.
Исходный код трюка:
// HACK Firefox 49: Нельзя выбрасывать буфер, который используется в asm.js
// в качестве кучи, чтобы не вызвать исключение out of memory.
const м_Помойка = (function()
{
const ВМЕСТИМОСТЬ_ПОМОЙКИ = 10e6;
var _сАдрес = '';
var _оПомойка = null;
var _кбВПомойке = 0;
function Выбросить(оБарахло)
{
if (typeof оБарахло !== 'object' || оБарахло === null)
{
return;
}
if (оБарахло.buffer)
{
оБарахло = оБарахло.buffer;
}
if (оБарахло.byteLength)
{
console.log(`[Помойка] Выбрасываю ${оБарахло.byteLength} байтов`);
if (!_оПомойка)
{
if (!_сАдрес)
{
_сАдрес = URL.createObjectURL(new Blob(
[`
'use strict';
self.onmessage = function(оСобытие)
{
if (!оСобытие.data)
{
self.close();
}
};
`],
{type: 'application/javascript'}
));
}
_оПомойка = new Worker(_сАдрес);
}
_кбВПомойке += оБарахло.byteLength;
_оПомойка.postMessage(оБарахло, [оБарахло]);
if (_кбВПомойке > ВМЕСТИМОСТЬ_ПОМОЙКИ)
{
Сжечь();
}
}
}
function Сжечь()
{
if (_оПомойка)
{
console.log(`[Помойка] Сжигаю ${_кбВПомойке} байтов`);
// terminate() не подходит, нужно дождаться когда барахло
// попадет в рабочий поток.
_оПомойка.postMessage(null);
_оПомойка = null;
_кбВПомойке = 0;
}
}
return {Выбросить, Сжечь};
})();
Функцию Сжечь() можно вызвать, чтобы очистить помойку после завершения её использования. В расширении функция вызывается после завершения трансляции.
Посмотрим результат после применения трюка:
[19]
[20]
Максимальное потребление памяти в Firefox упало на 70 МБ, а в Chrome — на 310 МБ. Без комментариев.
Измерять время подобных быстротекущих процессов — непростое занятие. Возможностей профилировщика JavaScript недостаточно из-за его невысокой точности и размазанности тестируемого кода по разным контекстам, часть которых достаточно быстро уничтожается. Меня в первую очередь интересовал вопрос: на сколько процентов возрастёт время работы расширения после добавления в него кода для освобождения памяти.
Тестирование проводилось следующим образом. В процессоре отключалось энергосбережение (C-states и понижение частоты у Intel). Запускалось расширение в свёрнутом окне. Большую часть времени процессор простаивал, потому что декодированием видео занимается видеокарта. Через 40 минут в Process Explorer [21] у процесса, в котором работает расширение, проверялось количество затраченных тактов процессора (CPU cycles).
Для обоих трюков количество тактов изменилось в пределах погрешности измерения, так что за быстродействие я не волнуюсь. В синтетическом же тесте в Firefox трюк с MessageChannel оказался в несколько раз медленнее трюка с Worker. В первую очередь быстродействие зависит от реализации в браузере передачи данных между контекстами в пределах одного процесса. Кстати, в Chrome быстродействие MessageChannel не так давно подняли [22].
Как видно, описанные трюки полезны, правда при достаточно специфических условиях. Большинству людей, работающих с JavaScript, к счастью, они никогда не пригодятся.
А тем, кто заинтересовался проблемой, я дам ещё один совет: старайтесь как можно реже освобождать «толстые» буферы. Например, в расширении я не выбрасываю использованный буфер, а кладу его на «балкон». Если нужно выделить память для данных, то сначала обшаривается балкон, и по возможности используется найденный там буфер, даже если его размер больше требуемого. В моём случае балкон сократил потребление памяти почти в два раза без применения вышеописанных трюков.
Автор: CoolCmd
Источник [23]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/javascript/253739
Ссылки в тексте:
[1] Chrome: https://chrome.google.com/webstore/detail/twitch-5/knankefoajngclnjgnelanfohgihifpc
[2] Firefox: https://addons.mozilla.org/firefox/addon/twitch_5/
[3] Image: https://habrastorage.org/files/929/b77/1bb/929b771bb7ee44baa22ae7639398b7fd.png
[4] Image: https://habrastorage.org/files/d3c/4bf/c99/d3c4bfc997c54ff3b8041843a1524b9f.png
[5] ArrayBuffer: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer
[6] ArrayBuffer.transfer(): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer/transfer
[7] asm.js: http://asmjs.org/
[8] WebAssembly: http://webassembly.org/
[9] MessageChannel: https://developer.mozilla.org/en-US/docs/Web/API/MessageChannel
[10] посылаем: https://developer.mozilla.org/en-US/docs/Web/API/MessagePort/postMessage
[11] закрыть: https://developer.mozilla.org/en-US/docs/Web/API/MessagePort/close
[12] стандарте: https://html.spec.whatwg.org/multipage/comms.html#dom-messageport-postmessage
[13] застревают: https://bugs.chromium.org/p/chromium/issues/detail?id=688862
[14] Image: https://habrastorage.org/files/918/d8f/1e0/918d8f1e07664279b9ea8c1cff34fa6c.png
[15] Worker: https://developer.mozilla.org/en-US/docs/Web/API/Worker
[16] Worker.postMessage(): https://developer.mozilla.org/en-US/docs/Web/API/Worker/postMessage
[17] более ленивый: https://bugs.chromium.org/p/chromium/issues/detail?id=713344
[18] завершить выполнение рабочего потока: https://developer.mozilla.org/en-US/docs/Web/API/DedicatedWorkerGlobalScope/close
[19] Image: https://habrastorage.org/files/10d/b5f/9d4/10db5f9d459b41fb9ed347d63c18fc11.png
[20] Image: https://habrastorage.org/files/d4d/cb1/97a/d4dcb197a4d2439886d596fe1f5c8340.png
[21] Process Explorer: https://technet.microsoft.com/en-us/sysinternals/processexplorer.aspx
[22] подняли: https://bugs.chromium.org/p/chromium/issues/detail?id=344814
[23] Источник: https://habrahabr.ru/post/327426/?utm_source=habrahabr&utm_medium=rss&utm_campaign=sandbox
Нажмите здесь для печати.