JS — однопоточный язык, но асинхронный код нередко описывают как «выполняющийся параллельно» — и это не совсем так. В статье разберёмся, что на самом деле происходит, когда вы вызываете fetch или setTimeout, и почему настоящая многопоточность в браузере — это отдельная история про Web Workers, а не про промисы.
Однопоточность
Очень скоро после первого знакомства с JS вы сталкиваетесь с концепцией однопоточности. Легко догадаться из самого слова, что оно означает: код выполняется в одном потоке.
Для людей, которые начинают изучать этот язык, это отличное подспорье, т.к. нет никакой сложной логики разделения задач по процессам и потокам: весь ваш код выполняется строго по порядку, метод за методом, строчка за строчкой, а следующая задача начнет выполняться, только когда закончит предыдущая, — как выполнение инструкции по шагам.
И вроде нет проблем писать код так, чтобы от него не требовалось параллельных действий, но тут сразу стоит сделать отступление: ведь в этом одном потоке выполняется не только код, написанный вами, но и многие другие процессы, такие как парсинг документа, отрисовка компонентов, выполнение анимаций и многое другое. А очень скоро после начала погружения в язык мы еще сталкиваемся с запросами по сети.
И тут может возникнуть вопрос: если вообще все выполняется в одном потоке, то и запросы по сети выполняются там же? Ну раз язык однопоточный, то запросы тоже должны быть в нем. Звучит логично, но такой подход был бы совершенно неудобен, потому что время запросов и обмена данными с сервером зависит от множества параметров, и ответ не всегда приходит мгновенно. Почему же тогда множественные запросы на страницах не блокируют анимации, и мы все еще можем как-то взаимодействовать с сайтом, вызывая обработчики разных пользовательских событий?
В этот момент появляется понятие асинхронности, под которым подразумевается, что мы можем что-то выполнять, не блокируя основной поток.
Чтобы самому проверить эффект однопоточности и понять, что было бы, если бы не было асинхронности, можно на mdn взять пример реализации синхронного запроса и попробовать повзаимодействовать с сайтом во время его выполнения. Пока запрос не завершится, вся интерактивная часть сайта просто встанет на паузу и не будет выполняться. К слову, синхронные запросы в основном потоке уже объявлены устаревшими, и браузер выведет предупреждение в консоль — как раз потому, что они намертво блокируют страницу.
var request = new XMLHttpRequest();request.open("GET", "/bar/foo.txt", false); // `false` makes the request synchronousrequest.send(null);if (request.status === 200) { console.log(request.responseText);}
Асинхронность — мнимая параллельность
Итак, вернемся к асинхронности. Асинхронность разработчику предоставляет Web API. К Web API относятся: fetch (и другие запросы по сети), setTimeout, setInterval и многие другие. Приведенные Web API работают по такой логике: мы передаем в них функции (колбэки) с синхронным кодом, который должен быть выполнен в момент наступления некоторого события. В JS есть еще промисы — объекты-обёртки для асинхронного кода, которые работают по схожему принципу: в момент, который считается успешным завершением, они выполняют синхронный код и передают результат дальше по цепочке.
Приведенные API являются асинхронными, поэтому они немного ломают привычное поведение однопоточности, т.к. задачи, описанные внутри колбэков этих API, пропускаются и выполняются в какой-то момент позже. И такой принцип работы создает иллюзию того, что раз это не блокирует интерфейс и не останавливает выполнение кода вашей программы, то это выполняется где-то параллельно. Мне кажется, для простоты объяснений я и сам не раз использовал именно слово «параллельно», но вот в этом «параллельно» на самом деле и есть основная иллюзия.
Корректнее сказать, что код выполняется не параллельно, а асинхронно, потому что асинхронность позволяет браузеру в момент ожидания события завершения операции выполнять какие-то другие действия. Т.е. мы не выполняем синхронный код, переданный в колбэк, параллельно с синхронным кодом, который у нас написан после асинхронной операции, — мы просто выполняем синхронный код, а потом выполняем синхронный код из колбэка, когда произошло событие, которого мы ждали.
Это не очень похоже на многопоточность или параллельные действия — это по сути ожидание момента, когда можно будет выполнить конкретный код. Это можно даже проверить: если написать код, в котором браузеру нечего ждать, а надо выполнять действия, то все ваши обертки асинхронности никак не изменят поведение кода в сравнении с синхронным.
function heavyTask(name, ms) { console.log(`${name} started`); const start = Date.now(); while (Date.now() - start < ms) { // тяжёлая синхронная работа } console.log(`${name} finished`);}async function run() { console.log('run start'); Promise.resolve().then(() => { heavyTask('Task A', 3000); }); Promise.resolve().then(() => { heavyTask('Task B', 3000); }); console.log('run end');}run();
Результатом работы такого кода будет вот такой вывод в консоль:
run startrun endTask A startedTask A finishedTask B startedTask B finished
Как видно из сообщений в консоли, Task B не начинает работу, пока Task A не закончит свои действия. Никаких параллельных вычислений. Все так, будто нет никаких Promise, а есть просто вызовы функций (за одним маленьким исключением в виде места вывода в консоль run end).
Event loop
Чтобы объяснить причину такого поведения, нужно немного коснуться темы event loop, стека вызовов, очереди задач и очереди микрозадач. Я постараюсь сделать это кратко: существует большое количество статей, в которых намного лучше объяснены их суть и принцип работы.
Стек вызовов — это механизм отслеживания того, какая из функций выполняется сейчас и какая будет вызвана следующей. Когда выполнение функции завершено, она удаляется из стека вызовов, и наступает очередь следующей. Очередей ожидания на самом деле две, и попадает в них разное: в очередь задач (macrotask queue) — колбэки Web API: setTimeout, setInterval, обработчики событий, а в очередь микрозадач (microtask queue) — колбэки промисов из then/catch/finally и код после await.
Цикл событий (event loop) следит за стеком вызовов: как только стек пустеет, он сначала выполняет все накопившиеся микрозадачи до полного опустошения их очереди и только потом берет одну задачу из очереди задач. После нее — снова все накопившиеся микрозадачи, и так по кругу.
Получается, у нас есть Web API — то, что выполняется где-то за пределами нашего движка JS, на стороне браузера. В момент завершения работы одного из элементов Web API колбэк попадает в соответствующую очередь и ждет, пока event loop передаст его на выполнение. Именно поэтому в примере выше задача A и задача B выполняются последовательно, а не параллельно.
Вот пошаговый разбор: сначала в стек вызовов попадает вывод в консоль run start. Дальше интерпретатор встречает Promise.resolve() — он ничего не «выполняет», а просто создаёт уже зарезолвленный промис, поэтому колбэк из then сразу отправляется в очередь микрозадач. То же самое происходит со вторым промисом — теперь в очереди микрозадач две функции, а стек вызовов занят выполнением функции run. Дальше в стек попадает вывод в консоль run end, функция run завершается, и стек освобождается. Теперь в стек попадает функция из первого промиса, и она начинает выполняться; функция из второго промиса стоит первой в очереди микрозадач, но event loop оставляет ее в ожидании, т.к. стек занят. И только в момент окончания работы первой функции в стек попадает вторая. Как видно, никакой параллельности.
Web Workers — реальная параллельность
Перед описанием работы кода я употребил фразу, что Web API выполняет код за пределами движка JS. Получается, если бы была возможность как-то сказать браузеру, что нужно выполнить какой-то код параллельно, то он бы смог это сделать. И с 2009–2010 годов такая возможность есть — Web Workers. Они отвечают именно за параллельность: весь JS-код воркера выполняется в отдельном потоке, где-то на фоне. Основной поток не блокируется, и можно взаимодействовать с сайтом, а браузер в этот момент будет выполнять сложные вычисления отдельно.
Из-за того, что код воркера выполняется в отдельном потоке, на него накладываются определенные ограничения: на то, что этот код может делать, к чему у него есть доступ и как с ним можно взаимодействовать. К примеру, из кода, который является частью воркера, нет доступа к DOM, т.е. не получится напрямую манипулировать DOM, покрасить кнопочку или навесить обработчик на блок; у воркеров нет доступа к alert и confirm, но зато есть доступ к таким Web API, как сетевые запросы, setTimeout и setInterval. Из-за того, что Web Worker находится в отдельном потоке, у него нет прямого доступа к переменным основного кода, поэтому вся передача данных происходит через postMessage.
// main.js — основной поток:// Создаём воркер — браузер загрузит и запустит этот файл в отдельном потокеconst worker = new Worker('worker.js');document.querySelector('#calc-btn').addEventListener('click', () => { // Передаём данные воркеру. Объект будет склонирован // (structured clone), а не передан по ссылке worker.postMessage({ limit: 50000000 }); console.log('Задача отправлена, интерфейс не заблокирован');});// Слушаем ответ от воркераworker.addEventListener('message', (event) => { // А вот тут мы уже в основном потоке — DOM доступен document.querySelector('#result').textContent = `Найдено простых чисел: ${event.data.count}`;});
// worker.js — выполняется в отдельном потоке:// Здесь нет ни document, ни window, ни alert.// Глобальный объект — self (WorkerGlobalScope)self.addEventListener('message', (event) => { const { limit } = event.data; // Тяжёлое синхронное вычисление. В основном потоке оно // повесило бы страницу на несколько секунд let count = 0; for (let n = 2; n < limit; n++) { if (isPrime(n)) count++; } // Единственный способ вернуть результат — postMessage self.postMessage({ count });});function isPrime(n) { for (let i = 2; i * i <= n; i++) { if (n % i === 0) return false; } return true;}
Стоит уточнить, что Web Workers бывают трех типов, и всё, что описано выше, — это dedicated worker: он принадлежит одной вкладке и умирает вместе с ней. Кроме него, существуют Shared Worker и Service Worker. Shared Worker — один экземпляр, к которому могут подключаться сразу несколько вкладок и iframe одного origin. Service Worker — прокси между приложением и сетью, который живет независимо от вкладок и используется для офлайн-режима и кэширования запросов.
Когда использовать Web Workers
Workers могут пригодиться в следующих кейсах:
-
Любые сложные и долгие вычисления (dedicated worker)
-
Предварительная загрузка и обработка больших объемов данных (dedicated worker)
-
Подсчёт или мониторинг событий (dedicated worker)
-
Синхронизация состояния между вкладками (Shared Worker)
-
Общий кэш или данные между вкладками (Shared Worker)
-
Централизованная подписка на сервер — например, одно WebSocket-соединение на все вкладки (Shared Worker)
-
Поддержание работы приложения при проблемах с сетью (Service Worker)
И еще во множестве других. Но из-за модели взаимодействия через сообщения Workers не очень подойдут для мелких и простых задач: это будет переусложнение, которое не даст видимого эффекта и прироста в производительности; передача больших объемов данных через postMessage может быть медленной из-за клонирования, а дебаг ошибок в многопоточном приложении усложняется.
Проблема медленной передачи, правда, частично решаема: ArrayBuffer и ряд других объектов можно не клонировать, а передать вторым аргументом postMessage как transferable — тогда владение памятью перейдет в Worker без копирования, но в исходном потоке объект станет недоступен.
А для случаев, когда потокам действительно нужна общая память, существует SharedArrayBuffer — буфер, который виден одновременно из основного потока и из воркера без всякого копирования, и Atomics — набор операций для безопасной работы с ним. Это уже настоящая многопоточность с её типичными проблемами вроде гонок данных, поэтому она заслуживает отдельной статьи.
Заключение
Web Worker не является серебряной пулей для любого кейса: чаще всего его использование может быть неоправданным из-за переусложнения, а где-то проще перенести логику на бэкенд. Попытка распараллелить то, что хорошо работает в одном потоке, только усложнит поддержку вашего кода, но в некоторых кейсах Workers могут помочь сделать вашу систему отзывчивее и приятнее в использовании. Поэтому понимание разницы в понятиях асинхронности и многопоточности, а также знание механизмов Event Loop и Web Workers позволят вам строить масштабные и сложные, но при этом отзывчивые и понятные системы.
ссылка на оригинал статьи https://habr.com/ru/articles/1064906/