Автор Yijun

Предисловие

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

async function recursive() {
  if( active ) return;
  // do something
  await recursive();
}

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

Обнаружение проблемы

После того, как заказчик подключился к Платформе производительности Node.js, часто отображалось Out of Memory (OOM) из-за внезапного увеличения памяти. Заказчик добавил правило оповещения @heap_used / @heap_limit ›0.5, чтобы файл моментального снимка кучи мог быть сгенерирован для анализа, когда куча относительно мала или когда происходят утечки.

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

Определение проблемы

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

Анализ снимка кучи

Давайте сначала посмотрим на отчет об утечке памяти:

Как показано в предыдущем отчете, размер файла составляет почти 1 ГБ. Ключевое слово context указывает, что это объект контекста, созданный во время выполнения функции, а не обычный объект (например, закрытие). После завершения функции этот объект контекста не обязательно исчезает.

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

Однако одно это не дает надежного вывода. Давайте двигаться дальше.

Попробуйте просмотреть содержимое объекта по @ 22621725 и просмотреть ссылку на корень GC по @ 22621725. Ничего полезного не находим.

Представление кластера объектов показывает некоторую полезную информацию:

Мы можем видеть, что, начиная с @ 22621725, один объект контекста ссылается на другой объект контекста, и между ними включено обещание. Если вы знакомы с co, вы знаете, что co преобразует вызов, не являющийся Promise, в Promise. В этом случае Promise означает новый вызов генератора.

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

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

Этот вид показывает необычный объект: scheduleUpdatingTask.

Этот снимок кучи включает 390 285 scheduleUpdatingTask объектов. Щелкните курс, чтобы просмотреть дополнительную информацию:

Этот класс находится в функции файла /home/xxx/app/schedule/updateDeviceInfo.js() / updateDeviceInfo.js.

В настоящее время это единственные доступные подсказки. Далее перейдем к анализу кода.

Анализ кода

После получения авторизации от нашего клиента нам удалось получить соответствующий код и найти объект scheduleUpdatingTask в файле app / schedule / updateDeviceInfo.js.

// Run the service, wait for a while after it runs successfully and continue
// Stop the task if the lock fails to be obtained
const scheduleUpdatingTask = function* (ctx) {
  if (! taskActive) return;
  try {
    yield doSomething(ctx);
  } catch (e) {
    // Service exception capture is required so that the next scheduling can run normally even if the current task fails
    ctx.logger.error(e);
  }
  yield scheduleUpdatingTask(ctx);
};

Во всем проекте единственный повторный вызов scheduleUpdatingTask - это вызов, сделанный самим собой. Обычно это называется рекурсивным вызовом.

Однако сказать, что это рекурсивный вызов, не совсем правильно. Если это реальный рекурсивный вызов, стек уже испытает переполнение.

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

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

В этом фрагменте кода ясно, что условие завершения If (! taskActive) return; не выполняется.

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

const co = require('co');
function sleep(ms) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve();
    }, ms);
  });
}
function* task() {
  yield sleep(2);
  console.log(process.memoryUsage());
  yield task();
}
co(function* () {
  yield task();
});

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

Можно предположить, что причиной этой проблемы могут быть асинхронные функции:

function sleep(ms) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve();
    }, ms);
  });
}
async function task() {
  await sleep(2);
  console.log(process.memoryUsage());
  await task();
}
task();

Результат показывает, что использование памяти все еще увеличивается.

Решение проблемы

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

Из предыдущего примера мы видим, что рекурсивные вызовы в co- или async-функциях могут задерживать освобождение памяти. Эта задержка приводит к накоплению в памяти и нехватке памяти. Однако означает ли это, что мы не можем использовать рекурсивные вызовы в этих сценариях? Ответ - нет.

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

Можем ли мы найти решение для продолжения работы приложения, не имея слишком длинной ссылки на контекстную ссылку? Ответ положительный:

async function task() {
  while (true) {
    await sleep(2);
    console.log(process.memoryUsage());
  }
}

В предыдущем примере кода замена рекурсивного вызова на цикл while (true) устраняет проблему ссылки на контекстную ссылку. Поскольку await внутренне вызывает планирование цикла событий, while (true) не будет блокировать основной поток.

Первоисточник