Когда 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 (цикл событий) — это механизм, который и обеспечивает всю эту магию. Представьте его как менеджера с очередью задач:
- Выполнить весь синхронный код, который есть прямо сейчас.
- Когда поток освободился — взять из очереди готовый результат (файл прочитан, ответ от сети пришёл, таймер сработал) и выполнить связанный с ним колбэк.
- Повторять бесконечно.
Важное следствие: если ваш синхронный код долго считает (тяжёлый цикл на миллион итераций), 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/catch — await «разворачивает» отклонённый 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и сторонние асинхронные библиотеки. - Инструменты разработки — настройка проекта, запуск и отладка асинхронного кода.