Асинхронность: промисы, async/await и цикл событий
Зачем это нужно
Всё, что вы будете писать в блокчейн-треке, — это ожидание. Запрос к ноде идёт по сети. Транзакция подтверждается в следующем блоке. Подпись считается на ключе. Ни одна из этих операций не отвечает мгновенно, и все они пишутся одним и тем же набором приёмов.
Вот честный замер, сделанный только что на публичной ноде Waves:
высота блока: 5386318 | заняло мс: 562
Полсекунды на один запрос. Если в интерфейсе кошелька нужно показать баланс, курс и историю — это уже полторы секунды, за которые страница просто стоит. А может быть полсекунды, если написать правильно. Разница между этими двумя вариантами — три символа в коде, и в этой главе мы разберём, какие именно.
Что здесь будет:
- почему у JavaScript один поток, хотя у машины восемь ядер, и почему это не проблема;
- что такое цикл событий и почему
setTimeout(f, 0)выполняется последним; - как из ада колбэков выросли промисы, а из промисов —
async/await; - в чём разница между «подряд» и «вместе» и почему она стоит секунду на каждой паре запросов;
- четыре способа дождаться нескольких промисов и когда какой брать;
- как ошибка в асинхронном коде убивает процесс целиком и что с этим делать;
- как отменить запрос, который слишком долго идёт;
- какие четыре ошибки TypeScript ловит на этом за вас — с настоящими кодами и текстами.
Все примеры в этой главе действительно запускались на Node.js v24.14.1, все замеры времени — настоящие, все сообщения об ошибках скопированы из терминала как есть.
- Зачем это нужно
- Один поток на восемь ядер
- Что значит «заблокировать поток»
- Цикл событий: как поток решает, что делать дальше
- Как это писали раньше: колбэки
- Промис: обещание значения
- async/await: то же самое, но читается сверху вниз
- async-функция всегда возвращает промис
- Забыли await: как выглядит эта ошибка
- Подряд или вместе: где уходит секунда
- Четыре комбинатора: all, allSettled, race, any
- Асинхронность внутри циклов: три варианта и одна ловушка
- Ошибки: try/catch, .catch и один способ их потерять
- Непойманный отказ убивает процесс
- Отмена: AbortController
- Повтор с нарастающей паузой
- Что здесь ловит TypeScript
- Живой запрос к ноде
- for await и асинхронные генераторы
- Семь ошибок, на которых теряют вечер
- Тренажёр: набрать вслепую
- Экзамен по главе
- Куда дальше: проверенные ресурсы
Один поток на восемь ядер
Начнём с факта, который многих сначала пугает.
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идут первыми. - Потом очередь микрозадач — целиком, до дна. Туда попадают
.then,.catch,.finally, продолжения послеawaitиqueueMicrotask. Отсюда3. - Потом ровно одна макрозадача. Туда попадают
setTimeout,setInterval, готовые сетевые ответы, чтение файла. После неё цикл снова идёт в пункт 2. Отсюда4— последним.
Разница между двумя очередями не косметическая. Микрозадачи выгребаются все, включая те, которые добавились по ходу дела. Это значит, что бесконечно порождающие себя микрозадачи заблокируют программу так же надёжно, как цикл while:
function forever() {
Promise.resolve().then(forever); // никогда не отпустит поток
}
Макрозадачи так не могут: за один оборот берётся ровно одна.
Как это писали раньше: колбэки
До 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 — операция завершилась ошибкой, внутри лежит причина.
Переход происходит ровно один раз и навсегда. Осевший промис нельзя вернуть в ожидание, нельзя изменить его значение, нельзя «переотклонить». Это свойство и делает промисы предсказуемыми, в отличие от колбэков, которые библиотека могла вызвать дважды по недосмотру.
Та же задача на цепочке промисов:
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 в конце. Но читается всё ещё как техническая конструкция, а не как описание задачи.
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-функции не бросает исключение наружу, а возвращает отклонённый промис.
- Разрешает
awaitвнутри. Безasyncсловоawait— синтаксическая ошибка (кроме верхнего уровня модуля). - Заворачивает возвращаемое значение в промис.
return 92превращается вPromise.resolve(92). Если вы вернули промис — он не заворачивается дважды. - Заворачивает исключения в отклонённый промис.
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
Ровно вдвое. И это при том, что работы выполнено одинаково, поток по-прежнему один, ядро одно.
Объяснение простое: 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 — не единственный способ дождаться нескольких промисов, и выбор между ними важнее, чем кажется.
Запустим все четыре на одном наборе, где второй промис заведомо падает:
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: они получат массив промисов вместо массива значений.
Ошибки: 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() { }
Экзамен по главе
Сводная проверка по всей главе: 18 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
-
MDN: асинхронный JavaScript — учебный раздел от Mozilla целиком, на русском.
-
MDN: Promise — справочник по всем методам, включая
allSettledиany. -
MDN: AbortController — отмена операций, сигналы,
AbortSignal.timeoutиAbortSignal.any. -
Learn JavaScript: промисы и async/await — самый подробный русскоязычный разбор с задачами и решениями.
-
Jake Archibald. In The Loop — доклад про цикл событий, на который до сих пор ссылается вся отрасль.
-
Node.js: Event Loop, Timers, and process.nextTick — как цикл событий устроен именно в Node.js, от авторов.
-
TypeScript Handbook: Utility Types —
Awaited,ReturnTypeи остальные вспомогательные типы. -
Waves Node REST API — та самая нода, у которой мы спрашивали высоту блока, со всеми её методами.
-
Видео и материалы сообщества — ниже — ролики по теме и ссылки от студентов и преподавателей смотри в самом низу страницы.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.