Перейти к основному содержимому
Блокчейн29ГЛАВА 29Асинхронность: промисы, async/await и цикл событийawaitevent loop

Асинхронность: промисы, async/await и цикл событий

Прочитано0%
Квизы0/5
Тренажёрыне пройден0/1
Уровень 1 · Новичок

Зачем это нужно​

Всё, что вы будете писать в блокчейн-треке, — это ожидание. Запрос к ноде идёт по сети. Транзакция подтверждается в следующем блоке. Подпись считается на ключе. Ни одна из этих операций не отвечает мгновенно, и все они пишутся одним и тем же набором приёмов.

Вот честный замер, сделанный только что на публичной ноде Waves:

высота блока: 5386318 | заняло мс: 562

Полсекунды на один запрос. Если в интерфейсе кошелька нужно показать баланс, курс и историю — это уже полторы секунды, за которые страница просто стоит. А может быть полсекунды, если написать правильно. Разница между этими двумя вариантами — три символа в коде, и в этой главе мы разберём, какие именно.

Что здесь будет:

  • почему у JavaScript один поток, хотя у машины восемь ядер, и почему это не проблема;
  • что такое цикл событий и почему setTimeout(f, 0) выполняется последним;
  • как из ада колбэков выросли промисы, а из промисов — async/await;
  • в чём разница между «подряд» и «вместе» и почему она стоит секунду на каждой паре запросов;
  • четыре способа дождаться нескольких промисов и когда какой брать;
  • как ошибка в асинхронном коде убивает процесс целиком и что с этим делать;
  • как отменить запрос, который слишком долго идёт;
  • какие четыре ошибки TypeScript ловит на этом за вас — с настоящими кодами и текстами.

Все примеры в этой главе действительно запускались на Node.js v24.14.1, все замеры времени — настоящие, все сообщения об ошибках скопированы из терминала как есть.

Один поток на восемь ядер​

Начнём с факта, который многих сначала пугает.

import os from 'node:os';
console.log('ядер у машины:', os.cpus().length);
ядер у машины: 8

Восемь ядер. А ваш код при этом выполняется в одном потоке. Всегда. Никакой ваш await, никакой Promise.all не создаёт второй поток и не начинает выполнять две ваши функции одновременно.

Проверим это прямо:

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function worker(name) {
for (let i = 0; i < 3; i++) {
console.log(name, 'шаг', i);
await sleep(10);
}
}

await Promise.all([worker('A'), worker('B')]);
A шаг 0
B шаг 0
A шаг 1
B шаг 1
A шаг 2
B шаг 2

Строки идеально чередуются. Не «A A A B B B» и не случайная каша, какая была бы при настоящих потоках. Это ровное чередование — подпись одного потока, который переключается между двумя задачами в строго определённые моменты: там, где стоит await.

Отсюда главное определение всей главы.

Параллельно — две работы выполняются физически одновременно, на разных ядрах. В JavaScript ваш код так не работает никогда.

Асинхронно — одна работа начата и передана кому-то другому (операционной системе, сетевой карте, диску), а поток тем временем занялся следующей. Когда ответ придёт, поток к ней вернётся.

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

Именно поэтому асинхронность в JavaScript отлично решает задачу «сто одновременных запросов к ноде» и совершенно не решает задачу «посчитать сто хешей». Первое — ожидание, второе — настоящая работа процессора. Для второго в Node.js есть отдельный инструмент (worker_threads), и это тема другой главы.

Что значит «заблокировать поток»​

Раз поток один, любая длинная синхронная работа останавливает вообще всё. Посмотрите:

setTimeout(() => console.log('таймер на 0 мс сработал'), 0);

const t = Date.now();
let s = 0;
while (Date.now() - t < 1000) s++; // занят весь поток

console.log('цикл крутился 1000 мс');
console.log('а теперь очередь дойдёт до таймера');
цикл крутился 1000 мс
а теперь очередь дойдёт до таймера
таймер на 0 мс сработал

Таймер был поставлен на ноль миллисекунд и сработал через секунду с лишним. Не потому, что таймер сломан — а потому, что весь этот время поток был занят циклом while, и до обработки таймера просто не доходили руки.

Важно

Запомните это как правило: сработало «через 0 мс» означает «как только освободится поток». Ни один таймер в JavaScript не даёт гарантии точного времени. Указанное число — это минимум, а не обещание.

В браузере такой цикл заморозил бы страницу целиком: не нажимались бы кнопки, не крутились бы спиннеры, не выделялся бы текст. В Node.js — перестал бы отвечать сервер. Один заблокированный поток — одно неотзывчивое приложение.

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

Цикл событий: как поток решает, что делать дальше​

Теперь самое важное устройство во всём JavaScript. Запустим четыре строки:

console.log('1 синхронно');
setTimeout(() => console.log('4 setTimeout(0)'), 0);
Promise.resolve().then(() => console.log('3 микрозадача .then'));
console.log('2 тоже синхронно');
1 синхронно
2 тоже синхронно
3 микрозадача .then
4 setTimeout(0)

Строки выполнились в порядке 1, 2, 3, 4 — хотя в коде они написаны в порядке 1, 4, 3, 2. Это не случайность и не оптимизация: это точный, описанный в стандарте порядок, и он объясняется устройством цикла событий.

ОДИН ПОТОК, ДВЕ ОЧЕРЕДИ, ОДНА ПЕТЛЯстек вызовов1 синхронно2 тоже синхронновыполняется до конца, без паузмикрозадачи3 .then, await, queueMicrotaskмакрозадачи4 setTimeout, ввод-вывод, сетьцикл событийстек пуст?сначала ВСЕ микрозадачипотом ОДНА макрозадачаи снова сначаламикрозадачи выгребают до дна — поэтому .then всегда обгоняет setTimeout(0)пока стек занят, очереди просто ждут: ни один таймер не сработает вовремя
Цикл событий: стек выполняется до конца, потом выгребается вся очередь микрозадач, потом одна макрозадача

Правила ровно три:

  1. Сначала весь синхронный код. Пока стек вызовов не опустеет, ни одна очередь не трогается. Отсюда 1 и 2 идут первыми.
  2. Потом очередь микрозадач — целиком, до дна. Туда попадают .then, .catch, .finally, продолжения после await и queueMicrotask. Отсюда 3.
  3. Потом ровно одна макрозадача. Туда попадают setTimeout, setInterval, готовые сетевые ответы, чтение файла. После неё цикл снова идёт в пункт 2. Отсюда 4 — последним.

Разница между двумя очередями не косметическая. Микрозадачи выгребаются все, включая те, которые добавились по ходу дела. Это значит, что бесконечно порождающие себя микрозадачи заблокируют программу так же надёжно, как цикл while:

function forever() {
Promise.resolve().then(forever); // никогда не отпустит поток
}

Макрозадачи так не могут: за один оборот берётся ровно одна.

Отвечено вопросов: 0 из 5
Сколько потоков выполняет ваш JavaScript-код?
Почему setTimeout(f, 0) выполняется последним?
Что попадает в очередь микрозадач?
Чем очередь микрозадач опаснее очереди макрозадач?
Пока выполняется цикл while на секунду, что происходит с уже пришедшим сетевым ответом?
Отвечено верно: 0 из 5

Как это писали раньше: колбэки​

До 2015 года асинхронность в JavaScript выражалась одним способом — передать функцию, которую вызовут «когда будет готово». Такую функцию называют колбэком (callback, «обратный вызов»).

Задача: получить баланс, потом курс, потом валюту — и всё это перемножить.

const cb = (v, ms, done) => setTimeout(() => done(null, v), ms);

cb(1500, 30, (e1, balance) => {
cb(92, 30, (e2, rate) => {
cb('RUB', 30, (e3, cur) => {
console.log('колбэки:', balance * rate, cur);
});
});
});
колбэки: 138000 RUB

Работает. Но обратите внимание на форму: каждый следующий шаг — на уровень глубже. Три шага — три уровня вложенности. Пять шагов — пять. Плюс на каждом уровне свой параметр ошибки (e1, e2, e3), который надо проверять руками, и если забыть — ошибка исчезнет бесследно.

За эту форму приём и получил прозвище callback hell, «ад колбэков», или «пирамида судьбы»: код уезжает вправо, а обработка ошибок дублируется на каждом уровне.

Интересный факт

Стиль (error, result) — первым параметром всегда ошибка — называется error-first callback и был принят в Node.js как соглашение в 2009 году. Он до сих пор встречается в старых библиотеках, поэтому узнавать его нужно уметь, даже если сами вы так уже не пишете.

Промис: обещание значения​

В ES6 (2015) в язык добавили объект Promise. Идея простая: вместо того чтобы передавать функцию внутрь, асинхронная операция возвращает объект, который представляет будущий результат.

ПРОМИС МЕНЯЕТ СОСТОЯНИЕ РОВНО ОДИН РАЗpendingответа ещё нетfulfilledесть значениеrejectedесть причина отказаawait / .thenзначение приходит сюдаcatch / .catchошибка приходит сюдаобратной дороги нет: осевший промис навсегда хранит своё значение или ошибкуfinally срабатывает в обоих случаях и ничего не меняет в передаваемом значении
Промис живёт в одном из трёх состояний и меняет его ровно один раз

У промиса три состояния:

  • pending — операция идёт, ответа ещё нет;
  • fulfilled — операция завершилась успешно, внутри лежит значение;
  • rejected — операция завершилась ошибкой, внутри лежит причина.

Переход происходит ровно один раз и навсегда. Осевший промис нельзя вернуть в ожидание, нельзя изменить его значение, нельзя «переотклонить». Это свойство и делает промисы предсказуемыми, в отличие от колбэков, которые библиотека могла вызвать дважды по недосмотру.

Та же задача на цепочке промисов:

const load = (v, ms) => new Promise((r) => setTimeout(() => r(v), ms));

load(1500, 30)
.then((balance) => load(92, 30).then((rate) => ({ balance, rate })))
.then(({ balance, rate }) => load('RUB', 30).then((cur) => console.log('then:', balance * rate, cur)));
then: 138000 RUB

Лучше: вложенность есть, но её меньше, и ошибку со всей цепочки можно поймать одним .catch в конце. Но читается всё ещё как техническая конструкция, а не как описание задачи.

Нажми на часть выражения
Отвечено вопросов: 0 из 5
Сколько раз промис может сменить состояние?
Что такое pending?
Что должно попадать в reject?
Когда нужно писать new Promise руками?
Чем цепочка .then лучше вложенных колбэков?
Отвечено верно: 0 из 5

async/await: то же самое, но читается сверху вниз​

В ES2017 добавили синтаксис, который убрал из промисов всю церемонию. Та же задача третий раз:

const load = (v, ms) => new Promise((r) => setTimeout(() => r(v), ms));

async function main() {
const balance = await load(1500, 30);
const rate = await load(92, 30);
const cur = await load('RUB', 30);
console.log('await:', balance * rate, cur);
}
await main();
await: 138000 RUB

Три уровня вложенности превратились в три строки подряд. Код читается ровно так, как звучит задача: получи баланс, получи курс, получи валюту, перемножь.

Нажми на часть выражения

Важно понимать: async/await — это не замена промисам, а другой способ их записать. Под капотом ровно те же промисы и та же очередь микрозадач. await — это буквально .then, записанный без функции.

Совет

Отсюда практическое следствие, которое экономит часы: любую функцию с .then можно переписать на await и наоборот, и результат будет идентичным. Если вы встретили в чужом коде цепочку из пяти .then и не понимаете её — перепишите на await и прочитайте сверху вниз.

async-функция всегда возвращает промис​

Это правило без исключений, и его невыполнение — источник самой частой ошибки новичка.

async function getRate() {
return 92;
}

console.log('async-функция без await вернула:', getRate());
console.log('это Promise?', getRate() instanceof Promise);

const v = await getRate();
console.log('после await:', v, typeof v);
async-функция без await вернула: Promise { 92 }
это Promise? true
после await: 92 number

Функция написана так, будто возвращает число 92. Но слово async перед ней означает: что бы ты ни вернул, я заверну это в промис. В выводе это видно буквально — Promise { 92 }, то есть «промис, внутри которого лежит 92».

То же самое верно и для ошибок: throw внутри async-функции не бросает исключение наружу, а возвращает отклонённый промис.

  1. Разрешает await внутри. Без async слово await — синтаксическая ошибка (кроме верхнего уровня модуля).
  2. Заворачивает возвращаемое значение в промис. return 92 превращается в Promise.resolve(92). Если вы вернули промис — он не заворачивается дважды.
  3. Заворачивает исключения в отклонённый промис. throw new Error('x') превращается в Promise.reject(new Error('x')).

Всё. Ни потоков, ни ускорения, ни магии. Три преобразования.

Забыли await: как выглядит эта ошибка​

Раз async-функция возвращает промис, а вы забыли его дождаться — вы получаете объект промиса там, где ждали значение. И вот что происходит дальше:

async function balance() { return 1500; }

const wrong = balance(); // забыли await

console.log('забыли await, считаем:', wrong + 100);
console.log('забыли await, сравниваем:', wrong > 1000);
забыли await, считаем: [object Promise]100
забыли await, сравниваем: false

Разберём оба ответа, потому что оба важны.

[object Promise]100. Оператор + увидел объект и строку не увидел — тогда он привёл объект к строке. Объект Promise при приведении к строке даёт [object Promise]. Дальше сложение строк дало [object Promise]100. Программа не упала, ошибки нет, в базу уедет строка [object Promise]100.

false. Оператор > привёл объект к числу. Промис в число не приводится, получился NaN, а любое сравнение с NaN даёт false. То есть проверка «баланс больше тысячи» ответила «нет» на балансе в полторы тысячи.

Важно

Ни одна из этих двух строк не является ошибкой с точки зрения JavaScript. Программа продолжит работать и выдаст неправильный результат молча. Именно поэтому забытый await — самая дорогая ошибка в асинхронном коде: она не падает, она врёт.

Ловится это двумя способами: TypeScript (мы дойдём до кодов ошибок ниже) и правило линтера no-floating-promises.

Как узнать эту ошибку по симптомам:

  • в консоли Promise { <pending> } или Promise { значение } вместо самого значения;
  • в строке появилось [object Promise];
  • сравнение чисел неожиданно вернуло false;
  • undefined при обращении к полю: wrong.height у промиса, конечно, нет.

Подряд или вместе: где уходит секунда​

А вот главный практический приём всей главы. Две операции, каждая по полсекунды:

function loadBalance() {
return new Promise((resolve) => setTimeout(() => resolve(1500), 500));
}
function loadRate() {
return new Promise((resolve) => setTimeout(() => resolve(92), 500));
}

console.time('подряд');
const b1 = await loadBalance();
const r1 = await loadRate();
console.timeEnd('подряд');

console.time('вместе');
const [b2, r2] = await Promise.all([loadBalance(), loadRate()]);
console.timeEnd('вместе');
подряд: 1.003s
вместе: 501.458ms

Ровно вдвое. И это при том, что работы выполнено одинаково, поток по-прежнему один, ядро одно.

ДВА ЗАПРОСА ПО 500 МС: ГДЕ УХОДИТ СЕКУНДАдва await подрядбаланс, 500 мскурс, 500 мсизмерено: 1.003s — второй запрос ждал, пока закончится первыйPromise.allбаланс, 500 мскурс, 500 мсизмерено: 501.458ms — оба запроса ушли в сеть сразуawait не ускоряет работу — он решает, ждать её сейчас или чуть позже
Одинаковая работа на одной шкале: два await подряд против Promise.all

Объяснение простое: await не запускает операцию, он её ждёт. Операция стартует в момент вызова функции.

  • В первом варианте вызов loadRate() физически написан после await loadBalance() — а значит, выполнится только через полсекунды.
  • Во втором варианте обе функции вызываются внутри массива, то есть обе стартуют немедленно, а Promise.all ждёт уже запущенные.

Посмотрите на две соседние строки с await и задайте один вопрос:

Нужен ли результат первой строки, чтобы начать вторую?

  • Нужен — оставляйте await подряд. Например: сначала получить адрес по имени, потом баланс по адресу. По-другому нельзя.
  • Не нужен — объединяйте в Promise.all. Например: баланс и курс. Они друг о друге ничего не знают.

Этот вопрос стоит задавать каждый раз, когда два await стоят рядом. В интерфейсе кошелька из пяти независимых запросов такая проверка превращает две с половиной секунды в полсекунды.

Есть и промежуточный вариант — запустить заранее, дождаться позже:

const balanceP = loadBalance(); // ушло в сеть прямо сейчас
const rateP = loadRate(); // и это тоже

console.log('пока оба летят, можно делать что-то ещё');

const balance = await balanceP;
const rate = await rateP;

Это тот же выигрыш, что и Promise.all, просто записанный руками. Годится, когда между запуском и ожиданием нужно вставить синхронную работу.

Четыре комбинатора: all, allSettled, race, any​

Promise.all — не единственный способ дождаться нескольких промисов, и выбор между ними важнее, чем кажется.

ЧЕТЫРЕ СПОСОБА ДОЖДАТЬСЯ НЕСКОЛЬКИХ ПРОМИСОВallждёт, пока все выполнилисьпадает от первой ошибки[a, b, c]allSettledждёт, пока все закончилисьне падает никогда[{status, value|reason}]raceждёт, пока первый закончилсяошибка тоже считаетсязначение или ошибкаanyждёт, пока первый УСПЕШНЫЙошибки пропускаетзначение или AggregateError
Четыре комбинатора: чего каждый ждёт и что делает с ошибкой

Запустим все четыре на одном наборе, где второй промис заведомо падает:

const ok = (ms, v) => new Promise((res) => setTimeout(() => res(v), ms));
const bad = (ms, m) => new Promise((_, rej) => setTimeout(() => rej(new Error(m)), ms));

try {
await Promise.all([ok(50, 'a'), bad(10, 'сеть недоступна'), ok(50, 'c')]);
} catch (e) {
console.log('all упал целиком:', e.message);
}

const r = await Promise.allSettled([ok(50, 'a'), bad(10, 'сеть недоступна'), ok(50, 'c')]);
console.log(JSON.stringify(r, (k, v) => (v instanceof Error ? v.message : v)));

console.log('race:', await Promise.race([ok(300, 'медленный'), ok(30, 'быстрый')]));
console.log('any:', await Promise.any([bad(10, 'первый узел лёг'), ok(60, 'второй узел ответил')]));
all упал целиком: сеть недоступна
[{"status":"fulfilled","value":"a"},{"status":"rejected","reason":"сеть недоступна"},{"status":"fulfilled","value":"c"}]
race: быстрый
any: второй узел ответил

Прочитайте вторую строку внимательно: allSettled дождался всех трёх и вернул массив отчётов. У успешных — status: "fulfilled" и value. У упавшего — status: "rejected" и reason. Ничего не потеряно, ничего не упало.

  • Promise.all — когда без всех результатов дальше делать нечего. Экран кошелька: нет курса — нечего показывать. Падает от первой же ошибки, остальные промисы продолжают выполняться, но их результат вы уже не увидите.
  • Promise.allSettled — когда часть результатов лучше, чем ничего. Опрос десяти нод: три не ответили, семь ответили — работаем с семью. Никогда не падает.
  • Promise.race — когда нужен самый быстрый ответ. Классическое применение — приделать таймаут: гонка между запросом и промисом, который отклоняется через три секунды.
  • Promise.any — когда нужен первый успешный. Три зеркала одной ноды: неважно какое ответит, важно чтобы ответило. Падает только если упали все, и тогда бросает AggregateError со списком всех причин.

Ошибка выбора обходится дорого: all вместо allSettled в опросе нод означает, что одна упавшая нода из десяти обнулит весь результат.

Асинхронность внутри циклов: три варианта и одна ловушка​

Задача: загрузить данные по четырём идентификаторам. Каждая загрузка — 200 мс.

const ids = [1, 2, 3, 4];
const load = (id) => new Promise((r) => setTimeout(() => r(id * 100), 200));

console.time('for...of');
const seq = [];
for (const id of ids) seq.push(await load(id));
console.timeEnd('for...of');
console.log(seq);

console.time('map + Promise.all');
const par = await Promise.all(ids.map((id) => load(id)));
console.timeEnd('map + Promise.all');
console.log(par);
for...of: 805.023ms
[ 100, 200, 300, 400 ]
map + Promise.all: 200.574ms
[ 100, 200, 300, 400 ]

Результат идентичный, время отличается в четыре раза — ровно во столько, сколько элементов в массиве. На ста элементах разница была бы стократной.

А теперь ловушка, на которую попадаются практически все:

const out = [];
ids.forEach(async (id) => {
out.push(await load(id));
});
console.log('сразу после forEach:', out);

await new Promise((r) => setTimeout(r, 400));
console.log('через 400 мс:', out);
сразу после forEach: []
через 400 мс: [ 100, 200, 300, 400 ]

Массив пуст. forEach вызвал четыре async-функции, каждая вернула промис — и forEach эти промисы выбросил, потому что он ничего не возвращает и ничего не ждёт по своей природе. Строка после forEach выполнилась мгновенно, до того как хоть одна загрузка закончилась.

Через 400 мс данные появились. То есть работа шла, но программа про неё уже забыла.

Важно

forEach и async несовместимы. Всегда. forEach не умеет ждать, и никакой await внутри него этого не изменит.

Замены ровно две:

  • нужен порядок и последовательность — for (const x of arr) с await внутри;
  • нужна скорость — await Promise.all(arr.map(...)).

То же ограничение у map без Promise.all, у filter и у sort: они получат массив промисов вместо массива значений.

Отвечено вопросов: 0 из 5
Два await подряд на запросах по 500 мс. Сколько займёт?
В какой момент стартует асинхронная операция?
Как за секунду решить, объединять ли два await в Promise.all?
Что напечатает console.log сразу после ids.forEach(async ...)?
Одна из десяти нод не отвечает. Какой комбинатор нужен?
Отвечено верно: 0 из 5

Ошибки: try/catch, .catch и один способ их потерять​

Внутри async-функции ошибки ловятся обычным try/catch — это одно из главных удобств синтаксиса.

async function loadBalance() { throw new Error('узел не ответил'); }

try {
await loadBalance();
} catch (e) {
console.log('поймали через try/catch:', e.message);
}

loadBalance().catch((e) => console.log('поймали через .catch:', e.message));
поймали через try/catch: узел не ответил
поймали через .catch: узел не ответил

Оба способа равноправны. try/catch удобнее, когда обработка идёт внутри той же функции; .catch — когда вы просто хотите повесить обработчик и идти дальше.

А вот способ потерять ошибку — и он выглядит абсолютно нормально:

try {
loadBalance(); // await забыт
} catch (e) {
console.log('этого не будет:', e.message);
}
console.log('строка после try выполнилась');
строка после try выполнилась

catch не сработал. Причина: без await вызов loadBalance() просто создал отклонённый промис и вернул его. Никакого исключения в этом месте не бросалось — бросить его было бы некому, функция ещё даже не дошла до throw в тот момент, когда try уже закончился.

Совет

Правило: try/catch ловит асинхронную ошибку только вместе с await. Нет await — нет и поимки. Это ещё одна причина, по которой забытый await так опасен: он одновременно ломает результат и глушит обработку ошибок.

Пара слов про finally. Он срабатывает в обоих случаях и не меняет передаваемое значение — идеальное место для «убрать спиннер» или «закрыть соединение»:

async function load() {
setLoading(true);
try {
return await fetchBalance();
} finally {
setLoading(false); // выполнится и при успехе, и при ошибке
}
}

Непойманный отказ убивает процесс​

Это поведение изменилось в Node.js 15 и до сих пор удивляет тех, кто учился по старым материалам. Запустим файл, где ошибка не поймана вообще никак:

async function loadBalance() { throw new Error('узел не ответил'); }

loadBalance();
setTimeout(() => console.log('эта строка не выполнится'), 100);
Error: узел не ответил
at loadBalance (file:///.../a6.mjs:1:38)
at file:///.../a6.mjs:2:1
...
Node.js v24.14.1
код выхода: 1

Процесс умер. Строка из setTimeout не выполнилась — до неё просто не дошло. Код выхода 1 означает, что если это был сервер под управлением systemd или Docker, контейнер сейчас перезапускается.

Раньше Node.js на такое только печатал предупреждение и продолжал работать. Теперь — падает. И это правильно: программа с потерянной ошибкой находится в неизвестном состоянии, и продолжать работу опаснее, чем упасть.

  • Каждый промис должен быть либо дождан (await), либо обработан (.catch). Третьего не дано. Именно это проверяет правило линтера @typescript-eslint/no-floating-promises, и его стоит включить с первого дня.
  • Если результат вам действительно не нужен — напишите это явно: void loadBalance().catch(logError). Так видно, что промис брошен намеренно, а не по забывчивости.
  • Обработчик последней надежды на входе в приложение: process.on('unhandledRejection', (e) => { log(e); process.exit(1); }). Он нужен, чтобы записать ошибку в лог перед смертью, а не чтобы продолжить работу.
  • В браузере непойманный отказ процесс не убивает, но попадает в консоль и в window.onunhandledrejection. Пользователь при этом видит просто зависший экран без объяснений.

Отмена: AbortController​

Запрос к ноде может идти вечно. Пользователь ушёл с экрана, а запрос всё ещё летит и всё ещё займёт поток, когда вернётся. Для отмены в языке есть отдельный механизм.

const ac = new AbortController();
setTimeout(() => ac.abort(), 100);

try {
const res = await fetch('https://httpbin.org/delay/5', { signal: ac.signal });
console.log('дошли:', res.status);
} catch (e) {
console.log('отменили:', e.name, '|', e.message);
}
отменили: AbortError | This operation was aborted

Запрос на пять секунд был прерван через сто миллисекунд. Контроллер отдаёт signal, сигнал передаётся в fetch, вызов abort() переводит промис в отклонённый с ошибкой AbortError.

Для самого частого случая — «просто таймаут» — есть готовая однострочная форма:

try {
await fetch('https://httpbin.org/delay/5', { signal: AbortSignal.timeout(200) });
} catch (e) {
console.log('AbortSignal.timeout:', e.name, '|', e.message);
}
AbortSignal.timeout: TimeoutError | The operation was aborted due to timeout

Обратите внимание: имена ошибок разные — AbortError при ручной отмене и TimeoutError при истечении таймаута. Это позволяет различить их в catch и, например, повторить запрос при таймауте, но не повторять при уходе пользователя со страницы.

Интересный факт

AbortSignal.timeout() появился в Node.js 17.3 и в браузерах в 2022 году. До него таймаут делали через Promise.race с промисом, который отклоняется по setTimeout, — этот приём вы ещё встретите в чужом коде, и теперь знаете, что его можно заменить одной строкой.

Есть и AbortSignal.any([a, b]) — сигнал, который срабатывает от любого из нескольких. Пригождается, когда запрос надо отменять и по таймауту, и по уходу пользователя.

Повтор с нарастающей паузой​

Сеть ненадёжна, а публичная нода может ответить ошибкой просто потому, что в неё сейчас стучатся все. Стандартный ответ на это — повторить, но не сразу и не бесконечно.

async function withRetry(fn, times = 5) {
for (let i = 1; i <= times; i++) {
try {
return await fn();
} catch (e) {
console.log('ошибка:', e.message);
if (i === times) throw e;
const pause = 100 * 2 ** (i - 1);
console.log('ждём', pause, 'мс и пробуем снова');
await new Promise((r) => setTimeout(r, pause));
}
}
}

console.log(await withRetry(flaky));
ошибка: попытка 1: узел не ответил
ждём 100 мс и пробуем снова
ошибка: попытка 2: узел не ответил
ждём 200 мс и пробуем снова
ответ с попытки 3

Паузы удваиваются: 100, 200, 400, 800. Приём называется экспоненциальная задержка (exponential backoff), и он не про вежливость, а про физику: если нода лежит под нагрузкой, десять клиентов, долбящих её каждые 10 мс, не дадут ей встать.

Три детали, которые отличают рабочий повтор от учебного:

  • Последняя попытка бросает ошибку дальше (if (i === times) throw e). Молча вернуть undefined после пяти неудач — худшее, что может сделать такая функция.
  • Повторять нужно не всё. Ошибка сети — да. Ошибка «неверная подпись транзакции» — нет, она повторится столько же раз, сколько вы попробуете.
  • К паузе добавляют случайную добавку (jitter, pause + Math.random() * 100). Иначе сто клиентов, упавших одновременно, повторят запрос тоже одновременно.

Что здесь ловит TypeScript​

Всё, что мы разобрали, — это JavaScript. TypeScript добавляет к этому проверки, и именно на асинхронности они окупаются быстрее всего. Вот четыре настоящие ошибки компилятора tsc 6.0.3, каждая — из реального файла.

1. async с не-промисным типом возврата.

async function getRate(): number {
return 92;
}
error TS1064: The return type of an async function or method must be the global
Promise<T> type. Did you mean to write 'Promise<number>'?

Компилятор прямо предлагает правильный ответ. Тип async-функции всегда Promise<чего-то>.

2. await вне async-функции.

function getRate(): number {
const r = await fetch('https://example.com');
return 92;
}
error TS1308: 'await' expressions are only allowed within async functions
and at the top levels of modules.

3. Обращение к полю промиса вместо значения — тот самый забытый await.

async function getRate(): Promise<number> { return 92; }
const r = getRate();
console.log(r.toFixed(2));
error TS2339: Property 'toFixed' does not exist on type 'Promise<number>'.

Вот ради этой строчки и стоит писать на TypeScript. В чистом JavaScript эта же программа упала бы во время работы — или, что хуже, не упала бы, а тихо выдала [object Promise].

4. Промис там, где ждали значение.

async function getRate(): Promise<number> { return 92; }
const r: number = getRate();
error TS2322: Type 'Promise<number>' is not assignable to type 'number'.
  • Promise<T> — тип промиса, внутри которого лежит T. Тип async-функции всегда такой.
  • Awaited<T> — распаковывает промис: Awaited<Promise<number>> даёт number. Разворачивает и вложенные промисы. Нужен, когда вы пишете обёртку над чужой асинхронной функцией.
  • ReturnType<typeof f> — тип того, что возвращает f. В паре с Awaited даёт «тип значения, которое реально придёт»: Awaited<ReturnType<typeof loadBalance>>.
  • Promise.all сохраняет типы по позициям: из [Promise<number>, Promise<string>] получится [number, string], а не (number | string)[]. Деструктуризация после него полностью типизирована.
  • unknown в catch — по умолчанию пойманная ошибка имеет тип unknown, а не Error. Прежде чем читать e.message, нужна проверка e instanceof Error. Это не придирка: бросить в JavaScript можно что угодно, включая строку и число.
Важно

И главное ограничение, которое надо помнить всегда: типы стираются при компиляции. Если нода вернула строку там, где вы написали Promise<number>, TypeScript вам об этом не скажет — он вообще не участвует в выполнении программы. Данные, пришедшие из сети, проверяют обычным кодом или библиотекой вроде Zod. Тип — это ваше обещание компилятору, а не проверка чужого сервера.

Живой запрос к ноде​

Теперь соберём всё вместе на настоящей сети. Публичная нода Waves отвечает на простой запрос без всякой авторизации:

const t0 = performance.now();
const h = await fetch('https://nodes.wavesnodes.com/blocks/height').then((r) => r.json());
console.log('высота блока:', h.height, '| заняло мс:', Math.round(performance.now() - t0));
высота блока: 5386318 | заняло мс: 562

Пять миллионов триста восемьдесят шесть тысяч блоков — столько их было в сети Waves на момент написания этой главы. Число растёт примерно на один блок в минуту, так что у вас оно будет другим, и это лучшее доказательство, что запрос настоящий.

А вот как выглядит тот же код в промышленном виде — с типом, таймаутом и разбором ошибок:

type HeightResponse = { height: number };

async function getHeight(node: string, ms = 3000): Promise<number> {
const res = await fetch(`${node}/blocks/height`, { signal: AbortSignal.timeout(ms) });
if (!res.ok) throw new Error(`нода ответила ${res.status}`);
const data = (await res.json()) as HeightResponse;
if (typeof data.height !== 'number') throw new Error('нода вернула не число');
return data.height;
}

Четыре вещи, которых не было в учебном варианте, и каждая закрывает реальную дыру:

  • AbortSignal.timeout(ms) — запрос не может висеть вечно.
  • if (!res.ok) — fetch не бросает ошибку на ответах 404 и 500. Он спокойно вернёт вам объект ответа со статусом 500, и без этой проверки вы попытаетесь разобрать страницу с ошибкой как JSON. Это одна из самых частых ловушек fetch.
  • as HeightResponse — res.json() возвращает any, то есть отключает проверки. Приведение восстанавливает тип для дальнейшего кода.
  • Проверка typeof data.height — то самое «типы стираются». Приведение выше ничего не проверило, проверяет только эта строка.

for await и асинхронные генераторы​

Последний инструмент — для случая «данные приходят порциями и их заранее неизвестно сколько». Обход блокчейна — ровно такая задача.

async function* blocks(from, to) {
for (let h = from; h <= to; h++) {
await new Promise((r) => setTimeout(r, 50)); // здесь был бы запрос к ноде
yield { height: h, txs: h % 3 };
}
}

for await (const b of blocks(101, 104)) {
console.log('блок', b.height, 'транзакций', b.txs);
}
блок 101 транзакций 2
блок 102 транзакций 0
блок 103 транзакций 1
блок 104 транзакций 2

Разберём синтаксис по частям:

Нажми на часть выражения

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

Совет

for await пригодится вам не только с генераторами. Так читают потоки Node.js: for await (const chunk of process.stdin) — построчное чтение большого файла без загрузки его целиком в память.

Семь ошибок, на которых теряют вечер​

1. Забыли await. Симптомы: [object Promise] в строке, Promise { <pending> } в консоли, сравнение чисел вернуло false, undefined вместо поля. Лечится TypeScript и правилом no-floating-promises.

2. await в цикле там, где нужен Promise.all. Симптом: страница грузится ровно во столько раз дольше, сколько элементов в списке. Измерено: 805 мс против 200 мс на четырёх элементах.

3. async внутри forEach. Симптом: массив пуст сразу после цикла, а через секунду волшебным образом заполняется. forEach не умеет ждать — берите for...of или map + Promise.all.

4. try/catch без await. Симптом: catch не срабатывает, ошибка уходит в непойманный отказ и роняет процесс. try/catch ловит асинхронную ошибку только вместе с await.

5. Promise.all там, где нужен allSettled. Симптом: одна упавшая нода из десяти обнуляет весь результат.

6. fetch без проверки res.ok. Симптом: SyntaxError: Unexpected token '<' — вы пытаетесь разобрать HTML-страницу с ошибкой 500 как JSON. fetch отклоняет промис только при сетевом сбое, но не при ответе сервера с ошибкой.

7. Повтор запроса без паузы и без ограничения. Симптом: нода отвечает всё хуже, а в логах тысячи запросов в секунду. Нужны экспоненциальная задержка, ограничение числа попыток и случайная добавка.

Тренажёр: набрать вслепую​

Эти конструкции вы будете писать каждый день. Наберите их, пока рука не начнёт делать это без участия головы.

Цель: скорость ≥ 110 зн/мин, точность ≥ 90%

async function load() { }
Отвечено вопросов: 0 из 5
Почему try/catch без await не поймал ошибку async-функции?
Что делает Node.js 15 и новее при непойманном отказе промиса?
Чем отличаются AbortError и TimeoutError?
fetch вернул ответ со статусом 500. Что сделает промис?
Зачем в повторе запроса удваивать паузу?
Отвечено верно: 0 из 5

Экзамен по главе​

Сводная проверка по всей главе: 18 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Куда дальше: проверенные ресурсы​

Видео по теме

ВидеоЧто такое Event Loop в JavaScript? Event Loop Простыми словамиUlbi TV
ВидеоJavaScript событийный цикл Event Loop, асинхронный код, промисы (Promise), async / awaitАлександр Ламков — Friendly Frontend
ВидеоУрок 4. JavaScript. Асинхронность. Что такое Event Loop. JS SetTimeout 0Владилен Минин
Видео#13. JavaScript. Как работает Async, Await, чем отличается от Promise (+ 3 асинхронных примера)Василий Муравьев
ВидеоВебинар: Асинхронность в JavaScript. Таймеры, промисы, async/awaitloftblog

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.