08. Promise in Depth

Согласно стандарту, у объекта new Promise(executor) при создании есть четыре внутренних свойства:

  • PromiseState – состояние, вначале «pending».

  • PromiseResult – результат, при создании значения нет.

  • PromiseFulfillReactions – список функций-обработчиков успешного выполнения.

  • PromiseRejectReactions – список функций-обработчиков ошибки.

Promise jobs

Когда функция-executor вызывает reject или resolve, то PromiseState становится resolved или rejected, а все функции-обработчики из соответствующего списка перемещаются в специальную системную очередь PromiseJobs.

Эта очередь автоматически выполняется, когда интерпретатору «нечего делать». Иначе говоря, все функции-обработчики выполнятся асинхронно, одна за другой, по завершении текущего кода. Исключение из этого правила – если resolve возвращает другой Promise. Тогда дальнейшее выполнение ожидает его результата (в очередь помещается специальная задача), и функции-обработчики выполняются уже с ним.

Promise reactions

Добавляет обработчики в списки один метод: .then(onResolved, onRejected). Метод .catch(onRejected) – всего лишь сокращённая запись .then(null, onRejected). Он делает следующее:

  • Если PromiseState === "pending", то есть Promise ещё не выполнен, то обработчики добавляются в соответствующие списки.

  • Иначе обработчики сразу помещаются в очередь на выполнение.

В .then(), если один из обработчиков не указан, то Promise добавляет его «от себя», следующим образом:

  • Для успешного выполнения – функция Identity, которая выглядит как arg => arg, то есть возвращает аргумент без изменений.

  • Для ошибки – функция Thrower, которая выглядит как arg => throw arg, то есть генерирует ошибку.

Example

Для следующего примера:

const promise = new Promise((resolve, reject) => resolve(1));

promise.then(result => {
  console.log(result); // 1
  return "f1";
});

promise.then(result => {
  console.log(result); // 1
  return "f2";
});

Вид объекта promise после этого:

  • Все обработчики вешались на тот же Promise

  • Добавленные нами обработчики f1, f2,

  • Автоматические добавленные обработчики ошибок "Thrower".

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

Когда в будущем выполнятся обработчики f1, f2, то их результат будет передан в новые Promise по стандартному принципу:

  • Если вернётся обычное значение (не Promise), новый Promise перейдёт в resolved с ним.

  • Если был throw, то новый Promise перейдёт в состояние "rejected" с ошибкой.

  • Если вернётся Promise, то используем его результат (он может быть как resolved, так и rejected).

Promise Leaks Memory

This piece of code will leak memory and eventually crash your Node process or browser:

// Usage of `setTimeout` is to prove that we have
// async boundaries, can be removed.

function signal(i) {
  return new Promise(cb => setTimeout(() => cb(i)));
}

function loop(n) {
  return signal(n).then(i => {
    if (i % 1000 == 0) console.log(i);
    return loop(n + 1);
  });
}

loop(0).catch(console.error);

// This is equivalent with this async function:
async function loop(n) {
  const i = await signal(n);
  if (i % 1000 == 0) console.log(i);

  // Recursive call
  return loop(n + 1);
}

Проблема здесь в том, что не очищается память при вызове предыдущих then и растет глубина стека.

Unfortunately, just like a regular function using the call stack for those recursive calls, the Promise implementation is abusing the heap memory, not chaining then calls correctly. And that sample should not leak, the Promise implementation should be able to do the equivalent of Tail-Call Optimization and in such a case eliminate frames in the then chain being created.

Promises is Eager, not Lazy

When you create a Promise, it’s already busy doing its thing. I mean this:

console.log("before");
const promise = new Promise(function fn(resolve, reject) {
  console.log("hello");
  // ...
});
console.log("after");

In the console, you will see before, hello, after in that order. So the Promise is eager to call its implementation.

Eager is less general than lazy because it sets restrictions: you cannot reuse eager primitives means that you are restricted from doing that. But you can use lazy primitives one or multiple times, they don’t put any restriction on how many times you can reuse them.

Promises is never synchronous

One of the design decisions for Promises was to make them resolve earliest at the end of the current event loop, in order to facilitate solving race conditions with multiple promises created together. It means this code:

console.log("before");
Promise.resolve(42).then(x => console.log(x));
console.log("after");

Will show before, after, 42 in the console.

As a practical result, you can convert from synchronous to Promise, but cannot convert from Promise to synchronous. That’s just an artificial restriction, because callbacks would be able to convert from sync to callback then from callback to sync.

Last updated