Найти и обезвредить: утечки памяти в Node.js

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

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

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

Пространство Интернета постоянно пополняется сложными жаргонизмами, в которых весьма непросто разобраться. Данная статья будет построена по другому принципу — просто и понятно. Вы узнаете о том, что такое утечки памяти и каковы их причины; как их легко обнаружить и диагностировать с помощью Chrome Developer Tools. 

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

Вкратце напомню вам структуру памяти в V8. 

Главным образом она делится на две области: стек (stack) и кучу (heap). 

1.Стек — это область памяти, в которой хранятся статические данные, включающие фреймы методов/функций, примитивные значения и указатели на объекты. Он управляется ОС. 

2.Куча — это самая большая область памяти, в которойV8хранит объекты или динамические данные.Здесь же происходит сборка мусора.

Цитируя Дип К Сасидхаран, разработчика и одного из авторов книги “Развитие полного стека с JHipster”, отметим, что 

“V8 управляет памятью кучи с помощью сборки мусора. Проще говоря, он освобождает память, используемую висячими объектами, т.е. объектами, на которые нет прямых или косвенных (через ссылку в другом объекте) ссылок из стека, для освобождения пространства с целью создания нового объекта. 

Сборщик мусора в V8 отвечает за восстановление неиспользуемой памяти для повторного ее применения в процессе работы движка. Сборка мусора происходит по поколениям (объекты в куче распределяются по группам в зависимости от времени жизни и удаляются на разных этапах). В V8 существуют два этапа и три разных алгоритма сборки мусора”. 

Что такое утечки памяти? 

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

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

В чем же причина утечек памяти в JS? 

Автоматическое управление памятью, подразумевающее сборку мусора в V8, предназначено для предотвращения ее утечек. Например, циклические ссылки больше не вызывают беспокойства, но все-таки могут возникать из-за нежелательных ссылок в куче или по каким-либо другим причинам. 

Рассмотрим несколько самых распространенных причин:

1.Глобальные переменные. Поскольку на них в JavaScript ссылается корневой узел (глобальный объект window или ключевое слово this), то они никогда не подвергаются сборке мусора в течение всего жизненного цикла приложения и будут занимать память до тех пор, пока оно выполняется. И это относится ко всем объектам, на которые ссылаются глобальные переменные, а также к их потомкам. Наличие большого графа объектов со ссылками из корня может привести к утечке памяти. 

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

3. Замыкания. ЗамыканияJavaScript обладают превосходным свойством запоминать окружающий их контекст, вследствие чего ссылки на крупные объекты кучи, используемые в них, сохраняются дольше, чем требуется. 

4. Таймеры и события. Использование setTimeout, setInterval, Observers и слушателей событий может вызвать утечки памяти в том случае, если ссылки на объекты кучи хранятся в их обратных вызовах без правильной обработки. 

Лучшие практики для предотвращения утечек памяти 

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

Сокращение использования глобальных переменных 

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

Обходимся без случайных глобальных переменных 

Когда вы присваиваете значение необъявленной переменной, JavaScript по умолчанию определяет ее как глобальную. Это может произойти по ошибке и привести к утечке памяти. Подобное может случиться в результате присвоения переменной к this.

// Эта переменная будет определена как глобальная 
 global() => {
    foo = "Message";
}

// Эта переменная также станет глобальной, поскольку глобальные функции содержат глобальное `this` как контекстуальное в нестрогом режиме. 
 global() => {
    this.foo = "Message";
}

Во избежание таких сюрпризов, всегда пишите код JavaScript в режиме strict и используйте нотацию 'use strict'; в начале файла JS. 

В режиме strict выше приведенный пример приведет к ошибке. Однако при использовании ES модулей и компиляторов, таких как TypeScript или Babel, нет необходимости включать данный режим, так как он будет задействован по умолчанию. В последних версиях Node.js вы можете активировать режим strict глобально, сопроводив выполнение команды node флагом --use_strict

"use strict";

// Эта переменная не будет определена как глобальная 
 global() => {
    foo = "Message"; // выкинет ошибку runtime error
}

// Эта переменная не станет глобальной, так как глобальные функции содержат свое собственное `this` в строгом режиме 
 hello() => {
    this.foo = "Message";
}

И наконец, помните, что не следует привязывать глобальное this к функциям, использующим методы bind или call, так как это лишает использование режима strict всякого смысла. 

Умеренное использование глобальной области видимости 

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

  1. Минимизируйте использование глобальной области видимости. Вместо этого рассмотрите возможность применения локальной области внутри функций, так как они будут удалены в процессе сборки мусора для освобождения памяти. Если вам вынужденно приходится прибегать к использованию глобальной переменной, то задайте ей значение null, когда в ней уже не будет необходимости. 
  2. Используйте глобальные переменные только для констант, кэша и переиспользуемых объектов-одиночек. Не стоит применять их в целях избежания передачи значений в коде. Для обмена данными между функциями и классами передавайте значения в качестве параметров или атрибутов объектов. 
  3. Не храните крупные объекты в глобальной области видимости. Если же вам приходится это делать, то не забудьте определить их как null, когда они более не нужны. В отношении объектов кэша рекомендуется установить обработчик для периодической очистки, препятствующий их неограниченному росту. 

Эффективное использование стека 

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

Конечно, использование исключительно статических данных непрактично. В реальных приложениях нам приходится работать со многими объектами и динамическими данными. Но мы можем оптимизировать применение переменных стека при помощи ряда приемов: 

1.Избегайте использования ссылок переменных стека на объекты кучи по мере возможности и не храните неиспользуемые переменные; 

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

Эффективное использование кучи 

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

1.По возможности копируйте объекты вместо того, чтобы передавать ссылки. Их передача возможна только в том случае, если объект крупный или операция копирования требует больших затрат.  

2.По максимуму обходитесь без мутаций объекта. Вместо этого для их копирования используйте распространение объекта или Object.assign

3.Вместо создания множественных ссылок на объект просто его скопируйте. 

4.Используйте переменные с коротким жизненным циклом. 

5.Старайтесь не создавать огромные деревья объектов. Если же это неизбежно, то обеспечьте им короткий жизненный цикл в локальной области видимости. 

Грамотное использование замыканий, таймеров и обработчиков событий 

Как уже было отмечено ранее, замыкания, таймеры и обработчики событий — это те области, где могут произойти утечки памяти. Начнем с замыканий, как наиболее часто встречающихся в коде JavaScript. Посмотрим на следующий код команды Meteor, который приводит к утечке памяти, так как переменная longStr не подлежит удалению и увеличивает объём занимаемой памяти. 

var theThing = null;
var replaceThing = function () {
  var originalThing = theThing;
  var unused = function () {
    if (originalThing)
      console.log("hi");
  };
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log(someMessage);
    }
  };
};
setInterval(replaceThing, 1000);

Выше обозначенный код создает несколько замыканий, которые удерживают ссылки на объекты. В этом случае устранение утечки памяти возможно путем определения originalThing как null в конце выполнения функции replaceThing. Подобных ситуаций тоже можно избежать, создавая копии объектов и соблюдая выше описанный немутабельный подход. 

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

Тоже самое относится к слушателям событий и наблюдателям. Как только они выполнили свою задачу, очистите их. Не оставляйте слушателей в постоянно работающем состоянии, особенно если они удерживают ссылки на объекты из родительской области видимости. 

Заключение 

В настоящее время утечки памяти в JavaScript не являются такой уж большой проблемой, как бывало раньше, благодаря эволюции движков JS и улучшениям в языке. Но если мы не будем внимательны, то они по-прежнему будут происходить и приводить к снижению производительности и сбоям в работе приложения/ОС. 

Во-первых, чтобы убедиться, что наш код не приводит к утечкам памяти в приложении Node.js, нужно разобраться в принципах ее управления движком V8. Во-вторых, важно понять, что послужило их причиной.

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

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Abhijeet Srivastava: How to Avoid Memory Leaks in Node.js

Предыдущая статьяНаивный байесовский алгоритм
Следующая статьяКогда параллелизм превосходит конкурентность