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

Task Queue та Microtask Queue: пріоритети виконання

Task Queue та Microtask Queue: пріоритети виконання

Навіщо дві черги завдань?

У попередній лекції ми дізналися, що Event Loop працює з двома типами завдань: macrotasks (макрозавдання) та microtasks (мікрозавдання). На перший погляд це може здатися надлишковою складністю — чому б просто не мати одну чергу завдань за принципом FIFO (First In, First Out)?

Відповідь криється у різних вимогах до продуктивності та реактивності. Асинхронні операції мають різні характеристики:

⏱️ Macrotasks: важкі операції

Призначення: Обробка зовнішніх подій та тривалих операцій

Характеристики:

  • Можуть тривати довго (десятки мілісекунд)
  • Пов'язані з IO операціями
  • Можуть викликати рендеринг UI між виконанням

Приклади: setTimeout, події миші/клавіатури, мережеві запити

⚡ Microtasks: швидкі реакції

Призначення: Негайна реакція на зміни стану

Характеристики:

  • Мають виконуватися якнайшвидше (мікросекунди)
  • Не повинні блокувати надовго
  • Виконуються всі підряд перед рендерингом

Приклади: Promise.then(), queueMicrotask(), MutationObserver

Історична причина: Коли ECMAScript 2015 (ES6) представив Promise, виникла потреба у механізмі, який гарантував би негайне виконання .then() callbacks після резолвінгу проміса. Додавати їх до звичайної черги макрозавдань (разом з setTimeout) означало б непередбачувані затримки.

Аналогія з реального світа:Уявіть відділення банку:
  • Macrotasks — звичайна черга клієнтів, яких обслуговують по одному. Між клієнтами співробітник може виконати інші справи (аналог рендерингу UI).
  • Microtasks — термінові повідомлення, які співробітник зобов'язаний опрацювати всі підряд відразу після завершення обслуговування клієнта, перш ніж взяти наступного.

Детальна архітектура черг завдань

Для глибшого розуміння розгляньмо внутрішню структуру Event Loop з точки зору управління чергами.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

package "JavaScript Runtime Environment" {
    
    [Call Stack] as Stack #DBEAFE
    
    package "Event Loop Core" {
        [Event Loop\nДиспетчер] as Loop #FEF3C7
    }
    
    package "Macrotask Queue" #DCFCE7 {
        queue "setTimeout\ncallbacks" as Timer
        queue "UI Events\n(click, keydown)" as Events
        queue "I/O Operations\n(Node.js)" as IO
    }
    
    package "Microtask Queue" #E0E7FF {
        queue "Promise\ncallbacks" as Promise
        queue "queueMicrotask" as QM
        queue "MutationObserver" as MO
        note right of Promise
          Вищий пріоритет!
          Виконуються ВСІ підряд
        end note
    }
    
    [Web APIs /<br/>Node C++ APIs] as API #F3E8FF
}

Stack -down-> Loop : "Стек порожній?"
API -right-> Timer
API -right-> Events
API --> Promise : "Promise resolved"

Loop -up-> Stack : "Додає callback"
Loop --> Promise : "1. Перевіряє"
Loop --> QM : "2. Виконує всі"
Loop --> MO : "3. По черзі"
Loop --> Timer : "4. Потім бере ОДНЕ"
Loop --> Events : "5. Макрозавдання"

@enduml

Життєвий цикл однієї ітерації Event Loop

Розгляньмо детально, що відбувається за одну повну ітерацію (tick) Event Loop:

Фаза 1: Виконання поточного завдання у Call Stack

Event Loop чекає, доки Call Stack повністю не звільниться. Жоден callback не може бути доданий до стеку, поки там виконується код.

Приклад:

function processData() {
    console.log('Processing...')
    // Складні обчислення
    for (let i = 0; i < 1e6; i++) {}
    console.log('Done')
}

processData() // Виконується до кінця, Event Loop чекає

Фаза 2: Спустошення Microtask Queue (Checkpoint)

Коли стек стає порожнім, Event Loop обов'язково виконує всі мікрозавдання, які є у черзі. Це називається microtask checkpoint (контрольна точка мікрозавдань).

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

Приклад нескінченного циклу:

function recursiveMicrotask() {
    queueMicrotask(() => {
        console.log('Microtask')
        recursiveMicrotask() // Додає нове мікрозавдання
    })
}

recursiveMicrotask()
// Консоль буде спамитися "Microtask", браузер зависне
// Макрозавдання НІКОЛИ не виконаються

Фаза 3: Перевірка необхідності рендерингу (тільки браузер)

Після завершення всіх мікрозавдань браузер оцінює, чи потрібно оновити екран. Рендеринг не відбувається після кожного макрозавдання — браузер намагається підтримувати 60 FPS (один кадр кожні ~16.67 мс).

Умови рендерингу:

  • Минуло достатньо часу з попереднього кадру
  • Є зміни у DOM або CSS
  • Є анімації через requestAnimationFrame

Фаза 4: Виконання ОДНОГО макрозавдання

Event Loop бере перше завдання з Macrotask Queue та додає його до Call Stack. Після завершення цього макрозавдання цикл повертається до Фази 2 (знову перевіряє мікрозавдання).

Важливо: Лише одне макрозавдання за ітерацію. Це гарантує, що мікрозавдання та рендеринг мають шанс виконатися між важкими операціями.

Псевдокод повного циклу

while (eventLoop.isRunning) {
    // Фаза 1: Чекаємо на порожній Call Stack
    if (!callStack.isEmpty()) {
        continue // Повертаємося на початок циклу
    }

    // Фаза 2: Microtask Checkpoint
    while (microtaskQueue.hasTasks()) {
        const microtask = microtaskQueue.dequeue()
        callStack.execute(microtask)

        // Якщо під час виконання додалися нові мікрозавдання — виконуємо їх теж
    }

    // Фаза 3: Рендеринг (тільки браузер)
    if (browser.needsRendering()) {
        browser.performRender()
    }

    // Фаза 4: Одне макрозавдання
    if (macrotaskQueue.hasTasks()) {
        const macrotask = macrotaskQueue.dequeue()
        callStack.execute(macrotask)
    } else {
        // Немає завдань — очікуємо нових подій
        eventLoop.waitForEvents()
    }
}

Порівняння типів завдань

Для кращого розуміння розгляньмо детальну таблицю відмінностей:

ХарактеристикаMacrotasksMicrotasks
ДжерелаsetTimeout, setInterval, setImmediate (Node.js), IO callbacks, UI eventsPromise.then(), queueMicrotask(), async/await, MutationObserver, process.nextTick() (Node.js)
Кількість за ітераціюОдне завданняВсі завдання підряд
ПріоритетНижчийВищий
Рендеринг після виконанняМоже відбутися (якщо настав час кадру)Ні, рендеринг лише після макрозавдання
Ризик блокуванняНизький (одне за раз)Високий (якщо генерують нові мікрозавдання)
Типовий час виконання4–10 мс (залежить від операції)< 1 мс (має бути швидким)
ПризначенняВажкі асинхронні операціїШвидка реакція на зміни стану

Складні сценарії взаємодії черг

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

Сценарій 1: Вкладені проміси та таймери

console.log('Start')

setTimeout(() => {
    console.log('Timeout 1')

    Promise.resolve().then(() => {
        console.log('Promise inside Timeout 1')
    })

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

Promise.resolve()
    .then(() => {
        console.log('Promise 1')

        setTimeout(() => {
            console.log('Timeout 3')
        }, 0)
    })
    .then(() => {
        console.log('Promise 2')
    })

console.log('End')

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

Сценарій 2: Ланцюжок промісів з async/await

console.log('1: Start')

async function asyncFunc() {
    console.log('2: Async function start')

    await Promise.resolve()

    console.log('3: After await')

    return 'Result'
}

asyncFunc().then((result) => {
    console.log('4: Then -', result)
})

Promise.resolve().then(() => {
    console.log('5: Promise 1')
})

console.log('6: End')

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

node script.js
$ node script.js
1: Start
2: Async function start
6: End
5: Promise 1
3: After await
4: Then - Result

Чому саме такий порядок?

::

::

Сценарій 3: Node.js — особливий випадок process.nextTick

У Node.js є додатковий API для роботи з чергами — process.nextTick(). Це не мікрозавдання у класичному розумінні, а окрема черга з вищим пріоритетом за проміси.

console.log('Start')

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

Promise.resolve().then(() => {
    console.log('Promise')
})

process.nextTick(() => {
    console.log('nextTick')
})

console.log('End')

Вивід у Node.js:

node app.js
$ node app.js
Start
End
nextTick
Promise
setTimeout

Пріоритет у Node.js:

  1. Синхронний код
  2. nextTick Queueprocess.nextTick()
  3. Microtask QueuePromise.then(), queueMicrotask()
  4. Macrotask QueuesetTimeout, setImmediate
Небезпека nextTick:Оскільки process.nextTick() має найвищий пріоритет, рекурсивний виклик може повністю заблокувати Event Loop:
function recursiveNextTick() {
    process.nextTick(() => {
        console.log('Tick')
        recursiveNextTick()
    })
}

recursiveNextTick()
// Макрозавдання, мікрозавдання та IO операції НІКОЛИ не виконаються
// Node.js process "зависне"
Коли використовувати:
  • Коли потрібна гарантована послідовність виконання перед будь-якими IO операціями
  • Для емуляції синхронного API через асинхронний (рідкісні випадки)
Найкраща практика: Використовуйте queueMicrotask() або Promise.resolve().then() замість nextTick, якщо немає специфічних причин.

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

Об'єднаймо всі знання у одну комплексну діаграму:

Loading diagram...
stateDiagram-v2
    [*] --> SyncCode: Запуск скрипта
    
    SyncCode: Виконання синхронного коду<br/>у Call Stack
    SyncCode --> NextTick: Stack порожній<br/>(тільки Node.js)
    
    NextTick: process.nextTick Queue<br/>Виконати ВСІ
    NextTick --> Microtasks: nextTick Queue порожня
    
    Microtasks: Microtask Queue<br/>Виконати ВСІ
    Microtasks --> Microtasks: Нові мікрозавдання<br/>додалися?
    Microtasks --> CheckRender: Microtask Queue порожня
    
    CheckRender: Потрібен рендеринг?<br/>(тільки браузер)
    CheckRender --> Render: Так, настав час кадру
    CheckRender --> CheckMacro: Ні
    
    Render: Оновлення DOM та CSS<br/>requestAnimationFrame
    Render --> CheckMacro
    
    CheckMacro: Є макрозавдання<br/>у черзі?
    CheckMacro --> Macrotask: Так
    CheckMacro --> Wait: Ні
    
    Macrotask: Виконати ОДНЕ<br/>макрозавдання
    Macrotask --> NextTick: Завершено
    
    Wait: Очікування IO подій<br/>Режим Idle
    Wait --> NextTick: Нова подія

Практичні патерни роботи з чергами

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

Патерн 1: Розбиття важкої роботи на шматки

Коли потрібно виконати ресурсомістку операцію (обробка великого масиву, складні обчислення), краще розбити її на частини, даючи Event Loop змогу обслуговувати інші події.

async function processLargeArray(array, processItem) {
    const chunkSize = 100 // Обробляємо по 100 елементів за раз
    let index = 0

    while (index < array.length) {
        // Обробляємо шматок даних
        const chunk = array.slice(index, index + chunkSize)

        for (const item of chunk) {
            processItem(item)
        }

        index += chunkSize

        // Даємо Event Loop обробити інші завдання
        await new Promise((resolve) => setTimeout(resolve, 0))

        // Оновлюємо прогрес-бар (якщо є)
        updateProgressBar((index / array.length) * 100)
    }

    console.log('Обробка завершена')
}

// Використання
const data = Array.from({ length: 10000 }, (_, i) => i)

processLargeArray(data, (item) => {
    // Складна операція з кожним елементом
    Math.sqrt(item) * Math.PI
})

Чому це працює?

  • await new Promise(resolve => setTimeout(resolve, 0)) створює макрозавдання
  • Між макрозавданнями Event Loop може виконати:
    • Мікрозавдання (проміси)
    • Оновлення UI (рендеринг)
    • Обробку подій користувача (кліки, прокрутка)
Альтернатива для браузерів: Використовуйте requestIdleCallback для виконання роботи у "вільний" час:
function processWhenIdle(tasks) {
    function work(deadline) {
        while (tasks.length > 0 && deadline.timeRemaining() > 0) {
            const task = tasks.shift()
            task()
        }

        if (tasks.length > 0) {
            requestIdleCallback(work)
        }
    }

    requestIdleCallback(work)
}

Патерн 2: Гарантований порядок виконання через мікрозавдання

Коли потрібно виконати код одразу після поточної операції, але до будь-яких макрозавдань, використовуйте queueMicrotask.

class DataStore {
    constructor() {
        this.data = []
        this.listeners = []
    }

    add(item) {
        this.data.push(item)

        // Сповіщаємо підписників через мікрозавдання
        // Це гарантує, що всі синхронні операції завершилися
        queueMicrotask(() => {
            this.listeners.forEach((listener) => listener(item))
        })
    }

    subscribe(callback) {
        this.listeners.push(callback)
    }
}

// Використання
const store = new DataStore()

store.subscribe((item) => {
    console.log('Додано:', item)
})

store.add('Item 1')
store.add('Item 2')
store.add('Item 3')

console.log('Всі додавання завершені')

// Вивід:
// Всі додавання завершені
// Додано: Item 1
// Додано: Item 2
// Додано: Item 3

Переваги мікрозавдань:

  • Всі синхронні операції (add) завершаться першими
  • Listeners будуть викликані до будь-яких таймерів або IO операцій
  • Batch-обробка подій (можна накопичити зміни та сповістити один раз)

Патерн 3: Debounce через проміси

Класичний debounce з таймерами можна покращити за допомогою промісів для кращої інтеграції з async/await:

function debounce(fn, delay) {
    let timeoutId
    let pendingPromise

    return function debounced(...args) {
        // Скасовуємо попередній виклик
        clearTimeout(timeoutId)

        // Створюємо новий проміс, якщо немає активного
        if (!pendingPromise) {
            pendingPromise = new Promise((resolve) => {
                timeoutId = setTimeout(async () => {
                    const result = await fn.apply(this, args)
                    resolve(result)
                    pendingPromise = null
                }, delay)
            })
        }

        return pendingPromise
    }
}

// Використання
const searchAPI = debounce(async (query) => {
    const response = await fetch(`/api/search?q=${query}`)
    return response.json()
}, 300)

// У обробнику input
input.addEventListener('input', async (e) => {
    const results = await searchAPI(e.target.value)
    displayResults(results)
})

Переваги:

  • Можна використовувати await для отримання результату
  • Автоматична обробка помилок через .catch()
  • Краща інтеграція з сучасним асинхронним кодом

Типові підводні камені та як їх уникати

Пастка 1: Зациклювання мікрозавдань

// ❌ НЕПРАВИЛЬНО — безкінечний цикл
let counter = 0

function infiniteMicrotasks() {
    queueMicrotask(() => {
        console.log(counter++)
        infiniteMicrotasks() // Додає нове мікрозавдання
    })
}

infiniteMicrotasks()

setTimeout(() => {
    console.log('Це НІКОЛИ не виконається')
}, 0)

Проблема: Microtask Queue ніколи не стане порожньою, тому Event Loop не дійде до макрозавдань.

Рішення:

// ✅ ПРАВИЛЬНО — додаємо умову виходу
let counter = 0
const MAX_ITERATIONS = 1000

function safeMicrotasks() {
    queueMicrotask(() => {
        console.log(counter++)

        if (counter < MAX_ITERATIONS) {
            safeMicrotasks()
        }
    })
}

Пастка 2: Очікування негайного виконання setTimeout(0)

let value = 0

setTimeout(() => {
    value = 100
}, 0)

console.log(value) // 0, а не 100!

Пояснення: setTimeout додає callback до Macrotask Queue. Синхронний код (console.log) виконується до будь-яких макрозавдань.

Правильний підхід:

let value = 0

Promise.resolve().then(() => {
    value = 100
    console.log(value) // 100
})

// Або async/await
async function updateValue() {
    await Promise.resolve()
    value = 100
    console.log(value)
}

Пастка 3: Очікування рендерингу після зміни DOM

// ❌ НЕПРАВИЛЬНО
button.addEventListener('click', () => {
    loadingSpinner.style.display = 'block'

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

    loadingSpinner.style.display = 'none'
})

// Користувач НІКОЛИ не побачить спінер, бо рендеринг відбувається після завершення обробника

Рішення:

// ✅ ПРАВИЛЬНО
button.addEventListener('click', async () => {
    loadingSpinner.style.display = 'block'

    // Даємо браузеру час відрендерити спінер
    await new Promise((resolve) => setTimeout(resolve, 0))

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

    loadingSpinner.style.display = 'none'
})
Ще краще: Винести важкі обчислення у Web Worker:
button.addEventListener('click', () => {
    loadingSpinner.style.display = 'block'

    const worker = new Worker('heavy-task.js')

    worker.onmessage = (e) => {
        console.log('Результат:', e.data)
        loadingSpinner.style.display = 'none'
    }

    worker.postMessage({ iterations: 1e9 })
})

Інструменти для діагностики черг

Chrome DevTools: Performance Profiler

Chrome дозволяє візуалізувати роботу Event Loop через вкладку Performance:

Крок 1: Відкрийте DevTools

Натисніть F12 або Cmd+Option+I (macOS)

Крок 2: Перейдіть до вкладки Performance

Натисніть кнопку Record (червоне коло)

Крок 3: Виконайте операцію

Виконайте код, який потрібно проаналізувати (наприклад, натисніть кнопку на сторінці)

Крок 4: Зупиніть запис

Натисніть Stop. Ви побачите timeline з:

  • Call Stack — які функції виконувалися
  • Microtasks — помічені як Promise.then
  • MacrotaskssetTimeout, Event listeners
  • Rendering — коли браузер оновлював екран

Node.js: --trace-events

У Node.js можна увімкнути трейсинг подій:

node --trace-events-enabled --trace-event-categories v8,node.async_hooks script.js

Це створить файл node_trace.*.log, який можна відкрити у Chrome DevTools → PerformanceLoad profile.

Підсумок: майстерність роботи з чергами

⚡ Мікрозавдання для швидкості

Використовуйте Promise.then() або queueMicrotask() для операцій, які мають виконатися якнайшвидше після поточного коду.

⏱️ Макрозавдання для поділу роботи

Використовуйте setTimeout(fn, 0) для розбиття важких операцій та надання часу на рендеринг UI.

🚫 Уникайте зациклювань

Завжди додавайте умови виходу для рекурсивних мікрозавдань. Пам'ятайте, що мікрозавдання можуть заблокувати Event Loop.

🔍 Профілюйте продуктивність

Використовуйте DevTools для виявлення вузьких місць. Довгі макрозавдання (>50 мс) — це червоний прапорець для оптимізації.
Наступна стаття:У фінальній лекції ми розглянемо асинхронні патерни та Best Practices: від callbacks до async/await, обробку помилок, паралельне виконання промісів, race conditions та архітектурні підходи до побудови масштабованих асинхронних систем.
Copyright © 2026