Эффективное использование замыканий JavaScript

Node.js внешний интерфейс сервер JavaScript

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

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

В этом руководстве будут рассмотрены 3 основных варианта использования замыканий в Node:

  • обработчик завершения
  • Промежуточная функция
  • функция слушателя

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

Замыкания и асинхронное программирование

Если вы знакомы с традиционным последовательным программированием, вы можете задать следующие вопросы, пытаясь понять асинхронную модель в первый раз:

  • Если функция вызывается асинхронно, как вы гарантируете, что код позади (или вокруг) может обрабатывать доступные данные в этой области при ее вызове? Или, другими словами, как реализовать остальной код, зависящий от результатов и побочных эффектов асинхронных вызовов?
  • После выполнения асинхронного вызова программа продолжает выполнять код, не связанный с асинхронным вызовом. Как продолжить выполнение, вернувшись в исходную область вызова после завершения асинхронного вызова?

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

Например, взгляните на следующий код JavaScript:


 function outer(a) {
  var b= 20; 
  function inner(c) {
   var d = 40;
   return a * b / (d  c);
 }
 return inner;
}
 
var x = outer(10);
var y = x(30);

Вот снимка того же кода в сейфе в прямом эфире:

演示闭包行为的调试会话组合屏幕截图的第一部分

нажмите, чтобы увеличить изображение

演示闭包行为的调试会话组合屏幕截图的第二部分

innerФункция вызывается в строке 17 (строка 11 в предыдущем листинге) и выполняется в строке 11 (строка 5 листинга). В строке 16 (строка 10 в листинге) вызовouterфункция - она ​​возвращаетinnerфункция. Как показано на скриншоте, в строке 17 происходит вызовinnerфункция, и когда она выполняется в строке 11, она имеет доступ к своим локальным переменным (cа такжеd)а такжеouterПеременные, определенные в функции (aа такжеb) — хотя пара делается в строке 16outerВызов функции завершилсяouterобъем функции.

«Чтобы избежать утечек памяти, важно понимать, когда и как долго метод обратного вызова остается доступным».

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

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

Вариант использования 1: обработчик завершения

В режиме обработчика завершения поместите функцию (C1) в качестве параметра метода (M1), И вM1позвони, когда закончишьC1как обработчик завершения. В рамках этого шаблонаM1внедрение гарантирует, что, когда это больше не требуетсяC1После этого ссылка на C1, которую он хранит, очищается.C1часто нужно звонитьM1Один или несколько элементов данных в диапазоне. Создается замыкание, обеспечивающее доступ к этой области видимости.C1определение времени. Распространенный подход заключается в использованииM1где анонимные методы определены встроенными. результатом будетC1замыкание, обеспечивающее доступ кM1Возможность использования всех переменных и параметров.

Примером являетсяsetTimeout()метод. По истечении времени таймера вызывается функция завершения, и функция завершения, зарезервированная для таймера, очищается (C1) Цитировать:


 function CustomObject() {
}
 
function run() {
  var data = new CustomObject()
  setTimeout(function() {
    data.i = 10
  }, 100)
}
run()

Функция завершения использует вызов изsetTimeoutконтекст методаdataПеременная. даже вrun()На замыкания, созданные для обработчиков завершения, можно ссылаться после завершения метода.CustomObject, без сбора мусора.

сохранение памяти

Контекст закрытия — это место, где определена функция завершения (C1), контекст создаетсяC1Переменные и параметры, доступ к которым осуществляется в области видимости, составляются.C1Закрытия сохраняются до тех пор, пока:

  • Метод завершения вызывается и завершает работу,илиТаймер очищается.
  • не произойдет правильноC1другие цитаты. (Для анонимных функций, если выполняются предыдущие условия в этом списке, других ссылок не будет.)

используяИнструменты разработчика Chrome, мы можем видеть, чтоTimeoutобъект через_onTimeoutполе и иметь функцию завершения (передаетсяsetTimeoutАнонимный метод) ссылка:

来自 Chrome 开发者工具的屏幕截图表明,Timout 对象拥有对完成函数的引用

нажмите, чтобы увеличить изображение

Хотя таймер истек,Timeoutобъект,_onTimeout 字段а функции закрытия хранятся в куче по ссылке на них — события тайм-аута, ожидающие в системе. Ожидающие события в цикле событий удаляются при активации таймера и завершении последующих обратных вызовов. Все 3 объекта больше не доступны, и они могут быть собраны в последующих циклах сборки мусора.

Когда таймер сброшен (черезclearTimeoutметод), начнется с_onTimeoutполе удаляет функцию завершения, и, даже если основная функция сохраняетTimeoutСсылка на объект сохраняется, и (пока нет других ссылок на функцию) функция может быть собрана в последующих циклах сборки мусора.

На этом снимке экрана показано сравнение до и после срабатывания таймера.Отвал свай:

该屏幕截图对比了执行计时器之前和之后的堆转储

нажмите, чтобы увеличить изображение

Столбец #Новые показывает новые объекты, добавленные между дампами, а столбец #Удаленные показывает объекты, собранные между дампами. Выделенный раздел показывает,CustomObjectприсутствовал в первом дампе, но был собран и не попал во второй, тем самым освободив 12 байт памяти.

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

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

Вариант использования 2: промежуточные функции

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

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


   function readData() {
  var buf = new Buffer(1024 * 1024 * 100)
  var index = 0
  buf.fill('g')  //simulates real read
     
  return function() {
    index++
    if (index < buf.length) { 
      return buf[index-1]   
    } else {
      return ''
    } 
  }
}
 
var data = readData()
var next = data()
while (next !== '') {
  // process data()
  next = data()
}

В этом случае, покаdataпеременная все еще находится в области видимости, она останетсяbuf.bufРазмер буфера может привести к резервированию большого объема памяти, даже если это не так очевидно для разработчика приложения. Мы можем увидеть этот эффект с помощью инструментов разработчика Chrome, как это сделано вwhileСнимок, полученный после цикла, показывает: больший буфер зарезервирован, хотя больше не используется.

来自 Chrome 开发者工具的屏幕截图显示保留了更大的缓冲区

нажмите, чтобы увеличить изображение

сохранение памяти

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


   // Manual cleanup 
data = null;

Этот код разрешает сборку мусора контекста закрытия. Вот скриншот из дампа кучи (послеdataУстановить какnullpost-fetch) указывает, что сборка мусора может быть выполнена для сохраненных данных путем отбрасывания вручную:

在将 data 值设置为 null 后获取的堆转储快照

нажмите, чтобы увеличить изображение

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

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

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

Например, функцию из предыдущего примера в этом разделе можно переписать так:


   return function() {
    index++;
    if (index < buf.length) { 
      return buf[index-1]   
    } else {
      buf = null
      return 
    } 
  }

Этот выпуск гарантирует, что большие буферы могут быть собраны, когда они больше не нужны.

Вариант использования 3: функция слушателя

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

«Функции прослушивателя чаще всего вызывают утечку памяти».

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


   var EventEmitter = require('events').EventEmitter
var ev = new EventEmitter()
 
function run() {
    var buf = new Buffer(1024 * 1024 * 100)
    var index = 0
    buf.fill('g')
    ev.on('readNext', function() {
      var ret = buf[index]
      index++
      return ret
    });
}

сохранение памяти

Скриншот ниже (после вызоваrun()метод) показывает, как для больших буферовbufРезерв памяти. Как видно из дерева доминаторов, этот большой буфер сохраняется благодаря связи с этим событием:

该屏幕截图显示,为大型缓冲区保留了内存

нажмите, чтобы увеличить изображение

Данные, хранящиеся в функции обратного вызова (слушателе), останутся активными до тех пор, пока обработчик не будет дерегистрирован — даже после того, как все данные будут прочитаны. В некоторых случаях данные могут больше не требоваться между обратными вызовами для прослушивателя. Если возможно, обычно лучше распределять данные по мере необходимости, а не хранить их между вызовами.

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


   // Because our closure is anonymous, we can't remove the listener by name, 
// so we clean all listeners.
ev.removeAllListeners()

Известным примером этого варианта использования является типичная реализация HTTP-сервера:


   var http = require('http');
 
function runServer() {
 
    /* data local to runServer, but also accessible to
     * the closure context retained for the anonymous 
     * callback function by virtue of the lexical scope
     * in the outer enclosure.
     */
    var buf = new Buffer(1024 * 1024 * 100);
    buf.fill('g');
     
    http.createServer(function (req, res) {
      res.end(buf);
    }).listen(8080);
 
}
runServer();

Хотя в этом примере показан удобный способ использования внутренней функции, обратите внимание, что функция обратного вызова (и объект буфера) активны до тех пор, пока активен объект сервера. Объект подлежит сбору только после выключения сервера. Как видно на скриншоте ниже, буфер останется активным из-за его использования прослушивателем запросов сервера:

该屏幕截图显示,为大型缓冲区保留了内存

нажмите, чтобы увеличить изображение

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

заключительные замечания

Замыкания — это мощная конструкция программирования, которая позволяет связывать код и данные более гибкими и неожиданными способами. Однако программисты, привыкшие к устаревшим языкам, таким как Java или C++, могут быть не знакомы с его семантикой области видимости. Чтобы избежать утечек памяти, важно понимать характеристики замыканий и их жизненный цикл.