Отладка утечек памяти в приложениях Node.js
Опубликовано: 2022-03-11Однажды я водил Audi с двигателем V8 с двойным турбонаддувом, и его производительность была невероятной. Я ехал со скоростью около 140 миль в час по трассе Ил-80 недалеко от Чикаго в 3 часа ночи, когда на дороге никого не было. С тех пор термин «V8» стал для меня ассоциироваться с высокой производительностью.
Хотя Audi V8 очень мощный, вы все равно ограничены емкостью вашего бензобака. То же самое касается Google V8 — движка JavaScript, лежащего в основе Node.js. Его производительность невероятна, и есть много причин, по которым Node.js хорошо работает во многих случаях, но вы всегда ограничены размером кучи. Когда вам нужно обработать больше запросов в приложении Node.js, у вас есть два варианта: масштабирование по вертикали или масштабирование по горизонтали. Горизонтальное масштабирование означает, что вам нужно запускать больше параллельных экземпляров приложения. Если все сделано правильно, вы сможете обслуживать больше запросов. Вертикальное масштабирование означает, что вам необходимо улучшить использование памяти и производительность вашего приложения или увеличить ресурсы, доступные для вашего экземпляра приложения.
Недавно меня попросили поработать над приложением Node.js для одного из моих клиентов Toptal, чтобы исправить проблему с утечкой памяти. Приложение, сервер API, должно было обрабатывать сотни тысяч запросов каждую минуту. Исходное приложение занимало почти 600 МБ ОЗУ, поэтому мы решили взять горячие конечные точки API и реализовать их заново. Накладные расходы становятся очень дорогими, когда вам нужно обслуживать много запросов.
Для нового API мы выбрали restify с родным драйвером MongoDB и Kue для фоновых заданий. Звучит как очень легкий стек, верно? Не совсем. Во время пиковой нагрузки новый экземпляр приложения может потреблять до 270 МБ ОЗУ. Поэтому моя мечта иметь два экземпляра приложения на 1X Heroku Dyno испарилась.
Арсенал отладки утечки памяти Node.js
Мемвотч
Если вы ищете «как найти утечку в узле», первым инструментом, который вы, вероятно, найдете, является memwatch . Оригинальный пакет давно заброшен и больше не поддерживается. Однако вы можете легко найти более новые версии в списке ответвлений GitHub для репозитория. Этот модуль полезен, потому что он может генерировать события утечки, если видит, что куча увеличивается за 5 последовательных сборок мусора.
Куча дампа
Отличный инструмент, который позволяет разработчикам Node.js делать моментальные снимки кучи и проверять их позже с помощью инструментов разработчика Chrome.
Нод-инспектор
Еще более полезная альтернатива heapdump, потому что она позволяет подключаться к работающему приложению, снимать дамп кучи и даже отлаживать и перекомпилировать его на лету.
Взять «инспектора узлов» на спин
К сожалению, вы не сможете подключиться к рабочим приложениям, работающим на Heroku, потому что он не позволяет отправлять сигналы запущенным процессам. Однако Heroku — не единственная хостинговая платформа.
Чтобы испытать инспектор узлов в действии, мы напишем простое приложение Node.js с использованием restify и поместим в него небольшой источник утечки памяти. Все эксперименты здесь проводятся с Node.js v0.12.7, который был скомпилирован для V8 v3.28.71.19.
var restify = require('restify'); var server = restify.createServer(); var tasks = []; server.pre(function(req, res, next) { tasks.push(function() { return req.headers; }); // Synchronously get user from session, maybe jwt token req.user = { id: 1, username: 'Leaky Master', }; return next(); }); server.get('/', function(req, res, next) { res.send('Hi ' + req.user.username); return next(); }); server.listen(3000, function() { console.log('%s listening at %s', server.name, server.url); });
Приложение здесь очень простое и имеет очень очевидную утечку. Задачи массива будут расти в течение срока службы приложения, что приведет к его замедлению и, в конечном итоге, к сбою. Проблема в том, что мы пропускаем не только замыкание, но и целые объекты запроса.
Сборщик мусора в V8 использует стратегию остановки мира, поэтому это означает, что чем больше объектов у вас в памяти, тем больше времени потребуется для сбора мусора. В логе ниже хорошо видно, что в начале жизни приложения сбор мусора занимает в среднем 20 мс, а спустя несколько сотен тысяч запросов это занимает около 230 мс. Людям, которые пытаются получить доступ к нашему приложению, теперь придется ждать на 230 мс дольше из-за GC. Также вы можете видеть, что сборщик мусора вызывается каждые несколько секунд, что означает, что каждые несколько секунд пользователи будут испытывать проблемы с доступом к нашему приложению. И задержка будет расти до тех пор, пока приложение не рухнет.
[28093] 7644 ms: Mark-sweep 10.9 (48.5) -> 10.9 (48.5) MB, 25.0 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 7717 ms: Mark-sweep 10.9 (48.5) -> 10.9 (48.5) MB, 18.0 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 7866 ms: Mark-sweep 11.0 (48.5) -> 10.9 (48.5) MB, 23.2 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 8001 ms: Mark-sweep 11.0 (48.5) -> 10.9 (48.5) MB, 18.4 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. ... [28093] 633891 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 357.3 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 635672 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 331.5 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 637508 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 357.2 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested].
Эти строки журнала печатаются, когда приложение Node.js запускается с флагом –trace_gc :
node --trace_gc app.js
Предположим, что мы уже запустили наше приложение Node.js с этим флагом. Прежде чем подключить приложение с помощью node-spector, нам нужно отправить ему сигнал SIGUSR1 запущенному процессу. Если вы запускаете Node.js в кластере, убедитесь, что вы подключаетесь к одному из подчиненных процессов.
kill -SIGUSR1 $pid # Replace $pid with the actual process ID
Делая это, мы переводим приложение Node.js (точнее, V8) в режим отладки. В этом режиме приложение автоматически открывает порт 5858 с протоколом отладки V8.
Наш следующий шаг — запустить node-spector, который подключится к интерфейсу отладки работающего приложения и откроет другой веб-интерфейс на порту 8080.
$ node-inspector Node Inspector v0.12.2 Visit http://127.0.0.1:8080/?ws=127.0.0.1:8080&port=5858 to start debugging.
Если приложение работает в рабочей среде и у вас установлен брандмауэр, мы можем туннелировать удаленный порт 8080 на локальный хост:
ssh -L 8080:localhost:8080 [email protected]
Теперь вы можете открыть веб-браузер Chrome и получить полный доступ к инструментам разработки Chrome, прикрепленным к вашему удаленному производственному приложению. К сожалению, инструменты разработчика Chrome не будут работать в других браузерах.
Найдем утечку!
Утечки памяти в V8 не являются настоящими утечками памяти, какими мы их знаем из приложений C/C++. В JavaScript переменные не исчезают в пустоте, они просто «забываются». Наша цель — найти эти забытые переменные и напомнить им, что Добби свободен.
Внутри инструментов разработчика Chrome у нас есть доступ к нескольким профилировщикам. Нас особенно интересует запись выделения кучи , которая запускается и делает несколько моментальных снимков кучи с течением времени. Это дает нам четкое представление о том, какие объекты просачиваются.
Начните записывать выделение кучи и давайте смоделируем 50 одновременных пользователей на нашей домашней странице с помощью Apache Benchmark.
ab -c 50 -n 1000000 -k http://example.com/
Прежде чем делать новые снимки, V8 будет выполнять сборку мусора по меткам, поэтому мы точно знаем, что в снимке нет старого мусора.
Устранение утечки на лету
После сбора снимков распределения кучи в течение 3 минут мы получаем что-то вроде следующего:
Мы можем ясно видеть, что есть несколько гигантских массивов, множество объектов IncomingMessage, ReadableState, ServerResponse и Domain, а также в куче. Попробуем проанализировать источник утечки.
При выборе heap diff на диаграмме от 20 до 40 мы будем видеть только те объекты, которые были добавлены через 20 с после запуска профилировщика. Таким образом, вы можете исключить все обычные данные.
Отмечая, сколько объектов каждого типа находится в системе, мы расширяем фильтр с 20 секунд до 1 минуты. Мы видим, что массивы, уже достаточно гигантские, продолжают расти. Под «(массивом)» мы видим, что есть много объектов «(свойства объекта)» с одинаковым расстоянием. Эти объекты являются источником нашей утечки памяти.
Также мы можем видеть, что объекты «(замыкание)» также быстро растут.
Также было бы удобно посмотреть на строки. Под списком строк есть много фраз «Hi Leaky Master». Это тоже может дать нам некоторую подсказку.

В нашем случае мы знаем, что строка «Hi Leaky Master» может быть собрана только по маршруту «GET /».
Если вы откроете путь ретейнеров, вы увидите, что на эту строку каким-то образом ссылаются через req , тогда создается контекст, и все это добавляется к некоему гигантскому массиву замыканий.
Итак, на данный момент мы знаем, что у нас есть какой-то гигантский массив замыканий. Давайте на самом деле пойдем и дадим имя всем нашим замыканиям в режиме реального времени на вкладке источников.
После того, как мы закончим редактирование кода, мы можем нажать CTRL+S, чтобы сохранить и перекомпилировать код на лету!
Теперь давайте запишем еще один снимок распределения кучи и посмотрим, какие замыкания занимают память.
Понятно, что SomeKindOfClojure() — наш злодей. Теперь мы видим, что замыкания SomeKindOfClojure() добавляются к некоторым задачам с именами массивов в глобальном пространстве.
Легко видеть, что этот массив просто бесполезен. Можем закомментировать. Но как освободить память, которая уже занята? Очень просто, мы просто присваиваем задачам пустой массив, и при следующем запросе он будет переопределен, а память будет освобождена после следующего события GC.
Добби свободен!
Жизнь мусора в V8
Куча V8 разделена на несколько разных пространств:
- Новое пространство : Это пространство относительно небольшое и имеет размер от 1 МБ до 8 МБ. Здесь размещено большинство объектов.
- Old Pointer Space : имеет объекты, которые могут иметь указатели на другие объекты. Если объект выживает достаточно долго в новом пространстве, он перемещается в старое пространство указателя.
- Старое пространство данных : содержит только необработанные данные, такие как строки, числа в коробках и массивы неупакованных двойников. Сюда же перемещаются объекты, достаточно долго пережившие GC в Новом Пространстве.
- Пространство больших объектов : в этом пространстве создаются объекты, которые слишком велики, чтобы поместиться в другие пространства. Каждый объект имеет в памяти свою собственную
mmap
-область. - Кодовое пространство : содержит ассемблерный код, сгенерированный JIT-компилятором.
- Пространство ячейки, пространство ячейки свойства, пространство карты : это пространство содержит
Cell
s,PropertyCell
s иMap
s. Это используется для упрощения сборки мусора.
Каждое пространство состоит из страниц. Страница — это область памяти, выделенная операционной системой с помощью mmap. Размер каждой страницы всегда составляет 1 МБ, за исключением страниц в пространстве больших объектов.
V8 имеет два встроенных механизма сборки мусора: Scavenge, Mark-Sweep и Mark-Compact.
Scavenge — очень быстрая техника сборки мусора, работающая с объектами в New Space . Scavenge — это реализация алгоритма Чейни. Идея очень проста, Новое Пространство разделено на два равных полупространства: В Космос и Из Космоса. Scavenge GC происходит, когда To-Space заполнен. Он просто меняет местами места «В» и «От» и копирует все живые объекты в пространство «В пространство» или перемещает их в одно из старых пространств, если они пережили две очистки, а затем полностью стирается из пространства. Мусорщики работают очень быстро, однако у них есть накладные расходы, связанные с сохранением кучи двойного размера и постоянным копированием объектов в памяти. Причина использования мусорщиков в том, что большинство объектов умирают молодыми.
Mark-Sweep и Mark-Compact — это еще один тип сборщика мусора, используемый в V8. Другое название — полный сборщик мусора. Он помечает все живые узлы, затем очищает все мертвые узлы и дефрагментирует память.
Производительность сборщика мусора и советы по отладке
Хотя для веб-приложений высокая производительность может быть не такой уж большой проблемой, вы все равно захотите избежать утечек любой ценой. На этапе маркировки в полном сборщике мусора приложение фактически приостанавливается до завершения сборки мусора. Это означает, что чем больше объектов у вас в куче, тем дольше будет выполняться сборка мусора и тем дольше пользователям придется ждать.
Всегда давайте имена замыканиям и функциям
Гораздо проще проверять трассировку стека и кучи, когда все ваши замыкания и функции имеют имена.
db.query('GIVE THEM ALL', function GiveThemAllAName(error, data) { ... })
Избегайте больших объектов в горячих функциях
В идеале вы хотите избегать больших объектов внутри горячих функций, чтобы все данные помещались в New Space . Все операции, связанные с процессором и памятью, должны выполняться в фоновом режиме. Также избегайте триггеров деоптимизации для горячих функций, оптимизированная горячая функция использует меньше памяти, чем неоптимизированные.
Горячие функции должны быть оптимизированы
Горячие функции, которые выполняются быстрее, но при этом потребляют меньше памяти, заставляют GC запускаться реже. V8 предоставляет несколько полезных инструментов отладки для обнаружения неоптимизированных или деоптимизированных функций.
Избегайте полиморфизма для IC в горячих функциях
Встроенные кэши (IC) используются для ускорения выполнения некоторых фрагментов кода либо путем кэширования доступа к свойствам объекта obj.key
, либо какой-либо простой функцией.
function x(a, b) { return a + b; } x(1, 2); // monomorphic x(1, “string”); // polymorphic, level 2 x(3.14, 1); // polymorphic, level 3
Когда x(a,b) запускается в первый раз, V8 создает мономорфную IC. Когда вы вызываете x
во второй раз, V8 стирает старую IC и создает новую полиморфную IC, которая поддерживает оба типа операндов, целые и строковые. Когда вы вызываете IC в третий раз, V8 повторяет ту же процедуру и создает другую полиморфную IC уровня 3.
Однако есть ограничение. После того, как уровень IC достигает 5 (можно изменить с помощью флага –max_inlining_levels ), функция становится мегаморфной и больше не считается оптимизируемой.
Интуитивно понятно, что мономорфные функции работают быстрее всего, а также требуют меньше памяти.
Не добавляйте в память большие файлы
Это очевидно и хорошо известно. Если вам нужно обработать большие файлы, например большой CSV-файл, читайте его построчно и обрабатывайте небольшими фрагментами вместо загрузки всего файла в память. Бывают довольно редкие случаи, когда одна строка csv будет больше 1 МБ, что позволит вам уместить ее в New Space .
Не блокировать основной поток сервера
Если у вас есть какой-то популярный API, обработка которого занимает некоторое время, например API для изменения размера изображений, переместите его в отдельный поток или превратите в фоновое задание. Интенсивные операции ЦП блокируют основной поток, заставляя всех других клиентов ждать и продолжать отправлять запросы. Необработанные данные запроса накапливались в памяти, что приводило к увеличению времени завершения полной сборки мусора.
Не создавайте ненужные данные
Однажды у меня был странный опыт с restify. Если вы отправите несколько сотен тысяч запросов на недопустимый URL-адрес, то память приложения быстро увеличится до сотен мегабайт, пока через несколько секунд не запустится полный сборщик мусора, после чего все вернется на круги своя. Оказывается, для каждого недопустимого URL-адреса restify генерирует новый объект ошибки, который включает в себя длинные трассировки стека. Это заставляло вновь созданные объекты размещаться в пространстве больших объектов , а не в новом пространстве .
Доступ к таким данным может быть очень полезен во время разработки, но, очевидно, не требуется в производственной среде. Поэтому правило простое - не генерируйте данные, если они вам точно не нужны.
Знай свои инструменты
И последнее, но не менее важное: нужно знать свои инструменты. Существуют различные отладчики, ловцы утечек и генераторы графиков использования. Все эти инструменты могут помочь вам сделать ваше программное обеспечение быстрее и эффективнее.
Заключение
Понимание того, как работает сборщик мусора и оптимизатор кода V8, является ключом к производительности приложения. V8 компилирует JavaScript в собственную сборку, и в некоторых случаях хорошо написанный код может обеспечить производительность, сравнимую с приложениями, скомпилированными GCC.
И, если вам интересно, новое приложение API для моего клиента Toptal, хотя и есть возможности для улучшения, работает очень хорошо!
Joyent недавно выпустила новую версию Node.js, в которой используется одна из последних версий V8. Некоторые приложения, написанные для Node.js v0.12.x, могут быть несовместимы с новой версией v4.x. Однако в новой версии Node.js приложения получат огромное улучшение производительности и использования памяти.