Как работает очередь микротасков в JavaScript

Рейтинг: 0Ответов: 2Опубликовано: 31.07.2025

Не понимаю как работает очередь микротасков.

Запускается функция Promise.all([somePromise()]), которая внутри запускает функцию somePromise. Внутри функции somePromise есть зарезолвленный промис, который имеет цепочку промисов. Первая часть цепочки выводит в консоль then1, а вторая и третья часть then2 и then3. Сразу возвращается неявно зарезовленный промис функции somePromise т.к функция имеет ключевое слово async.

В моей голове был следующий вывод в консоль: then1 -> ['Completed] -> then2 -> then3

Как я представляю работу Event Loop: вызывается функция Promise.all() -> вызывается функция somePromise() -> внутри функции somePromise резолвится промис и к нему крепится цепочка промисов, первый коллбэк цепочки отправляется в очередь микротасков -> функция somePromise() возвращает зарезолвленный промис со значением "Completed" => зарезолвленный промис для Promis.all подхватывает then отправляя в очередь микротасков новый коллбэк -> синхронные функции завершались и очищается Call Stack -> Event Loop начинает проходить по другим очередями -> видит коллбэк от промиса test -> переводит его в Call Stack -> этот коллбэк резолвится и в очередь микротасков попадает второй коллбэк из цепочки -> Call Stack снова пусто и Event Loop опять проходит по очередям и видит коллбэк от Promise.all -> Переводит его в Call Stack и выполняет -> Event Loop очищается, опять проходит по очередям -> переводит в Call Stack второй коллбэк промиса test -> выполняет его и добавляет в очередь микротасков третий коллбэк цепочки -> выполняет третий коллбэк.

Но почему-то это не так работает и в консоль выводится следующее:

  • then1
  • then2
  • ['Completed']
  • then3

Я хочу разобраться почему такой вывод и хочу научиться лучше понимать работу микро/макро тасков (хоть тут и нету макротасков).

async function somePromise() {
    Promise.resolve()
        .then(() => {
            console.log('then1')
        })
        .then(() => {
            console.log('then2')
        })
        .then(() => {
            console.log('then3')
        })

    return 'Completed'
}


Promise.all([somePromise()])
    .then((data) => {
        console.log(data)
    })

Ответы

▲ 2Принят

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

function somePromise() {
    Promise.resolve()
        .then(/* A */() => {
            console.log('then1')
        })
        .then(/* B */() => {
            console.log('then2')
        })
        .then(/* C */() => {
            console.log('then3')
        })

    return Promise.resolve('Completed');
}

Далее, Promise.all внутри себя создает новый промис (через конструктор промиса), и в переданной в этот конструктор функции вызывает then у каждого элемента массива промисов. Для конкретно твоего примера (в массиве ровно один элемент) можно очень приближенно изобразить работу Promise.all вот так:

function promiseAll(arr) {
    return new Promise(res => arr[0].then(/* D */(v) => res([v])));
}

promiseAll([somePromise()])
    .then(/* E */(data) => {
        console.log(data)
    })

Если в твоем примере заменить Promise.all на promiseAll, результат будет тем же. И тут видно, что для резолва промиса, возвращенного promiseAll, потребовался "дополнительный" микротаск (then внутри promiseAll), из-за чего ['Completed'] отъехал на один пункт вниз, проиграв одну позицию в гонке с цепочкой промисов, находящейся внутри somePromise.

Я пометил все моменты создания микротасков буквами (/* А */ и т.д.)

На синхронном этапе создаются последовательно А и D. Далее микротаск А создает микротаск В, а потом D создает E. Это наглядно показывает, почему Е оказался после B

▲ 2

Рассмотрим приведенный вами пример:

async function somePromise() {
  Promise.resolve()
    .then(() => {
      console.log("then1");
    })
    .then(() => {
      console.log("then2");
    })
    .then(() => {
      console.log("then3");
    });

  return "Completed";
}

Promise.all([somePromise()]).then((data) => {
  console.log(data);
});

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

then1
then2
then3
["Completed"]

Либо

["Completed"]
then1
then2
then3

Если немножко поразмыслить, то логично предположить, что первый вариант более логичен. Даже особо не вдаваясь в подробности: есть некий внутренний код, который сразу выполнится, это приведет к тому, что зарезолвится наружний промис и в итоге выполнится внешний console.log.

Что мы видим на деле (Chome/Node.js/Firefox - в принципе, везде):

then1
then2
["Completed"]
then3

Что-то тут не так. Почему именно после второго "внутреннего" промиса? Почему не после первого, например? То есть уже странно что ни в начале, ни в конце, так мало того еще и где-то вообще непонятно где.

Давайте разбираться. Для начала, уберем один лишний "слой" промисов (а именно, Promise.all:

async function somePromise() {
  Promise.resolve()
    .then(() => {
      console.log("then1");
    })
    .then(() => {
      console.log("then2");
    })
    .then(() => {
      console.log("then3");
    });

  return "Completed";
}

somePromise().then((data) => {
  console.log(data);
});

then1
["Completed"]
then2
then3

Ага! Супер! Можем сделать вывод, что Promise.all добавляет дополнительное действие в очередь, тем самым отодвигая выполнения кода в его then ветви. Пока что все выглядит довольно логично. Но остается вопрос: почему наружний then "прерывает" выполнение очереди внутренних? Причем прерывает вроде бы, на первый взгляд, непрерывную цепочку.

Предлагаю для начала немного изменить код для наглядности вывода. Сделаем выводы inner 1, inner 2 и inner 3 для "внутренних" промисов (внутренними я их называю потому что они создаются внутри функции, вызываемой снаружи) и вывод наружнего промиса обозначим как outer. Это все просто чтобы было проще рассуждать, по факту это ничего в работе скрипта не меняет.

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

async function somePromise() {
  Promise.resolve()
    .then(() => {
      console.log("inner 1");
    })
    .then(() => {
      console.log("inner 2");
    })
    .then(() => {
      console.log("inner 3");
    });

  return;
}

somePromise().then((data) => {
  console.log("outer");
});

inner 1
outer
inner 2
inner 3

Вывод все еще тот же (точнее, его порядок).

Теперь давайте пройдемся по коду и по порядку его исполнения.

  1. Первым делом вызывается somePromise.
  2. Выполняется первая строка этой функции, и мы создаем промис, который сразу же зарезолвится.
  3. Следующая строка, then - добавит коллбэек в очередь задач. Внимание: добавит, а не инициирует исполнение.
  4. Поскольку код у нас все-таки выполняется синхронно, следующим на очереди будет второй then, а потом - третий. Только огромная раз с первым у них заключается в том, что они пока что ниичего в очередь не добавят. Почему? Потому то добавить что-то в очередь второй сможет только после того, как зарезолвится первый, а третий сможет добавить в очередь свою задачу только после того, как зарезолвится второй.

Таким образом, на данном этапе у нас очередь состоит из одного коллбека, первого: [inner 1].

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

Очередь задач на данном этапе: [inner 1, outer].

  1. Синхронный стак закончился, event loop начинает обращаться к задачам из очередей. Из очереди микро задач, в нашем случае (поскольку только она что-то содержит).
  2. Берем первую задачу из очереди и исполняем ее (console.log("inner 1")).

Очередь задач на данном этапе: [outer].

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

Очередь задач на данном этапе: [outer, inner 2].

  1. Выполняется следующая задача - outer (console.log("inner 1")).

Очередь задач на данном этапе: [inner 2].

  1. Выполняется следующая задача из очереди (console.log("inner 2")).

Очередь задач на данном этапе: [] - пустая!

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

Очередь задач на данном этапе: [inner 3].

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

  2. Очередь пустая, выполнение окончено. Вывод скрипта - inner 1, outer, inner 2, inner 3.

Если мы добавим больше задач во внутреннюю цепочку, ничего не изменится и вывод наружнего никуда не "съедет":

async function somePromise() {
  Promise.resolve()
    .then(() => {
      console.log("inner 1");
    })
    .then(() => {
      console.log("inner 2");
    })
    .then(() => {
      console.log("inner 3");
    })
    .then(() => {
      console.log("inner 4");
    })
    .then(() => {
      console.log("inner 5");
    })
    .then(() => {
      console.log("inner 6");
    });

  return;
}

somePromise().then((data) => {
  console.log("outer");
});

outer все еще остается вторым в выводе.

Еще нагляднее станет, если добавить больше внешних then'ов:

async function somePromise() {
  Promise.resolve()
    .then(() => {
      console.log("inner 1");
    })
    .then(() => {
      console.log("inner 2");
    })
    .then(() => {
      console.log("inner 3");
    });

  return;
}

somePromise()
  .then((data) => {
    console.log("outer 1");
  })
  .then((data) => {
    console.log("outer 2");
  })
  .then((data) => {
    console.log("outer 3");
  });

inner 1
outer 1
inner 2
outer 2
inner 3
outer 3

С одной стороны, непонятно, почему наружние стали чередоваться, а не запускаться после внутренних (как было бы логично предположить, глядя на код). С другой стороны, подумайте вот о чем: точно такое же поведение было и в прошлом случае, просто поскольку у нас был всего один наружний промис, чередоваться ему было попросту не с чем! Точнее, не с чем кроме первого внутреннего, именно поэтому мы и видели такой вывод: inner 1, outer 1, inner 2, <outer 2, которого на самом деле не было>, inner 3, <outer 3, которого на самом деле не было>.

Но все-таки у меня лично остается открытым вопрос, почему же они чередуются. Давайте разберемся и с этим:

По началу все идет как и в предыдущем примере, до 10-го пункта. На 10 пункте появляется важное отличие: в данном случае после outer 1 у нас следует outer 2, который добавляется в очередь, и она становится [inner 2, outer 2]. Дальше выполняется inner 2, который регистрирует inner 3, но следующий в очереди на выполнение - outer 2. outer 2 регистрирует выполнение outer 3, но выполняется следующим inner 3 (потому что он был следующий в очереди). Отсюда и чередование.


Возвращаясь к Promise.all. Добавим его к нашему последнему примеру:

async function somePromise() {
  Promise.resolve()
    .then(() => {
      console.log("inner 1");
    })
    .then(() => {
      console.log("inner 2");
    })
    .then(() => {
      console.log("inner 3");
    });

  return;
}

Promise.all([somePromise()])
  .then((data) => {
    console.log("outer 1");
  })
  .then((data) => {
    console.log("outer 2");
  })
  .then((data) => {
    console.log("outer 3");
  });

inner 1
inner 2
outer 1
inner 3
outer 2
outer 3

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

Было:

in1     in2    in3
    out1   out2   out3

А стало просто:

in1     in2    in3
           out1   out2   out3

То есть выполнение всех "наружних" промисов просто сместилось на одну "ячейку" вправо. Почему? Из-за Promise.all, понятно. Но все-таки, почему?

А потому, что Promise.all сам возвращает промис, который тоже добавляет выполнение своего "коллбэка" в очередь. Глядя на вывод с дыркой из последнего блока кода (вывода), несложно догадаться, куда именно:

in1       in2    in3
    (СЮДА)   out1   out2   out3

Что вполне логично. Перед выполнением первого then из списка наружних, нужно будет для начала разрешить Promise.all. Поэтому его "разрешение" становится следующей задачей после inner 1, тем самым "отодвигая" выполнение наружних then'ов.

Вроде бы все должно быть понятно.


Важные напоминания (для кого-то, возможно, будут выводы):

  1. Асинхронный код на то и асинхронный, и в том и заключается его особая сложность, что иногда бывает довольно сложно предсказать, в каком именно порядке он будет выполняться.
  2. Что на самом деле важно, это не абсолютный порядок, а относительный, а именно:
    1. Код во второй ветви then, следующей за первой ветвью then всегда выполнится позже. То есть их относительный порядок выполнения будет всегда соблюдаться и будет понятен, и будет читаться "из кода": всегда будет сначала then1, потом then2, потом then3. Никогда then3 не начнет выполняться раньше чем then1, напрмер. Поэтому они и называются then - "потом"/"после"/"(по)следовательно".
    2. Цепочка thenов никогда не гарантирует вам, что действия в ней будут выполняться непрерывно. Это не так. Цепочка промисов может быть прервана другими действиями, добавленными в очередь. Если бы это было не так, то асинхронность как концепт не имела бы никакого смысла.