javascript

Один поток, а всё успевает: как работает цикл событий в JavaScript

  • суббота, 25 июля 2026 г. в 00:00:04
https://habr.com/ru/companies/otus/articles/1062056/

Привет, Хабр!

«JavaScript однопоточный, но асинхронный» — фраза, которую слышал каждый и которая при этом ровным счётом ничего не объясняет. Как один поток умудряется одновременно ждать ответ сервера, отсчитывать таймер и обрабатывать клики? Почему setTimeout(fn, 0) срабатывает не сразу? Почему промис обгоняет таймер с нулевой задержкой, хотя оба вроде бы асинхронные? Почему тяжёлый цикл вешает всю страницу, а сетевой запрос — нет?

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

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

Три места, где живёт ваш код

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

  2. Очередь макрозадач (её ещё называют очередью задач, task queue). Сюда попадают колбэки таймеров, обработчики событий, колбэки ввода‑вывода.

  3. Очередь микрозадач. Сюда попадают колбэки промисов, всё, что после await, вызовы queueMicrotask и колбэки MutationObserver.

Сам цикл событий крутит бесконечно одно и то же:

  1. Стек не пуст — ничего не делаем, ждём.

  2. Стек опустел — полностью разгребаем очередь микрозадач.

  3. Берём одну задачу из очереди макрозадач, выполняем.

  4. Снова полностью разгребаем микрозадачи.

  5. В браузере — при необходимости отрисовываем кадр.

  6. Повторяем.

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

Пока стек не опустеет, ни одна очередь не разбирается.

Классическая задачка на порядок

console.log('1');

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

Promise.resolve().then(() => console.log('3'));

console.log('4');

Вывод: 1, 4, 3, 2.

  • console.log('1') — синхронно, печатается сразу. setTimeout колбэк не выполняет, он отдаёт его браузеру, который через ноль миллисекунд положит его в очередь макрозадач.

  • Promise.resolve().then(...) тоже не выполняет колбэк на месте — ставит его в очередь микрозадач. console.log('4') — синхронно.

Синхронный код кончился, стек опустел. Цикл событий сначала вычерпывает микрозадачи — печатается 3. И только потом берёт макрозадачу — печатается 2.

Отсюда правило: промисы всегда обгоняют таймеры, даже с нулевой задержкой.

Усложняем:

setTimeout(() => {
  console.log('таймер 1');
  Promise.resolve().then(() => console.log('промис внутри таймера'));
}, 0);

setTimeout(() => console.log('таймер 2'), 0);

Вывод: таймер 1, промис внутри таймера, таймер 2. Между двумя макрозадачами очередь микрозадач разгребается полностью — поэтому промис, созданный внутри первого таймера, успевает раньше второго таймера.

Ещё веселее с цепочками:

Promise.resolve()
  .then(() => console.log('A'))
  .then(() => console.log('B'));

Promise.resolve()
  .then(() => console.log('C'))
  .then(() => console.log('D'));

Вывод: A, C, B, D. Каждый then — отдельная микрозадача, и следующая в цепочке ставится в очередь только когда выполнилась предыдущая. Поэтому цепочки идут не подряд, а чередуются, как карты при тасовании.

Почему setTimeout(fn, 0) — это не «сразу»

Ноль в setTimeout означает не «немедленно», а «как можно раньше, но не раньше, чем освободится стек и подойдёт очередь».

setTimeout(() => console.log('прошло?'), 0);

const start = Date.now();
while (Date.now() - start < 3000) {}   // блокируем поток на три секунды

console.log('цикл кончился');

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

Есть и вторая причина, чисто техническая. Спецификация задаёт минимальную задержку в 4 миллисекунды для вложенных таймеров начиная с пятого уровня вложенности. То есть setTimeout внутри setTimeout внутри setTimeout начнёт замедляться сам по себе, и рекурсивный таймер с нулём в реальности будет тикать раз в четыре миллисекунды, а не быстрее.

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

Из этого же следует известный анти‑паттерн — setTimeout(fn, 0) как способ «дождаться, пока отрисуется DOM». Иногда срабатывает, иногда нет: момент отрисовки привязан к очереди макрозадач неявно, гарантий никаких. Если нужен именно кадр, есть requestAnimationFrame, который вызывается прямо перед отрисовкой. А если нужно «после того как кадр нарисовался» — это requestAnimationFrame внутри requestAnimationFrame, приём известный и слегка уродливый.

Чем на самом деле является async/await

async/await — синтаксис поверх промисов, и знание про очереди объясняет его поведение целиком.

async function foo() {
  console.log('старт foo');
  await bar();
  console.log('после await');
}

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

console.log('1');

async function foo() {
  console.log('2');
  await null;            // даже await над не-промисом создаёт микрозадачу
  console.log('4');
}

foo();
console.log('3');

Вывод: 1, 2, 3, 4. Строка с двойкой выполняется синхронно при вызове foo(), а всё после await откладывается в микрозадачу — поэтому тройка из основного кода успевает раньше.

Обратите внимание на await null. Даже если вы ждёте не промис, а обычное значение, откладывание всё равно происходит: значение заворачивается в разрешённый промис, остаток функции уезжает в очередь. Никакого «раз значение уже есть, продолжим сразу» не бывает.

Отсюда заблуждение: async не делает функцию параллельной. Она всё так же выполняется в единственном потоке, просто умеет отдавать управление в точках await. Тяжёлый цикл внутри async‑функции блокирует всё ровно так же, как и снаружи, потому что внутри цикла отдавать управление негде.

Функция всегда возвращает промис

async function getUser() {
  return { name: 'Иван' };     // возвращаем объект
}

const user = getUser();
console.log(user.name);        // undefined
console.log(user);             // Promise { ... }

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

Обработка ошибок

Внутри async работает обычный try/catch, потому что await превращает отклонённый промис в исключение:

async function load() {
  try {
    const data = await fetch('/api/data').then(r => r.json());
    return data;
  } catch (err) {
    console.error('не получилось', err);
    return null;
  }
}

А вот это уже не сработает:

try {
  doSomethingAsync();       // без await
} catch (err) {
  // сюда не попадём никогда
}

Функция вернула промис и ушла, ошибка случится позже, когда блок try давно закончился. Ловить будет некому, и в браузере вы получите unhandledrejection в консоли, если, конечно, туда смотрите.

Отсюда правило: у каждого промиса должен быть кто‑то, кто разберёт его ошибку. Либо await в try/catch, либо .catch(), либо явное решение «мне всё равно», записанное в коде, а не подразумеваемое.

await в цикле, из‑за которого всё тормозит

Самое частое, что встречается:

const results = [];
for (const id of ids) {
  results.push(await fetchUser(id));    // ждём каждого по очереди
}

Сто пользователей по сто миллисекунд — десять секунд. При этом запросы независимы, и никакой причины ждать их последовательно нет.

const results = await Promise.all(ids.map(id => fetchUser(id)));

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

Тут стоит разобрать несколько тонкостей, потому что Promise.all тоже умеет удивлять

  • Он падает целиком от одного отказа. Если из ста запросов упал один, вы не получите ничего — ни успешных результатов, ни понимания, кто именно упал. Когда важны все ответы независимо от отдельных провалов:

const results = await Promise.allSettled(ids.map(id => fetchUser(id)));

const ok = results.filter(r => r.status === 'fulfilled').map(r => r.value);
const failed = results.filter(r => r.status === 'rejected');
  • Он не отменяет остальные запросы при падении. Промисы в JavaScript вообще неотменяемы. Упал первый — остальные девяносто девять всё равно доедут до сервера и вернутся, просто результат никто не заберёт. Если это принципиально, нужен AbortController:

const controller = new AbortController();

try {
  await Promise.all(
    ids.map(id => fetch(`/api/users/${id}`, { signal: controller.signal }))
  );
} catch (err) {
  controller.abort();      // обрываем то, что ещё в полёте
  throw err;
}
  • Он запускает всё одновременно. Сто параллельных запросов может не понравиться серверу, а тысяча — почти наверняка. Нужен предел параллельности. Простейший вариант без библиотек:

async function mapLimit(items, limit, fn) {
  const results = new Array(items.length);
  let cursor = 0;

  async function worker() {
    while (cursor < items.length) {
      const i = cursor++;
      results[i] = await fn(items[i]);
    }
  }

  await Promise.all(Array.from({ length: limit }, worker));
  return results;
}

const users = await mapLimit(ids, 5, fetchUser);

Пять «работников» разбирают общую очередь — классическая схема, которой хватает в девяти случаях из десяти.

И последнее: есть законные случаи для последовательного await. Когда следующий запрос действительно зависит от предыдущего:

const user = await fetchUser(id);
const orders = await fetchOrders(user.accountId);   // нужен accountId

Тут всё правильно. Проблема только там, где зависимости нет, а ожидание есть.

Как подвесить браузер (и как этого не делать)

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

function processAll(items) {
  return items.map(item => heavyTransform(item));   // миллион элементов = замерший UI
}

Обернуть в async тут не поможет ни на грамм: код внутри всё равно синхронный, отдавать управление негде.

Разбить на куски

Самый простой подход — резать работу на порции и между ними отдавать управление циклу событий:

async function processInChunks(items, chunkSize = 500) {
  const out = [];
  for (let i = 0; i < items.length; i += chunkSize) {
    for (const item of items.slice(i, i + chunkSize)) {
      out.push(heavyTransform(item));
    }
    await new Promise(r => setTimeout(r, 0));   // даём циклу вздохнуть
  }
  return out;
}

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

Важно, что здесь именно setTimeout, а не queueMicrotask или Promise.resolve(). Микрозадача управление браузеру не вернёт — она выполнится в той же порции, до отрисовки.

Кстати, есть более честный инструмент для этого — scheduler.yield(), который отдаёт управление и при этом гарантирует, что ваша работа продолжится раньше, чем прочие задачи в очереди.

Унести в другой поток

Радикальный и правильный вариант для тяжёлых вычислений — веб‑воркер:

// main.js
const worker = new Worker('heavy.js');
worker.postMessage(items);
worker.onmessage = (e) => render(e.data);

// heavy.js
self.onmessage = (e) => {
  const result = e.data.map(heavyTransform);
  self.postMessage(result);
};

Воркер — это настоящий отдельный поток со своим стеком и своим циклом событий. Он считает, пока основной поток спокойно рисует интерфейс.

Два ограничения.

  1. У воркера нет доступа к DOM. Он умеет считать, парсить, сжимать, шифровать, обрабатывать данные — но рисовать должен основной поток. Обмен идёт сообщениями.

  2. Данные при передаче копируются. Большой массив уедет в воркер копией, и само копирование может занять заметное время. Обходится это передачей владения:

const buffer = new ArrayBuffer(50_000_000);
worker.postMessage(buffer, [buffer]);   // передаём, а не копируем
// после этого buffer в основном потоке пустой

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

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

Тонкости

Микрозадача может подвесить страницу намертво

Ловушка, которая объясняется ровно правилом «вычерпать полностью»:

function loop() {
  Promise.resolve().then(loop);    // каждая микрозадача плодит следующую
}
loop();

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

С макрозадачами так не получится — они берутся по одной за оборот, между ними всё остальное успевает:

function loop() {
  setTimeout(loop, 0);      // страница живая, хоть и подгружена
}
loop();

Вся разница — в тех самых «полностью» и «одну».

Обработчики, пережившие свой компонент

Классическая утечка, которая на самом деле про цикл событий:

function mount() {
  const data = new Array(1_000_000);
  window.addEventListener('resize', () => process(data));
}

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

Лечится дисциплиной снятия обработчиков — или, что удобнее, тем же AbortController:

const controller = new AbortController();

window.addEventListener('resize', handler, { signal: controller.signal });
document.addEventListener('scroll', other, { signal: controller.signal });

// снимаем всё разом
controller.abort();

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

Гонка между отложенными результатами

Ещё одно следствие того, что порядок ответов не гарантирован:

input.addEventListener('input', async (e) => {
  const results = await search(e.target.value);
  render(results);          // а если предыдущий запрос вернётся позже?
});

Пользователь печатает «ноут», потом «ноутбук». Запрос за «ноут» ушёл первым, но вернулся вторым — и на экране результаты для «ноут», хотя в поле «ноутбук».

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

let latest = 0;

input.addEventListener('input', async (e) => {
  const ticket = ++latest;
  const results = await search(e.target.value);
  if (ticket !== latest) return;    // пока ждали, ушёл новый запрос
  render(results);
});

Счётчик растёт на каждое нажатие, и отрисовывается только тот результат, чей номер всё ещё актуален.

В Node.js всё немного иначе

Если пишете под Node, стоит знать про два дополнительных механизма.

  • process.nextTick() — это отдельная очередь, которая разбирается раньше микрозадач промисов. То есть приоритет ещё выше, чем у then.

  • setImmediate() — макрозадача, но выполняющаяся в специальной фазе цикла, отдельно от таймеров.

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

Для повседневного кода эти детали редко важны, но когда важны — важны сильно, и объясняют, например, почему setTimeout(fn, 0) и setImmediate(fn) на верхнем уровне дают недетерминированный порядок.

Ну и что с этим вообще делать

Вся механика помещается в три предложения. Синхронный код выполняется на стеке, и пока он не закончится, не происходит вообще ничего. Когда стек опустел, полностью разбирается очередь микрозадач — это промисы и всё, что после await. Потом берётся одна макрозадача — таймер или обработчик события — и круг замыкается.

Из этих трёх предложений выводится всё остальное, и в этом их ценность. setTimeout(fn, 0) не про «сразу», а про «когда дойдёт очередь». Промисы обгоняют таймеры, потому что микрозадачи разбираются первыми и целиком. async не создаёт параллельности, а только размечает точки, где можно отдать управление. Бесконечная микрозадача вешает вкладку, а бесконечная макрозадача — нет.

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

Не браузер решает, когда вас прервать, — прерваться можете только вы, вернув управление. Отсюда растёт всё практическое: и разбиение тяжёлой работы на куски, и веб‑воркеры, и Promise.all вместо последовательного ожидания. Это всё способы отпустить поток раньше, чем пользователь успеет заметить, что страница не отвечает.

Забавно, кстати, что модель, которую придумали для довольно скромной задачи — не блокировать браузер во время сетевых запросов, — оказалась настолько удачной, что её потом растащили по другим языкам. И async/await в Python, C#, Rust и Swift работают на тех же принципах, разве что очередей и приоритетов там иногда побольше.

Когда промисы, таймеры и async/await начинают давать неожиданный порядок выполнения, одной схемы цикла событий бывает недостаточно.

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

  • 29 июля в 20:00. «Собери себя сам: пишем трекер привычек на чистом JavaScript». Записаться

  • 4 августа в 20:00. «Многозадачность в Python. Асинхронность, процессы, потоки». Записаться

  • 18 августа в 20:00. «Python asyncio: gather, wait, TaskGroup на практике». Записаться

Больше бесплатных уроков и других полезных подборок смотрите в дайджесте.