Пытаемся управлять освобождением памяти в 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()
{
    let _оПомойка = 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;

    let _сАдрес     = '';
    let _оПомойка   = null;
    let _кбВПомойке = 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 МБ. Без комментариев.

Обновление: этот трюк в Firefox вызывает утечку виртуального адресного пространства.

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


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

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

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

ЗАКЛЮЧЕНИЕ


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

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

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

Поделиться публикацией

Комментарии 12

    +4
    Попытка управлять руками памятью в системах с автоматическим управлением (и кучей эвристик в этом управлении), как правило, ни к чему хорошему не приводит. В Хроме дела не «хуже», чем в Фаерфоксе, а, вероятно, оптимальнее — если нет дефицита памяти _в данный конкретный момент времени_ (никто не запрашивает), то можно и отложить тяжёлый запуск коллектора, накопив бОльший блок для освобождения. В ситуации с дефицитом памяти этот график наверняка станет другим. Вся подобная ручная возня с памятью тянется мифологией и сказаниями предков ещё из тёмных времён MS-DOS и сплетается с патологическими формами «я лучше производителей платформы знаю как должно быть». Может, и действительно лучше, но тогда стОит заняться написанием патчей для этой платформы, а не конструированием костылей на уровне выше.
      +3
      Я в статье привёл два примера, когда стратегия «вся свободная память — моя память» (кстати, популярная как раз в эпоху MS-DOS) не всегда работает. Дополню.

      Допустим, браузер забил всю оперативку мусором. Другое приложение тоже ориентируется на количество свободной памяти. Оно видит, что памяти нет, и выделяет для некой своей операции буфер пониженного размера, что приводит к понижению производительности. Или выделяет такой же объём, допустим гигабайт, а операционная система, чтобы удовлетворить требование, перемещает часть занятой памяти (занятой мусором) в файл подкачки. В этот момент браузер видя, что памяти маловато, наконец начинает убирать мусор, только уже поздновато…

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

      В идеальном мире, где у людей куча свободного времени — да. В реальном, написать костыль + пнуть разработчиков в багтрекере намного быстрее.
        0
        Наверное, вам стОит изучить, как устроена виртуальная память (и когда приложение в современных операционных системах получает сообщение о невозможности выделения памяти, и как соотносятся RTL-malloc'и с системными вызовами выделения памяти). Всё сильно сложнее, чем в MS-DOS, и приложения легко могут запрашивать памяти больше, чем есть реально планок в компьютере (пока не начнут использовать эту память — тут уже включается подкачка или жёсткие эксепшны о недостатке). «Чем меньше цифры в ГУЕ менеджера памяти, тем лучше» — культ карго чистой воды, отзывчивость системы достигается не снижением этих цифр, а стабилизацией их значений (уменьшением количества изломов на гребёнке графика, если говорить грубо, но это тоже очень грубо и не должно быть единственным аспектом оптимизации).
          0
          Сейчас проверил, как Chrome 58 ведет себя в условиях нехватки памяти. Во время работы расширения запустил в фоне утиль, который в цикле выделяет, заполняет и тут же освобождает почти всю свободную оперативную память. Результат: в файл подкачки ушло примерно 400 МБ, кэш винды упал до нуля, а процесс с расширением… все также бодро лопает почти 500 МБ. Может бы и дальше продолжил расти, у меня сейчас нет времени долго тесты гонять.

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

          Если система стоит на HDD, то в вышеприведенной ситуации об отзывчивости можно только мечтать.
      –2
      Несколько запущенных копий расширения довольно быстро его исчерпают и приведут либо к «падению» процесса, либо к проблемам воспроизведения видео

      Как минимум спорное утверждение.

      Да и вообще: chrome + 15 расширений + overдомного вкладок + отладка прямо в браузере и как-то все норм.

        +2
        Как минимум спорное утверждение.

        Я привёл в статье ссылку на chromium issue 713344, в которой процесс разрастался до 3.5 ГБ. Если запустить тот пример (переместив с диска в инет) в нескольких вкладках, то процесс падает из-за недостатка памяти. Причём падает 64-битный браузер, похоже у него где-то в коде есть лимит на 4 ГБ на процесс.
          0
          Но это баг GC, который надо исправлять, а не «особенность» его стратегии.
          Я заводил похожий issue 610158, сразу признали багом и кинулись чинить.
          Правда, дальнейшую судьбу бага я так и не понял :) Вскоре появилась пометка Fixed, но в текущей стабильной версии 58 он проявляется всё равно. За год изменения не докатились из dev до stable?
            +1
            За год изменения не докатились из dev до stable?

            omahaproxy.appspot.com говорит, что коммит попал в версию 52. Если ошибка до сих пор присутствует, то наверное нужно создать новую issue. А может будет достаточно отписаться в старой.
          +1

          К сожалению тут зависит от системы, а точнее от конфигурации компа и приложений. Я, помнится, оставил свой ноут (8GB, ubuntu 16.04) с запущенным в хроме вк на сообщениях (по моим наблюдениям самый активный и стабильный сжиратель памяти). Через часов 9-10 пришёл, еле переключив на tty1, запустил htop и офигел — кроме браузера я оставил ещё свою ide от JB, которая написана на java, вечном пинаемом за сожранную память. Тк вот, ide крутила девовский nodejs на 20% памяти. Ещё примерно 10% на себя забрала сама ось и её графика. Остальные 70 были безжалостно сожраны хромом…
          Пришлось вырубать с кнопки.

          +2
          Если в Firefox сборщик мусора достаточно неплохо справляется, то в Chrome он явно отлынивает от работы
          И правильно делает!

          Вы смотрите на график с точки зрения пользователя PC, который подключен к розетке и возможно страдает от нехватки RAM. В этом случае «оптимизация» GC выглядит круто!

          Но с другой стороны, из-за вашего решения, пользователи смартфонов и ноутбуков начинают судорожно искать зарядное устройство и розетку, потому как вместо того, чтобы «отлынивать» браузер в панике пинает GC, чтобы тот освободил целых ~20 Мб памяти!

          Не стоит забывать, что уже сейчас в вебе больше 50% — мобильные пользователи, и дальше эта цифра будет только рости. Возможно ваш плагин и работает для десктопных браузеров, но они так же запускаются на ноутбуках, которым важна автономность. Автономность 1-3 часа без розетки — это очень больно :-(
            0
            Ничто не мешает браузеру менять поведение GC в зависимости от типа питания. В моем случае питание было от розетки. И расширение предназначено для настольных компов.

            Но идея интересная. Можно вызывать navigator.getBattery() и не использовать трюки если питание от аккумулятора.
              0
              GC занимается не только сборкой, но и выделением памяти и просто так взять и поменять алгоритм на лету — это не тривиальная задача.
              Да, изменить частоту сборки — в теории можно, но в этом случае ноутбук будет сильнее греться и даже куллером жужжать начнет — это тоже неприятный опыт для пользователя, по-моему, даже более неприятный, чем небольшие тормоза какого-то приложения.

              ps: в любом случае, прежде чем внедрять такой подход стоит лишний раз подумать, стоит ли так сильно усложнять систему (поддержку) и какой профит от этого будет?

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

          Самое читаемое