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

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

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


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



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


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


  • Категории проблем с памятью
  • Ограничения по памяти для разных типов данных
     Heap
     Buffer, TypedArray
     String
     Map, Set
     Call stack
     Типичные задачи, в которых можно наткнуться на ограничения по памяти
  • Soft-утечки
     Пример из продакшена
     Как получаются soft-утечки
     Как их обнаружить
     Как найти причину
      1. Memory Allocation Timeline
      2. Техника трёх снапшотов
      3. queryObjects
      Тренируемся находить утечки
  • Hard-утечки
     Пример из продакшена
     Как бороться
  • Нестандартные оптимизации памяти в Node.js
     Исходный код
     Module._pathCache
     Несколько версий пакета в node_modules
     require('./data.json')
  • Заключение


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


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}

  • Описание возвращаемого результата на MDN.
  • Поддержка в браузерах на сайте Can I use.

С появлением 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-битная) и объёма физической памяти.

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


Версия Heap size 32-bit Heap size 64-bit
Node.js <= 11 700 МБ 1400 МБ
Chrome <= 82, Node.js 12 Физ. память / 4, но не меньше 128 МБ и не больше 1 ГБ Физ. память / 2, но не меньше 256 МБ и не больше 2 ГБ
Chrome >= 83, Node.js >= 14 См. выше См. выше. Если физ. памяти >= 16 ГБ, то максимум увеличен до 4 ГБ

Подробности о значениях и логике выбора в 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 32-bit 64-bit
8.2.0…13 2**30-1 (~1 ГБ) 2**31-1 (~2 ГБ)
14 2**30-1 (~1 ГБ) 2**32-1 (~4 ГБ)
15…16 2**30-1 (~1 ГБ) 2**32 (4 ГБ)

Пример кода:


// 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 (<anonymous>)

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

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

Теперь о браузере 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<size_t>(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 может попасть в сообщение, и это может помешать группировке и подсчёту однотипных ошибок):


Платформа Сообщение
Node.js RangeError: Invalid typed array length: N
Chrome RangeError: Invalid typed array length: N
Chrome RangeError: Array buffer allocation failed
Safari Error: Out of memory
Firefox RangeError: invalid array length

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


Версия Макс. длина Int8Array Макс. длина Int32Array
Chrome 99…102 MacOS и Windows 2_145_386_496 (2**31-2_097_152, ~2 ГБ) 536_346_624 (2**29-524_288, ~512 МБ)
Chrome 99 Android 1_073_741_823 (2**30-1, ~1 ГБ) 334_082_048
Safari 13 MacOS 2_147_483_647 (2**31-1, ~2 ГБ) 536_870_911 (2**29-1, ~512 МБ)
Firefox 96…100 MacOS и Windows 8_589_934_592 (2**33, 8 ГБ) 2_147_483_648 (2**31, 2 ГБ)

Для всех браузеров кроме 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('						
Источник: https://habr.com/ru/company/yandex/blog/666870/


Интересные статьи

Интересные статьи

Это вольный перевод поста одного из сильных разработчиков Postgres - Andres Freund. Кроме того что разработчик сильный, так еще и статья довольно интересная и раскрывает детали того как р...
Большинство разработчиков редко задумываются о том, как реализовано управление памятью в JavaScript. Движок обычно делает все за программиста, так что последнему нет смысла размышлять...
Доброго времени суток, друзья! В подавляющем большинстве случаев нам, как JavaScript-разработчикам, не нужно беспокоиться о работе с памятью. Движок это делает за нас. Тем не ме...
Пожалуй, все java-разработчики, участвующие в коммерческих проектах рано или поздно сталкиваются с проблемой утечки памяти, влекущей за собой медленную работу приложения и почти неизб...
Привет, друзья! Меня зовут Петр, я представитель малого белорусского бизнеса со штатом чуть более 20 сотрудников. В данной статье хочу поделиться негативным опытом покупки 1С-Битрикс. ...