Память в браузерах и в Node.js: ограничения, утечки и нестандартные оптимизации
Интро: почему я написал эту статью
Меня зовут Виктор, я разрабатываю страницу результатов поиска Яндекса. Несмотря на внешнюю простоту, поисковая выдача — сложная штука: на каждый запрос генерируется своя уникальная страница, на которой в зависимости от запроса может присутствовать блок Картинок, Карты, Переводчик, видеоплеер и многие другие компоненты. Все они должны запускаться и работать в памяти обычных бюджетных телефонов, которые использует большинство наших пользователей. Браузерам должно хватать ресурсов, чтобы пользователь не видел вот такого:
На своих серверах мы должны генерировать сотни миллионов уникальных страниц в сутки — это сложнее, чем просто отдавать одни и те же ресурсы. Генерация страницы не должна быть слишком требовательной к памяти сервера.
Разрабатывая проект на JavaScript (TypeScript, ClojureScript или каком-то другом языке, транслируемом в JavaScript), мы привыкли создавать объекты, массивы, строки и вообще писать код, как будто память бесконечна. Это не так. Я расскажу о видах проблем с памятью, о том, какие ограничения мы часто забываем и как их можно преодолеть. В ответ браузеры и пользователи скажут вам спасибо.
Категории проблем с памятью
JavaScript наряду с Java, C# и Python принадлежит к языкам с автоматической сборкой мусора.
Проблемы с памятью в таких языках можно разделить на три категории:
- Не-утечки: код и данные, которые упираются в ограничения по памяти. Ограничения могут быть либо искусственными — заложенными в язык или среду выполнения, либо естественными — вытекающими из характеристик железа, на котором выполняется код.
- Soft-утечки (мягкие, локальные утечки): что-то мешает сборщику мусора освободить память, например, список содержит в одном из элементов ссылку на давно не нужный объект.
- Hard-утечки (жёсткие, глобальные утечки): память не освобождается, пока браузер или вся операционная система не будут перезапущены.
Важно отличать утечки от неоптимального кода с высоким потреблением памяти: иногда слова «у меня страница течёт» некорректно используются там, где открыто жадное до памяти приложение, но утечки при этом отсутствуют.
Далее я подробно расскажу о каждой из трёх категорий: что она из себя представляет, как её обнаружить, как с ней бороться.
Ограничения по памяти для разных типов данных
Систематизировать свои знания по ограничениям меня побудила эта заметка Романа Дворнова.
Heap
Во многих реализациях языков с автоматической сборкой мусора — хоть и не во всех — для динамического выделения памяти используется куча, она же heap. Например, это языки на основе JVM — Java, Kotlin и так далее. Кучу использует и движок JavaScript V8. Соответственно, размер кучи — самое главное ограничение, с которого надо начинать разбираться.
Компактное и интересное описание того, как в V8 устроена память вообще и куча в частности, можно найти в этой статье. Вот самая ценная картинка оттуда:
В 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):
В других браузерах — 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('