TypeScript Конкурентность в TypeScript: event loop, Promise и async/await
0%

Конкурентность в TypeScript: event loop, Promise и async/await

Конкурентность в 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 и колбэков в одной функции — источник гонок и двойных вызовов.

Источники

Что дальше

Асинхронный код особенно сложно тестировать. Переходим к тестированию: пирамида, Vitest/Jest, моки, property-based и интеграция в CI.

Тестирование

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов