Разобрано, как работает асинхронность в 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‑участки в воркеры. Эти простые шаги возвращают управление ритмом и превращают асинхронность из загадки в понятный инструмент.

