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

Пытаемся управлять освобождением памяти в JavaScript

Пытаемся управлять освобождением памяти в JavaScript - 1

В JavaScript есть тысячи способов выделить память, но разработчики языка лишили нас права её освобождать. Этим занимается сборщик мусора (Garbage collector, GC), функций управления которым также нет. В большинстве случаев он неплохо справляется со своей работой, однако когда в программе непрерывно освобождаются большие объёмы данных, порядка мегабайта в секунду, сборщик мусора может тупить, из-за чего процесс браузера разрастается в памяти до невменяемых размеров. В этой статье я покажу пару грязных трюков, с помощью которых можно ускорить освобождение памяти.

ПОДРОБНЕЕ О ПРОБЛЕМЕ

В качестве примера будет выступать расширение для Chrome [1] и Firefox [2], которое показывает видео — прямые трансляции — непрерывно загружая из сети, обрабатывая и освобождая массивы двоичных данных размером в несколько мегабайт. Взгляните на потребление памяти (working set) процессом браузера, в котором работает расширение. Зелёный цвет — Chrome 57, красный — Firefox 52. Сам график был любезно предоставлен виндовой оснасткой perfmon2.msc.

Пытаемся управлять освобождением памяти в JavaScript - 2 [3]
Пытаемся управлять освобождением памяти в JavaScript - 3 [4]

Если в 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

MessageChannel [9] предназначен для передачи данных между контекстами. У него есть два порта: в один данные посылаем [10], из другого принимаем. Интересной особенностью является возможность закрыть [11] принимающий порт. Что в этом случае произойдёт с посылаемыми данными? Есть два варианта:

  • Раз ни посылающей, ни принимающей стороне данные не нужны, они будут переданы сборщику мусора. В стандарте [12] вроде бы описывается такое поведение. Мне, прикладному программисту, сложно понять стандарт, написанный для разработчиков браузера, поэтому «вроде бы».
  • Они застрянут в канале.

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

На практике имеем разброд и шатание. В 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;

Смотрим, как влияет трюк на работу расширения. Сравните с красным графиком в начале статьи.

Пытаемся управлять освобождением памяти в JavaScript - 4 [14]

Максимальное потребление памяти упало на 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 {Выбросить, Сжечь};
})();

Функцию Сжечь() можно вызвать, чтобы очистить помойку после завершения её использования. В расширении функция вызывается после завершения трансляции.

Посмотрим результат после применения трюка:
Пытаемся управлять освобождением памяти в JavaScript - 5 [19]
Пытаемся управлять освобождением памяти в JavaScript - 6 [20]

Максимальное потребление памяти в Firefox упало на 70 МБ, а в Chrome — на 310 МБ. Без комментариев.

БЫСТРОДЕЙСТВИЕ

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

Тестирование проводилось следующим образом. В процессоре отключалось энергосбережение (C-states и понижение частоты у Intel). Запускалось расширение в свёрнутом окне. Большую часть времени процессор простаивал, потому что декодированием видео занимается видеокарта. Через 40 минут в Process Explorer [21] у процесса, в котором работает расширение, проверялось количество затраченных тактов процессора (CPU cycles).

Для обоих трюков количество тактов изменилось в пределах погрешности измерения, так что за быстродействие я не волнуюсь. В синтетическом же тесте в Firefox трюк с MessageChannel оказался в несколько раз медленнее трюка с Worker. В первую очередь быстродействие зависит от реализации в браузере передачи данных между контекстами в пределах одного процесса. Кстати, в Chrome быстродействие MessageChannel не так давно подняли [22].

ЗАКЛЮЧЕНИЕ

Как видно, описанные трюки полезны, правда при достаточно специфических условиях. Большинству людей, работающих с JavaScript, к счастью, они никогда не пригодятся.

А тем, кто заинтересовался проблемой, я дам ещё один совет: старайтесь как можно реже освобождать «толстые» буферы. Например, в расширении я не выбрасываю использованный буфер, а кладу его на «балкон». Если нужно выделить память для данных, то сначала обшаривается балкон, и по возможности используется найденный там буфер, даже если его размер больше требуемого. В моём случае балкон сократил потребление памяти почти в два раза без применения вышеописанных трюков.

По поводу кириллицы в исходниках

  • Русский язык мой родной.
  • Не люблю иностранные языки (а ещё запеканку).
  • Код писался для себя, за деньги напишу хоть на суахили.
  • Я ничего никому не навязываю.
  • Приличные люди о вкусах не спорят.
  • Жду оригинальных искромётных шуток про 1С.

Автор: 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