Конкурентность в TypeScript: от Event Loop до Web Workers
Вы когда-нибудь замечали, как ваш браузер или Node.js-приложение «зависает» во время выполнения сложной математической операции или обработки большого JSON? Знакомое чувство бессилия, когда интерфейс перестаёт реагировать на клики, а сервер — на новые запросы. Всё дело в том, что по умолчанию JavaScript (и его строгий брат TypeScript) — однопоточный язык. Но это не значит, что мы обречены на медленные программы. Наоборот, грамотное использование конкурентности в TypeScript позволяет создавать быстрые и отзывчивые приложения.
В этой статье мы не просто перечислим термины. Мы разберём, как на самом деле работают async/await, что скрывается под капотом Event Loop, можно ли в TypeScript использовать «горутины» как в Go, и когда стоит применять настоящие потоки через Web Workers. Поехали!
1. Асинхронность vs Параллелизм: в чём разница?
Прежде чем углубляться в код, давайте договоримся о терминах. Многие путают эти понятия, а зря.
- Асинхронность (конкурентность) — это умение программы начинать задачу, переключаться на другую, пока первая ждёт (например, ответа от сервера), и возвращаться к ней позже. В один момент времени выполняется только одна инструкция.
- Параллелизм — это выполнение двух и более задач в буквально один и тот же момент времени на разных ядрах процессора.
TypeScript из коробки даёт нам асинхронность. Параллелизм же требует создания отдельных потоков — в браузере это Web Workers, в Node.js — worker_threads. Запомните эту разницу, иначе рискуете написать код, который будет «тормозить» из-за блокировки основного потока.
2. Event Loop и коллбеки — фундамент конкурентности TypeScript
Главный секрет асинхронности в JavaScript/TypeScript — это Event Loop (цикл событий). Представьте, что у вас есть очередь задач. Основной поток берёт задачу, выполняет её, если она синхронная — сразу выдаёт результат. Если же это асинхронная операция (например, setTimeout или fetch), то она отправляется в «сторону» (в Web API или libuv), а основной поток продолжает выполнять следующий код.
Когда асинхронная операция завершается, её коллбек попадает в очередь (Task Queue или Microtask Queue). Event Loop постоянно проверяет, не пуст ли стек вызовов, и если он пуст — берёт первую задачу из очереди и выполняет.
// Простой пример, который это демонстрирует
console.log('1. Начало');
setTimeout(() => {
console.log('3. Таймер сработал');
}, 0);
Promise.resolve().then(() => {
console.log('2. Микротаска выполнена');
});
console.log('4. Конец');
// Вывод: 1, 4, 2, 3
Обратите внимание: Promise (микротаска) выполнился раньше setTimeout (макротаска), хотя задержка у таймера 0 мс. Это важное знание для оптимизации TypeScript.
3. Async/Await — сахар, который меняет всё
С появлением ES2017 мир JS разделился на «до» и «после». Async/await — это синтаксический сахар над промисами, который делает асинхронный код похожим на синхронный. Он не добавляет потоков, но радикально повышает читаемость.
// Функция, симулирующая задержку
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Асинхронная функция с использованием async/await
async function fetchUserData(userId: number): Promise<string> {
console.log(`Запрашиваю данные пользователя ${userId}...`);
await delay(2000); // Имитация сетевого запроса
return `Данные пользователя ${userId}`;
}
async function main() {
console.log('Старт');
const user1 = await fetchUserData(1);
console.log(user1);
const user2 = await fetchUserData(2);
console.log(user2);
console.log('Финиш');
}
main();
// Выполнение: Старт -> пауза 2с -> данные 1 -> пауза 2с -> данные 2 -> Финиш
Это последовательное выполнение. А что если мы хотим запустить оба запроса параллельно? Для этого конкурентность в TypeScript предлагает использовать Promise.all:
async function mainParallel() {
console.log('Старт параллельного выполнения');
const results = await Promise.all([
fetchUserData(1),
fetchUserData(2)
]);
console.log(results); // ['Данные пользователя 1', 'Данные пользователя 2']
console.log('Финиш');
}
mainParallel();
// Выполнение: Старт -> пауза 2с -> результат -> Финиш
Оба запроса запускаются почти одновременно, и общее время выполнения сокращается с 4 до 2 секунд. Это и есть истинная мощь асинхронного программирования TypeScript.
4. Горутины в TypeScript: миф или реальность?
Если вы пришли из мира Go, вы наверняка скучаете по лёгким горутинам. Напомним: горутины — это очень дешёвые (весом в несколько килобайт) потоки выполнения, управляемые рантаймом Go. В TypeScript нет встроенных горутин, но их концепцию можно эмулировать.
В Node.js и браузерах роль «лёгких потоков» выполняют микротаски (промисы). Однако есть важное отличие: горутины в Go могут выполняться параллельно на разных ядрах, а промисы — нет. Для настоящих «горутин» в TypeScript придётся использовать Web Workers или worker_threads.
Тем не менее, если вам нужно просто «запустить и забыть» множество лёгких задач, не блокируя поток, вы можете использовать подход с генераторами или просто массу промисов:
// Эмуляция горутин через массив промисов
const tasks: Promise<void>[] = [];
for (let i = 0; i < 10; i++) {
tasks.push(
delay(Math.random() * 1000).then(() => console.log(`Задача ${i} завершена`))
);
}
await Promise.all(tasks);
console.log('Все задачи выполнены');
Это не горутины в классическом понимании, но для большинства задач веб-разработки такого подхода более чем достаточно.
5. Потоки в TypeScript: Web Workers и worker_threads
Когда вам нужен настоящий параллелизм — например, для обработки изображений, сложных вычислений или парсинга больших файлов без блокировки UI — на помощь приходят потоки.
Web Workers в браузере
Web Worker — это скрипт, который выполняется в отдельном потоке операционной системы. Он не имеет доступа к DOM, но может обмени