Асинхронный JavaScript
JSON:
{
"title": "Асинхронный JavaScript: как понять механизмы event loop, Promise и async/await без заучивания",
"keywords": "асинхронный JavaScript, event loop, Promise, async await, микротаски, макротаски, очередь вызовов, Web API, однопоточность, конкурентность",
"description": "Разбираем техническую механику асинхронного JavaScript: event loop, microtask queue, phases. Учимся отличать Promise от setTimeout, разбираем реальные примеры с порядком выполнения. Без воды — только спецификации и архитектура.",
"html_content": "Вы открываете консоль браузера, пишете три строки кода — и результат путает вас. setTimeout отрабатывает не через указанные 0 миллисекунд, а после всего синхронного кода. Promise.then — до setTimeout, хотя оба асинхронны. Знакомо? Здесь нет магии. Есть строгая спецификация ECMAScript и архитектура event loop, которую вы можете проследить пошагово. Именно этот слой — механизмы, а не синтаксис — отличает глубокое понимание асинхронного JavaScript от поверхностного. Сейчас вы разберетесь, как на самом деле работает очередь задач, микротаски и фазы event loop, и больше никогда не попадете в ловушку «почему этот коллбек выполнился раньше, чем тот».
Главное отличие асинхронного JavaScript от других языков (Python, Java, C#) — однопоточная модель с конкурентностью на основе цикла событий. Никаких потоков в классическом понимании. Вы не создаете thread, не работаете с блокировками. Вместо этого — очередь задач, управляемая event loop. Каждый тик цикла проходит через фазы: таймеры (setTimeout, setInterval), операции ввода-вывода (fetch, readFile), фаза ожидания, фаза close. Между фазами — проверка очереди микротасок (Promise.then/catch/finally, queueMicrotask, MutationObserver). Эта архитектура не дается интуитивно, но предсказуема на 100%, если вы знаете порядок фаз. В этой статье вы разберете event loop именно так, как его видят браузеры Chromium и Node.js 2026 года.
Конкретный пример, который вы сможете повторить в консоли: setTimeout(() => console.log('таймер'), 0); Promise.resolve().then(() => console.log('промис')); console.log('синхронный'); Результат: сначала 'синхронный', затем 'промис', затем 'таймер'. Почему? Promise — микротаска, выполняется после выполнения синхронного кода, но до следующей фазы таймеров. setTimeout — макротаска, ставится в очередь на фазу таймеров. Различие в типах задач — ключевой технический нюанс, который вы начнете видеть в любом асинхронном коде. Понимание этого уровня позволяет отладить баги с порядком обновления UI, race conditions в асинхронных цепочках, и точно контролировать момент выполнения.
Как устроен event loop: техническая схема на 2026 год
В основе event loop — очередь задач и очередь микротасок. Первая содержит макротаски: setTimeout, setInterval, обработчики событий (click, load), а также фрагменты рендеринга (requestAnimationFrame в браузерах). Вторая — микротаски: Promise.then, async/await (после await вас ждет микротаска), queueMicrotask, MutationObserver. Спецификация HTML Living Standard (актуальная на 2026 год) определяет следующий порядок: выполнить одну макротаску — потом выполнить все микротаски — затем обновить рендеринг (если необходимо) — повторить. Важно: микротаски могут порождать новые микротаски, и они будут выполнены до того, как event loop перейдет к следующей макротаске. Именно это создает эффект «зависания» при глубокой рекурсии Promise.resolve().then(...).
Когда вы вызываете Promise.resolve().then(fn), fn не выполняется сразу. Он попадает в очередь микротасок. Движок V8, SpiderMonkey или JavaScriptCore помещает ссылку на функцию в специальный внутренний список. Event loop проверяет этот список после завершения синхронного выполнения и перед любыми макротасками. Даже если setTimeout(0) поставлен раньше в коде, его callback окажется в очереди макротасок. Приоритет микротасок выше — это жесткое правило. В Node.js 2026 года очередь микротасок проверяется между каждой фазой event loop (таймеры, I/O коллбеки, idle, poll, check, close), что дает больше точек входа для promises.
Отличие от альтернатив в других языках: в Python asyncio использует cooperative multitasking с корутинами, явно передающими управление через await. В JavaScript вы не передаете управление явно — любой Promise автоматически создает микротаску. Это архитектурное решение делает асинхронный код более декларативным, но добавляет сложности с порядком. Для сравнения: в .NET Task — это объект, управляемый пулом потоков, event loop там не требуется. В Rust — асинхронные фьючерсы опрашиваются явно через poll. Только JavaScript и его окружения (браузер, Node.js) используют единый поток + event loop как базовую модель.
Разница между Promise, setTimeout и requestAnimationFrame: спецификации и нюансы
Чтобы точно управлять асинхронным кодом, важно знать различия в контрактах этих API на уровне спецификаций. Promise — это микротаска, гарантированно выполняемая до следующей макротаски (включая рендеринг). setTimeout(fn, delay) — макротаска, с минимальным delay в 4 мс для вложенных таймеров (согласно HTML spec: если вложенность > 5, то minimum delay = 4 мс). Это не баг, а требование безопасности, предотвращающее зависание вкладки. requestAnimationFrame — не макротаска и не микротаска, а специальная задача, выполняемая перед рендерингом. Порядок: микротаски → requestAnimationFrame → рендеринг → макротаски.
Техническая деталь: queueMicrotask() — прямой API для постановки микротаски без создания Promise. Это быстрее, чем Promise.resolve().then(), потому что не создается лишний wrapper-объект. Если вы пишете производительный код (например, для игры или анимации на канвасе), используйте queueMicrotask для операций, которые должны выполниться до следующего кадра. В Node.js 2026 года queueMicrotask и process.nextTick имеют похожее поведение, но process.nextTick выполняется до любых микротасок (в собственной очереди nextTickQueue). Это различие специфично для Node.js и не имеет аналогов в браузерах.
Для веб-разработки на 2026 год критичное знание: обработчики событий (click, input) создают макротаски, если они не привязаны через addEventListener с опцией passive. Но внутри них могут быть сгенерированы микротаски из Promise.all, async-функций. Если вы вызываете event.stopPropagation() внутри микротаски, это может привести к неочевидному поведению, потому что обработка событий уже завершилась. Эти тонкости выявляются только при глубоком понимании event loop.
Как async/await меняет очередь: технический разбор
async-функция — синтаксический сахар над генераторами и Promise. Когда вы пишете await promise, выполнение приостанавливается в месте await, управление возвращается в event loop, а остаток функции помещается как микротаска. Пример: async function foo() { console.log('1'); await bar(); console.log('3'); } — после вызова foo() вы увидите '1', затем выполнение покинет функцию, дойдет до точки await, и только после того, как Promise от bar() зарезолвится, микротаска с '3' добавится в очередь. Таким образом, console.log('3') не будет выполнен, пока не завершатся все синхронные операции и микротаски, стоящие в очереди раньше.
Важный технический нюанс: await Promise.resolve() буквально создает микротаску. Если у вас есть цепочка из 100 await, каждый создает микротаску. Event loop не выйдет на фазу макротасок, пока не завершит все эти микротаски. Это может блокировать обработку событий (scroll, click) на время выполнения длинной цепочки. Решение — разбивать тяжелые асинхронные операции через setTimeout(fn, 0) для макротаски, давая браузеру шанс рендерить и обрабатывать ввод. Этот прием называется yielding — уступка управления циклу событий.
По сравнению с генераторами (yield), async/await имеет одно критическое преимущество: автоматическое распространение ошибок через try/catch. Генераторы требуют ручного вызова .throw() для обработки исключений. Но по производительности в V8 генераторы на 10-15% быстрее для простых итераций (бенчмарки 2026 года). Выбор между async/await и генераторами — это trade-off между читаемостью и производительностью. Для сетевых запросов async/await — стандарт, для потоков данных — генераторы.
Технические стандарты и отличия окружений (браузер vs Node.js)
- Браузер (Chromium 120+): event loop по спецификации HTML — 6 фаз: таймеры (setTimeout), I/O коллбеки (poll), idle (внутреннее), check (setImmediate не поддерживается), close (коллбеки закрытия). Микротаски проверяются после каждой макротаски и между фазами poll и check. requestAnimationFrame — отдельная очередь перед рендерингом, не является макротаской. Основное отличие от Node.js — отсутствие setImmediate и фазы check, а также наличие рендеринга.
- Node.js 21+ (2026): event loop libuv — 7 фаз: timers, pending callbacks, idle, prepare, poll, check, close callbacks. Микротаски и process.nextTick проверяются между каждой фазой. setImmediate — макротаска в фазе check, выполняется после poll. setTimeout — фаза timers. Приоритет: process.nextTick → микротаски → таймеры → setImmediate. Важно: process.nextTick не является частью event loop HTML, это Node.js-only API с высочайшим приоритетом.
- Web Workers (в браузере): каждый воркер имеет отдельный event loop, изолированный от основного потока. Микротаски и макротаски обрабатываются так же, но нет доступа к DOM. Это позволяет выполнять тяжелые асинхронные вычисления без блокировки UI. Отличие от основного потока: воркеры не имеют requestAnimationFrame, но поддерживают setTimeout, fetch (с ограничениями), queueMicrotask.
- Deno и Bun (альтернативные окружения): Deno использует Rust-реализацию Tokio как event loop с поддержкой async/await на уровне ядра. Bun — JavaScriptCore с собственным event loop, оптимизированным под скорость (до 2x быстрее Node.js в некоторых бенчмарках). В обоих окружениях приоритет микротасок идентичен, но порядок фаз может незначительно отличаться (например, Bun выполняет коллбеки I/O до таймеров).
- Ограничение глубины микротасок: спецификации не вводят лимит на количество микротасок, но браузеры (Chrome, Firefox) могут принудительно остановить выполнение после ~1000 микротасок, если событие не обрабатывается (защита от бесконечной рекурсии). Node.js не имеет жесткого лимита, но рекомендует избегать более 10000 микротасок подряд.
- Влияние на производительность: создание одной микротаски через queueMicrotask занимает ~50 нс в V8 (2026 год). Создание Promise — ~100 нс. setTimeout(0) — ~1 мкс (из-за таймерного слоя). Для высоконагруженных систем (веб-сокеты, real-time) предпочтительнее queueMicrotask, так как он не создает оверхед таймеров. Эмпирическое правило: для уступок управления (yielding) используйте setTimeout, для реактивных обновлений — queueMicrotask.
- Отладка event loop: Chrome DevTools — вкладка Performance, запись, смотрим «Task» и «Microtasks». Node.js — флаг --trace-event-categories node.async_hooks или используйте async_hooks.createHook для отслеживания каждого Promise. В 2026 году доступны утилиты perf_hooks в Node.js для мониторинга длительности фаз event loop.
Практические шаги: как навсегда запомнить порядок выполнения асинхронного кода
- Шаг 1: Определите синхронный код. Запустите код мысленно. Все, что не обернуто в setTimeout, Promise, async, выполняется немедленно. Синхронный код — это последовательность операций в том порядке, как написано. Его выполнение не прерывается асинхронными задачами.
- Шаг 2: Выделите все макротаски. setTimeout, setInterval, requestAnimationFrame, setImmediate (Node.js), обработчики событий (click, load) — явные макротаски. Запомните: setTimeout(0) — это макротаска, которая гарантированно выполнится не ранее, чем через 4 мс (если вложенный). requestAnimationFrame — специальная макротаска, привязанная к циклу рендеринга.
- Шаг 3: Выделите все микротаски. Promise.then/catch/finally, async-функции (после await), queueMicrotask, MutationObserver. Микротаски имеют приоритет перед макротасками. Каждая микротаска обрабатывается до перехода к следующей макротаске. Если микротаска добавляет новую микротаску, она выполняется в том же цикле.
- Шаг 4: Постройте очередь. Сначала идет синхронный код (все до первой асинхронной операции). Затем выполняется первая макротаска? Нет. Сначала event loop проверяет очередь микротасок. Если она не пуста, выполняются все микротаски до опустошения. Только потом берется первая макротаска. После ее обработки — снова все микротаски. И так по кругу.
- Шаг 5: Учтите специальные случаи. В Node.js process.nextTick опережает все микротаски. Если вы используете setImmediate и setTimeout вместе, порядок зависит от фазы event loop. В браузере requestAnimationFrame — это отдельная очередь, которая выполняется после микротасок, но до рендеринга. Важно: если вы запускаете requestAnimationFrame внутри первого вызова, он не будет выполнен в этом же цикле, а только в следующем.
- Шаг 6: Проверьте на реальном примере. Вставьте в консоль код: setTimeout(() => console.log('A'), 0); Promise.resolve().then(() => console.log('B')); console.log('C'); queueMicrotask(() => console.log('D')); Результат: C, B, D, A. Почему? Синхронный C выполнен первым. Микротаски B и D добавлены. После синхронного блока event loop выполняет все микротаски: сначала B, потом D. Затем фаза таймеров — A. Запомните этот порядок как эталонный.
- Шаг 7: Примените к реальному сценарию. Представьте, что вы загружаете данные через fetch, обновляете UI и ставите таймер. async function load() { const data = await fetch('/api'); // 1. микротаска после ответа; updateUI(data); // 2. синхронный код внутри функции; setTimeout(clearLoader, 100); // 3. макротаска; } load(); console.log('запрос отправлен'); // 0. синхронный. Порядок: 0 (синхронный), затем 1 (микротаска после ответа),
Добавлено: 23.04.2026
