Одина нитка JavaScript проти багатопроцесного двигуна браузера
Дізнайтеся, чому JavaScript виконується на одній потоці, тоді як браузери одночасно обробляють мережеві операції, відображення та таймери за допомогою механізму циклу подій.
JavaScript виконується на одній потоці.
Ви, ймовірно, читали це твердження більше разів, ніж можете порахувати.
Проте коли ви завантажуєте сучасну веб-сторінку, ви можете бачити, як одночасно відбуваються всі наступні речі:
Надсилається мережевий запит.
Завантажується та декодується зображення.
Плавно відтворюється анімація CSS.
Сторінка миттєво реагує на клік.
Контент відображається на екрані.
І ваш код JavaScript продовжує виконуватися протягом усього часу.
То що насправді відбувається під капотом?
Якщо у JavaScript є лише один потік, хто тоді керує всім іншим?
Відповідь на це запитання розкриває одну з найважливіших ідей щодо того, як насправді працюють браузери:
JavaScript та браузер — це не одне й те саме.
У JavaScript є основний потік
Коли розробники називають JavaScript однопотоковим, вони мають на увазі саме спосіб виконання коду JavaScript.
Ваш код виконується на одному основному потоці JavaScript.
Візьмемо цей фрагмент як приклад:
console.log("One");
console.log("Two");
console.log("Three");
Кожен рядок виконується строго після попереднього.
JavaScript не буде виконувати ці три оператори одночасно на одному потоці.
Існує лише одна стек викликів для роботи.
У будь-який момент часу виконується лише один фрагмент коду JavaScript.
Це і є сутністю поняття однопотоковості у цьому контексті.
Але тут ситуація стає більш складною.
Сам браузер відповідає за набагато більше, ніж просто виконання ваших скриптів.
У браузера є більше завдань, ніж лише виконання JavaScript
Браузер — це набагато складніша система, ніж просто двигун JavaScript.
Йому потрібно керувати такими завданнями, як:
- Завантаження даних через мережу
- Розкладання таймерів
- Захоплення даних з клавіатури та вказівника
- Створення кадрів на екрані
- Декодування зображень
- Відтворення звуку
- Керування відтворенням відео
- Зберігання даних на диску
- Розрахунок макету сторінки
- Малювання пікселів під час оформлення
- Об’єднання шарів під час композиції
- Різноманітні інші завдання, якими займаються браузер та операційна система
Ваш код JavaScript не виконує все це безпосередньо.
Натомість JavaScript може деlegувати завдання браузеру, щоб той займався деталями.
Наприклад:
fetch("/api/users");
Ваш скрипт ініціює запит.
Але ваш JavaScript не відкриває роз’єм вручну та не передає окремі байти безпосередньо з сервера.
Ця відповідальність лежить на браузері та системах, які знаходяться під ним.
Як тільки надходить відповідь, браузер планує виконання вашого функціоналу на зворотний виклик або продовження обіцянки у потоці JavaScript.
Це значно відрізняється від уявлення, що:
"JavaScript керує абсолютно всім."
Уявіть JavaScript як одного працівника в набагато більшій системі
Уявіть ресторан як ментальну модель.
JavaScript — це єдиний сервер, який приймає замовлення.
Браузер представляє собою всю діяльність ресторану.
Багато інших працівників відповідають за різні завдання на задньому плані.
JavaScript може сказати:
"Мені потрібні ці дані від сервера."
У цей момент браузер бере на себе керування мережевою операцією.
JavaScript не потрібно зупинятися та чекати, поки не надійде кожен байт.
Він може вільно переходити до інших завдань.
Як тільки операція завершується та готова до обробки JavaScript, відповідне завдання додається до черги для виконання на потоці JavaScript.
Це основа функціонування асинхронних API браузера.
Що ж відбувається з fetch()?
Розгляньмо цей приклад:
console.log("Start");
fetch("/api/users")
.then(() => {
console.log("Users received");
});
console.log("End");
Початківці часто уявляють собі щось на кшталт цього:
Start
↓
fetch()
↓
wait for server
↓
Users received
↓
End
Але це не є точним зображенням того, що відбувається.
Виконання JavaScript не зупиняється під час виклику fetch() у очікуванні повернення відповіді.
Більш точне зображення виглядає так:
JavaScript
│
├── Start fetch
│
▼
Browser handles network work
│
│
└───────────────┐
│
JavaScript │
continues │
│
▼ │
console.log("End") │
│
▼
Response becomes available
│
▼
Promise continuation
gets scheduled
│
▼
JavaScript runs it
З огляду на цей процес, вивід у консолі зазвичай виглядає так:
Start
End
Users received
Ключовою деталлю тут є не лише те, що fetch() працює асинхронно.
Найважливіше – це хто насправді чекає.
Шина виконання JavaScript ніколи не блокується під час очікування від мережі.
Цикл подій поєднує всі елементи
Саме тут вступає в дію цикл подій.
Можна уявити середовище JavaScript як простір, де виконується код, разом із механізмами координації, які визначають, коли можна продовжити асинхронну роботу.
Спрощена версія цієї системи виглядає так:
Browser
│
┌──────────┼───────────┐
│ │ │
Network Timers User Input
│ │ │
└──────────┼───────────┘
│
▼
Scheduling queues
│
▼
Event Loop
│
▼
Call Stack
│
▼
JavaScript
Пам’ятайте, що ця діаграма є спрощенням.
Фактична внутрішня структура браузера значно складніша, і різні движки реалізують ці механізми по-своєму.
Проте вона передає основну ідею:
Виконання JavaScript — це лише одна частина набагато більшої системи.
Таймери не таємно виконують ваш код у фоновому режимі
Розгляньмо цей приклад:
setTimeout(() => {
console.log("Done");
}, 1000);
Привабливий спосіб уявити це такий:
„JavaScript запускає окрему нитку, яка відлічує одну секунду.“
Ця уява вводить вас в оману.
Саме браузер, а не JavaScript, реалізує поведінку таймерів. Як тільки мине вказаний проміжок часу, функція-колбек стає кандидатом на запланування. Лише тоді JavaScript її справді виконує — і лише тоді, коли основна нитка стає вільною для її обробки.
Це означає, що код на кшталт цього — погана ідея:
setTimeout(() => {
console.log("Done");
}, 1000);
while (true) {}
Чому це ламає все?
Як тільки затримка закінчується, калебек вважається готовим до виконання, але основний потік застрягає у нескінченному циклі та ніколи не стає доступним. У калебека немає способу проникнути всередину та перервати виконання будь-якого коду JavaScript у цей момент.
Браузер абсолютно може знати наступне:
„Таймер спрацював.“
Але цього недостатньо — JavaScript все одно потребує можливості для фактичного виконання:
Main JavaScript thread
while (true) {
// never finishes
}
↓
Timer becomes ready
↓
Callback waits
↓
JavaScript never becomes available
Це одна з ключових відмінностей, які варто запам’ятати.
Асинхронність не означає, що ваш калебек виконується на іншому потоці.
Чому тоді сторінка не залишається постійно замороженою?
Тому що виконання JavaScript — це лише одна з багатьох речей, якими браузер займається в будь-який момент часу.
Проте є один нюанс, на який варто звернути увагу.
Багато завдань, які впливають на швидкість реакції вашої сторінки — включаючи виконання JavaScript та окремі етапи процесу відображення — відбуваються на цьому самому основному потоці.
Саме тому код на кшталт цього все одно може заблокувати сторінку:
const start = performance.now();
while (performance.now() - start < 5000) {
// expensive work
}
Протягом цілих п’яти секунд основний потік зайнятий виконанням цього циклу. Тим часом будь-які операції обробки взаємодії чи етапи відображення, які також потребують основного потоку, змушені чекати своєї черги.
Саме про цю ситуацію йдеться, коли кажуть:
„JavaScript працює в однопоточному режимі.“
Конкурентність на рівні браузера, однопоточність на рівні JavaScript
Це ключовий момент, який потрібно пам’ятати.
Браузер як цілісний процес може розподіляти різні завдання між кількома потоками. Деталі відрізняються в залежності від браузера та операційної системи, але по суті сучасні браузери створені для одночасної обробки великої кількості завдань.
Орієнтовно, робота може бути розподілена так:
Browser
│
├── JavaScript execution
├── Network activity
├── Rendering-related work
├── Image/media processing
├── Browser services
└── Other internal tasks
Проте жоден з цих механізмів не дає можливості написати щось на кшталт:
runThisFunctionOnAnotherBrowserThread();
щоб довільний код JavaScript перейшов на інший поток. Ваш звичайний JavaScript все одно виконується за моделлю однопотокової обробки, яка у нього завжди була. Якщо вам конкретно потрібно перемістити важкі обчислення в JavaScript з основного потоку, саме для цього існують Web Workers.
Висновок
Однопотоковий JavaScript не означає, що браузер в цілому обмежений одним потоком.
Ваш код виконується на одній основній нитці, тоді як сам браузер виконує широкий спектр інших завдань за допомогою власних внутрішніх механізмів.
Такі процеси, як мережеві запити, таймери, обробка введення користувача, відображення та декодування медіаконтенту, можуть відбуватися незалежно від виконання вашого JavaScript.
Як тільки будь-яка з цих фонових операцій готова до продовження, браузер організовує відповідне викликання функції-відповідача або продовження обіцянки, щоб ваш JavaScript міг це перейняти.
Проте основна нитка все одно несе великий тягар.
Довготривале виконання JavaScript на цій нитці затримуватиме все інше, що конкурує за неї — взаємодію, відображення та інші завдання у черзі.
Таким чином ми отримуємо браузер, який в цілому є досить конкурентним, але виконання JavaScript залишається суто однонитковим.
Розуміння цього розділення пояснює як те, на що здатний JavaScript у браузері, так і де лежать його обмеження.
Ключові висновки
Запам’ятайте ці три моменти:
- Сам JavaScript є однопотоковим. Ваш код виконується послідовно, крок за кроком, у основному потоці.
- Браузер — це більше, конкурентне середовище. Окрім виконання вашого JavaScript, він паралельно керує мережевими з’єднаннями, таймерами, відображенням контенту, введенням даних та обробкою медіа.
- Асинхронність не означає багатопотокового виконання вашого коду. Браузер сам очікує, а потім повертає керування JavaScript, як тільки у основному потоці з’являється вільне місце.
Корисний спосіб сформулювати це:
Browser
│
├── JavaScript execution
├── Network activity
├── Timers
├── User input
├── Rendering
├── Media processing
└── Other browser systems
JavaScript — це лише один з компонентів, які працюють у межах цієї більшої системи, а не сама система.
З таким підходом ідеї, як цикл подій, асинхронні API, заморожування інтерфейсу та конкурентність на рівні браузера, стають набагато зрозумілішими для аналізу.
Пов’язана література
- Як process.nextTick() тихо пригнічує цикл подій Node.js — Пояснює, чому рекурсивні виклики process.nextTick() повністю блокують фазу опитування libuv, та як виправити цю проблему за допомогою setImmediate().
- Розуміння замикань у JavaScript та циклу подій — Дізнайтеся, як замикання зберігають змінні зовнішнього контексту, та як цикл подій організовує стек викликів, мікрозавдання та макрозавдання за допомогою практичних прикладів коду.