К этому моменту у вас должно быть четкое представление о том, как работают промисы и как их использовать для реализации наиболее распространенных конструкций потока управления. Поэтому сейчас самое подходящее время для обсуждения сложной темы, которую должен знать и понимать каждый профессиональный разработчик Node.js. Этот расширенный раздел посвящен утечке памяти, вызванной бесконечными цепочками разрешения промисов. Ошибка, по-видимому, влияет на реальную спецификацию Promises/A+, поэтому никакая совместимая реализация не застрахована.

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

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

function leakingLoop () {
  return delay(1)
  .then(() => {
    console.log(`Tick ${Date.now()}`)
    return leakingLoop()
  })
}

Только что определенная функция LeakingLoop() использует функцию delay() (которую мы создали в начале этой главы) для имитации асинхронной операции. Когда заданное количество миллисекунд истекло, мы печатаем текущую метку времени и рекурсивно вызываем текущую петлю(), чтобы начать операцию заново. Интересно то, что промис, возвращенный функциейleakingLoop(), который, в свою очередь, зависит от следующего вызова текущейLoop() и так далее. Эта ситуация создает цепочку промисов, которые никогда не устанавливаются, и вызывает утечку памяти в реализациях промисов, которые строго следуют спецификации промисов/A+, включая промисы Javascript ES6.

Чтобы продемонстрировать утечку, мы можем попробовать запустить функциюleakingLoop() много раз, чтобы подчеркнуть последствия утечки:

for(let i = 0; i < 1e6; i++) {
  leakingLoop()
}

Затем мы можем зациклиться на отпечатке памяти процесса, используя наш любимый инспектор процессов, и заметить, как он бесконечно растет, пока (через несколько минут) процесс не рухнет полностью.

Решение проблемы — разорвать цепочку на разрешении Promise. Мы можем сделать это, удостоверившись, что статус промиса, возвращаемого утечкойLoop(), не зависит от обещания, возвращаемого следующим вызовом утечкаLoop().

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

function nonLeakingLoop() {
  delay(1)
    .then(() => {
      console.log(`Tick ${Date.now()}`)
      nonLeakingLoop()
    })
}

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

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

function nonLeakingLopWithErrors () {
  return new Promise((resolve, reject) => {
    (function internalLoop() {
      delay(1)
        .then(() => {
          console.log(`Tick ${Date.now()}`)
          internalLoop()
        })
        .catch(err => {
          reject(err)
        })
    })
  })
}

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

Третье решение использует async/await. На самом деле, с помощью async/await мы можем имитировать рекурсивную цепочку промисов с помощью простого бесконечного цикла while, например следующего:

async function nonLeakingLoopAsync () {
  while (true) {
    await delay(1)
    console.log(`Tick ${Date.now()}`)
  }
}

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

Мы должны отметить, что у нас все равно будет утечка памяти, если вместо цикла while мы решим реализовать решение async/await с фактическим асинхронным рекурсивным шагом, например следующим:

async function leakingLoopAsync () {
  await delay(1)
  console.log(`Tick ${Date.now()}`)
  return leakingLoopAsync()
}

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

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