← назад к разделу

Когда backend-код читает файл, ходит в базу данных или вызывает чужой HTTP-сервис, он ждёт ответа. Вопрос в том, что происходит во время ожидания. В TypeScript на Node это устроено иначе, чем в большинстве языков с потоками, и понимание этого механизма — основа всего серверного кода.

Зачем вообще асинхронность

Представьте сервер, который обрабатывает запросы по очереди и на каждом «походе в базу» замирает на 50 миллисекунд. Пока он ждёт, он не делает ничего — а в это время могли бы обслуживаться сотни других запросов. Ожидание ввода-вывода (диск, сеть, база) — это почти всегда не работа процессора, а простое ожидание чужого ответа.

Асинхронность решает именно это: пока одна операция ждёт ответа, программа занимается другими делами. Короткая формула: не блокируем поток на ожидании, а просим уведомить нас, когда результат будет готов.

Однопоточность и неблокирующий ввод-вывод

Node выполняет ваш JavaScript/TypeScript-код в одном потоке. Это значит, что в любой момент времени работает ровно один кусок вашего кода — никаких «двух функций одновременно». Звучит как ограничение, но на практике это упрощает жизнь: нет гонок за общие переменные, как в многопоточных языках.

Секрет в том, что ввод-вывод не блокирует этот поток. Когда вы просите прочитать файл, Node передаёт операцию операционной системе (или своему пулу потоков под капотом) и сразу освобождает основной поток для другой работы. Когда файл прочитан, Node вернётся к вашему коду, чтобы передать результат.

console.log("1");

setTimeout(() => {
  console.log("3"); // выполнится позже, когда поток освободится
}, 0);

console.log("2");

// Вывод: 1, 2, 3 — даже при задержке 0 мс

Даже с нулевой задержкой 3 печатается последним: колбэк не выполняется «сразу», он ждёт, пока текущий код досчитает до конца.

Event loop простыми словами

Event loop (цикл событий) — это механизм, который и обеспечивает всю эту магию. Представьте его как менеджера с очередью задач:

  1. Выполнить весь синхронный код, который есть прямо сейчас.
  2. Когда поток освободился — взять из очереди готовый результат (файл прочитан, ответ от сети пришёл, таймер сработал) и выполнить связанный с ним колбэк.
  3. Повторять бесконечно.
diagram

Важное следствие: если ваш синхронный код долго считает (тяжёлый цикл на миллион итераций), event loop заблокирован и не может обработать другие запросы. Однопоточная модель прощает ожидание ввода-вывода, но не прощает тяжёлых вычислений в основном потоке.

Ещё одна деталь: у Node есть микрозадачи (колбэки от Promise) и макрозадачи (таймеры, ввод-вывод). Микрозадачи имеют приоритет — они выполняются раньше следующего таймера. На практике это редко мешает, но объясняет, почему результат Promise приходит «почти сразу», а setTimeout — заметно позже.

Колбэки: как было раньше

Исторически асинхронность выражали колбэками — функцией, которую передают «на потом»:

import { readFile } from "node:fs";

readFile("config.json", "utf-8", (err, data) => {
  if (err) {
    console.error("Не удалось прочитать файл", err);
    return;
  }
  console.log("Содержимое:", data);
});

Проблема возникает, когда операций несколько и они зависят друг от друга. Колбэки вкладываются друг в друга, образуя «лесенку» вправо — её прозвали callback hell:

readFile("a.txt", "utf-8", (err1, a) => {
  readFile("b.txt", "utf-8", (err2, b) => {
    readFile("c.txt", "utf-8", (err3, c) => {
      // обработка ошибок размазана, читать тяжело
      console.log(a, b, c);
    });
  });
});

Promise: обещание результата

Promise — это объект-обещание: «здесь будет результат, когда операция завершится». У Promise три состояния: ожидание (pending), успех (fulfilled) и ошибка (rejected). Результат получают через .then, ошибку — через .catch:

import { readFile } from "node:fs/promises";

readFile("config.json", "utf-8")
  .then((data) => console.log("Содержимое:", data))
  .catch((err) => console.error("Ошибка:", err));

Главное преимущество — цепочки. Зависимые операции выстраиваются в линию, а не в лесенку:

fetchUser(1)
  .then((user) => fetchOrders(user.id)) // вернули новый Promise
  .then((orders) => console.log("Заказов:", orders.length))
  .catch((err) => console.error("Где-то упало:", err));

Один .catch в конце ловит ошибку из любого звена цепочки — это уже намного аккуратнее колбэков.

async/await: асинхронный код, который читается как обычный

async/await — это синтаксический сахар над Promise. Функция, помеченная async, всегда возвращает Promise, а await приостанавливает её до готовности результата — не блокируя при этом поток:

import { readFile } from "node:fs/promises";

async function loadConfig(): Promise<string> {
  const data = await readFile("config.json", "utf-8");
  return data; // функция вернёт Promise<string>
}

Зависимые операции теперь выглядят как обычный последовательный код:

async function showOrders(userId: number): Promise<void> {
  const user = await fetchUser(userId);
  const orders = await fetchOrders(user.id);
  console.log(`У ${user.name} заказов: ${orders.length}`);
}

Под капотом всё те же Promise и event loop — но читать и сопровождать такой код заметно проще. Сегодня это основной стиль асинхронности в TypeScript на backend.

Обработка ошибок в async-коде

В async/await ошибки ловят обычным try/catchawait «разворачивает» отклонённый Promise в обычное исключение:

async function loadConfig(): Promise<string> {
  try {
    return await readFile("config.json", "utf-8");
  } catch (err) {
    console.error("Не смогли прочитать конфиг:", err);
    return "{}"; // запасное значение
  }
}

Если же вы работаете с Promise напрямую — ошибку ловит .catch. Главное правило: у каждой асинхронной операции должна быть обработка ошибки хотя бы где-то по пути. Promise без .catch (или async-функция без try/catch и без обработки у вызывающего) приводит к «unhandled rejection» — Node ругается в логи, а в новых версиях может и завершить процесс.

// Опасно: ошибку никто не ловит
async function risky() {
  await fetchUser(999); // если упадёт — unhandled rejection
}

// Хорошо: вызывающий ловит ошибку
risky().catch((err) => console.error(err));

Параллельные операции: Promise.all

Частая грабля — выполнять независимые операции по очереди, хотя их можно запустить вместе:

// Медленно: ждём по очереди, суммарно ~600 мс
const a = await fetchA(); // 200 мс
const b = await fetchB(); // 200 мс
const c = await fetchC(); // 200 мс

Если a, b и c не зависят друг от друга, запускаем их параллельно через Promise.all — он ждёт, пока завершатся все, и возвращает массив результатов:

// Быстро: запустили разом, ждём самый долгий — ~200 мс
const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]);

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

const results = await Promise.allSettled([fetchA(), fetchB(), fetchC()]);
for (const r of results) {
  if (r.status === "fulfilled") {
    console.log("ок:", r.value);
  } else {
    console.error("упало:", r.reason);
  }
}

Типичные грабли

  • Забыли await. const data = readFile(...) без await положит в data не результат, а сам Promise. TypeScript часто подсказывает это типом Promise<...>.
  • Тяжёлые вычисления в основном потоке. Долгий синхронный цикл блокирует event loop и «вешает» весь сервер. Для счётной нагрузки выносите работу в worker threads.
  • Последовательно вместо параллельно. Цепочка await для независимых операций тратит время зря — используйте Promise.all.
  • forEach с async внутри. array.forEach(async ...) не дожидается завершения колбэков. Для последовательной обработки используйте обычный for...of с await, для параллельной — Promise.all(array.map(...)).
  • Promise без обработки ошибки. Любой Promise, который может упасть, должен иметь .catch или быть внутри try/catch.

Коротко

  • Node выполняет ваш код в одном потоке, но ввод-вывод не блокирует этот поток.
  • Event loop — менеджер очереди: доделывает синхронный код, затем выполняет колбэки готовых операций.
  • Эволюция стиля: колбэки (callback hell) → Promise (.then/.catch, цепочки) → async/await (читается как обычный код).
  • Ошибки: в async/await — try/catch, у Promise — .catch; необработанный rejection — это проблема.
  • Независимые операции запускайте параллельно через Promise.all (или Promise.allSettled, если нужны все результаты).
  • Тяжёлые вычисления в основном потоке блокируют весь сервер — это главное ограничение однопоточной модели.

Что почитать дальше

  • Основы JavaScript для TypeScript — переменные, функции и замыкания, на которых строятся колбэки и Promise.
  • Модули и npm — как подключать node:fs/promises и сторонние асинхронные библиотеки.
  • Инструменты разработки — настройка проекта, запуск и отладка асинхронного кода.