Асинхронность в JavaScript: как она устроена на самом деле

Разобрано, как работает асинхронность в JavaScript: что делает цикл событий, чем живут очереди задач, как промисы и async/await управляют временем и почему «однопоточность» не равна медлительности. Эта статья — карта механизма, где каждый зубец важен, а лишних деталей нет.

Картина исполнения кода в браузере и Node.js напоминает оркестр: один дирижёр, множество инструментов и строгая партитура. Кажется, будто все играют одновременно, но реальность строже: один поток глотает ноты по очереди, а помощь приходят от тех, кто подаёт сигналы из-за кулис — таймеров, сетевых колбеков, дисковых операций. Понять, как они слаженно создают иллюзию одновременности, значит научиться управлять ритмом приложения.

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

Что делает движок, когда код «ждёт»

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

В основе лежит стек — небольшой, но строгий. Пока в нём есть фреймы, поток занят. Любая долгая история — сетевой запрос, таймер, файловый ввод-вывод — не садится на стек напрямую; она уходит к окружающей инфраструктуре: в браузере — к Web APIs, в Node.js — к libuv и системным вызовам. Когда ответ назревает, в дело вступают очереди. Цикл событий, подобно часовому механизму, отмеряет такты: берёт задачу, запускает её на стеке до конца, затем проверяет микрозадачи, потом — следующую макрозадачу, снова микрозадачи, и так по кругу. Там, где разработчику мерещится «ожидание», движок просто аккуратно складывает обещания в карман, чтобы заняться ими в ближайший удобный момент, не застревая на одном месте.

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

Откуда берутся асинхронные задачи: таймеры, сеть, UI

Асинхронные задачи порождают среда и платформенные API: таймеры, сеть, дисковый ввод-вывод, событийная система интерфейса. Они выполняются вне главного стека и уведомляют движок, когда есть данные.

Таймеры выглядят просто, но точность их «тикания» зависит от нагрузки. setTimeout не гарантирует миллисекунду в миллисекунду — он ставит задачу в очередь, и она дождётся своего, когда стек освободится и микрозадачи отработают. Сеть и диски — иная природа: операционная система сигнализирует о готовности, libuv или Web APIs формируют колбек, а цикл событий забирает его в ближайшую подходящую фазу. События пользовательского интерфейса живут собственным календарём: браузер синхронизирует перерисовку с кадровой частотой, вплетая обработчики событий в общую ткань рендеринга, чтобы страница не дёргалась. На каждую из этих тропинок есть свои ограничения и нюансы: таймеры могут быть «притуплены» вкладкой в фоне, сетевые стеки оптимизируют конвейеры, а слушатели UI страдают от тяжёлых обработчиков, если им не дать дорогу.

  • Таймеры: setTimeout, setInterval, requestAnimationFrame — расписание и синхронизация с кадрами;
  • Сеть/IO: fetch, XHR, сокеты, файловые операции в Node.js — сигнал готовности приходит от ОС;
  • События UI: клики, скроллы, ввод — жесткая зависимость от частоты перерисовки и «чистоты» главного потока;
  • Микрозадачи: Promise callbacks, MutationObserver — быстрые служебные очереди между крупными задачами;
  • «Квази-задачи»: пострисовка и компоновка — особые фазы браузерного пайплайна, влияющие на отзывчивость.

В Node.js добавляются собственные фазы: таймеры, pending callbacks, idle/prepare, опрос дескрипторов (poll), чек (check), close callbacks. В каждой из них libuv раскладывает вызовы так, чтобы сетевая активность и файловые операции кормили цикл, а код на JavaScript оставался дирижёром, а не грузчиком тяжёлых ящиков.

Микрозадачи и макрозадачи: правила игры времени

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

Картина проста, пока не появится первый Promise: его колбеки попадают в очередь микрозадач, а значит исполнятся раньше, чем setTimeout с нулевой задержкой. Такая «тонкая» очередь годится для коротких служебных шагов: проталкивания цепочек промисов, реакции на быстрые мутации DOM, аккуратных пост-обработок. Макрозадачи — площадка для событий пользовательского ввода, сетевых откликов, таймеров. Когда стек пуст, цикл событий забирает микрозадачи до чистого листа — и только потом берёт следующую крупную задачу. Ошибка проектирования нередко кроется именно здесь: накрутка сотен микрозадач в погоне за «мгновенностью» приводит к заметным паузам в отрисовке, потому что цикл не получает шанса перейти к следующему кадру.

Очередь Примеры источников Когда исполняется Назначение
Микрозадачи Promise.then/catch/finally, queueMicrotask, MutationObserver Сразу после текущего кода и перед следующей макрозадачей Короткие реактивные шаги, поддержка цепочек, непрерывность логики
Макрозадачи setTimeout, setInterval, I/O события, message, UI события Каждый проход цикла после опустошения микрозадач Крупные единицы работы, интеграция с внешней средой

Практический вывод легко проверить на примере: Promise.resolve().then(…) всегда опередит setTimeout(…, 0). Отсюда вытекает привычка держать микрозадачи короткими, как щелчок выключателя: миг — и свет уже горит, но долго его не перелистывают, чтобы не сорвать расписание дома. Там, где хочется «уступить дорогу» интерфейсу, уместно вынести работу в макрозадачу или умело разрезать её на порции, синхронизируясь с кадром или освобождая очередь короткими ялдинками кода.

Промисы: контракт о будущем и порядок их исполнения

Промис — это объект‑обещание: он закрепляет единственный переход из pending в fulfilled или rejected и обеспечивает предсказуемый запуск колбеков в очереди микрозадач. Цепочки then образуют тонкий конвейер, где каждый шаг получает результат предыдущего.

Понимание промисов начинается с их строгой одноразовости: ни двойного успеха, ни смены решения — только один исход. Колбеки then/catch/finally попадают в микрозадачи и отрабатывают после текущей синхронной порции кода. Отсюда важный эффект: синхронные ошибки в конструкторе промиса ведут к немедленному отклонению, тогда как асинхронные отложатся до своей очереди. В цепочке каждый then возвращает новый промис; если вернуть значение — он тут же исполняется с этим значением, если бросить исключение — становится rejected, если вернуть другой промис — подождёт его исход. «Плоские» цепочки легко читаются и помогают удерживать инварианты бизнес‑логики без угрозы «адского треугольника» вложенных колбеков.

Состояние Что означает Как перейти Что запускается
pending Нет результата resolve(value) или reject(error) Ничего, колбеки ждут
fulfilled Есть значение Завершилось успешно then‑колбеки в микрозадачах
rejected Есть причина ошибки Завершилось с ошибкой catch‑колбеки в микрозадачах

Расклад сил у вспомогательных методов таков: Promise.all ждёт всех — падает от первого отказа; allSettled собирает исход каждого — полезно для агрегирующей аналитики; race берёт первый исход, а any — первый успех, отклоняясь только если не повезло никому. Отладка цепочек превращается в чистую геометрию: каждое звено — трансформация, каждое отклонение — понятная развилка. Изящный приём — единый catch в конце длинной цепи и аккуратные finally, где закрывают ресурсы, не трогая данные. Ошибка многих проектов — повесить тяжёлый then в петлю микрозадач и не дать движку поднять глаза к интерфейсу, превращая «быстрый» замысел в видимую задержку.

// Иллюстрация порядка: микрозадачи опережают макрозадачи
console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => console.log('C'));
console.log('D');
// Порядок: A, D, C, B

Async/await: синтаксис удобства и его границы

Async/await — сахар над промисами: await «раскладывает» then‑цепочку в линейный код, но не блокирует поток. Возобновление функции после await уезжает в микрозадачу, сохраняя правила очередей.

Сила синтаксиса — в читаемости, слабость — в незаметной последовательности там, где нужна параллельность. Несколько await подряд запускают операции друг за другом, тогда как независимые запросы логичнее стартовать вместе и собрать результат через Promise.all. И наоборот — там, где нужен строгий порядок, линейный вид экономит когнитивное усилие и убирает ошибки проскока. Ошибки ловятся обычным try/catch, но следует помнить: исключение вне await всплывёт синхронно, а то, что завернулось в промис, вспыхнет уже в микрозадаче. Рабочая дисциплина — не забывать про отмену: AbortController в браузере и контролируемые токены в собственных абстракциях позволяют не гонять «бесхозные» запросы. И ещё одна грань — не суёт ли await тяжёлую обработку в микрозадачу, мешая кадру обновиться; там к месту перенос в макрозадачу или порционирование работы с опорой на requestAnimationFrame.

  • Параллельный запуск: стартовать запросы сразу, собирать через Promise.all;
  • Отмена: AbortController/AbortSignal, таймауты с гонками Promise.race;
  • Порционирование: разрезать тяжёлые циклы, уступая место кадрам рендера;
  • Сбор ошибок: единый try/catch на блок, логгирование с контекстом запроса;
  • Управление очередью: очередь задач доменного уровня поверх микрозадач.
// Плохо: последовательные ожидания независимых запросов
const a = await fetch('/a');
const b = await fetch('/b');

// Лучше: параллельный запуск и сбор
const [a2, b2] = await Promise.all([fetch('/a'), fetch('/b')]);

// Отмена с таймаутом
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
try {
  const res = await fetch('/slow', { signal: controller.signal });
  clearTimeout(timeout);
} catch (e) {
  // обработка отмены/ошибки
}

Параллелизм без обмана: Web Workers и потоки в Node.js

JavaScript остаётся однопоточным в основном контексте, но тяжёлую работу можно вынести в отдельные потоки: в браузере — через Web Workers, в Node.js — через worker_threads или стримы с бэкпрешером. Это настоящий параллелизм, но с ценой маршалинга данных.

Воркеры — не серебряная пуля, а карман для вычислительных гирь. Они живут без доступа к DOM, общаются сообщениями, умеют делить память через SharedArrayBuffer и синхронизироваться примитивами Atomics. У узких мест два лица: стоимость копирования больших структур и архитекурная дисциплина — чтобы протокол обмена не превратился в клубок. В Node.js «тяжёлые» вычисления уместно переносить в worker_threads, освобождая event loop для сетевой жвачки. Там, где правит поток данных — помогают стримы и бэкпрешер: производитель отступает, когда потребитель захлёбывается, удерживая память и задержки в узде. Параллелизм работает, когда грамотно очерчены границы: большие числа — в воркеры, длинные байты — в стримы, координация — в событиях и контроллерах.

Сценарий Подход Плюсы Ограничения
Тяжёлое CPU‑вычисление в браузере Web Worker Не блокирует UI, масштабируется по ядрам Обмен сообщениями, нет доступа к DOM
CPU‑нагрузка в Node.js worker_threads Параллелизм, общий процесс Оркестрация, передача данных
Большие потоки данных Streams + backpressure Контроль памяти, конвейерность Сложность обработки ошибок/закрытий
// Пример воркера в браузере
// main.js
const worker = new Worker('worker.js');
worker.postMessage({ n: 45 });
worker.onmessage = (e) => {
  // получить результат и обновить UI
};

// worker.js
self.onmessage = (e) => {
  const res = fib(e.data.n); // тяжёлая функция
  self.postMessage(res);
};

Надёжная архитектура асинхронности: ошибки, таймауты, отмена

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

Таймауты — страховка от вечного ожидания: каждый сетевой прыжок должен знать, сколько ему отводится. Повторные попытки полезны там, где ошибки временные, но без нарастания задержки (exponential backoff) они превращаются в атаку на сервер. Идемпотентность — щит от дублей: одинаковый запрос не должен плодить побочные эффекты. Отмена — уважение к контексту: пользователь ушёл со страницы — запросы уходят вместе с ним. Важный слой — разметка сложных задач на порции и переход к requestIdleCallback или requestAnimationFrame, чтобы не воровать дыхание у интерфейса. А сверху — прозрачный журнал: логгирование с ключами корреляции, счётчики зависаний, тревоги по долгим микрозадачам.

  • Давать каждому ожиданию таймаут и план «Б»;
  • Повторять только идемпотентные операции, с нарастающей задержкой;
  • Отменять «осиротевшие» задачи и освобождать ресурсы;
  • Пилить тяжёлые вычисления на кадры/порции;
  • Записывать контекст: кто вызвал, что ждал, чем закончилось.
Симптом Вероятная причина Как проверить Что делать
UI «фризит» на пару секунд Тяжёлая синхронная функция в главном потоке Performance профилировщик, Long Tasks Вынести в воркер или нарезать по кадрам
Промисы «зависают» Нет resolve/reject, потерянный путь отмены Трассировка, таймауты, логи переходов Добавить таймаут, замкнуть все ветви
Непредсказуемый порядок колбеков Смешение микро/макрозадач Мини‑примеры, проверка очередей Стандартизировать очередь, упростить цепочки
Рост памяти Утечки в замыканиях, висящие подписки Heap snapshots, weak‑ссылки Отписки, финализаторы, слабые карты
// Таймаут как защита от вечного ожидания
function withTimeout(promise, ms) {
  return Promise.race([
    promise,
    new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), ms))
  ]);
}

FAQ: ответы на вопросы об асинхронности JS

Почему код с setTimeout(…, 0) выполняется позже, чем Promise.then?

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

Знание этого порядка помогает избегать неожиданных интерлейвингов: где критична мгновенная подстройка состояния — место микрозадачам; где нужен зазор для перерисовки — лучше макрозадача. В споре «ноль миллисекунд» и «следующая микрозадача» всегда побеждает вторая.

Блокирует ли await главный поток исполнения?

Нет, await не блокирует поток: он раскладывает then‑колбек в микрозадачу и возвращает управление циклу событий. Блокировка возникает от тяжёлого синхронного кода, а не от самого await.

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

Когда уместно использовать Web Worker, а когда — нет?

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

Решение опирается на вес работы и частоту. Если копирование данных в воркер дороже, чем сама функция, выгоды не будет; если же один расчёт способен «заморозить» страницу, перенос окупается сразу.

Чем Promise.all отличается от Promise.allSettled и что выбрать?

Promise.all ждёт всех и отклоняется от первой ошибки; allSettled всегда исполняется, возвращая исход каждого участника. Если нужен единый успешный результат — берётся all и отдельная обработка ошибок; для отчётов и частичных успехов — allSettled.

В продуктивных системах часто сочетают подходы: критичные шаги держат на all, а вспомогательные — на allSettled, чтобы UI мог показать максимум собранных данных, не ломая основной сценарий.

Почему длинные цепочки микрозадач вредят рендерингу?

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

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

Как правильно отменять асинхронные операции в браузере?

Стандартный способ — AbortController/AbortSignal: он поддерживается fetch и может быть встроен в собственные обёртки. Отмена — это не только «прервать запрос», но и корректно закрыть ресурсы и отписаться от колбеков.

Полезно связывать контроллер с жизненным циклом компонента или страницы, чтобы не оставлять висящих запросов и не тратить трафик на бесхозные ответы.

Что происходит с ошибками в async‑функции, если их не поймать?

Исключения внутри async ведут к отклонению возвращаемого промиса. Если его не awaited и не добавлено .catch, ошибка всплывает как неперехваченное отклонение, что в браузере и Node.js фиксируется отдельными событиями/хуками.

Здравый подход — завершать каждую корневую async‑операцию явной обработкой: await в вызывающем коде с try/catch или .catch в конце цепочки, оставляя ноль шансов необработанным отклонениям.

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

Чтобы привести асинхронный код в строй, полезно двигаться шагами. Сначала — карта потоков: где рождаются задачи, что ждёт ответа, какие очереди забиваются. Затем — страховка: таймауты, backoff, идемпотентность. Далее — разгрузка: перенос тяжёлого кода в воркеры, порционирование вычислений, объединение независимых ожиданий. После — наблюдаемость: профилировка кадра, трассировка промисов, алерты по «долгим задачам». И, наконец, постоянное ремесло: поддерживать ритм, не забывая, что каждый await — не точка в предложении, а запятая в длинной фразе приложения.

Действовать стоит сразу: выделить секцию критических путей, переписать последовательные ожидания на параллельные там, где это законно; ввести таймауты для всех внешних вызовов; внедрить AbortController в сетевые слои; добавить метрики длинных микрозадач и частоты «долгих кадров»; вынести узкие CPU‑участки в воркеры. Эти простые шаги возвращают управление ритмом и превращают асинхронность из загадки в понятный инструмент.