Память в браузерах и в Node.js: ограничения, утечки и нестандартные оптимизации

Интро: почему я написал эту статью

Меня зовут Виктор, я разрабатываю страницу результатов поиска Яндекса. Несмотря на внешнюю простоту, поисковая выдача — сложная штука: на каждый запрос генерируется своя уникальная страница, на которой в зависимости от запроса может присутствовать блок Картинок, Карты, Переводчик, видеоплеер и многие другие компоненты. Все они должны запускаться и работать в памяти обычных бюджетных телефонов, которые использует большинство наших пользователей. Браузерам должно хватать ресурсов, чтобы пользователь не видел вот такого:

l11rxyoyarh2sk26elmh94mlan0.jpeg

На своих серверах мы должны генерировать сотни миллионов уникальных страниц в сутки — это сложнее, чем просто отдавать одни и те же ресурсы. Генерация страницы не должна быть слишком требовательной к памяти сервера.

Разрабатывая проект на JavaScript (TypeScript, ClojureScript или каком-то другом языке, транслируемом в JavaScript), мы привыкли создавать объекты, массивы, строки и вообще писать код, как будто память бесконечна. Это не так. Я расскажу о видах проблем с памятью, о том, какие ограничения мы часто забываем и как их можно преодолеть. В ответ браузеры и пользователи скажут вам спасибо.


Категории проблем с памятью

JavaScript наряду с Java, C# и Python принадлежит к языкам с автоматической сборкой мусора.

Проблемы с памятью в таких языках можно разделить на три категории:


  1. Не-утечки: код и данные, которые упираются в ограничения по памяти. Ограничения могут быть либо искусственными — заложенными в язык или среду выполнения, либо естественными — вытекающими из характеристик железа, на котором выполняется код.
  2. Soft-утечки (мягкие, локальные утечки): что-то мешает сборщику мусора освободить память, например, список содержит в одном из элементов ссылку на давно не нужный объект.
  3. Hard-утечки (жёсткие, глобальные утечки): память не освобождается, пока браузер или вся операционная система не будут перезапущены.

Важно отличать утечки от неоптимального кода с высоким потреблением памяти: иногда слова «у меня страница течёт» некорректно используются там, где открыто жадное до памяти приложение, но утечки при этом отсутствуют.

Далее я подробно расскажу о каждой из трёх категорий: что она из себя представляет, как её обнаружить, как с ней бороться.


Ограничения по памяти для разных типов данных

Систематизировать свои знания по ограничениям меня побудила эта заметка Романа Дворнова.


Heap

Во многих реализациях языков с автоматической сборкой мусора — хоть и не во всех — для динамического выделения памяти используется куча, она же heap. Например, это языки на основе JVM — Java, Kotlin и так далее. Кучу использует и движок JavaScript V8. Соответственно, размер кучи — самое главное ограничение, с которого надо начинать разбираться.

Компактное и интересное описание того, как в V8 устроена память вообще и куча в частности, можно найти в этой статье. Вот самая ценная картинка оттуда:

V8 RSS and heap

В Chrome и других браузерах, основанных на Chromium, текущее состояние хипа можно узнать из performance.memory:

> console.log(performance.memory)
MemoryInfo {totalJSHeapSize: 10000000, usedJSHeapSize: 10000000, jsHeapSizeLimit: 3760000000}

С появлением performance.memory в Chrome связана интересная история. Во время роста популярности Gmail в 2010–2012 годах пользователи начали всё чаще жаловаться на высокое потребление памяти браузером. В Google разработчики Gmail пришли к разработчикам Chrome и убедили тех добавить возможность получать из JS информацию о состоянии памяти и усовершенствовать инструменты работы с памятью в DevTools. После этого разработчики Gmail добавили в своё почтовое приложение сбор данных о памяти у клиентов, нашли и исправили несколько утечек. И вообще — в разы уменьшили потребление памяти: примерно в два раза на медиане и в пять раз на 99-й процентили. Кроме исправления уже существующих багов, мониторинг памяти в Gmail помогает оперативно находить и устранять проблемы с памятью в новых версиях приложения и даже баги в сборщике мусора в новых версиях Chrome (обратите внимание, на вертикальной оси отложены не абсолютные значения в мегабайтах, а кратные какой-то базовой величине x):

Gmail memory consumption

В других браузерах — Firefox, Safari — память устроена аналогично. В Firefox даже есть специальная страница about:memory, на которой можно увидеть детальное состояние памяти и вызвать сборку мусора. Проблема в том, что в этих браузерах неизвестен способ из кода на JS получить состояние хипа. Если у вас есть такая информация — пишите в комментариях.

В Node.js состояние памяти можно узнать вызовом process.memoryUsage():

$ node
Welcome to Node.js v16.8.0.
Type ".help" for more information.
> console.log(process.memoryUsage())
{
  rss: 26689536,
  heapTotal: 6656000,
  heapUsed: 4633936,
  external: 893129,
  arrayBuffers: 11158
}

Документация по process.memoryUsage() находится здесь.

С получением текущего состояния хипа разобрались, теперь к ограничениям. В Chrome и Node.js максимальный размер хипа определяется при старте:


  • Если в командной строке указаны параметры, настраивающие размер хипа, то значение берётся оттуда.
  • Иначе значение вычисляется в зависимости от архитектуры (32- или 64-битная) и объёма физической памяти.

Насколько мне удалось разобраться, максимальный размер хипа определяется так (если у вас есть дополнения и уточнения — пишите, исправлю):

Подробности о значениях и логике выбора в Node.js можно почитать в этом комментарии к пул-реквесту. Соответствующий код в движке V8: Heap: HeapSizeFromPhysicalMemory, ResourceConstraints: ConfigureDefaults.

Параметры командной строки для V8, управляющие размерами областей хипа:

$ node --v8-options | grep -- -size
  …
  --min-semi-space-size (min size of a semi-space (in MBytes), the new space consists of two semi-spaces)
  --max-semi-space-size (max size of a semi-space (in MBytes), the new space consists of two semi-spaces)
  --max-old-space-size (max size of the old space (in Mbytes))
  --max-heap-size (max size of the heap (in Mbytes) both max_semi_space_size and max_old_space_size take precedence. All three flags cannot be specified at the same time.)
  --initial-heap-size (initial size of the heap (in Mbytes))
  --huge-max-old-generation-size (Increase max size of the old space to 4 GB for x64 systems withthe physical memory bigger than 16 GB)
  --initial-old-space-size (initial old space size (in Mbytes))
  …
  --stack-size (default size of stack region v8 is allowed to use (in kBytes))

Самый известный и широко используемый из них — это --max-old-space-size, позволяющий увеличить размер Old Space. Например, если на устройстве достаточно физической памяти (пусть будет 32 ГБ) и нашей программе не хватает 4 ГБ, выделенных ей по умолчанию, то мы можем запустить её так:

node --max-old-space-size=8000 index.js

О тонкостях настройки памяти, если Node.js выполняется в контейнере Docker, можно почитать в этой хабрастатье.


Buffer, TypedArray

Максимальная длина одного буфера или типизированного массива в Node.js ограничена константой. Сама константа добавлена в Node.js v8.2.0, но ограничение существовало и до этого, насколько я знаю.

require('buffer').constants.MAX_LENGTH

Значение константы зависит от версии и разрядности Node.js:

Пример кода:

// Node.js v12 64-bit

new Int8Array(2**31-1)
// Int8Array(2147483647)

new Int8Array(2**31)
// Uncaught RangeError: Invalid typed array length: 2147483648
//     at new Int8Array ()

new Uint32Array(2**31-1)
// Uint32Array(2147483647)

new Uint32Array(2**31)
// Uncaught RangeError: Invalid typed array length: 2147483648
//     at new Uint32Array ()

Теперь о браузере Chrome. В исходном коде V8 есть максимальное разрешённое значение длины типизированного массива v8:: TypedArray: kMaxLength:

  /*
   * The largest typed array size that can be constructed using New.
   */
  static constexpr size_t kMaxLength =
      internal::kApiSystemPointerSize == 4
          ? internal::kSmiMaxValue                  // 2147483647 (но это не точно)
          : static_cast(uint64_t{1} << 32); // 4294967296

К сожалению, v8::TypedArray::kMaxLength никак не прочитать из JS. Единственное, что я смог найти: в Node.js начиная с 14 версии значения JSArrayBuffer::kMaxByteLength и JSTypedArray::kMaxLength (оно равно v8::TypedArray::kMaxLength) доступны при включённой опции --allow-natives-syntax в виде функций %ArrayBufferMaxByteLength() и %TypedArrayMaxLength():

$ nvm use v16 && node --allow-natives-syntax
Welcome to Node.js v16.8.0.
Type ".help" for more information.
> %ArrayBufferMaxByteLength()
9007199254740991
> %TypedArrayMaxLength()
4294967296
> .exit

$ nvm use v14 && node --allow-natives-syntax
Welcome to Node.js v14.17.5.
Type ".help" for more information.
> %ArrayBufferMaxByteLength()
9007199254740991
> %TypedArrayMaxLength()
4294967295
> .exit

$ nvm use v12 && node --allow-natives-syntax
Welcome to Node.js v12.18.1.
Type ".help" for more information.
> %ArrayBufferMaxByteLength()
%ArrayBufferMaxByteLength()
                          ^
Uncaught SyntaxError: ArrayBufferMaxByteLength is not defined
> %TypedArrayMaxLength()
%TypedArrayMaxLength()
                     ^
Uncaught SyntaxError: TypedArrayMaxLength is not defined
> .exit

Но вернёмся к браузеру Chrome. При попытке создать типизированный массив с длиной, превышающей v8::TypedArray::kMaxLength, браузер выбросит ошибку:

> new Int8Array(2**33)
Uncaught RangeError: Invalid typed array length: 8589934592

Если длина будет меньше максимальной, но массив не поместится в память, браузер выбросит ошибку с другим сообщением:

> new Int8Array(2**32)
Uncaught RangeError: Array buffer allocation failed

В браузере Firefox максимальный объём типизированного массива в байтах maxByteLength() вычисляется как равный максимальному размеру внутреннего буфера ArrayBufferObject: maxBufferByteLength (). В 64-битной архитектуре с поддержкой больших буферов он равен 8 ГБ, в остальных случаях — 2 ГБ (2147483647, константа INT32_MAX). Максимальная разрешённая длина массива рассчитывается как maxByteLength() / BYTES_PER_ELEMENT, причём эти расчёты разбросаны по всему коду, например здесь и здесь. Как и в Chrome, эти значения недоступны из JS.

При невозможности создать типизированный массив желаемой длины N можно увидеть такие сообщения об ошибке (обратите внимание, что в Node.js и Chrome число N может попасть в сообщение, и это может помешать группировке и подсчёту однотипных ошибок):

С помощью этого и этого скрипта я определил максимальные достижимые размеры Int8Array и Int32Array:

Для всех браузеров кроме Chrome Android максимальная длина Int32Array в четыре раза меньше длины Int8Array (Int8 — это один байт, Int32 — четыре байта, поэтому элементов Int32 в той же памяти может поместиться в четыре раза меньше, чем элементов Int8). В Chrome Android я, скорее всего, столкнулся с нехваткой физической памяти телефона.


String

Длина строки находится в поле length. В Node.js максимальное значение length ограничено константой.

require('buffer').constants.MAX_STRING_LENGTH

Обратите внимание, что это не количество букв в строке, а именно число в поле length (в строгой формулировке — количество UTF-16 code units). Разные буквы и символы могут состоять из одного или двух code units:

console.log('Z'.length); // 1
console.log('Я'.length); // 1
console.log('
    
            

© Habrahabr.ru