- PVSM.RU - https://www.pvsm.ru -
Меня зовут Виктор, я разрабатываю страницу результатов поиска Яндекса. Несмотря на внешнюю простоту, поисковая выдача — сложная штука: на каждый запрос генерируется своя уникальная страница, на которой в зависимости от запроса может присутствовать блок Картинок, Карты, Переводчик, видеоплеер и многие другие компоненты. Все они должны запускаться и работать в памяти обычных бюджетных телефонов, которые использует большинство наших пользователей. Браузерам должно хватать ресурсов, чтобы пользователь не видел вот такого:
На своих серверах мы должны генерировать сотни миллионов уникальных страниц в сутки — это сложнее, чем просто отдавать одни и те же ресурсы. Генерация страницы не должна быть слишком требовательной к памяти сервера.
Разрабатывая проект на JavaScript (TypeScript, ClojureScript или каком-то другом языке, транслируемом в JavaScript), мы привыкли создавать объекты, массивы, строки и вообще писать код, как будто память бесконечна. Это не так. Я расскажу о видах проблем с памятью, о том, какие ограничения мы часто забываем и как их можно преодолеть. В ответ браузеры и пользователи скажут вам спасибо.
JavaScript наряду с Java, C# и Python принадлежит к языкам с автоматической сборкой мусора [27].
Проблемы с памятью в таких языках можно разделить на три категории:
Важно отличать утечки от неоптимального кода с высоким потреблением памяти: иногда слова «у меня страница течёт» некорректно используются там, где открыто жадное до памяти приложение, но утечки при этом отсутствуют.
Далее я подробно расскажу о каждой из трёх категорий: что она из себя представляет, как её обнаружить, как с ней бороться.
Систематизировать свои знания по ограничениям меня побудила эта заметка [28] Романа Дворнова.
Во многих реализациях языков с автоматической сборкой мусора — хоть и не во всех — для динамического выделения памяти используется куча [29], она же heap. Например, это языки на основе JVM — Java, Kotlin и так далее. Кучу использует и движок JavaScript V8. Соответственно, размер кучи — самое главное ограничение, с которого надо начинать разбираться.
Компактное и интересное описание того, как в V8 устроена память вообще и куча в частности, можно найти в этой статье [30]. Вот самая ценная картинка оттуда:
В Chrome и других браузерах, основанных на Chromium, текущее состояние хипа можно узнать из performance.memory
:
> console.log(performance.memory)
MemoryInfo {totalJSHeapSize: 10000000, usedJSHeapSize: 10000000, jsHeapSizeLimit: 3760000000}
С появлением performance.memory
в Chrome связана интересная история [33]. Во время роста популярности 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()
находится здесь [34].
С получением текущего состояния хипа разобрались, теперь к ограничениям. В Chrome и Node.js максимальный размер хипа определяется при старте:
Насколько мне удалось разобраться, максимальный размер хипа определяется так (если у вас есть дополнения и уточнения — пишите, исправлю):
Версия | 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 можно почитать в этом комментарии к пул-реквесту [35]. Соответствующий код в движке V8: Heap::HeapSizeFromPhysicalMemory [36], ResourceConstraints::ConfigureDefaults [37].
Параметры командной строки для 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, можно почитать в этой хабрастатье [38].
Максимальная длина одного буфера или типизированного массива в Node.js ограничена константой [39]. Сама константа добавлена в 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 [40]:
/*
* 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
) доступны [41] при включённой опции --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()
[42] вычисляется как равный максимальному размеру внутреннего буфера ArrayBufferObject::maxBufferByteLength() [43]. В 64-битной архитектуре с поддержкой больших буферов он равен 8 ГБ, в остальных случаях — 2 ГБ (2147483647, константа INT32_MAX
). Максимальная разрешённая длина массива рассчитывается как maxByteLength() / BYTES_PER_ELEMENT
, причём эти расчёты разбросаны по всему коду, например здесь [44] и здесь [45]. Как и в 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 |
С помощью этого [46] и этого [47] скрипта я определил максимальные достижимые размеры 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 я, скорее всего, столкнулся с нехваткой физической памяти телефона.
Длина строки находится в поле length [48]. В Node.js максимальное значение length
ограничено константой [39].
require('buffer').constants.MAX_STRING_LENGTH
Обратите внимание, что это не количество букв в строке, а именно число в поле length
(в строгой формулировке — количество UTF-16 code units). Разные буквы и символы могут состоять из одного или двух code units:
console.log('Z'.length); // 1
console.log('Я'.length); // 1
console.log('
'.length); // 2
Как один code point (для того же смайлика) превращается в два code unit, можно прочитать в спецификации ES2016 [49].
В браузерах тоже есть ограничения на максимальную длину строки. Конкретные значения я определил с помощью такого скрипта [50] и свёл в таблицу:
Версия | string.length |
---|---|
Node.js 12 | 1_073_741_799 (2**30-25, ~1 ГБ) |
Node.js 16 | 536_870_888 (2**29-24, ~512 МБ) |
Chrome 94…101 MacOS и Windows | 536_870_888 (2**29-24, ~512 МБ) |
Chrome 94 Android | Определить не удалось, примерно на 250 мегабайтах на моём телефоне падает вкладка или весь браузер |
Safari 13 | 2_147_483_647 (2**31-1, ~2 ГБ) |
Firefox <= 64 | 268_435_455 (2**28-1, ~256 МБ) |
Firefox >= 65 [51] | 1_073_741_822 (2**30-2, ~1 ГБ) |
Спецификация ES2016 [52] | 9_007_199_254_740_991 (2**53-1) |
Два важных момента:
При превышении допустимой длины строки Node.js и браузеры выдают такие сообщения об ошибке:
Платформа | Сообщение |
---|---|
Node.js, Chrome | RangeError: Invalid string length |
Safari | Error: Out of memory |
Firefox | InternalError: allocation size overflow |
В V8 (а следовательно, и в использующих его Node.js и Chrome) есть жёсткое ограничение на количество ключей в Map
и Set
. При превышении выбрасывается ошибка. В Node.js её текст довольно забавный — см. далее в таблице.
Браузер Firefox в текущей версии захардкоженных лимитов не имеет [54]. При добавлении новых данных в Map
и Set
он начинает всё сильнее тормозить, потом при первом запуске всё-таки выбрасывает ошибку «out of memory». При повторном запуске кода в том же окне он ещё сильнее тормозит, и ошибки дождаться практически невозможно. Кстати, «out of memory» — это не объект класса Error
со стеком, который обычно бросается как-то так...
throw new Error("out of memory");
… а простая строка [55], которую в JavaScript можно бросить вместо объекта ошибки:
throw "out of memory";
То есть из такой перехваченной ошибки получить стек и прийти по нему к проблемному месту не получится — поле stack
у строки в принципе отсутствует.
Браузер Safari ведёт себя похоже на Firefox — тоже не имеет лимитов, тоже при добавлении данных в Map
и Set
начинает всё сильнее тормозить, и получить ошибку я так ни разу и не смог.
Доступных из JS констант для максимального количества ключей я нигде не нашёл, значения определял с помощью скрипта [56]:
Версия | Количество ключей в Map и Set |
---|---|
Node.js 12…16 | 16_777_216 (2**24) |
Chrome 94…101 MacOS и Windows | 16_777_216 (2**24) |
Chrome 101 Android | 16_777_216 (2**24) |
Safari 13 | >15_000_000 (сильно тормозит) |
Firefox 92…96 MacOS и Windows | >131_000_000 (сильно тормозит) |
При превышении допустимого количества ключей в Node.js и Chrome и при исчерпании памяти в Firefox можно увидеть такие сообщения об ошибке:
Платформа | Сообщение |
---|---|
Node.js | RangeError: Value undefined out of range for undefined options property undefined |
Chrome | RangeError: Map maximum size exceeded |
Chrome | RangeError: Set maximum size exceeded |
Safari | (сильно тормозит, не дождался ошибки) |
Firefox | out of memory |
Глубина стека вызовов (максимальное количество вложенных вызовов функций, которое можно сделать) сейчас ни в одной среде выполнения жёстко не зафиксирована и зависит от разных факторов:
fn()
, fn.call()
, fn.apply()
). В комментарии к старому багрепорту в Firefox [57] отмечалось, что в одной особенно неудачной ночной сборке Firefox под Linux при использовании fn.call()
вместо fn()
глубина стека уменьшалась примерно с 7000-40 000 всего до 500. В первом приближении можно считать, что во всех браузерах fn.call()
занимает больше памяти на стеке, чем простой вызов функции fn()
.При превышении допустимой вложенности вызовов Node.js и браузеры выдают такие сообщения об ошибке:
Платформа | Сообщение |
---|---|
Node.js, Chrome | RangeError: Maximum call stack size exceeded |
Safari | RangeError: Maximum call stack size exceeded |
Firefox | InternalError: too much recursion |
Значения глубины стека я определил с помощью такого скрипта [59] и, округлив (повторю, что они меняются от запуска к запуску), свёл в таблицу:
Браузер | fn() без параметров |
fn(a, b, c, d, e) |
fn.call(this) без параметров |
fn.call(this, a, b, c, d, e) |
---|---|---|---|---|
Node.js 12 MacOS 10.15 | 15 700 | 7000 | 14 000 | 6300 |
Node.js 16 MacOS 10.15 | 14 000 | 6600 | 12 600 | 6000 |
Chrome 97 Android | 24 500 | 11 600 | 22 100 | 10 500 |
Chrome 96 и 97 MacOS 10.15 | 13 900 | 6600 | 12 500 | 6000 |
Chrome 96 и 97 Windows 10 | 13 900 | 6600 | 12 500 | 6000 |
Safari 13 MacOS | 36 000 | 25 000 | 28 000 | 23 000 |
Safari 15 iOS | 7900 | 5500 | 6200 | 5000 |
Firefox 96 MacOS 10.15 (непрогретый код) | 25 100 | 11 500 | 18 400 | 12 100 |
Firefox 96 MacOS 10.15 (прогретый код) | 39 400 | 23 600 | 29 600 | 19 700 |
Firefox 96 Windows 10 (непрогретый код) | 26 500 | 10 100 | 9700 | 8700 |
Firefox 96 Windows 10 (прогретый код) | 39 400 | 23 600 | 29 500 | 19 700 |
Конкретные значения не так важны, важнее увидеть порядок чисел и понять, что счёт допустимой вложенности сколько-нибудь сложных вызовов идёт всего лишь на тысячи. С другой стороны, исходя из багрепортов (вроде вышеупомянутого в Firefox) видим, что некоторые современные сайты и SPA уже имеют максимальную вложенность вызовов больше 500.
Максимальную вложенность вызовов можно оценить. Предположим, что при написании приложения X на фреймворке Y максимальная вложенность компонентов составит N. Например, легко представить десятикратную вложенность компонентов: приложение > страница > шапка > выпадающее меню > попап > список опций > опция > вложенное меню > попап > сообщение о версии приложения. Также предположим, что создание и рендер одного компонента во фреймворке Y требуют M вложенных вызовов. Тогда потребуется N*M
вложенных вызовов. В качестве упражнения предлагаю читателям Хабра самостоятельно найти конкретные значения N и M для своих приложений и фреймворков.
Получается, что сейчас в типичных случаях у нас есть как минимум десятикратный запас по глубине стека, но какая-либо проблема (например, неудачная сборка или инсталляция браузера, недостаток памяти на устройстве или слишком сложный код) может легко съесть этот запас. Приведу пример из личного опыта. На одном из моих прошлых мест работы в службу поддержки обратился пользователь, который не мог запустить наше SPA в Internet Explorer. Я начал разбираться, в логах увидел ошибку переполнения стека, измерил глубину стека в браузере пользователя — она была всего лишь около 270 (причину установить не удалось, помогло универсальное средство — переустановка Windows). Пример из другого проекта: ошибки переполнения стека на телефонах логируются в два раза чаще, чем на десктопах. В основном это касается бюджетных или не самых новых моделей вроде Huawei Y5 Lite, Huawei Honor 6A, Samsung Galaxy J2, Samsung Galaxy J5, Alcatel 1 5033D, BQ BQ-5035, BQ BQ-5514 и тому подобных.
Раз уж упомянул про максимальную вложенность компонентов, то позволю себе небольшой офтопик: размеры дерева компонентов (максимальная ширина, максимальная и медианная глубина, суммарное количество компонентов) неплохо подходят для оценки сложности приложения, а такие же размеры DOM-дерева документа в браузере годятся для оценки сложности вёрстки страницы.
Список, конечно же, не полон. Свои примеры можете присылать в комментариях.
Чтение файла в Node.js в память целиком. Налагаемые при этом ограничения:
Buffer
или String
).В процессе эксплуатации кода рано или поздно кто-то попытается прочитать слишком большой файл.
Как с этим бороться: использовать потоки и/или обрабатывать данные частями (пакетами, чанками).
Работа с JSON при помощи JSON.stringify()
и JSON.parse()
, то есть преобразование объекта в строку и обратно, или, как ещё говорят, сериализация и десериализация. Налагаемые ограничения:
Как и в предыдущем пункте, рано или поздно кто-то попытается обработать слишком большой объём данных.
Как с этим бороться: использовать пакеты @discoveryjs/json-ext [60], json-stream-stringify [61], big-json [62].
Именно такая проблема была в Webpack 4 при использовании webpack-cli
версий 4.2.0 и ниже — когда проект становился слишком большим, внезапно становилось невозможно получить граф зависимостей и информацию о сборке (файл stats.json
). Код записи файла fs.writeFileSync(dest, JSON.stringify(stats.toJson(…)))
упирался в ограничение на максимальную длину строки, получаемой после JSON.stringify()
. Проблема исправлена в webpack-cli@4.3.0
в этом пул-реквесте [63].
Сериализация объекта со множеством ссылок на один и тот же вложенный объект.
const parentObj = {value: 'long string'};
const items = [
{parent: parentObj, data: 1},
{parent: parentObj, data: 2},
{parent: parentObj, data: 3}
];
Массив items
содержит в каждом элементе в поле parent
ссылку на один и тот же объект parentObj
, и на сервере достаточно эффективно хранится в памяти: все parent
ссылаются на один и тот же parentObj
, то есть память расходуется на один объект parentObj
и items.length
ссылок на него. При сериализации для передачи клиенту наступает «взрыв»: в каждом элементе массива повторяется строка сериализованного объекта parentObj
:
JSON.stringify(items);
// '[{"parent":{"value":"long string"},"data":1},{"parent":{"value":"long string"},"data":2},{"parent":{"value":"long string"},"data":3}]'
Довольно просто наткнуться на такую проблему при использовании серверного и клиентского рендеринга вместе: одни и те же компоненты применяются и на сервере, и на клиенте. На сервере рендерим начальное состояние страницы, клиенту отдаём и разметку, и сериализованные данные, клиент производит гидрацию и далее по необходимости ререндерит компоненты.
Как с этим бороться:
Обработка очень больших массивов с помощью map(function(element, index, array) { /* … */ })
и аналогичных операций. Исходный массив передаётся в параметре array
в итератор, следовательно, он должен существовать до последней итерации и сборщик мусора ничего не может с ним сделать. При обработке очень больших массивов мы должны учитывать, что и исходный массив, и результат должны одновременно умещаться в памяти. Чтобы уменьшить потребление памяти, можно написать код так, чтобы все операции изменяли элементы в исходном массиве:
const array = [/* очень много данных */];
for (let i = 0; i < array.length; i++) {
// результат пишем в тот же элемент массива
array[i] = processElement(array[i], i);
}
Сюда же можно отнести использование операторов spread const array2 = [...array]
и rest const [a0, a1, ...array2] = array
— в какой-то момент исходный массив array
и массив array2
существуют в памяти одновременно.
Как с этим бороться: самое главное — внимательно и не спеша дочитать статью и понять, что проблема есть только при обработке очень, очень больших массивов. Не надо срочно переписывать половину проекта. Не надо писать в Твиттере, что «map() сжирает в два раза больше памяти, чем старый добрый цикл for(;;)». Не надо избегать использования map
, filter
, reduce
и всех остальных методов работы с массивами. Надо просто запомнить, что когда придётся обрабатывать действительно большой объём данных, можно использовать следующие приёмы:
.map().filter().reduce()
обрабатывать данные в один проход,
Есть два признака, что в коде возникла именно soft-утечка:
20 апреля 2020 года в релиз одного из наших проектов попал код с утечкой памяти в Node.js. Утечка была не настолько сильной, чтобы код успевал съесть всю память и упасть на машинах разработчиков или во время тестирования релиза, и стала заметна только на боевых серверах. Она проявилась на трёх графиках.
График 1. Количество аварийных завершений Node.js в час
Первым делом дежурные обратили внимание на то, что выросла частота падений Node.js. Падения неизбежно были и раньше, но только в периоды пиковой нагрузки (если экземпляр node не успевает обработать запрос за фиксированный промежуток времени — мы его принудительно перезапускаем) и на особенно тяжёлых запросах (в основном боты, которые парсят выдачу Яндекса, запрашивая по 50-100 документов на страницу), а теперь экземпляры Node.js стали падать и перезапускаться даже ночью, когда частота запросов минимальна.
График 2. Количество минорных сборок мусора (minor GC) в час
Из этого графика видно, что минорные сборки мусора стали чаще. Minor GC происходит, когда заканчивается память в young space — той части кучи, куда попадают свежесозданные объекты. График говорит, что появились какие-то проблемы с памятью, но не позволяет определить причину (рост потребления памяти или утечка).
График 3. Использование кучи (process.memoryUsage().heapUsed
) на всех серверах Node.js, 95-я процентиль, в мегабайтах
Из третьего графика стало ясно, что дело именно в утечке, а не в новом коде, которому просто надо больше памяти. Здесь видны и суточные колебания в зависимости от приходящих пользователей (люди обычно работают днём и спят ночью), и еженедельные (на выходных характер запросов меняется — люди в основном отдыхают, смотрят видео, заказывают доставку еды...), и колебания из-за рестартов отдельных серверов. Это осложняет анализ, но если обратить внимание на локальные минимумы по ночам, становится видно, что в ночь на 21 число локальный минимум стал заметно выше и короче по времени. То есть часть использованной памяти не освободилась, несмотря на сборку мусора. Именно это — признак утечки памяти.
Очень коротко: причина в несовпадении сроков жизни объектов.
Если подробнее, то причина в том, что долгоживущий объект ссылается на короткоживущие (следовательно, сборщик мусора не может их удалить). Эту формулировку можно разбить на два варианта:
Вот конкретные механизмы возникновения утечек, о которых я знаю:
window
в браузере, global
в Node.js) — как прямая, так и через несколько вложенных объектов.setInterval
, обработчики событий и тому подобное). Если не остановить setInterval
, его колбэк останется в памяти со всеми объектами, на которые он ссылается. Если не отписаться от события — аналогично, обработчик события со всеми объектами останется в памяти. Типичный пример: обработчики событий DOMContentLoaded
и load
. Эти события в процессе открытия страницы наступают только один раз, но немногие разработчики от них потом отписываются.В продакшене надо собирать данные о памяти клиентов и серверов. Напомню, что в Chrome это можно сделать с помощью performance.memory
, а в Node.js — с помощью process.memoryUsage()
. По собранным данным можно и нужно строить графики, и очень желательно настроить автоматические алерты, чтобы дежурные получали сигнал при неожиданном росте потребления памяти.
Есть хорошее правило: чем раньше обнаружишь ошибку — тем проще найти причину. Оно применимо и в нашем случае: часть утечек можно обнаружить и быстро устранить ещё до выкатывания релиза.
Если у вас есть А/Б-тестирование, то собираемые данные о памяти должны присутствовать в его результатах (да и другие технические метрики тоже неплохо показывать — например, Core Web Vitals [70]). Это позволит найти и починить утечку ещё на стадии эксперимента. Дополнительный бонус: при А/Б-тестировании легко понять, какой код включался в эксперименте и отсутствовал в контрольной группе, то есть область поиска в коде сильно сужается.
Ещё один этап, на котором можно найти утечку, — тестирование релиза. Для клиентского кода можно написать на Puppeteer тесты для поиска утечек [71]. Основная идея в том, что в Chrome DevTools (а следовательно, и в Puppeteer) есть команда queryObjects() [72] — она возвращает количество объектов, которые живы (на них кто-то ссылается) и принадлежат заданному классу. Если в тестируемом коде есть утечка — количество живых объектов будет расти. Тесты выглядят так [73].
При тестировании релиза серверный код тоже можно и нужно автоматически проверять на отсутствие утечек:
Чтобы понять, работает ли такой тест вообще, определить нужное количество запросов и настроить пороги для падения теста, лучше всего искусственно добавить в код утечку и отработать все детали на ней. Если тест получится достаточно быстрым и надёжным — можно его запускать на каждом пул-реквесте, а не только в релизе.
Мы для страницы результатов поиска сначала использовали 70 000 запросов, сейчас остановились на 50 000. Анализируем полный размер кучи heapTotal
, но графики для rss
, heapUsed
и external
тоже строим и иногда на них смотрим. Тест занимает примерно 45-55 минут. Для пул-реквеста это слишком долго, поэтому автоматически тест запускается только при сборке релиза, но при необходимости в любом пул-реквесте его можно запустить вручную. Приведу примеры получившихся графиков. Красным цветом нарисован heapTotal
для серверного кода, собранного из пул-реквеста, синим цветом — для основной ветки репозитория.
Пример 1. Обычный пул-реквест
Пример 2. Пул-реквест с утечкой памяти
На втором примере хорошо видно: несмотря на колебания, красный график неуклонно ползёт вверх и не собирается останавливаться.
Установить, что где-то в коде есть утечка, — лишь половина дела. Дальше надо понять, где конкретно она находится и каков механизм её возникновения. Проще всего это сделать, воспроизведя утечку, найдя в Chrome DevTools оставшийся в памяти объект и отследив путь от объекта до кода, который его создал.
Для серверного кода способ не меняется — его тоже можно отлаживать в Chrome DevTools. Для этого надо при запуске node указать опцию --inspect
(подробное описание есть в документации [74] по Node.js и статье [75] Пола Айриша):
node --inspect <ваш_скрипт>.js
Я знаю три способа, как в Chrome DevTools найти утекающие объекты.
Memory Allocation Timeline — инструмент в Chrome DevTools, который записывает выделение памяти под объекты и её освобождение. Он хорошо визуализирует, какие участки памяти остаются неосвобождёнными — именно в них и могут находиться утечки (а могут и не находиться). Инструмент применим при поиске утечек, которые можно воспроизвести в течение нескольких минут — именно на столько DevTools хватает ресурсов, чтобы записывать информацию о работе с памятью.
Документация [76] немного устарела (инструмент переименован и перемещён в другую вкладку, внешний вид сильно изменился), но для понимания сути происходящего её всё равно полезно прочитать.
Как пользоваться инструментом (актуальная для Chrome 97 инструкция):
Выбираем Allocation instrumentation on timeline (на скриншоте — внешний вид в Chrome 97).
Вертикальные полоски («палочки») на таймлайне — это память, которая в тот момент времени потребовалась каким-то объектам. Серый цвет — память уже освобождена, синий цвет — до сих пор занята. Полоска может быть частично синей, это значит, что часть памяти ещё используется.
После этого курсором выделяем интересующий нас отрезок времени — DevTools покажет, какие объекты из этого отрезка до сих пор живы. Объекты группируются по типам (конструкторам).
Почти все названия в круглых скобках — (compiled code)
, (array)
, (system)
, как на скриншоте — можно игнорировать. Это внутренние типы движка V8, наших утечек там нет. Исключений, пожалуй, всего два:
(string)
— примитивный тип строки. Если строки за время записи заняли очень большой объём памяти (колонки Shallow Size и Retained Size), то их нужно проанализировать.(closure)
— замыкание. Возникает, когда создаётся новая функция. Они обычно занимают мало памяти, но если создаётся много замыканий с одной и той же функцией внутри, то их тоже нужно проанализировать.Дальше анализируем объекты в памяти, ищем те, которых там быть не должно. Это сложный процесс, который сам по себе достоин отдельной статьи, поэтому здесь я углубляться не буду. Некоторые интересные детали можно узнать из видео:
Полезно потренироваться в использовании Memory allocation timeline на простых примерах с утечками и без них — к этому вопросу я вернусь дальше в статье.
Инструменты Chrome DevTools, кроме непрерывной записи аллокаций, умеют делать одиночные снимки состояния памяти (heap snapshot, далее — просто снапшоты) и сравнивать их между собой.
Memory Allocation Timeline, как я уже упоминал, может записать только несколько минут работы с вашим кодом, а снапшоты памяти можно делать хоть с интервалом в час. Поэтому техника трёх снапшотов незаменима при поиске редко возникающих и сложно воспроизводимых утечек, которые нельзя получить за одну-две минуты.
Кроме того, техника особенно хорошо работает со страницами и приложениями, в которых пользователь после каких-то действий регулярно возвращается к исходному состоянию (на странице Facebook — к фиду новостей, в Gmail — к списку писем и так далее).
Итак, пошаговая инструкция:
Делаем первый снапшот памяти.
Показываем в третьем снапшоте только те объекты, которые были созданы между первым и вторым снапшотом (Objects allocated between Snapshot 1 and Snapshot 2). Это очень важный момент, в котором часто ошибаются, поэтому повторю ещё раз: в третьем снапшоте смотрим только объекты, созданные между первым и вторым снапшотами.
Таким образом мы значительно уменьшаем количество объектов для проверки на утечку. Исключаются:
Ключевая идея техники трёх снапшотов: при отсутствии утечки все объекты, созданные между первым и вторым снапшотами (в пункте 3), должны освободиться в пункте 5 и исчезнуть из третьего снапшота.
При анализе обращаем внимание на количество объектов, равное или кратное количеству повторений в пункте 3 (количество объектов видно в колонке Constructor). Посмотрим на примере из скриншота: если мы проделали действие c утечкой 16 раз и видим «Child x16», «system / Context x16», «(closure) x160» — можем предположить, что на каждое наше действие создаётся и остаётся в памяти по одному экземпляру класса Child
и по 10 замыканий (closure)
.
Названия в круглых скобках вроде (compiled code)
(я уже упоминал про них выше по тексту) игнорируем, кроме (string)
и (closure)
. В (closure)
смотрим, для каких именно функций создаются замыкания: если там повторяется одна и та же функция, то в этом месте может быть утечка. Пример: на скриншоте ниже между первым и вторым снапшотами создано и осталось в памяти 11 замыканий для одной и той же функции init()
, расположенной в 119-й строке файла index.js
. Это очень похоже на утечку. В этом примере дальнейшим шагом будет определение, кто создаёт эти замыкания и как они удерживаются в памяти.
Я уже упоминал, что в Chrome DevTools есть команда queryObjects() [72], которая возвращает количество живых объектов заданного класса (с заданным конструктором).
Этой командой можно проверить, накапливаются ли в памяти объекты, и уточнить, какие именно:
> queryObjects(Object)
Array(54700)
> queryObjects(Object)
Array(54892)
> queryObjects(Object)
Array(55080) // количество всех объектов в памяти растёт
> queryObjects(Function)
Array(34232)
> queryObjects(Function)
Array(34302)
> queryObjects(Function)
Array(34347) // количество функций растёт
> queryObjects(HTMLElement)
Array(79)
> queryObjects(HTMLElement)
Array(79)
> queryObjects(HTMLElement)
Array(79) // количество DOM-элементов не меняется
Это скорее вспомогательный приём. Он работает только из DevTools (и из инструментов, которые умеют в Chrome DevTools Protocol [77]), требует явного указания класса объектов (соответствующий класс должен быть в глобальной области видимости) — зато позволяет быстро проверить конкретные сценарии утечек (накапливаются ли в памяти функции или ссылки на detached DOM-элементы) и тут же проинспектировать соответствующие объекты.
На чём тренироваться:
Дальше можно объединиться с коллегой по проекту: он добавляет утечку в вашу ветку кода в репозитории, вы — в его ветку, и каждый ищет утечку у себя в ветке, не заглядывая в коммиты.
Также полезно поисследовать код без утечек, чтобы понимать, как в Chrome DevTools выглядит «здоровое» приложение.
Ещё раз приведу ссылки на статью [33] и видео [83] от разработчиков Google про утечки в Gmail — там хорошо и понятно рассказано о мониторинге памяти, поиске и устранении утечек. Могут оказаться полезными старые, но всё ещё годные статьи: эта [84] и эта [85]. Перевод хорошей статьи про soft-утечки есть [86] на Хабре.
Ещё раз повторю главное отличие hard-утечек от soft-утечек: чтобы освободить утёкшую память, надо перезапустить приложение (браузер или Node.js) или даже перезагрузить операционную систему.
У меня есть только один довольно старый пример, который заодно проливает свет на такую же старую загадку, когда-то мучившую верстальщиков.
Много лет назад был такой браузер: Internet Explorer 7. Одна из проблем IE7 была связана с масштабированием (интерполяцией) больших изображений — при уменьшении картинка очень сильно портилась визуально. Особенно это касалось чертежей и рисунков, в которых всегда много тонких чётких линий.
В принципе это было исправимо. В CSS браузера было проприетарное свойство -ms-interpolation-mode
, управляющее режимом интерполяции. Оно имело два значения: bicubic
(бикубическая интерполяция изображения) и nearest-neighbor
(интерполяция по соседним пикселям), и значением по умолчанию служило именно nearest-neighbor
. Достаточно было дописать в CSS:
img {
-ms-interpolation-mode: bicubic;
}
И уменьшенные картинки начинали выглядеть намного лучше без заметного влияния на быстродействие.
Единственный вопрос, который возникал у многих в то время: ну почему разработчики IE7, реализовав качественный и достаточно быстрый алгоритм масштабирования, не включили его по умолчанию?!
На возможный ответ натолкнулись мои коллеги, когда разрабатывали просмотрщик отсканированных документов для одного крупного заказчика, у которого были в основном компьютеры с IE7. Они, конечно же, натолкнулись на очень некрасивое масштабирование отсканированных чертежей и схем формата A0, быстро нашли, что проблему можно исправить с помощью вышеуказанного фрагмента CSS, потроллили разработчиков IE7, которые не включили -ms-interpolation-mode: bicubic
по умолчанию, зарелизили новую версию просмотрщика...
… и начали получать багрепорты от заказчика: браузер тормозит, компьютеры к концу дня становятся колом, работать невозможно и так далее. Перезагрузка Windows помогала, но постепенно всё опять начинало тормозить. Когда коллегам удалось воспроизвести проблему на машине у себя в офисе и увидеть в диспетчере задач, что у IE7 при листании документов в просмотрщике растёт потребление памяти, — стало понятно, что дело в утечке. При этом обновление страницы как раз не помогало — память освобождалась только при закрытии всего браузера целиком (возможно, достаточно было закрыть вкладку с просмотрщиком — это было давно, я уже не помню). Перебрав все попавшие в релиз изменения, они обнаружили, что при удалении из CSS правила про -ms-interpolation-mode
или при замене на -ms-interpolation-mode: nearest-neighbor
утечка пропадает.
Только тогда стало понятно, почему разработчики браузера реализовали бикубическую интерполяцию, но не включили её в IE7 по умолчанию. Скорее всего, они не успели к релизу починить утечку и просто отключили приводящее к утечке поведение.
К сожалению, здесь мало что можно сделать:
Вот я и добрался до самого нетривиального. Нестандартные оптимизации могут пригодиться в ситуации, когда остальные возможности исчерпаны, но на серверах с Node.js надо освободить ещё немного памяти.
Node.js выделяет память на довольно неожиданные вещи, которые либо вообще не нужны, либо легко оптимизируются. Дальше я расскажу об оптимизациях, которые обнаружил сам.
Когда Node.js выполняет загрузку модуля через require
или import
, исходный код этого модуля в виде строки читается в память и остаётся там до самого конца.
В одном большом проекте мы собрали весь серверный код в три бандла:
После этого легко увидеть, что строки с исходным кодом занимают около 28 МБ памяти:
То есть комментарии, отступы, обёртки function (exports, require, module, __filename, __dirname)
вокруг CommonJS-модулей — всё это занимает память сервера. А исходный код в процессе работы практически никогда и никому не нужен.
Мы провели минимальную минификацию — убрали только отступы и комментарии, не трогая переводы строк, имена методов и переменных, чтобы стектрейсы ошибок остались понятными и полезными. Основной бандл «похудел» с 23 до 15 МБ.
Пул-реквест с таким минифицированным серверным кодом показал заметное уменьшение потребления памяти:
Module._pathCache
Node.js при загрузке модулей строит полный (абсолютный) путь к файлу из относительного, который указывается в require()
и import
. Построение полного пути — довольно медленный процесс, а одни и те же модули могут грузиться много раз из разных мест в коде. Для ускорения пути запоминаются в кэше с логичным названием Module._pathCache
.
Код кэширования:
Пути на сервере могут быть очень длинными, а модулей может быть очень много, поэтому кэш порой разрастается до десятков мегабайт:
После старта и прогрева сервера, когда все модули уже загружены в память, этот кэш становится бесполезен. Его можно очистить следующим кодом:
Object.keys(Module._pathCache)
.forEach(key => delete Module._pathCache[key]);
Это безопасно — при отсутствии пути в кэше получим только замедление, если понадобится загрузить какой-нибудь модуль ещё раз.
Если в node_modules оказывается несколько версий пакета (например, lodash@4.17.21
и lodash@4.17.15
одновременно, или вообще пять разных версий Moment.js с его гигантскими наборами данных для локалей и таймзон), то для каждой версии будет тратиться память на вышеперечисленные пункты. Устранение дублей поможет её сэкономить.
Чтобы найти дубли, я пользуюсь двумя инструментами:
Я использую dependencies-tree-builder в таком скрипте:
const chalk = require('chalk');
const buildTreeAsync = require('dependencies-tree-builder');
/**
* Максимально допустимое число разных версий пакетов, попадающих в сборку.
* Возможны разные валидные комбинации, например 12 версий одного пакета
* или 4 пакета, каждый трёх разных версий.
*
* @type {number}
*/
const MAX_COUNT_OF_DUPLICATED_PACKAGES = 12;
async function check({log}) {
const packageJson = require('./package.json');
let tree;
try {
tree = await buildTreeAsync(packageJson);
} catch (e) {
// При ошибке чистим кэш и пробуем снова
// Код в catch можно будет удалить после https://github.com/itwillwork/dependencies-tree-builder/pull/4
const fs = require('fs');
const path = require('path');
const CACHE_FILE = path.resolve(__dirname, './node_modules/dependencies-tree-builder/.packages.cache.json');
log(chalk.gray('Чистим кэш ' + CACHE_FILE));
fs.unlinkSync(CACHE_FILE);
const PackageCollector = require('dependencies-tree-builder/src/package_collector');
PackageCollector._cacheInstance = {};
// Пробуем снова
tree = await buildTreeAsync(packageJson);
}
const packages = Object.entries(tree.scoupe)
.filter(([, versions]) => Object.keys(versions).length > 1);
const countOfDuplicates = packages
.reduce((count, [, versions]) => count + Object.keys(versions).length, 0);
const pl = new Intl.PluralRules('ru');
const packagePlural = {one: 'пакет', few: 'пакета', many: 'пакетов'};
const versionPlural = {one: 'версия', few: 'версии', many: 'версий'};
if (countOfDuplicates <= MAX_COUNT_OF_DUPLICATED_PACKAGES) {
log(chalk.gray(
`В package.json есть ${packages.length} ${packagePlural[pl.select(packages.length)]} с несколькими версиями ` +
`(всего ${countOfDuplicates} ${versionPlural[pl.select(countOfDuplicates)]})`
));
return;
}
log(chalk.red.bold(
`В package.json есть ${packages.length} ${packagePlural[pl.select(packages.length)]} с несколькими версиями ` +
`(всего ${countOfDuplicates} ${versionPlural[pl.select(countOfDuplicates)]}):`
));
for (const [packageName, versions] of packages) {
log(chalk.blue(packageName) + ' ' + Object.keys(versions).join(', '));
Object.entries(versions).forEach(([version, {usages}]) => {
usages = usages.map(usage => (usage.length > 1) ? usage.slice(1).join('->') : usage[0]);
log(' ' + chalk.green(version) + ': ' + usages.join(', '));
});
}
}
check(console);
Скрипт считает количество дублей и показывает, кто именно использует разные версии одного и того же пакета.
Найденные дубли можно убрать несколькими способами:
"lodash": "4.17.15"
, а у другого указан диапазон версий "lodash": "^4.17.0"
, то мы можем заставить npm использовать и там и там одну версию, добавив явную зависимость "lodash": "4.17.15"
.alias
[93] в webpack.config.js, если код собирается через webpack.require('./data.json')
В Node.js есть штатная возможность читать файлы JSON как CommonJS-модули через require()
:
const data = require('./data.json');
Плюс такого подхода — простота и краткость. Но есть и два минуса:
Module
, который занимает примерно в полтора раза больше памяти, чем исходный файл JSON. То есть для файла data.json
размером 10 МБ будет израсходовано около 15 МБ оперативной памяти.Если на сервере надо читать большие JSON'ы (или много мелких), то можно уменьшить потребление памяти, переписав их загрузку:
// Плохо:
const data = require('./data.json');
// Лучше:
const data = JSON.parse(fs.readFileSync('./data.json', 'utf-8'));
// Для больших JSON'ов так ещё лучше:
const {parseChunked} = require('@discoveryjs/json-ext');
const data = await parseChunked(fs.createReadStream('data.json'));
Я бы хотел, чтобы эта статья помогла вам:
Автор: Виктор Хомяков
Источник [94]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/javascript/375730
Ссылки в тексте:
[1] Категории проблем с памятью: https://habr.com/ru/company/yandex/blog/666870/#1
[2] Ограничения по памяти для разных типов данных: https://habr.com/ru/company/yandex/blog/666870/#2
[3] Heap: https://habr.com/ru/company/yandex/blog/666870/#21
[4] Buffer, TypedArray: https://habr.com/ru/company/yandex/blog/666870/#22
[5] String: https://habr.com/ru/company/yandex/blog/666870/#23
[6] Map, Set: https://habr.com/ru/company/yandex/blog/666870/#24
[7] Call stack: https://habr.com/ru/company/yandex/blog/666870/#25
[8] Типичные задачи, в которых можно наткнуться на ограничения по памяти: https://habr.com/ru/company/yandex/blog/666870/#26
[9] Soft-утечки: https://habr.com/ru/company/yandex/blog/666870/#3
[10] Пример из продакшена: https://habr.com/ru/company/yandex/blog/666870/#31
[11] Как получаются soft-утечки : https://habr.com/ru/company/yandex/blog/666870/#32
[12] Как их обнаружить: https://habr.com/ru/company/yandex/blog/666870/#33
[13] Как найти причину: https://habr.com/ru/company/yandex/blog/666870/#34
[14] 1. Memory Allocation Timeline: https://habr.com/ru/company/yandex/blog/666870/#341
[15] 2. Техника трёх снапшотов: https://habr.com/ru/company/yandex/blog/666870/#342
[16] 3. queryObjects: https://habr.com/ru/company/yandex/blog/666870/#343
[17] Тренируемся находить утечки: https://habr.com/ru/company/yandex/blog/666870/#344
[18] Hard-утечки: https://habr.com/ru/company/yandex/blog/666870/#4
[19] Пример из продакшена: https://habr.com/ru/company/yandex/blog/666870/#41
[20] Как бороться: https://habr.com/ru/company/yandex/blog/666870/#42
[21] Нестандартные оптимизации памяти в Node.js: https://habr.com/ru/company/yandex/blog/666870/#5
[22] Исходный код: https://habr.com/ru/company/yandex/blog/666870/#51
[23] Module._pathCache: https://habr.com/ru/company/yandex/blog/666870/#52
[24] Несколько версий пакета в node_modules: https://habr.com/ru/company/yandex/blog/666870/#53
[25] require('./data.json'): https://habr.com/ru/company/yandex/blog/666870/#54
[26] Заключение: https://habr.com/ru/company/yandex/blog/666870/#6
[27] сборкой мусора: https://ru.wikipedia.org/wiki/%D0%A1%D0%B1%D0%BE%D1%80%D0%BA%D0%B0_%D0%BC%D1%83%D1%81%D0%BE%D1%80%D0%B0
[28] эта заметка: https://t.me/gorshochekvarit/29
[29] куча: https://ru.wikipedia.org/wiki/%D0%9A%D1%83%D1%87%D0%B0_(%D0%BF%D0%B0%D0%BC%D1%8F%D1%82%D1%8C)
[30] этой статье: https://deepu.tech/memory-management-in-v8/
[31] на MDN: https://developer.mozilla.org/en-US/docs/Web/API/Performance/memory
[32] на сайте Can I use: https://caniuse.com/mdn-api_performance_memory
[33] интересная история: https://www.html5rocks.com/en/tutorials/memory/effectivemanagement/
[34] здесь: https://nodejs.org/api/process.html#process_process_memoryusage
[35] в этом комментарии к пул-реквесту: https://github.com/nodejs/node/pull/25576#issuecomment-455737693
[36] Heap::HeapSizeFromPhysicalMemory: https://source.chromium.org/chromium/chromium/src/+/main:v8/src/heap/heap.cc;l=259;drc=18e03c38bb106648f1403e7030161b1c2aaaeb61
[37] ResourceConstraints::ConfigureDefaults: https://source.chromium.org/chromium/chromium/src/+/main:v8/src/api/api.cc;l=824;drc=2ffc79b7d4790b7893ecf86bbfd9e3d0153e10b6
[38] в этой хабрастатье: https://habr.com/ru/company/ruvds/blog/454522/
[39] константой: https://nodejs.org/api/buffer.html#buffer_buffer_constants
[40] v8::TypedArray::kMaxLength: https://source.chromium.org/chromium/chromium/src/+/main:v8/include/v8-typed-array.h;l=25
[41] доступны: https://github.com/nodejs/node/blob/8a3f28a05cd22dbdeb9233386344c47c936896e2/deps/v8/src/runtime/runtime-test.cc#L1376-L1386
[42] maxByteLength()
: https://searchfox.org/mozilla-central/rev/da6a85e615827d353e5ca0e05770d8d346b761a9/js/src/vm/TypedArrayObject.h#135
[43] ArrayBufferObject::maxBufferByteLength(): https://searchfox.org/mozilla-central/rev/da6a85e615827d353e5ca0e05770d8d346b761a9/js/src/vm/ArrayBufferObject.h#191
[44] здесь: https://searchfox.org/mozilla-central/rev/da6a85e615827d353e5ca0e05770d8d346b761a9/js/src/vm/TypedArrayObject.cpp#431
[45] здесь: https://searchfox.org/mozilla-central/rev/da6a85e615827d353e5ca0e05770d8d346b761a9/js/src/vm/TypedArrayObject.cpp#512
[46] этого: https://jsfiddle.net/woscyp86/3/
[47] этого: https://jsfiddle.net/5a4r9j3y/
[48] length: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length
[49] спецификации ES2016: https://262.ecma-international.org/7.0/#sec-utf16encoding
[50] такого скрипта: https://jsfiddle.net/2mL7os5r/5/
[51] Firefox >= 65: https://searchfox.org/mozilla-central/rev/da6a85e615827d353e5ca0e05770d8d346b761a9/js/public/String.h#330
[52] Спецификация ES2016: https://262.ecma-international.org/7.0/#sec-ecmascript-language-types-string-type
[53] Number.MAX_SAFE_INTEGER: https://262.ecma-international.org/7.0/#sec-number.max_safe_integer
[54] не имеет: https://searchfox.org/mozilla-central/rev/da6a85e615827d353e5ca0e05770d8d346b761a9/js/src/ds/OrderedHashTable.h#189
[55] простая строка: https://searchfox.org/mozilla-central/rev/da6a85e615827d353e5ca0e05770d8d346b761a9/js/src/vm/JSContext.cpp#265
[56] скрипта: https://jsfiddle.net/p4krbzdt/
[57] комментарии к старому багрепорту в Firefox: https://bugzilla.mozilla.org/show_bug.cgi?id=966173#c20
[58] комментарии: https://bugzilla.mozilla.org/show_bug.cgi?id=966173#c24
[59] такого скрипта: https://jsfiddle.net/zw8fdh0q/1/
[60] @discoveryjs/json-ext: https://www.npmjs.com/package/@discoveryjs/json-ext
[61] json-stream-stringify: https://www.npmjs.com/package/json-stream-stringify
[62] big-json: https://www.npmjs.com/package/big-json
[63] в этом пул-реквесте: https://github.com/webpack/webpack-cli/pull/2190
[64] ленивые коллекции из Lodash: https://lodash.com/docs/4.17.15#lodash
[65] трансдьюсеры из Ramda: https://simplectic.com/blog/2015/ramda-transducers-logs/
[66] пример утечки: https://blog.meteor.com/an-interesting-kind-of-javascript-memory-leak-8b47d2e7f156
[67] здесь: https://mrale.ph/blog/2012/09/23/grokking-v8-closures-for-fun.html
[68] на Хабре: https://habr.com/ru/company/surfingbird/blog/306252/
[69] в Википедии: https://en.wikipedia.org/wiki/Cache_replacement_policies
[70] Core Web Vitals: https://habr.com/ru/company/rambler_and_co/blog/544904/
[71] поиска утечек: https://media-codings.com/articles/automatically-detect-memory-leaks-with-puppeteer
[72] queryObjects(): https://developer.chrome.com/docs/devtools/console/utilities/#queryObjects-function
[73] выглядят так: https://github.com/chrisguttandin/standardized-audio-context/blob/1a2f86afb87dbd5de3db7a3057e74ba67ac54bcd/test/memory/module.js#L111
[74] документации: https://nodejs.org/en/docs/guides/debugging-getting-started/
[75] статье: https://medium.com/@paul_irish/debugging-node-js-nightlies-with-chrome-devtools-7c4a1b95ae27
[76] Документация: https://developer.chrome.com/docs/devtools/memory-problems/allocation-profiler/
[77] Chrome DevTools Protocol: https://github.com/ChromeDevTools/devtools-protocol
[78] песочница: https://codesandbox.io/s/autobind-debounce-memory-leak-jezks?file=/src/Child.js
[79] этом комментарии: https://github.com/andreypopp/autobind-decorator/issues/76#issuecomment-719563300
[80] этой статье: https://developer.chrome.com/docs/devtools/memory-problems/
[81] этой: https://nodesource.com/blog/memory-leaks-demystified
[82] этой: https://blog.logrocket.com/understanding-memory-leaks-node-js-apps/
[83] видео: https://www.youtube.com/watch?v=x9Jlu_h_Lyw
[84] эта: https://newrelic.com/blog/best-practices/using-chrome-developer-tools-to-find-memory-leaks
[85] эта: https://addyosmani.com/blog/taming-the-unicorn-easing-javascript-memory-profiling-in-devtools/
[86] есть: https://habr.com/ru/post/309318/
[87] v12.18.1: https://github.com/nodejs/node/blob/v12.18.1/lib/internal/modules/cjs/loader.js#L178
[88] v16.11.0: https://github.com/nodejs/node/blob/v16.11.0/lib/internal/modules/cjs/loader.js#L194
[89] npm find-dupes: https://docs.npmjs.com/cli/v8/commands/npm-find-dupes
[90] dependencies-tree-builder: https://github.com/itwillwork/dependencies-tree-builder
[91] npm dedupe: https://docs.npmjs.com/cli/v8/commands/npm-dedupe
[92] yarn resolutions: https://yarnpkg.com/configuration/manifest/#resolutions
[93] alias
: https://webpack.js.org/configuration/resolve/#resolvealias
[94] Источник: https://habr.com/ru/post/666870/?utm_source=habrahabr&utm_medium=rss&utm_campaign=666870
Нажмите здесь для печати.