Конкурентность в TypeScript: event loop, Promise и async/await
TypeScript исполняется на JavaScript-рантайме, а у него необычная модель: один поток. Здесь нет потоков, мьютексов и гонок в классическом смысле — и это одновременно огромное упрощение и источник особых ловушек. Чтобы писать корректный асинхронный код, надо понять, как однопоточный рантайм ухитряется обслуживать тысячи одновременных соединений. Ответ — event loop.
Однопоточная модель и event loop
Ключевое различие: конкурентность ≠ параллелизм. Параллелизм — это когда две вещи исполняются физически одновременно (на разных ядрах). Конкурентность — это когда несколько задач продвигаются попеременно, чередуясь на одном исполнителе. JavaScript даёт конкурентность на одном потоке через кооперативную многозадачность.
Модель состоит из трёх частей:
- Call stack (стек вызовов) — где исполняется синхронный код. Один стек = одна вещь в каждый момент.
- Web/C++ API — то, что делает работу вне движка: таймеры, сетевые запросы, чтение файлов. Их выполняет рантайм (браузер или libuv в Node), не ваш поток.
- Очереди задач + event loop — когда асинхронная операция завершается, её колбэк ставится в очередь. Event loop берёт задачи из очереди и кладёт на стек, как только стек пуст.
Критически важное правило приоритетов: между двумя макрозадачами event loop
опустошает всю очередь микрозадач. Микрозадачи — это колбэки промисов
(.then, продолжения await), queueMicrotask. Макрозадачи — setTimeout,
setInterval, I/O-колбэки. Отсюда классическая головоломка:
console.log("1: синхронно");
setTimeout(() => console.log("2: макрозадача (setTimeout)"), 0);
Promise.resolve().then(() => console.log("3: микрозадача (промис)"));
console.log("4: синхронно");
// Порядок вывода: 1, 4, 3, 2
// Сначала весь синхронный код (1, 4), затем ВСЕ микрозадачи (3),
// и только потом макрозадача (2) — даже с задержкой 0.
Практическое следствие №1: не блокируйте поток. Пока синхронный код крутится
на стеке, event loop стоит: ни один колбэк, ни один HTTP-запрос не обслуживается.
Тяжёлый синхронный цикл или JSON.parse гигантского объекта «замораживает» весь
сервер. Это главная ловушка модели. Тяжёлые вычисления выносят в воркеры (ниже).
Promise — значение, которого ещё нет
Promise<T> — объект, представляющий будущий результат асинхронной операции. Он
находится в одном из трёх состояний: pending (ожидает), fulfilled (успех,
есть значение T), rejected (ошибка). Переход происходит один раз и навсегда.
// Промис, который зарезолвится через задержку. Тип параметра явно указан.
function delay(ms: number): Promise<void> {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}
// Обёртка над колбэком в промис
function readFileP(path: string): Promise<string> {
return new Promise((resolve, reject) => {
fs.readFile(path, "utf8", (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
}
Промисы компонуются. Но цепочки .then().catch() быстро становятся
нечитаемыми, поэтому в современном коде их почти всегда заменяет async/await.
async / await — синхронный вид для асинхронного кода
async-функция всегда возвращает Promise. await «приостанавливает» функцию до
резолва промиса, отдавая управление event loop, — но не блокирует поток, в
отличие от синхронного ожидания. Под капотом await — это тот же промис, просто
записанный линейно.
async function loadDashboard(userId: string): Promise<Dashboard> {
const user = await fetchUser(userId); // ждём, поток свободен
const orders = await fetchOrders(user.id); // затем это
return { user, orders };
}
Ловушка: ненужная последовательность
Код выше исполняет запросы по очереди, хотя они независимы. Если orders не
зависит от user, это вдвое медленнее без причины. Запускайте независимые
операции параллельно:
async function loadDashboardFast(userId: string): Promise<Dashboard> {
// Оба промиса СТАРТУЮТ сразу, ждём оба одновременно
const [user, orders] = await Promise.all([
fetchUser(userId),
fetchOrders(userId),
]);
return { user, orders };
}
Правило: await в цикле — почти всегда красный флаг. Если итерации
независимы, собирайте промисы и ждите через Promise.all.
Обработка ошибок в async-коде
await превращает reject промиса в обычный throw — ловится try/catch:
async function safeLoad(id: string): Promise<User | null> {
try {
return await loadUser(id);
} catch (err) {
// err: unknown — сужаем, как в файле про ошибки
if (err instanceof NotFoundError) return null;
throw err; // неизвестное — пробрасываем выше
}
}
Нюанс: return await vs return. Пишите return await внутри try, иначе
промис вернётся до входа в catch, и ошибка не будет поймана здесь (при
включённом правиле no-return-await учитывайте эту тонкость).
Самая коварная ошибка — floating promise (незаваченный промис). Если не
сделать await и не обработать .catch, отклонение промиса станет
unhandledRejection — в новых версиях Node это по умолчанию роняет процесс.
// ПЛОХО: промис "плавает", ошибка потеряется/уронит процесс
saveMetrics(data);
// ХОРОШО: дождаться
await saveMetrics(data);
// либо явно "выстрелил и забыл" с обработкой ошибки:
void saveMetrics(data).catch((err) => logger.error(err));
Включите ESLint-правило @typescript-eslint/no-floating-promises — оно ловит это
статически. Это одно из самых ценных type-aware правил.
Комбинаторы промисов
// all: все успешны -> массив результатов. Одна ошибка -> общий reject (fail-fast).
const [a, b] = await Promise.all([taskA(), taskB()]);
// allSettled: ждёт ВСЕ, возвращает статусы, НЕ падает от одной ошибки.
const results = await Promise.allSettled([taskA(), taskB()]);
for (const r of results) {
if (r.status === "fulfilled") console.log(r.value);
else console.error(r.reason);
}
// race: первый завершившийся (успех ИЛИ ошибка) выигрывает.
const fastest = await Promise.race([primary(), replica()]);
// any: первый УСПЕШНЫЙ; если все упали -> AggregateError.
const firstOk = await Promise.any([mirror1(), mirror2()]);
Выбор: all — когда нужны все и любая ошибка фатальна; allSettled — когда
нужен отчёт по каждому (например, разослать N уведомлений и собрать, что не
удалось); race — таймауты и «первый ответивший реплики»; any — фолбэк по
зеркалам.
Отмена через AbortController
У промисов нет встроенной отмены. Стандартный механизм — AbortController и его
AbortSignal. Его понимают fetch, Node-стримы, таймеры, многие библиотеки.
async function fetchWithTimeout(url: string, ms: number): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms); // отменяем по таймауту
try {
return await fetch(url, { signal: controller.signal });
} finally {
clearTimeout(timer); // всегда убираем таймер
}
}
// Готовый хелпер для таймаута сигналом:
const signal = AbortSignal.timeout(5000);
await fetch(url, { signal });
// Объединить несколько причин отмены (таймаут + отмена пользователем):
const combined = AbortSignal.any([AbortSignal.timeout(5000), userSignal]);
При отмене промис отклоняется ошибкой с name === "AbortError" — её обычно
отличают от настоящих сбоев. Пробрасывайте signal сквозь все слои: контроллер →
сервис → HTTP-клиент, чтобы отмена запроса пользователем реально останавливала
работу вниз по стеку.
Ограничение конкурентности (паттерн p-limit)
Promise.all над 10 000 URL запустит 10 000 запросов разом — вы исчерпаете
сокеты, память, или вас забанят по rate limit. Нужно ограничить число
одновременных операций. Это паттерн «пул»/семафор. В проде берут
p-limit; вот минимальная реализация
для понимания:
// Ограничивает число одновременно выполняемых асинхронных задач до `concurrency`.
function pLimit(concurrency: number) {
let active = 0;
const queue: Array<() => void> = [];
const next = () => {
active--;
queue.shift()?.(); // запускаем следующую ждущую задачу
};
return function run<T>(fn: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const start = () => {
active++;
fn().then(resolve, reject).finally(next);
};
if (active < concurrency) start();
else queue.push(start); // слишком много активных — в очередь
});
};
}
// Обрабатываем 1000 URL, но не более 5 одновременно
const limit = pLimit(5);
const results = await Promise.all(
urls.map((url) => limit(() => fetch(url).then((r) => r.json()))),
);
Это один из самых практически важных паттернов для любой пакетной обработки, скрапинга, миграций данных.
Настоящий параллелизм: воркеры
Однопоточность значит, что CPU-тяжёлая задача блокирует всё. Для реального параллелизма нужны отдельные потоки/процессы — в Node это worker_threads, в браузере — Web Workers. У них своя память; общаются они сообщениями (message passing), поэтому классических гонок за память нет.
// worker.ts — исполняется в отдельном потоке
import { parentPort, workerData } from "node:worker_threads";
function heavyCompute(n: number): number {
let sum = 0;
for (let i = 0; i < n; i++) sum += Math.sqrt(i); // тяжёлый CPU-цикл
return sum;
}
parentPort?.postMessage(heavyCompute(workerData as number));
// main.ts — не блокирует event loop, пока воркер считает
import { Worker } from "node:worker_threads";
function runInWorker(n: number): Promise<number> {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL("./worker.js", import.meta.url), {
workerData: n,
});
worker.once("message", resolve);
worker.once("error", reject);
worker.once("exit", (code) => {
if (code !== 0) reject(new Error(`Воркер упал с кодом ${code}`));
});
});
}
Когда нужен реальный параллелизм: обработка изображений/видео, криптография,
сжатие, парсинг больших файлов, ML-инференс. Когда не нужен: обычный веб-
бэкенд, где работа — это в основном ожидание I/O (БД, сеть). Там однопоточный
event loop прекрасно справляется с высокой конкурентностью, и воркеры только
добавят накладных расходов на сериализацию сообщений. Для масштабирования по
ядрам на I/O-нагрузке используют не воркеры, а несколько процессов (Node
cluster или запуск N экземпляров за балансировщиком).
Стримы и backpressure
Что если файл или ответ БД больше, чем оперативная память? Нельзя загрузить его целиком. Стримы обрабатывают данные по кусочкам (chunks), не держа всё в памяти. Ключевое понятие — backpressure (обратное давление): если потребитель (например, запись на диск) медленнее производителя (чтение из сети), система должна притормозить производителя, а не копить неограниченный буфер в памяти.
import { pipeline } from "node:stream/promises";
import { createReadStream, createWriteStream } from "node:fs";
import { createGzip } from "node:zlib";
// pipeline автоматически управляет backpressure и корректно закрывает потоки при ошибке
await pipeline(
createReadStream("huge.log"), // источник (медленный I/O)
createGzip(), // трансформация
createWriteStream("huge.log.gz"), // сток
);
Всегда используйте pipeline (а не ручной .pipe()), потому что он корректно
обрабатывает ошибки и освобождает ресурсы. Стримы незаменимы для ETL, проксирования,
экспорта больших выгрузок. Современный веб-стандарт —
Web Streams API
(ReadableStream), поддержан и в Node, и в браузере, и хорошо сочетается с
async-итераторами:
// Асинхронный итератор — читаем поток построчно, кусок за куском
for await (const chunk of readableStream) {
process(chunk);
}
Типичные ошибки
- Блокировка event loop синхронным CPU-кодом — «сервер завис». Профилируйте, выносите в воркер.
- Floating promises — потерянные ошибки и краши. Всегда
await/.catch/void. awaitв цикле для независимых задач — потеря параллелизма.Promise.allбез лимита над огромным массивом — исчерпание ресурсов.- Забытый
AbortController— «утекающие» запросы, которые никто не отменяет. - Смешивание
asyncи колбэков в одной функции — источник гонок и двойных вызовов.
Источники
- MDN: The event loop.
- Доклад Jake Archibald «In The Loop» (микро/макрозадачи) и его визуализатор loupe.
- Node.js docs: The Node.js Event Loop, worker_threads, Stream, Backpressuring in Streams.
- p-limit.
Что дальше
Асинхронный код особенно сложно тестировать. Переходим к тестированию: пирамида, Vitest/Jest, моки, property-based и интеграция в CI.