javascript

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

  • четверг, 27 апреля 2017 г. в 03:14:27
https://habrahabr.ru/post/327426/
  • Клиентская оптимизация
  • Браузеры
  • JavaScript




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

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


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




Если в Firefox сборщик мусора достаточно неплохо справляется, то в Chrome он явно отлынивает от работы и напрашивается на увольнение. Забавно, что год назад картина была противоположной! Браузеры, и алгоритмы работы сборщика мусора в частности, постоянно изменяются, причем не всегда в лучшую сторону. И что, нам переписывать код после выхода новой версии браузера?

Мне могут возразить, что в наше время половина гигабайта — это семечки, даже в смартфонах памяти больше. Во-первых, я предпочитаю, чтобы свободная память (если она есть) использовалась для хранения не заведомо ненужного мусора, а полезных вещей, таких как кэш операционной системы. Во-вторых, большинство браузеров по прежнему 32-битные, а значит их адресное пространство заметно меньше 4-х гигабайт. Несколько запущенных копий расширения довольно быстро его исчерпают и приведут либо к «падению» процесса, либо к проблемам воспроизведения видео.

ПОИСК РЕШЕНИЯ


Данные хранятся в ArrayBuffer. Этот объект был специально создан для хранения и работы с большими объёмами двоичных данных. Однако у него нет функции, которая освобождает память, отведённую под буфер, или хотя бы меняющую размер буфера. В 2014 году компания Mozilla предложила добавить метод ArrayBuffer.transfer(), который в том числе позволял освободить память, оставляя объект в detached-состоянии. Несмотря на несложную реализацию функции, разработчики других браузеров отказались от её добавления. Счастье было так близко…

ArrayBuffer.transfer() был предложен в первую очередь для работы в паре с asm.js. Я проверил как обстоят дела с управлением памятью в текущей версии потомка asm.js, WebAssembly. Да никак, управлением памятью только в планах.

Как я сказал выше, после освобождения отведённой под объект памяти, этот объект переводится в detached-состояние. Как это выглядит на практике? Сишники наверное сразу подумали, что он заменяется на null. Нет, в качестве замены выступает «объект-пустышка», у которого свойство byteLength равно 0, а попытка доступа к содержимому буфера кидает (в Firefox) исключение TypeError: attempting to access detached ArrayBuffer. Такие пустышки занимают мало памяти, поэтому сборщик мусора хорошо справляется с их утилизацией.

У всех современных браузеров есть функция postMessage(), которая способна переводить ArrayBuffer в detached-состояние. Правда, она не освобождает буфер, а передаёт его в другой контекст (например, iframe или рабочий поток), поэтому для освобождения памяти нужны дополнительные действия. Далее я покажу два трюка, которые по-разному вызывают postMessage().

ТРЮК С MESSAGECHANNEL


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

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

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

На практике имеем разброд и шатание. В Chrome 55- и Firefox 50- освобождение памяти ускоряется. В Firefox 51+ память сразу освобождается. В Chrome 56 этот трюк применять нельзя, потому что данные застревают в канале.

Вот исходный код трюка:

// 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) — это код JavaScript, выполняемый параллельно с кодом JavaScript страницы (главным потоком). Буферы в рабочий поток перемещает метод Worker.postMessage(). Однако одного перемещения недостаточно. Буферы будут валяться в рабочем потоке и ждать, когда у сборщика мусора дойдут до них руки. Скорее всего станет только хуже, потому что по моим наблюдениям сборщик мусора в рабочем потоке более ленивый, чем на странице.

Чтобы получить профит, нужно завершить выполнение рабочего потока. Во время этой процедуры браузер достаточно быстро освободит всю выделенную потоку память. Не знаю, прописано ли это в стандарте. Я не проверял работоспособность трюка в относительно старых версиях 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 {Выбросить, Сжечь};
})();

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

Посмотрим результат после применения трюка:



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

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


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

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

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

ЗАКЛЮЧЕНИЕ


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

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

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