Раздел 5 · Асинхронность

Асинхронная синхронность · машинерия async/await

middle-senior~55 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Асинхронная синхронность

Название из роадмапа звучит как оксюморон, и в этом весь смысл. Сверху код выглядит ровно как обычный, синхронный: одна строка, потом вторая, потом возврат. А под капотом движок аккуратно режет твою функцию пополам по каждой 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.