← Курс/Как работает событийный цикл (Event Loop)?#126 из 257+40 XP

Как работает событийный цикл (Event Loop)?

Краткий ответ

JavaScript однопоточный — он выполняет только одну задачу за раз. Event Loop — это механизм, который позволяет JS быть асинхронным: пока выполняется синхронный код, асинхронные операции (таймеры, fetch) обрабатываются браузером/Node.js в фоне, а их колбэки ставятся в очередь. Event Loop следит за тем, чтобы очередь задач выполнялась, когда стек вызовов пуст. Ключевое: микрозадачи (Promise.then) всегда выполняются перед макрозадачами (setTimeout).

Полный разбор

Call Stack (Стек вызовов)

Работает по принципу LIFO (Last In, First Out). Каждый вызов функции добавляет **фрейм** в стек. Когда функция завершается — фрейм удаляется.

function c() { /* ... */ }
function b() { c() }
function a() { b() }
a()

Стек (растёт вниз):
│ c()  │ ← выполняется сейчас
│ b()  │
│ a()  │
│ main │
└──────┘

Когда стек **пуст** — Event Loop берёт следующую задачу из очереди.

Web APIs / Node.js APIs

Когда JS встречает асинхронную операцию, он передаёт её движку окружения:

setTimeout(fn, 1000)  → Timer API → ждёт 1000ms → кладёт fn в Macrotask Queue
fetch(url)            → Network API → ждёт ответ → кладёт колбэк в Microtask Queue

JS-поток при этом **не блокируется** — он продолжает выполнять синхронный код.

Две очереди: Microtask и Macrotask

┌─────────────────────────────────────────────────┐
│                   Call Stack                    │
└─────────────────────────────────────────────────┘
         ↑                           ↑
┌────────────────────┐  ┌───────────────────────┐
│  Microtask Queue   │  │   Macrotask Queue     │
│ (приоритет выше)   │  │  (приоритет ниже)     │
│                    │  │                       │
Promise.then()     │  │ setTimeout()          │
Promise.catch()    │  │ setInterval()         │
│ queueMicrotask()   │  │ I/O callbacks         │
│ MutationObserver   │  │ requestAnimationFrame │
└────────────────────┘  └───────────────────────┘

Алгоритм Event Loop

1. Выполнить весь синхронный код (пока стек не опустеет)
2. Выполнить ВСЕ микрозадачи из Microtask Queue
   (если во время выполнения микрозадачи добавились новые — тоже выполнить)
3. Взять ОДНУ макрозадачу из Macrotask Queue и выполнить её
4. Снова выполнить ВСЕ микрозадачи
5. Повторять с шага 3

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

Практический пример

console.log('1 — sync')

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

Promise.resolve()
  .then(() => console.log('3 — microtask 1'))
  .then(() => console.log('4 — microtask 2'))

console.log('5 — sync')

// Вывод:
// 1 — sync
// 5 — sync
// 3 — microtask 1
// 4 — microtask 2
// 2 — macrotask

Объяснение:

1. console.log('1') — синхронно

2. setTimeout — колбэк уходит в Macrotask Queue (через 0ms)

3. Promise.resolve().then(...) — колбэк в Microtask Queue

4. console.log('5') — синхронно

5. Стек пуст → выполняем все микрозадачи: 3, затем 4

6. Берём макрозадачу из очереди: 2

Почему Promise быстрее setTimeout(0)?

// Даже если setTimeout задержка = 0мс,
// Promise.then выполнится раньше, потому что
// микрозадачи имеют более высокий приоритет

setTimeout(() => console.log('setTimeout 0ms'), 0)
Promise.resolve().then(() => console.log('Promise.then'))

// Promise.then
// setTimeout 0ms

Опасность бесконечных микрозадач

// ПЛОХО: бесконечный цикл микрозадач заблокирует макрозадачи
function infiniteMicrotasks() {
  Promise.resolve().then(infiniteMicrotasks)
}
// Это заморозит браузер — setTimeout никогда не выполнится!

requestAnimationFrame

Macrotask → Microtasks → rAF → Paint → Macrotask → ...

requestAnimationFrame вызывается перед каждой перерисовкой (~60 раз в секунду). Используй его для анимаций вместо setTimeout.

Связанные уроки курса

  • Event Loop — подробный разбор
  • Промисы
  • async/await
  • Как отвечать на собеседовании

    **Начни со структуры**: "JavaScript однопоточный. Event Loop — механизм асинхронности. Есть Call Stack, Microtask Queue и Macrotask Queue."

    **Объясни приоритеты**: "Микрозадачи всегда выполняются перед макрозадачами. Promise.then — микрозадача. setTimeout — макрозадача."

    **Покажи пример**: запишите console.log + setTimeout(0) + Promise.then и объясни порядок вывода. Это классический вопрос.

    **Время ответа**: 3-4 минуты. Нарисуй схему с очередями — это очень помогает.

    Красные флаги ответа

    1. **"setTimeout(fn, 0) выполняется сразу"** — нет. Он попадает в макрозадачу и выполнится только после всех синхронных операций и микрозадач.

    2. **Путаница микрозадач и макрозадач** — если не знаешь разницу между Promise.then и setTimeout — это провал на большинстве JS-собеседований.

    3. **"JavaScript многопоточный"** — JS однопоточный. Web Workers — отдельные потоки, но они не имеют доступа к DOM и общаются через сообщения.

    Примеры

    Демонстрация порядка выполнения: синхронный код, микрозадачи (Promise), макрозадачи (setTimeout)

    // Классический вопрос на собеседовании: предскажи порядок вывода
    
    console.log('=== Старт ===')
    
    // Макрозадача (низкий приоритет)
    setTimeout(() => {
      console.log('setTimeout 1 (макрозадача)')
    
      // Микрозадача внутри макрозадачи
      Promise.resolve().then(() => {
        console.log('Promise внутри setTimeout (микрозадача)')
      })
    }, 0)
    
    // Микрозадачи (высокий приоритет)
    Promise.resolve()
      .then(() => {
        console.log('Promise.then #1 (микрозадача)')
        return 'значение'
      })
      .then((val) => {
        console.log('Promise.then #2 (микрозадача), получил:', val)
      })
    
    // Ещё одна микрозадача
    queueMicrotask(() => {
      console.log('queueMicrotask (микрозадача)')
    })
    
    // Ещё одна макрозадача
    setTimeout(() => {
      console.log('setTimeout 2 (макрозадача)')
    }, 0)
    
    console.log('=== Конец синхронного кода ===')
    
    /*
    Ожидаемый вывод:
    === Старт ===
    === Конец синхронного кода ===
    Promise.then #1 (микрозадача)
    queueMicrotask (микрозадача)
    Promise.then #2 (микрозадача)
    setTimeout 1 (макрозадача)
    Promise внутри setTimeout (микрозадача)
    setTimeout 2 (макрозадача)
    
    Почему именно так?
    1. Весь синхронный код выполнился: "Старт" и "Конец"
    2. Все микрозадачи: Promise.then #1, queueMicrotask, Promise.then #2
       (Promise.then #2 добавился в очередь когда выполнился #1)
    3. Первая макрозадача: setTimeout 1
    4. Микрозадачи после макрозадачи: Promise внутри setTimeout
    5. Следующая макрозадача: setTimeout 2
    */
    
    // ===== Визуализация Event Loop =====
    console.log('\n=== Визуализация очередей ===')
    
    function visualEventLoop() {
      const callStack = []
      const microtaskQueue = []
      const macrotaskQueue = []
    
      // Имитация состояния очередей
      function logState(action) {
        console.log(`\n[Action]: ${action}`)
        console.log(`  Call Stack:      [${callStack.join(', ')}]`)
        console.log(`  Microtask Queue: [${microtaskQueue.join(', ')}]`)
        console.log(`  Macrotask Queue: [${macrotaskQueue.join(', ')}]`)
      }
    
      callStack.push('main()')
      macrotaskQueue.push('setTimeout cb')
      microtaskQueue.push('Promise.then')
      logState('После синхронного кода')
    
      callStack.pop()
      logState('Стек пуст — берём микрозадачи')
    
      const micro = microtaskQueue.shift()
      callStack.push(micro)
      logState(`Выполняем микрозадачу: ${micro}`)
    
      callStack.pop()
      logState('Все микрозадачи выполнены')
    
      const macro = macrotaskQueue.shift()
      callStack.push(macro)
      logState(`Берём макрозадачу: ${macro}`)
    
      callStack.pop()
      logState('Цикл завершён')
    }
    
    visualEventLoop()