Асинхронная синхронность
Название из роадмапа звучит как оксюморон, и в этом весь смысл. Сверху код выглядит ровно как обычный, синхронный: одна строка, потом вторая, потом возврат. А под капотом движок аккуратно режет твою функцию пополам по каждой await-точке, расставляет паузы и возобновляет работу, когда промис будет готов. Этот урок про машинерию между этими двумя плоскостями: что компилятор делает с async function, как из той же идеи выросли сопрограммы и генераторы, как написать свой Promise, как ведут себя модули с await на верхнем уровне и где в этой связке прячутся ловушки.
Если урок 03-promise-and-async-await ещё не уложился, лучше сначала вернуться: дальше будет много мелкой моторики про микрозадачи и thenable.
1. Зачем вообще лезть под капот
История простая. Долго ты пишешь await и веришь, что это синхронность по форме, а по существу там какая-то магия. Магия терпит до первого бага, который не ловится в try/catch, или до первого деплоя, где сервис на старте висит десятки секунд непонятно от чего. И вот тут начинается разговор про машинерию.
Три причины разобраться. Первая, чисто диагностическая: когда видишь стектрейс с пометкой at async loadProfile, нужно мгновенно представлять, в какой точке функция остановилась и что сохранилось в её замыкании. Без модели конечного автомата ты будешь гадать, а с моделью читать стек как карту.
Вторая, инженерная. Понимая машинерию, ты сам напишешь нужный примитив: ограниченный пул задач, таймаут с отменой, очередь с приоритетами, мини-рантайм для сопрограмм. Никто не заставляет переписывать Promise в продакшене. Зато за полчаса соберёшь нужную обвязку из Promise, then и queueMicrotask, не таща в проект ещё одну зависимость.
Третья, эксплуатационная. Top-level await переопределил порядок инициализации модулей. Если в большом графе зависимостей завестись циклу с await, граф буквально не замкнётся, и приложение зависнет на старте без единой строки в логах.
2. async-функция как конечный автомат
Главный кадр всего урока такой. Каждая await-точка, это пауза, в которой функция отдаёт контроль наружу. Между паузами функция выполняется обычно, синхронно, как любая другая. А раз есть паузы, значит функцию можно разрезать на куски и склеить через переменную, которая помнит, где мы остановились. Эту переменную обычно называют state, и устроена она как переключатель switch. Получается тот самый конечный автомат.
Возьмём учебную функцию, которая дёргает два запроса по очереди:
const loadProfile = async (userId: string) => {
const user = await fetchUser(userId);
const orders = await fetchOrders(user.id);
return { user, orders };
};
В терминах автомата здесь три состояния. Нулевое, до первого await: вызвать fetchUser, повесить колбек, отдать управление наружу. Первое, между await-ами: пришёл user, вызвать fetchOrders. Второе, после второго await: пришли orders, собрать и вернуть результат.
Чтобы это было видно глазами, ниже встроен пошаговый интерактив. Жми “шаг” и смотри: какой case сейчас активен, кому принадлежит управление, что лежит в “регистрах” user и orders. Между активными шагами функция действительно спит, и это видно по индикатору наверху.
Если переписать тот же loadProfile руками, без async/await, получится такая раскладка. Это учебный десугар, не пиши такой код в продакшене:
const loadProfile = (userId: string) =>
new Promise((resolve, reject) => {
let state = 0;
let user: User;
let orders: Order[];
const step = (input?: unknown, error?: unknown) => {
try {
if (error) throw error;
switch (state) {
case 0: {
state = 1;
fetchUser(userId).then(
(value) => step(value),
(err) => step(undefined, err),
);
return;
}
case 1: {
user = input as User;
state = 2;
fetchOrders(user.id).then(
(value) => step(value),
(err) => step(undefined, err),
);
return;
}
case 2: {
orders = input as Order[];
resolve({ user, orders });
return;
}
}
} catch (err) {
reject(err);
}
};
step();
});
Что важно увидеть в этом куске:
- Локальные переменные
userиordersпришлось выселить из тела функции в замыкание-снаружи. Иначе они бы просто потерялись между паузами, а нам нужно, чтобы первое значение дожило до того момента, когда придёт второе. - Обёртка
try/catchтеперь общая на весь шаг, а ошибка превращается вrejectвнешнего промиса. Уasync functionповедение ровно такое же: throw внутри означает, что наружу уйдёт rejected-промис. - Каждая
await-точка превратилась вthen(success, failure)плюс раннийreturn, который буквально отдаёт управление обратно event loop’у. stateведёт счёт. Если в стектрейсе ты видишь, что “функция упала после второго await”, это и есть значениеstateв этом десугаре. Полезный мысленный приём при отладке.
Реальный V8, конечно, не пишет под капотом JavaScript-овский switch. Он генерирует машинный код, в котором тот же эффект достигается приостановкой контекста и записью точки возврата прямо во внутренние структуры рантайма. Историю и трюки оптимизаций описывает пост V8: Faster async functions: после версии 7.2 один await стоит примерно как один then, но с правильным async stack trace.
Та же модель объясняет, почему await “просто работает” внутри циклов, условий и try/catch. Компилятор расставляет состояния везде, где может потребоваться пауза. Внутри for каждая итерация добавляет ещё одну точку выхода и точку возврата, и автомат крутится по этим точкам, пока не дойдёт до конца тела функции.
3. Как мы жили до async/await: история про co
В 2013 году в Node ещё нет ни нативного async, ни нативного Promise (он добавлен в спецификацию ES2015 и в V8 чуть позже). Зато есть генераторы, добавленные в Node 0.11. И TJ Holowaychuk, тот самый автор Express и Koa, замечает простую вещь: yield в генераторе ведёт себя ровно как пауза. А значит, если научить кого-то снаружи смотреть на отданное значение, ждать его и возвращать обратно через iterator.next(value), то получится своими руками воспроизвести await. Так появляется библиотека tj/co: три-четыре сотни строк, которые на много лет становятся фактическим стандартом асинхронного кода в Node.
Сопрограммы, это и есть ключ. Обычная функция, начавшись, идёт до return и забывает себя. Сопрограмма умеет сказать “стоп, я ещё не закончила, разбуди меня позже” и потом возобновиться с тем же стеком и теми же локальными переменными. JavaScript-овский генератор, это и есть сопрограмма с интерфейсом итератора.
Маленький рантайм, который превращает генератор в async-функцию, помещается в десяток строк:
const run = <T>(genFn: () => Generator<Promise<unknown>, T>): Promise<T> => {
const iterator = genFn();
return new Promise((resolve, reject) => {
const step = (method: 'next' | 'throw', value?: unknown) => {
let result;
try {
result = iterator[method](value);
} catch (err) {
reject(err);
return;
}
if (result.done) {
resolve(result.value);
return;
}
Promise.resolve(result.value).then(
(resolved) => step('next', resolved),
(err) => step('throw', err),
);
};
step('next');
});
};
Вызывается так:
const loadProfile = (userId: string) =>
run(function* () {
const user = (yield fetchUser(userId)) as User;
const orders = (yield fetchOrders(user.id)) as Order[];
return { user, orders };
});
Семантически почти неотличимо от async/await. Разница в двух местах. Первая, типы теряются на yield, отсюда касты в TypeScript. Вторая, движок не вяжет такой генератор со своим async stack trace, и в стеке ты видишь голый рантайм-фрейм. Зато весь рантайм у тебя перед глазами: yield отдаёт значение наружу, ранер дожидается резолва, передаёт результат обратно в iterator.next(value). Это и есть двусторонний обмен, которым сопрограммы отличаются от обычных функций.
co дожил в продакшене до 2017 года и тихо ушёл в архив, когда async/await доехал до Node 7.6 без флага. Но как учебная модель он бессмертен: если ты понимаешь co, ты понимаешь, что делает компилятор async-функции под капотом.
Лучше всего это чувствуется, когда собираешь ранер сам. Ниже редактор с такой задачей: свой co, в котором отказ промиса долетает до try/catch внутри генератора. Тесты гоняются прямо в браузере, ставить ничего не нужно.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.