[Перевод] Понимание сборки мусора и отлов утечек памяти в Node.js

Плохие отзывы в прессе о Node.js часто относятся к проблемам с производительностью. Это не значит, что с Node.js больше проблем, чем с другими технологиями. Просто пользователь должен иметь в виду некоторые особенности её работы. Хотя у технологии пологая кривая обучения, обеспечивающие её работу механизмы довольно сложные. Необходимо понять их, чтобы предотвратить появление ошибок производительности. И если что-то пойдет не так, необходимо знать, как быстро привести всё в порядок. В этой статье Даниэль Хан рассказывает о том, как Node.js управляет памятью и как отследить связанные с памятью проблемы.

5f4e00697ace420ca2ff18aa23e35adf.jpg
В отличие от платформ вроде PHP, приложения на Node.js являются долгосрочными процессами. В этом есть ряд положительных сторон — например возможность один раз подключиться к базе данных и использовать это соединение для всех запросов. Но эта же особенность может создавать проблемы. Для начала давайте взглянем на основы Node.js.

591b5f6a831f4f10a01b79f0f6333f6e.png
Настоящий австрийский сборщик мусора

Node.js — это С++ программа, контролируемая JavaScript-движком V8

Google V8 — движок, который изначально был написан для Google Chrome, но мог использоваться и автономно. Поэтому он идеально подходит для Node.js и является, по сути, единственной частью платформы, которая «понимает» JavaScript. V8 компилирует JavaScript в машинный код и исполняет его. Во время исполнения движок управляет выделением и очисткой памяти по мере необходимости. Это значит, что если речь заходит об управлении памятью в Node.js, фактически мы говорим о V8.

Здесь можно посмотреть простой пример того, как использовать V8 с точки зрения C++.

Схема памяти V8

Выполняющаяся программа всегда может быть представлена через некоторое количество места, выделенного в памяти. Это место называется Resident Set. V8 использует схему, похожую на схему Java Virtual Machine, и делит память на сегменты:

Code: выполняемый в данный момент код.
Stack: содержит все примитивные типы значений (вроде integer или Boolean) с указателями, ссылающимися на объекты в куче и определяющими поток управления программы.
Heap: сегмент памяти, предназначенный для хранения ссылочных типов вроде объектов, строк и замыканий.

1cdf83aaa96a4402ae1ff7ceb1398ce3.png
Схема памяти V8

В Node.js данные о текущем использовании памяти можно получить, вызвав process.memoryUsage ().

Функция вернет объект, содержащий:

  • размер Resident Set;
  • общий размер кучи;
  • объем используемого в куче места.

Эту функцию можно использовать для записи использования памяти в течение какого-то времени и построения графика, отображающего, как V8 управляет памятью.

9dfd8f9328a94bf897b17c3e9f00391d.png
Использование памяти Node.js в зависимости от времени

Мы видим, что график используемого места в куче является крайне неустойчивым, но всегда остается в определенных границах, чтобы сохранить постоянным значение среднего потребления. Процесс, выделяющий и освобождающий память, называется сборкой мусора.

Введение в сборку мусора

Каждой программе, потребляющей память, необходим механизм резервирования и освобождения пространства. В С и С++ эту функцию выполняют команды malloc () и free (), как показано на примере ниже:

char * buffer;
buffer = (char*) malloc (42);

// Do something with buffer
free (buffer);


Мы видим, что программист отвечает за освобождение неиспользуемой памяти. Если программа только выделяет память и не освобождает её, куча будет расти до тех пор, пока не исчерпается используемая память, что приведет к аварийному завершению программы. Мы называем это утечкой памяти.

Как мы уже знаем, в Node.js JavaScript компилируется в машинный код с помощью V8. Полученные после компиляции структуры данных не могут ничего сделать со своим исходным представлением и просто управляются при помощи V8. Это значит, что мы не можем активно выделять и очищать память в JavaScript. V8 использует для решения этой проблемы широко известный механизм — сборку мусора.

Принцип сборки мусора довольно прост: если на сегмент памяти никто не ссылается, можно считать, что он не используется, и очистить его. Однако процесс получения и хранения этой информации довольно сложный, так как в коде могут присутствовать цепочные ссылки и перенаправления, формирующие сложную структуру графа.

944ca4339eea451fa17005893f4d9e7b.png
Граф кучи. Красный объект может быть удален только в том случае, если на него больше нет ссылок

Сборка мусора — процесс достаточно дорогостоящий, потому что он прерывает выполнение приложения, что, естественно, отражается на производительности. Чтобы исправить эту ситуацию, V8 использует 2 типа сборки мусора:

  • Scavenge — быстрый, но неполный;
  • Mark-Sweep — относительно медленный, но очищает все неиспользуемые ссылки.

Отличный пост, содержащий очень подробную информацию о сборке мусора, можно найти по этой ссылке.

Теперь, посмотрев на график, полученный с помощью process.memoryUsage (), можно легко различить разные типы сборки мусора: рисунок, напоминающий зубцы пилы, отмечает работу Scavenge, падения вниз — Mark-Sweep.

Используя встроенный модуль node-gc-profiler, можно получить ещё больше информации о работе сборщика мусора. Модуль подписывается на события сборщика мусора и транслирует их в JavaScript.

Возвращаемый объект указывает на тип сборки мусора и продолжительность. Опять же, эти данные можно легко отобразить графически, чтобы было понятнее, как всё работает.

92b3c758da8d477b9afc05afa5550f0d.png
Продолжительность и частота запусков сборщика мусора

Отчетливо видно, что Scavenge запускается гораздо чаще, чем Mark-Sweep. В зависимости от сложности приложения, продолжительность может варьироваться. Примечательно, что на этом графике можно увидеть частые и непродолжительные запуски Mark-Sweep, функция которых мне пока не понятна.

Когда что-то идет не так

Если сборщик мусора сам очищает память, с чего бы нам волноваться? На самом деле, в ваших логах с легкостью могут возникнуть утечки памяти.

34d8853c6fde4c418de236e66b601536.png
Исключение, вызванное утечкой памяти

Используя созданный ранее график, мы можем наблюдать, как память постоянно засоряется!

2cf8318b44b040f59f4a30ee7db8ec2e.png
Прогресс утечки памяти

Сборщик мусора делает всё возможное, чтобы освободить память. Но с каждым запуском мы видим, что потребление памяти постоянно увеличивается, а это — явный признак утечки памяти. Раз уж мы знаем, как можно точно обнаружить утечку памяти, давайте посмотрим, что нужно делать, чтобы её вызвать.

Создаем утечку памяти

Некоторые утечки очевидны — как хранение данных в глобальных переменных (например, складывание IP-адресов всех вошедших пользователей в массив). Другие не так заметны — например, известная утечка памяти Walmart из-за пропуска небольшого выражения в коде ядра Node.js, на поиск источника которой ушли недели.

Я не собираюсь рассматривать здесь ошибки в коде ядра. Давайте просто посмотрим на сложноопределяемую утечку в коде из блога Meteor, которую вы запросто можете допустить и в своем коде.

46af9629fb6c49ecbc132d3cfd77c7fd.png
Введение ошибки в ваш JavaScript-код

На первый взгляд выглядит нормально. Можно было бы подумать, что theThing перезаписывается при каждом вызове replaceThing (). Проблема в том, что someMethod имеет собственную закрытую область видимости в качестве контекста. Это значит, что someMethod () знает об unused () и, даже если unused () никогда не будет вызвана, этот факт помешает сборщику мусора освободить память от originalThing. Просто потому, что есть слишком много косвенных вызовов. Это не баг, но может привести к утечкам памяти, которые будет сложно отследить.

Правда было бы здорово, если бы можно было заглянуть в кучу и посмотреть, что там сейчас находится? К счастью, такая возможность есть! V8 позволяет сделать дамп кучи на текущий момент, а V8-profiler — использовать этот функционал для JavaScript.

/**
 * Simple userland heapdump generator using v8-profiler
 * Usage: require('[path_to]/HeapDump').init('datadir')
 *
 * @module HeapDump
 * @type {exports}
 */

var fs = require('fs');
var profiler = require('v8-profiler');
var _datadir = null;
var nextMBThreshold = 0;


/**
 * Init and scheule heap dump runs
 *
 * @param datadir Folder to save the data to
 */
module.exports.init = function (datadir) {
    _datadir = datadir;
    setInterval(tickHeapDump, 500);
};

/**
 * Schedule a heapdump by the end of next tick
 */
function tickHeapDump() {
    setImmediate(function () {
        heapDump();
    });
}

/**
 * Creates a heap dump if the currently memory threshold is exceeded
 */
function heapDump() {
    var memMB = process.memoryUsage().rss / 1048576;

    console.log(memMB + '>' + nextMBThreshold);

    if (memMB > nextMBThreshold) {
        console.log('Current memory usage: %j', process.memoryUsage());
        nextMBThreshold += 50;
        var snap = profiler.takeSnapshot('profile');
        saveHeapSnapshot(snap, _datadir);
    }
}

/**
 * Saves a given snapshot
 *
 * @param snapshot Snapshot object
 * @param datadir Location to save to
 */
function saveHeapSnapshot(snapshot, datadir) {
    var buffer = '';
    var stamp = Date.now();
    snapshot.serialize(
        function iterator(data, length) {
            buffer += data;
        }, function complete() {

            var name = stamp + '.heapsnapshot';
            fs.writeFile(datadir + '/' + name , buffer, function () {
                console.log('Heap snapshot written to ' + name);
            });
        }
    );
}

Этот простой модуль создает файл дампа кучи, если использование памяти постоянно возрастает. Да, существуют гораздо более сложные подходы к определению аномалий, но для наших целей и этого будет достаточно. В случае утечки памяти у вас может оказаться много таких файлов. Так что нужно пристально следить за этим и добавить возможность оповещения в этот модуль. Такой же функционал для работы с дампом кучи предоставляет Chrome, и для анализа дампов V8-profiler можно использовать Chrome Developer Tools.

b5113053f0a042c2b568f88058e5eb56.png
Chrome Developer Tools

Один дамп кучи может и не помочь, потому что вы не будете видеть, как изменяется куча со временем. Поэтому Chrome Developer Tools позволяет сравнивать различные файлы. Сопоставляя 2 дампа, мы получаем дельту значений, которая показывает, какие структуры возрастают между двумя дампами:

0132314dea8d495a9f72f8ec301d3ec5.png
Сравнение дампов показывает нашу утечку

Здесь мы видим нашу проблему. На переменную, которая содержит строку из звездочек и называется longStr, ссылается originalThing, на которую ссылается какой-то метод, на который ссылается… думаю, вы поняли. Это длинная череда вложенных ссылок и контекстов замыканий не позволяет очистить longStr. Хотя этот пример и приводит к очевидным результатам, процесс всегда одинаков:

  • Создать несколько дампов кучи с разницей во времени и с разным объемом выделенной памяти.
  • Сравнить несколько дампов, чтобы понять, какие значения растут.

В заключение

Как видите, процесс сборки мусора является довольно сложным, и даже валидный код может вызвать утечки памяти. Используя встроенную функциональность V8 вместе с Chrome Developer Tools, можно понять, что приводит к утечкам памяти и, если вы встроите этот функционал в свое приложение, иметь всё необходимое для решения подобной проблемы, когда она возникнет.

Остается один вопрос: как можно исправить утечку? Ответ прост: просто добавьте theThing = null; в конец функции, и вы спасены.

© Habrahabr.ru