Перейти к содержанию
Educora
Продвинутый22 мин17 / 27

Цикл событий и конкурентность

Загляни внутрь движка: стек вызовов, очередь задач и очередь микрозадач. Научись точно предсказывать порядок выполнения `setTimeout`, промисов и `await`.

Проверь себя
В этом уроке ты узнаешь
  • Описывать стек вызовов, очередь задач и очередь микрозадач
  • Предсказывать порядок вывода в коде, где смешаны синхронный код, setTimeout, промисы и await
  • Выбирать между последовательным и параллельным асинхронным кодом: Promise.all, allSettled, race, any

Классический вопрос на собеседовании: «В каком порядке этот код что-то выведет?» — и перед тобой смесь console.log, setTimeout(..., 0) и Promise.then. Даже опытные разработчики часто ошибаются. JavaScript работает в одном потоке, но обрабатывает тысячи кликов, запросов и таймеров. Секрет — в цикле событий (event loop). Как только поймёшь три структуры — стек вызовов, очередь задач и очередь микрозадач, — такие головоломки превратятся в простую арифметику.

Стек вызовов

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

JavaScript
function third() {
  console.log('3. third is on top of the stack');
}

function second() {
  console.log('2. second calls third');
  third();
  console.log('4. back in second');
}

function first() {
  console.log('1. first calls second');
  second();
  console.log('5. back in first');
}

first();
▸ Ожидаемый результат
1. first calls second
2. second calls third
3. third is on top of the stack
4. back in second
5. back in first
Стек: first → second → third. Каждая функция продолжает работу только после того, как завершился вызов внутри неё. Бесконечная рекурсия переполняет стек, и движок выбрасывает ошибку.

Задачи и микрозадачи

Когда асинхронная работа (таймер, ответ сети, клик) завершается, её колбэк не выполняется сразу — он ждёт в очереди. Основных очередей две. В очередь задач (macrotask) попадают setTimeout, setInterval, события пользователя и сообщения. В очередь микрозадач — колбэки промисов (then, catch, finally), код после await и queueMicrotask. Цикл событий обрабатывает их так:

  1. 1
    Выполнить текущий код до конца

    Скрипт (или одна задача) работает, пока стек не опустеет.

  2. 2
    Выполнить ВСЕ микрозадачи

    Включая новые микрозадачи, добавленные по ходу, — пока очередь полностью не опустеет.

  3. 3
    При необходимости отрисовать страницу

    Браузер выводит изменения на экран.

  4. 4
    Взять ОДНУ задачу из очереди

    Например, один колбэк таймера, — и снова к шагу 1.

JavaScript
console.log('A: script start');

setTimeout(() => console.log('B: timeout'), 0);

Promise.resolve()
  .then(() => console.log('C: promise 1'))
  .then(() => console.log('D: promise 2'));

queueMicrotask(() => console.log('E: microtask'));

console.log('F: script end');
▸ Ожидаемый результат
A: script start
F: script end
C: promise 1
E: microtask
D: promise 2
B: timeout
Сначала синхронные A и F. Затем микрозадачи: C и E уже стояли в очереди, а D добавилась, когда закончилась C. Таймер B — последним, как отдельная задача.

async/await в очереди

await приостанавливает только свою async-функцию; код после await продолжается как микрозадача. Внимание: код до первого await выполняется синхронно — сразу при вызове функции.

JavaScript
async function load() {
  console.log('2. load starts: synchronous part');
  await null;
  console.log('4. after await: microtask');
}

console.log('1. script start');
setTimeout(() => console.log('6. timeout: task'), 0);
load();
Promise.resolve().then(() => console.log('5. then: microtask'));
console.log('3. script end');
▸ Ожидаемый результат
1. script start
2. load starts: synchronous part
3. script end
4. after await: microtask
5. then: microtask
6. timeout: task

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

JavaScript
setTimeout(() => {
  console.log('timeout 1');
  Promise.resolve().then(() => console.log('-> microtask inside timeout 1'));
}, 0);

setTimeout(() => console.log('timeout 2'), 0);

Promise.resolve().then(() => {
  console.log('microtask 1');
  setTimeout(() => console.log('timeout 3, added by microtask 1'), 0);
});
▸ Ожидаемый результат
microtask 1
timeout 1
-> microtask inside timeout 1
timeout 2
timeout 3, added by microtask 1

Последовательно и параллельно

JavaScript однопоточный, но ожидания могут перекрываться: сетевые запросы и таймеры отслеживает в фоне браузер (или Node.js). Это называется конкурентностью: одновременно «в работе» много дел, но в каждый момент выполняется только один кусок JavaScript. Частая ошибка: await внутри цикла отправляет запросы один за другим. Если задачи независимы, запусти их все вместе и жди через Promise.all.

JavaScript
const task = (label, ms) =>
  new Promise((resolve) => {
    setTimeout(() => {
      console.log(`${label} done`);
      resolve(label);
    }, ms);
  });

console.log('Sequential:');
await task('A (200 ms)', 200);
await task('B (100 ms)', 100);

console.log('Parallel:');
const results = await Promise.all([task('A (200 ms)', 200), task('B (100 ms)', 100)]);
console.log(results);
▸ Ожидаемый результат
Sequential:
A (200 ms) done
B (100 ms) done
Parallel:
B (100 ms) done
A (200 ms) done
[ 'A (200 ms)', 'B (100 ms)' ]
Последовательный вариант занимает ~300 мс, параллельный — ~200 мс; при параллельном запуске B заканчивает первым, но Promise.all возвращает результаты в порядке массива. await на верхнем уровне работает здесь, как в модулях.
МетодКогда завершаетсяРезультат
Promise.allкогда все успешны или при первой ошибкемассив значений или первая ошибка
Promise.allSettledкогда все завершились; никогда не отклоняетсямассив { status, value } / { status, reason }
Promise.raceкогда завершится первый (успех или ошибка)самый быстрый исход
Promise.anyпри первом успехе; если все неудачны — ошибкапервое успешное значение или AggregateError
JavaScript
const ok = (value, ms) =>
  new Promise((resolve) => setTimeout(() => resolve(value), ms));
const fail = (reason, ms) =>
  new Promise((_, reject) => setTimeout(() => reject(new Error(reason)), ms));

const settled = await Promise.allSettled([ok('menu', 100), fail('no stock', 50)]);
console.log(settled.map((result) => result.status));

console.log(await Promise.any([fail('server 1 down', 50), ok('server 2', 100)]));
console.log(await Promise.race([ok('fast', 50), ok('slow', 150)]));

try {
  await Promise.all([ok('menu', 100), fail('no stock', 50)]);
} catch (error) {
  console.log('Promise.all failed:', error.message);
}
▸ Ожидаемый результат
[ 'fulfilled', 'rejected' ]
server 2
fast
Promise.all failed: no stock
Задание

Не переставляя строки, добейся вывода в порядке 1, 2, 3, 4: оберни нужные строки в setTimeout(() => ..., 0) или Promise.resolve().then(() => ...).

Задание · JavaScript
console.log('4. task');
console.log('3. microtask');
console.log('1. sync');
console.log('2. sync');
▸ Ожидаемый результат
1. sync
2. sync
3. microtask
4. task
Задание

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

Задание · JavaScript
const prices = { tea: 2, bread: 1, jam: 5 };

function getPrice(item, ms) {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log(`loaded ${item}`);
      resolve(prices[item]);
    }, ms);
  });
}

// sequential and slow: rewrite with Promise.all
const tea = await getPrice('tea', 300);
const bread = await getPrice('bread', 100);
const jam = await getPrice('jam', 200);

console.log('Total:', tea + bread + jam);
▸ Ожидаемый результат
loaded bread
loaded jam
loaded tea
Total: 8

Главное

  • У JavaScript один стек вызовов: в каждый момент работает только один фрагмент кода, а долгая синхронная работа замораживает всё.
  • Таймеры и события попадают в очередь задач, колбэки промисов и код после await — в очередь микрозадач.
  • Порядок: синхронный код → все микрозадачи → одна задача → снова все микрозадачи.
  • В async-функции код до первого await выполняется синхронно.
  • Независимые задачи запускай вместе и жди через Promise.all/allSettled; race и any берут первый результат.

Проверь себя

Вопросов: 10. Каждый правильный ответ приносит XP.

1 / 10
Что и в каком порядке выведет console.log(1); setTimeout(() => console.log(2), 0); Promise.resolve().then(() => console.log(3)); console.log(4);?