Асинхронність

Event Loop: серце асинхронності в JavaScript

Event Loop: серце асинхронності в JavaScript

Чому JavaScript однопотоковий, але не блокується?

Уявіть типову веб-сторінку: користувач натискає кнопку, яка надсилає запит на сервер для завантаження даних. Якби JavaScript працював синхронно, інтерфейс повністю заморожувався б на час очікування відповіді — неможливо було б натиснути інші кнопки, прокрутити сторінку або навіть побачити анімацію завантаження.

Але ми знаємо, що насправді цього не відбувається. Браузер залишається відгукуючим навіть під час виконання тривалих операцій. Секрет цієї магії криється в Event Loop (цикл подій) — фундаментальному механізмі, який дозволяє JavaScript бути однопотоковим (single-threaded), але при цьому асинхронним (asynchronous).

Ключова відмінність:
  • Однопотоковий означає, що JavaScript виконує код послідовно, по одній інструкції за раз у межах одного потоку.
  • Асинхронний означає, що тривалі операції (мережеві запити, таймери, читання файлів) не блокують виконання іншого коду.
Ці два поняття здаються суперечливими, але Event Loop дозволяє їм співіснувати.

Архітектурний контекст: JavaScript Runtime Environment

Перш ніж занурюватися в деталі Event Loop, важливо зрозуміти екосистему, в якій він працює. JavaScript-код не виконується у вакуумі — він функціонує у середовищі виконання (runtime environment), яке надає всі необхідні інструменти для асинхронної роботи.

Компоненти JavaScript Runtime

Loading diagram...
graph TB
    subgraph Runtime["🌐 JavaScript Runtime Environment (Browser / Node.js)"]
        subgraph JSEngine["⚙️ JavaScript Engine (V8, SpiderMonkey)"]
            Heap["🗂️ Memory Heap<br/>(Об'єкти, змінні)"]
            CallStack["📚 Call Stack<br/>(Стек викликів)"]
        end
        
        WebAPIs["🌍 Web APIs / C++ APIs<br/>(setTimeout, fetch, DOM Events)"]
        
        subgraph EventLoopSystem["🔄 Event Loop System"]
            TaskQueue["⏱️ Task Queue<br/>(Macrotasks)"]
            MicroQueue["⚡ Microtask Queue<br/>(Promises, queueMicrotask)"]
            EventLoop["♻️ Event Loop<br/>(Диспетчер)"]
        end
    end
    
    Code["💻 Ваш JavaScript код"] --> CallStack
    CallStack -->|"setTimeout(), fetch()"| WebAPIs
    WebAPIs -->|"Callback готовий"| TaskQueue
    WebAPIs -->|"Promise resolved"| MicroQueue
    
    EventLoop -->|"1. Перевіряє"| CallStack
    EventLoop -->|"2. Якщо стек порожній"| MicroQueue
    EventLoop -->|"3. Потім бере"| TaskQueue
    MicroQueue -->|"Додає до"| CallStack
    TaskQueue -->|"Додає до"| CallStack
    
    style JSEngine fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style EventLoopSystem fill:#10b981,stroke:#059669,color:#ffffff
    style WebAPIs fill:#f59e0b,stroke:#d97706,color:#ffffff
    style EventLoop fill:#ef4444,stroke:#dc2626,color:#ffffff

Розберемо кожен компонент детально.

Call Stack (Стек викликів функцій)

Call Stack — це структура даних типу стек (Last In, First Out — LIFO), яка відстежує поточне виконання коду. Коли викликається функція, вона додається на вершину стеку. Коли функція завершується, вона знімається зі стеку.

📥 Push (Додавання)

Коли викликається функція, в стек додається фрейм виконання (execution context) з локальними змінними, аргументами та адресою повернення.

📤 Pop (Видалення)

Коли функція досягає return або завершується, її фрейм видаляється зі стеку, і керування повертається до попередньої функції.

🔝 Вершина стеку

Функція на вершині стеку — це та, яка виконується прямо зараз. JavaScript може виконувати лише одну функцію одночасно.

Приклад роботи стеку:

function multiply(a, b) {
    return a * b
}

function square(n) {
    return multiply(n, n)
}

function printSquare(num) {
    const result = square(num)
    console.log(result)
}

printSquare(5)

Візуалізація виконання:

Loading diagram...
sequenceDiagram
    autonumber
    participant Code as 💻 Код
    participant Stack as 📚 Call Stack
    
    Note over Code,Stack: Початок виконання
    Code->>Stack: printSquare(5)
    Note right of Stack: [printSquare]
    
    Code->>Stack: square(5)
    Note right of Stack: [printSquare, square]
    
    Code->>Stack: multiply(5, 5)
    Note right of Stack: [printSquare, square, multiply]
    
    Stack->>Stack: multiply повертає 25
    Note right of Stack: [printSquare, square]
    
    Stack->>Stack: square повертає 25
    Note right of Stack: [printSquare]
    
    Code->>Stack: console.log(25)
    Note right of Stack: [printSquare, console.log]
    
    Stack->>Stack: console.log завершується
    Note right of Stack: [printSquare]
    
    Stack->>Stack: printSquare завершується
    Note right of Stack: [ порожній ]
    
    Note over Code,Stack: Виконання завершено
Важлива властивість: Call Stack є синхронним — код виконується послідовно, рядок за рядком. Якщо функція займає багато часу (наприклад, складні обчислення у циклі), весь інтерфейс браузера заблокується до завершення цієї функції.

Stack Overflow: коли стек переповнюється

Оскільки Call Stack має обмежений розмір (зазвичай близько 10 000–50 000 фреймів, залежно від середовища), занадто глибока рекурсія може призвести до помилки Stack Overflow (переповнення стеку).

function recursiveFunction() {
    recursiveFunction() // Викликає саму себе нескінченно
}

recursiveFunction()
// RangeError: Maximum call stack size exceeded
Небезпека нескінченної рекурсії:Коли функція викликає саму себе без базового випадку (base case), стек продовжує зростати доти, доки не вичерпає доступну пам'ять. Це призводить до аварійного завершення скрипта.Правильна рекурсія завжди має умову виходу:
function factorial(n) {
    if (n <= 1) return 1 // Базовий випадок — зупиняє рекурсію
    return n * factorial(n - 1)
}

console.log(factorial(5)) // 120

Memory Heap (Купа пам'яті)

На відміну від стеку, Memory Heap — це неструктурована область пам'яті, де зберігаються об'єкти, масиви, функції та інші складні структури даних. Примітивні значення (числа, рядки, булеві) зазвичай зберігаються безпосередньо у Call Stack, тоді як об'єкти створюються у купі, а у стеку зберігаються лише посилання (references) на них.

function createUser() {
    const user = { name: 'Alice', age: 25 } // Об'єкт створюється у Heap
    return user // Повертається посилання на об'єкт
}

const alice = createUser() // alice містить посилання, а не сам об'єкт
Автоматичне управління пам'яттю:JavaScript використовує Garbage Collector (збирач сміття), який автоматично звільняє пам'ять від об'єктів, на які більше не посилається жодна змінна. Це відбувається у фоновому режимі та не блокує Event Loop.

Web APIs: міст між JavaScript та браузером

JavaScript Engine (V8, SpiderMonkey, JavaScriptCore) сам по собі не знає про DOM, мережеві запити, таймери або події миші. Ці можливості надає середовище виконання — браузер (у випадку веб-застосунків) або Node.js (для серверного коду).

Приклади Web APIs:

setTimeout / setInterval
Timing API
Відкладене виконання коду. Таймер працює поза JavaScript Engine — у браузері або у C++ API Node.js.
fetch / XMLHttpRequest
Network API
Мережеві запити виконуються у окремому потоці браузера, не блокуючи Call Stack.
DOM Events (addEventListener)
Event API
Події миші, клавіатури, фокуса обробляються браузером. Коли подія спрацьовує, callback додається у чергу завдань.
IndexedDB / Web Workers
Storage & Threading
Асинхронне сховище даних та можливість виконання JavaScript у окремих потоках (Web Workers).

Ключовий момент: Коли ви викликаєте setTimeout() або fetch(), JavaScript Engine делегує цю роботу Web API, звільняючи Call Stack для виконання іншого коду. Після завершення операції Web API розміщує callback-функцію у чергу завдань (Task Queue), звідки Event Loop згодом перенесе її назад у Call Stack.

Loading diagram...
sequenceDiagram
    participant JS as 💻 JavaScript Code
    participant Stack as 📚 Call Stack
    participant Web as 🌍 Web API (Browser)
    participant Queue as ⏱️ Task Queue
    participant Loop as ♻️ Event Loop
    
    JS->>Stack: setTimeout(callback, 1000)
    Stack->>Web: Передає таймер (1 секунда)
    Note over Stack: setTimeout завершився<br/>Stack порожній
    
    JS->>Stack: console.log('Hello')
    Note over Stack: Виконується одразу
    Stack->>Stack: Завершено
    
    Note over Web: ...Чекає 1 секунду...
    Web->>Queue: Таймер спрацював → додає callback
    
    Loop->>Stack: Перевіряє — порожній?
    Loop->>Queue: Бере callback з черги
    Queue->>Stack: Додає callback до стеку
    
    Stack->>Stack: Виконує callback
    Note over Stack: console.log('Timeout!')

Event Loop: диригент оркестру

Тепер, коли ми розуміємо структуру середовища виконання, можемо перейти до самого Event Loop. Це безкінечний цикл, який постійно моніторить стан Call Stack та черг завдань, виконуючи просту, але критично важливу роль:

Якщо Call Stack порожній, Event Loop бере наступне завдання з черги та додає його до стеку.

Це і є вся суть Event Loop — він синхронізує асинхронні операції, забезпечуючи, що callback-функції виконуються у правильний момент, не блокуючи основний потік.

Псевдокод Event Loop

// Спрощена модель Event Loop
while (true) {
    // Фаза 1: Виконати всі мікрозавдання (Microtasks)
    while (microtaskQueue.length > 0) {
        const microtask = microtaskQueue.shift()
        executeInCallStack(microtask)
    }

    // Фаза 2: Перевірити, чи є макрозавдання (Macrotasks)
    if (taskQueue.length > 0) {
        const task = taskQueue.shift()
        executeInCallStack(task)
    }

    // Фаза 3: Рендеринг (якщо потрібно оновити UI)
    if (needsRendering()) {
        renderUI()
    }

    // Якщо немає завдань — очікувати нових подій
    if (taskQueue.length === 0 && microtaskQueue.length === 0) {
        waitForEvents()
    }
}
Важливо розуміти:Event Loop — це не частина JavaScript Engine. Це механізм середовища виконання (браузера або Node.js), який координує роботу JavaScript з асинхронними API.У браузері Event Loop також відповідає за оновлення UI (рендеринг), тоді як у Node.js він керує файловою системою, мережевими сокетами та іншими системними ресурсами.

Дві категорії завдань: Macrotasks та Microtasks

Event Loop працює з двома чергами завдань, які мають різні пріоритети виконання:

⏱️ Macrotasks (Макрозавдання)

Важкі асинхронні операції, які виконуються по одному за ітерацію Event Loop:

  • setTimeout, setInterval
  • setImmediate (Node.js)
  • UI рендеринг (браузер)
  • I/O операції (читання файлів у Node.js)
  • DOM Events callbacks

⚡ Microtasks (Мікрозавдання)

Легкі завдання з вищим пріоритетом, які виконуються всі підряд перед наступним макрозавданням:

  • Promise.then(), Promise.catch(), Promise.finally()
  • queueMicrotask(callback)
  • MutationObserver (браузер)
  • process.nextTick() (Node.js — навіть вищий пріоритет за проміси)

Ключове правило:

Пріоритет виконання Event Loop:
  1. Call Stack — виконується поточний синхронний код до кінця
  2. Microtask Queue — виконуються всі мікрозавдання підряд
  3. Macrotask Queue — виконується одне макрозавдання
  4. Повернення до кроку 2 (знову всі мікрозавдання)
  5. Рендеринг (якщо потрібно)
  6. Перехід до наступного макрозавдання
Висновок: Мікрозавдання завжди виконуються раніше за макрозавдання, навіть якщо макрозавдання було додане першим!

Практичний приклад: порядок виконання

Розгляньмо складний приклад, який демонструє взаємодію всіх компонентів Event Loop:

console.log('1: Синхронний код - Start')

setTimeout(() => {
    console.log('2: setTimeout (Macrotask)')
}, 0)

Promise.resolve()
    .then(() => {
        console.log('3: Promise.then (Microtask 1)')
        return Promise.resolve()
    })
    .then(() => {
        console.log('4: Promise.then (Microtask 2)')
    })

queueMicrotask(() => {
    console.log('5: queueMicrotask (Microtask 3)')
})

console.log('6: Синхронний код - End')

Який буде порядок виводу?

Візуалізація життєвого циклу Event Loop

Loading diagram...
graph TB
    Start([Запуск JavaScript]) --> SyncCode[Виконання синхронного коду<br/>у Call Stack]
    
    SyncCode --> CheckStack{Call Stack<br/>порожній?}
    CheckStack -->|Ні| SyncCode
    CheckStack -->|Так| MicroCheck{Є мікрозавдання<br/>у черзі?}
    
    MicroCheck -->|Так| ExecMicro[Виконати ВСІ мікрозавдання<br/>підряд]
    ExecMicro --> MicroCheck
    
    MicroCheck -->|Ні| MacroCheck{Є макрозавдання<br/>у черзі?}
    MacroCheck -->|Так| ExecMacro[Виконати ОДНЕ<br/>макрозавдання]
    
    ExecMacro --> RenderCheck{Потрібен<br/>рендеринг UI?}
    RenderCheck -->|Так| Render[Оновити екран<br/>браузера]
    RenderCheck -->|Ні| MicroCheck
    Render --> MicroCheck
    
    MacroCheck -->|Ні| Wait[Очікування нових подій<br/>система переходить у режим idle]
    Wait --> MicroCheck
    
    style Start fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style SyncCode fill:#10b981,stroke:#059669,color:#ffffff
    style ExecMicro fill:#8b5cf6,stroke:#7c3aed,color:#ffffff
    style ExecMacro fill:#f59e0b,stroke:#d97706,color:#ffffff
    style Render fill:#ec4899,stroke:#db2777,color:#ffffff
    style Wait fill:#64748b,stroke:#475569,color:#ffffff

Реальний приклад: завантаження даних з API

Тепер застосуємо знання про Event Loop до практичного сценарію — асинхронного завантаження даних з API.

console.log('🚀 Початок завантаження даних')

// Симуляція API запиту
function fetchUser(userId) {
    return fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
        .then((response) => response.json())
        .then((user) => {
            console.log(`✅ Користувач отриманий: ${user.name}`)
            return user
        })
}

// Виконання
fetchUser(1).then((user) => {
    console.log(`📧 Email: ${user.email}`)
})

setTimeout(() => {
    console.log('⏰ Таймер спрацював (500 мс)')
}, 500)

console.log('🔄 Код продовжує виконуватися')

Порядок виконання:

Крок 1: Синхронний код

Виконується console.log('🚀 ...'), потім викликається fetchUser(1). Функція fetch() делегує HTTP-запит браузеру (Web API), а сама одразу повертає проміс у стані pending.

Далі реєструється таймер setTimeout (також делегується Web API) та виводиться console.log('🔄 ...').

Вивід:

🚀 Початок завантаження даних
🔄 Код продовжує виконуватися

Крок 2: Очікування відповіді сервера

Call Stack порожній. Event Loop перевіряє черги — поки що вони теж порожні. JavaScript "спить" у режимі очікування подій.

Крок 3: Відповідь від сервера (припустимо, через 200 мс)

Браузер отримує відповідь від API. Callback з першого .then() (парсинг JSON) додається до Microtask Queue.

Event Loop виконує мікрозавдання:

  • Парситься JSON
  • Виводиться console.log('✅ Користувач отриманий: ...')
  • Повертається об'єкт user, що запускає наступний .then()

Другий callback також є мікрозавданням і виконується одразу:

  • Виводиться console.log('📧 Email: ...')

Крок 4: Таймер спрацьовує (через 500 мс)

Callback з setTimeout додається до Macrotask Queue. Event Loop бере його після того, як всі мікрозавдання завершені.

Вивід:

⏰ Таймер спрацював (500 мс)

Повний вивід програми:

node app.js
$ node app.js
🚀 Початок завантаження даних
🔄 Код продовжує виконуватися
Користувач отриманий: Leanne Graham
📧 Email: Sincere@april.biz
Таймер спрацював (500 мс)

Типові помилки розуміння Event Loop

Навіть досвідчені розробники іноді припускаються концептуальних помилок при роботі з асинхронним кодом. Розгляньмо найпоширеніші непорозуміння.

Помилка 1: "setTimeout з нульовою затримкою виконається одразу"

console.log('1')

setTimeout(() => {
    console.log('2')
}, 0) // Затримка 0 мілісекунд!

console.log('3')

Що виведе цей код?

Помилка 2: "Проміси виконуються паралельно"

console.log('Start')

Promise.resolve()
    .then(() => {
        console.log('Promise 1')
        // Важкі обчислення
        for (let i = 0; i < 1e9; i++) {}
    })
    .then(() => {
        console.log('Promise 2')
    })

console.log('End')

Чи заблокується основний потік під час виконання циклу у промісі?

Помилка 3: "Рекурсивні проміси безпечніші за рекурсивні функції"

function recursivePromise(n) {
    if (n === 0) return Promise.resolve('Done')

    return Promise.resolve(n).then(() => {
        return recursivePromise(n - 1)
    })
}

recursivePromise(100000) // Чи призведе до Stack Overflow?

Взаємодія Event Loop з рендерингом браузера

Одна з найважливіших особливостей Event Loop у браузері — його зв'язок з оновленням UI (rendering). Браузер не може оновити екран під час виконання JavaScript-коду — він чекає, доки Call Stack стане порожнім.

Цикл "JavaScript → Rendering"

Loading diagram...
graph LR
    A[JS виконання] --> B{Call Stack<br/>порожній?}
    B -->|Ні| A
    B -->|Так| C[Мікрозавдання]
    C --> D{Черга порожня?}
    D -->|Ні| C
    D -->|Так| E[Макрозавдання]
    E --> F[Рендеринг UI]
    F --> A
    
    style A fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style C fill:#8b5cf6,stroke:#7c3aed,color:#ffffff
    style E fill:#f59e0b,stroke:#d97706,color:#ffffff
    style F fill:#ec4899,stroke:#db2777,color:#ffffff

Що це означає на практиці?

const button = document.getElementById('btn')
const status = document.getElementById('status')

button.addEventListener('click', () => {
    status.textContent = 'Обробка...'

    // Важкі обчислення
    for (let i = 0; i < 1e9; i++) {}

    status.textContent = 'Завершено'
})

Проблема: Користувач ніколи не побачить текст "Обробка...", бо браузер не встигне оновити DOM до завершення циклу. Він одразу відобразить "Завершено".

Рішення — дати браузеру час на рендеринг:

button.addEventListener('click', async () => {
    status.textContent = 'Обробка...'

    // Даємо Event Loop виконати рендеринг
    await new Promise((resolve) => setTimeout(resolve, 0))

    // Тепер важкі обчислення
    for (let i = 0; i < 1e9; i++) {}

    status.textContent = 'Завершено'
})
requestAnimationFrame для анімацій:Якщо потрібно синхронізувати код з оновленням екрана (60 FPS), використовуйте requestAnimationFrame:
function animate() {
    // Код анімації виконується перед наступним кадром
    element.style.transform = `translateX(${position}px)`

    if (position < 500) {
        requestAnimationFrame(animate)
    }
}

requestAnimationFrame(animate)
requestAnimationFrame має вищий пріоритет за setTimeout та виконується синхронізовано з частотою оновлення екрана.

Підсумок: ключові висновки

🧵 Однопотоковість

JavaScript виконує код послідовно у Call Stack. Паралельність досягається делегуванням роботи Web APIs та Event Loop.

♻️ Event Loop — це диспетчер

Цикл подій координує виконання асинхронних операцій, забираючи завдання з черг та додаючи їх до стеку.

⚡ Мікрозавдання мають пріоритет

Проміси та queueMicrotask виконуються раніше за setTimeout, навіть якщо додані пізніше.

🎨 Рендеринг чекає на порожній стек

Браузер оновлює UI лише коли Call Stack порожній. Тривалий синхронний код блокує інтерфейс.
Наступні кроки:У наступній статті ми детальніше розглянемо Task Queue та Microtask Queue: чому існують дві черги, як працює process.nextTick() у Node.js, та які підводні камені можуть виникнути при складних сценаріях взаємодії макрозавдань і мікрозавдань.
Copyright © 2026