[Перевод] Поиск проблемных промисов в JavaScript

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

Асинхронность означает возможность написания кода, который не подвержен обычным проблемам, связанным с потокобезопасностью, и поддерживает выполнение множества задач в «параллельном» режиме. Например — ожидание считывания файла не блокирует другие вычисления.

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

image-loader.svg

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

Эти соединения что-то захватывало.

Речь идёт о кодовой базе в миллионы строк кода, которой в течение нескольких лет занимались десятки программистов. Может — проблема заключается в неразрешённых промисах? Я решил исследовать этот вопрос.

Проблема останова поднимает голову


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

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

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

Типичное решение проблемы останова заключается в том, чтобы добавить в систему временные ограничения. Если функция не завершает работу в пределах N секунд — мы считаем, что она где-то застопорилась. Может, это и не так, но мы решаем, что это так, и «убиваем» программу.

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

Но «убийство» процесса сервера при каждом недоразумении — это решение не идеальное. И, кроме того, оснащение каждого промиса в кодовой базе тайм-аутом — это, в лучшем случае, очень сложно.

Но не так всё плохо! Проблему останова можно решить для подмножества распространённых паттернов.

Паттерны проблемных промисов


В прочитанной мной публикации «Нахождение проблемных промисов в асинхронных JavaScript-программах» Алимадади с соавторами выделили распространённые паттерны, которые приводят к появлению неразрешённых промисов, и представили программу PromiseKeeper. Эта программа находит потенциально неразрешаемые промисы с использованием графа промисов.

▍Краткий рассказ о JavaScript-промисах


Та публикация начинается с обзора JavaScript-промисов.

Промисы представляют асинхронные вычисления и могут пребывать в трёх состояниях: pending (ожидание), fulfilled (успешное разрешение) и rejected (отклонение). Вначале они оказываются в состоянии pending.

Код, реагирующий на результат работы промиса (обработчик определённого события), регистрируют с помощью метода .then().

// Немедленно разрешается со значением 17
const promise = Promise.resolve(17)
promise.then(
  function fulfilledReaction(value) {
    console.log({ value })
  },
  function rejectedReaction(error) {
    console.log({ error })
    throw error
  }
)


В коде, на практике, опускают второй параметр для того чтобы сосредоточиться на коде, который выполняется при успешном разрешении промиса (fulfilledReaction). Обычно так поступают при построении цепочек промисов:

// Немедленно разрешается со значением 17
const promise = Promise.resolve(17)
promise.then(value => value + 1)
       .then(value => value + 1)
       .then(function (value) => { console.log(value) })


Каждый вызов .then() приводит к созданию нового промиса, который разрешается возвращаемым значением кода, реагирующего на завершение работы предыдущего промиса. Обратите внимание на то, что последний вызов неявно разрешается со значением undefined. Дело в том, что в JavaScript функция, которая не возвращает что-либо, неявным образом возвращает undefined.

Важная деталь.

В цепочку промисов можно добавить механизм обработки ошибок с использованием .catch():

// Немедленно разрешается со значением 17
const promise = Promise.resolve(17)
promise.then( ... )
       .then( ... )
       .then( ... )
       .catch(err => ...)


Каждый промис, созданный .then(), неявно определяет код, реагирующий на отклонение промиса, аналогичный конструкции err => throw err. Это значит, что .catch() в конце цепочки промисов может отреагировать на ошибки, возникшие в любом из предыдущих промисов.

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

// Немедленно разрешается со значением 17
const promise = Promise.resolve(17)
promise
  .then(undefined) // используется стандартный механизм value => value
  .then((value) => console.log(value))


Полагаю, подобное чаще происходит случайно, чем намеренно.

Промисы можно связывать, используя один промис для разрешения другого промиса:

const p0 = Promise.resolve(17) // Немедленно разрешается
const p1 = Promise.reject("foo") // Немедленно отклоняется
p0.then(function (v) {
  return p1
})


Здесь состояние промиса p0 связано с p1. То есть — неименованный промис, созданный в строке 3, отклоняется со значением foo.

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

▍Паттерн №1: необработанное отклонение промиса


Обычным источником ошибок при работе с промисами являются необработанные отклонения промисов.

Это происходит, когда неявно отклоняют промис, выдавая ошибку в коде, реагирующем на успешное завершение промиса:

promise.then(function (val) {
  if (val > 5) {
    console.log(val)
  } else {
    throw new Error("Small val")
  }
})


Так как код, реагирующий на успешное завершение промиса, выполняется в отдельном асинхронном контексте, JavaScript не передаёт эту ошибку в главный поток. Ошибка «проглатывается» и разработчик никогда не узнает о том, что она произошла.

Исправить это можно, использовав метод .catch():

promise
  .then(function (val) {
    if (val > 5) {
      console.log(val)
    } else {
      throw new Error("Small val")
    }
  })
  .catch((err) => console.log(err))


Теперь у нас появляется возможность обработать ошибку.

Но мы не перевыбросили эту ошибку! Если другой промис, связанный с этим, или объединённый с ним в цепочку, полагается на этот код, ошибка будет оставаться «проглоченной». Код продолжит выполняться.

Попробуйте следующее в консоли браузера:

const p = Promise.resolve(17)
p.then(function (val) {
  throw new Error("Oops")
  return val + 1
})
  .catch(function (err) {
    console.log(err)
  })
  .then(function (val) {
    console.log(val + 1) // prints NaN
  })


Можно ожидать, что в этом коде выполняются вычисления 17 + 1 = 18, но, из-за неожиданной ошибки, мы получаем NaN. Промис, неявно созданный .catch(), неявно разрешается (а не отклоняется) со значением undefined.

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

▍Паттерн №2: незавершённые промисы


Новые промисы пребывают в состоянии pending до тех пор, пока не будут успешно разрешены или отклонены (то есть — завершены). Но если промис не завершается, его можно назвать остановившимся промисом. Он навсегда останется в состоянии pending, не давая выполняться коду, полагающемуся на его завершение.

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

Авторы вышеупомянутой публикации показывают пример проблемы из node-promise-mysql, где connection.release() возвращает промис, который никогда не разрешается.

Этот пример сложно свести к нескольким строкам кода, поэтому вот — кое-что попроще:

const p0 = new Promise((resolve, reject) => null)
const p1 = Promise.resolve(17)
p0.then((result) => p1)
  .then((value) => value + 1)
  .then((value) => console.log(value)) // ожидается 18


Последний промис соединён цепочкой из .then() с промисом p0, который никогда не разрешается и не отклоняется. Этот код может выполняться вечно, но он никогда не выведет никакого значения.

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

▍Паттерн №3: неявные возвраты и код, реагирующий на результаты работы промиса


Цепочка из промисов прерывается без выдачи каких-либо ошибок в том случае, если разработчик забывает о включении в код выражения return.

Эта проблема похожа на ту, связанную с «проглатыванием» ошибок, о которой я уже рассказывал. Вот фрагмент кода из Google Assistant, который приводят Алимадади с соавторами:

handleRequest (handler) {
    if (typeof handler === 'function') {
        const promise = handler(this)
        if (promise instanceof Promise) {
            promise.then(result => {
                debug(result)
                return result
            }).catch(reason => {
                this.handleError('function failed')
                this.tell(!reason.message ? ERROR_MESSAGE : reason.message)
                return reason
            })
        }
    }
}


Метод handleRequest() использует объект Map с обработчиками, предоставленными разработчиком, для организации асинхронной работы с запросами Assistant. Объект handler (обработчик) может быть либо коллбэком, либо промисом.

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

Но все эти возвраты выполняются внутри кода, реагирующего на разрешение или отклонение промиса. Промис не осуществляет возврата значения. Результат реакции на промис handler теряется.

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

Поиск антипаттернов с помощью графа промисов


Алимадади с соавторами создали программу PromiseKeeper, которая динамически анализирует кодовую базу на JavaScript и рисует графы промисов.

KuGtnhns__eS8t6ZosPBQHkSpuQGy2Q1fOhDIbkb


Граф промисов

Мне не удалось запустить эту программу, поэтому передам то, о чём рассказали авторы публикации.

▍Графы промисов


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

  • Вершины-промисы (p) представляют собой случаи запуска промисов.
  • Вершины-значения (v) представляют значения, с которыми разрешаются или отклоняются промисы. Это могут быть функции.
  • Вершины-функции (f) — это функции, зарегистрированные для обработки разрешения или отклонения промиса.
  • Вершины-механизмы синхронизации (s) представляют собой все использованные в коде вызовы Promise.all() или Promise.race().
  • Рёбра разрешения или отклонения промиса (v)→(p) указывают на связи вершин-значений с вершинами-промисами. Они помечены как resolve или reject.
  • Рёбра регистрации обработчиков (p)→(f) указывают на связи между промисом и функцией. Они помечены как onResolve или onReject.
  • Рёбра связей (p1)→(p2) показывают взаимоотношения между связанными промисами.
  • Рёбра return или throw (f)→(v) показывают связи функций и значений. Они помечены как return или throw.
  • Рёбра механизмов синхронизации указывают на связи, идущие от множества промисов к одному механизму синхронизации промисов. Они помечаются как resolved, rejected или pending на основании того, как ведёт себя новый промис.


Вот аннотированная версия вышеприведённого графа.

beo3RPTAZulNEa80tRBZ7Z_3RyDdXs44oIJ6plwo


Аннотированный граф промисов

▍Использование PromiseKeeper для поиска анти-паттернов


Программа PromiseKeeper направлена на конструирование и визуализацию графа промисов кода. Она анализирует код динамически, по мере выполнения тестов. Динамический контекст выполнения кода позволяет находить анти-паттерны, которые не видны при анализе самого кода.

Вот на какие анти-паттерны обращает внимание программа:

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


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

Реализация PromiseKeeper основана на фреймворке для динамического анализа JavaScript-кода Jalangi. В нём имеются коллбэки, которые реагируют на события жизненного цикла промисов.

Мне не удалось заставить работать PromiseKeeper на моём компьютере, но Алимадади с соавторами сообщают о том, что не вполне благополучные промисы встречаются почти во всех кодовых базах, в которых используется JavaScript.

77c7fb53726b48d333344bdd386b9fcb.jpg


Отчёт по выявленным анти-паттернам

Интересно то, что 1012 экземпляров незавершённых промисов встречаются всего в 17 местах кода Node Fetch.

Авторы сообщили, что в ходе их эксперимента 43% промисов оказались неразрешёнными. Это, вероятнее всего, указывает на неполноту тестов, а не на то, что популярные программные продукты, которые они исследовали, безнадёжно «поломаны».

Что делать?


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

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

// Выводит полезное сообщение об ошибке когда
// отклонение промиса оказывается необработанным
process.on("unhandledRejection", (err, promise) => {
  const stack = err instanceof Error ? err.stack : ""
  const message = err instanceof Error ? err.message : err

  Logger.error("Unhandled promise rejection", {
    message,
    stack,
    promise,
  })
})


Ещё можно попробовать Node.js-модуль async_hooks, который позволяет наблюдать за жизненным циклом промисов, и попытаться выявлять промисы, которые выполняются слишком долго. Можно, например, сравнивать время работы промиса с заданным тайм-аутом и выводить в консоль предупреждение.

У меня была интересная попытка использования async_hooks для выявления промисов, выполняющихся слишком долго, но особого толку из этого не вышло. Нельзя получить ссылку на контекст выполнения (только — C-указатель). То есть — можно увидеть, что что-то работает медленно, но о том, что это такое, узнать нельзя.

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

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

image-loader.svg

© Habrahabr.ru