В 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
Анонимный метод) ссылка:
нажмите, чтобы увеличить изображение
Хотя таймер истек,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
Снимок, полученный после цикла, показывает: больший буфер зарезервирован, хотя больше не используется.
нажмите, чтобы увеличить изображение
сохранение памяти
Даже после того, как приложение завершит промежуточную функцию, ссылка на эту функцию поддерживает связанное с ней замыкание. Чтобы сделать эти данные доступными для сбора, приложение должно переопределить эту ссылку, например, установив ссылку на промежуточную функцию следующим образом:
// Manual cleanup
data = null;
Этот код разрешает сборку мусора контекста закрытия. Вот скриншот из дампа кучи (послеdata
Установить какnull
post-fetch) указывает, что сборка мусора может быть выполнена для сохраненных данных путем отбрасывания вручную:
нажмите, чтобы увеличить изображение
Выделенная строка указывает на то, что буфер собран и связанная с ним память освобождена.
Часто можно создать промежуточные функции, чтобы ограничить потенциальные утечки памяти. Например, промежуточная функция, позволяющая выполнять добавочное чтение больших наборов данных, может удалять ссылки на возвращенные части данных. В этих случаях, однако, необходимо позаботиться о том, чтобы этот подход не создавал проблем для других частей приложения, которые обращаются к данным не промежуточным способом.
При создании 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++, могут быть не знакомы с его семантикой области видимости. Чтобы избежать утечек памяти, важно понимать характеристики замыканий и их жизненный цикл.