Один тред, чтобы править всеми: мифы и правда о конкурентности в JavaScript
Когда-то давно, в эпоху статичных сайтов и кнопок «Отправить», которые блокировали весь интерфейс, JavaScript жил счастливо в одном потоке. Ему было достаточно: пришёл запрос — выполнился, ушёл запрос — интерфейс замер. Но времена изменились. Сегодня мы ждём от веб-приложений мгновенной реакции: скролл не должен тормозить, анимации — плавные, а загрузка данных — незаметной. И тут в игру вступает конкурентность.
Многие разработчики путают конкурентность (concurrency) с параллелизмом (parallelism). В JavaScript, особенно в браузере, мы чаще имеем дело с первым. Это как жонглёр: он не держит все мячи в воздухе одновременно (это параллелизм), но он так ловко переключается между ними, что создаётся иллюзия одновременности. В этой статье мы разберём три ключевых инструмента для организации конкурентности в JS: async/await, горутины (через генераторы и Web Workers) и классические потоки. Поехали!
Async/Await: элегантный асинхронный код
async/await — это синтаксический сахар над промисами (Promises), который появился в ES2017. Он не делает код многопоточным, но делает его асинхронным. Когда вы пишете await, JavaScript «засыпает» в этой функции, освобождая поток для других задач. Как только промис разрешается, функция просыпается и продолжает выполнение. Это идеальный инструмент для I/O операций: запросы к API, чтение файлов, таймеры.
Простой пример: загрузка данных с сервера
// Функция, которая возвращает Promise
function fetchUser(id) {
return new Promise((resolve) => {
setTimeout(() => resolve({ id, name: 'Алексей' }), 1000);
});
}
// Асинхронная функция
async function loadUserProfile(userId) {
console.log('Начинаем загрузку...');
const user = await fetchUser(userId);
console.log('Пользователь загружен:', user.name);
return user;
}
loadUserProfile(42);
console.log('Этот код выполнится сразу, не дожидаясь загрузки!');
Обратите внимание: console.log('Этот код выполнится сразу...') сработает раньше, чем console.log('Пользователь загружен'). Поток не блокируется — это и есть конкурентность в действии.
Обработка ошибок
async function fetchWithRetry(url, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Ошибка HTTP');
return await response.json();
} catch (error) {
if (i === retries - 1) throw error;
console.log(`Попытка ${i + 1} не удалась, пробуем снова...`);
}
}
}
Когда использовать: любые операции с внешними ресурсами (API, базы данных, файловая система в Node.js). async/await — ваш основной инструмент для асинхронного кода.
Горутины в JavaScript: миф или реальность?
Термин «горутина» пришёл из Go и обозначает легковесный поток выполнения. В JavaScript нет нативных горутин, но есть генераторы (Generator Functions) и Web Workers, которые могут имитировать подобное поведение. Генераторы позволяют приостанавливать и возобновлять выполнение функции, что даёт контроль над потоком исполнения.
Генераторы как основа конкурентности
function* taskGenerator() {
console.log('Запуск задачи 1');
yield 'Задача 1 завершена';
console.log('Запуск задачи 2');
yield 'Задача 2 завершена';
console.log('Запуск задачи 3');
yield 'Задача 3 завершена';
}
const iterator = taskGenerator();
console.log(iterator.next().value); // Выполняет задачу 1
// В этот момент можно сделать что-то ещё
console.log(iterator.next().value); // Выполняет задачу 2
console.log(iterator.next().value); // Выполняет задачу 3
Генераторы не дают настоящей параллельности, но они идеальны для кооперативной многозадачности. Вы сами решаете, когда передать управление. В сочетании с промисами генераторы превращаются в мощный инструмент, на котором, кстати, был построен ранний async/await (через co библиотеку).
Web Workers: настоящая многопоточность
Если вам нужно выполнить тяжёлые вычисления (например, обработка изображений или криптография) без блокировки UI, Web Workers — ваш выбор. Они запускаются в отдельном потоке и общаются с основным через сообщения.
// main.js
const worker = new Worker('worker.js');
worker.postMessage({ data: [1, 2, 3, 4, 5] });
worker.onmessage = (event) => {
console.log('Результат из воркера:', event.data);
};
// worker.js
self.onmessage = (event) => {
const result = event.data.data.map(x => x * x);
self.postMessage(result);
};
Важно: Web Workers не имеют доступа к DOM, но они идеальны для CPU-интенсивных задач. В Node.js аналогом являются Worker Threads.
Event Loop: дирижёр оркестра конкурентности
Чтобы понять, как всё это работает, нужно заглянуть под капот — в Event Loop. Это механизм, который бесконечно проверяет стек вызовов и очереди задач. Когда стек пуст, Event Loop берёт задачу из очереди макрозадач (setTimeout, fetch) или микрозадач (Promise.then, queueMicrotask) и помещает её в стек.
Вот упрощённая схема:
- Выполняется синхронный код.
- Микрозадачи (Promise) выполняются сразу после синхронного кода.
- Макрозадачи (setTimeout) — в следующем цикле.
console.log('1: Синхронный код');
setTimeout(() => console.log('2: setTimeout'), 0);
Promise.resolve().then(() => console.log('3: Promise'));
console.log('4: Ещё синхронный код');
// Вывод: 1, 4, 3, 2
Это знание помогает избежать ошибок: если вы поместите тяжёлую синхронную операцию (например, цикл на миллион итераций), Event Loop застынет и интерфейс «замрёт». Решение — разбить задачу на части через setTimeout или передать в Worker.
Сравнение подходов: когда что использовать?
Давайте сведём всё в таблицу (мысленно, так как мы в HTML):
- Async/Await + Promises — для I/O операций (сеть, файлы, базы данных). Легко читается, поддерживается всеми современными средами.
- Генераторы — для слож