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

Контекст и модели: AsyncLocalStorage, акторы, STM, Effect

senior~30 мин

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

Контекст и модели: AsyncLocalStorage, акторы, STM, Effect

Это последний урок раздела. Мы прошли контракты (фаза 1), модель исполнения и оси классификации, инструменты конкурентности. Осталось подняться на самый верхний уровень: как переносить контекст сквозь async-цепочку и какие существуют целостные модели управления состоянием и эффектами, когда промисов и мьютексов уже мало. Здесь меньше готовых рецептов и больше архитектурных идей. Это нормально для Senior-урока: задача не выучить API, а увидеть, куда ведут дороги.

1. AsyncLocalStorage: контекст без прокидывания

Типичная боль: нужно протащить trace-id запроса (или текущего пользователя, или соединение транзакции) через десяток функций, ни одна из которых логически про него не знает. Прокидывать аргументом, значит засорить каждую сигнатуру.

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

import { AsyncLocalStorage } from 'node:async_hooks';

const als = new AsyncLocalStorage<{ traceId: string }>();

function handler(req: Request): Promise<Response> {
  return als.run({ traceId: crypto.randomUUID() }, async () => {
    log('начали обработку'); // увидит traceId
    await loadUser(); // и здесь, глубоко внутри, тоже
    return new Response('ok');
  });
}

function log(message: string): void {
  const store = als.getStore();
  console.log(`[${store?.traceId}] ${message}`);
}

Магия в том, что getStore внутри loadUser вернёт тот же traceId, даже через несколько await. Контекст переносится через точки уступки. Два параллельных запроса не перепутаются: у каждого своя цепочка. Под капотом за это отвечает async_hooks, который отслеживает, какая операция кого породила. Это та же context-tracking, что в OpenTelemetry разносит спаны по запросам.

Цена: async_hooks добавляет накладные расходы, и в горячем пути это заметно. Поэтому контекст кладут точечно (запрос, транзакция), а не на всё подряд.

2. Актор: изоляция вместо блокировок

В уроке 10-concurrency-tools мы боролись с гонками через мьютексы. Модель акторов заходит с другой стороны: убрать разделяемое состояние вовсе.

Актор, это сущность с приватным состоянием и почтовым ящиком. Снаружи в него нельзя записать, только послать сообщение. Сообщения обрабатываются по одному. Раз состояние не разделяется, а ящик обрабатывается последовательно, гонок внутри актора нет по построению.

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

В JavaScript акторы ложатся на worker-ы: каждый worker это изолят со своей памятью, а postMessage это и есть отправка в mailbox. Библиотеки вроде comlink прячут сериализацию и делают вызов worker-а похожим на обычный async-метод.

// псевдокод актора поверх worker_threads
class CounterActor {
  #count = 0; // приватно, снаружи не видно

  receive(message: { type: 'inc' } | { type: 'get' }): number | void {
    if (message.type === 'inc') this.#count += 1;
    if (message.type === 'get') return this.#count;
  }
}

Одна и та же идея работает на разных масштабах: объект Smalltalk, процесс Erlang, микросервис. Везде это изолированное состояние плюс обмен сообщениями. Поэтому акторная архитектура дотягивается до request-reply, pub-sub, event sourcing и CQRS, к которым мы вернёмся в разделе 09-backend.

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

3. STM: транзакции в памяти

Третий подход к разделяемому состоянию, STM. Вместо того чтобы захватывать блокировку, ты описываешь изменение как транзакцию над специальными ячейками. Среда применяет её атомарно, а при конфликте откатывает и повторяет. Это оптимистичный контроль: исходим из того, что конфликты редки, и платим только когда они случились.

import { Effect, TxRef } from 'effect';

const transfer = (from: TxRef.TxRef<number>, to: TxRef.TxRef<number>, amount: number) =>
  Effect.gen(function* () {
    const balance = yield* TxRef.get(from);
    if (balance < amount) return yield* Effect.fail('недостаточно средств');
    yield* TxRef.set(from, balance - amount);
    yield* TxRef.update(to, (value) => value + amount);
  });

// две параллельные транзакции не потеряют деньги: конфликт откатит и повторит одну
await Effect.runPromise(Effect.tx(transfer(accountA, accountB, 50)));

В Effect транзакционные ячейки называются TxRef, а Effect.tx очерчивает границу транзакции: чтения и записи внутри копятся в журнале и применяются разом на выходе. Отдельного типа для транзакции нет, это обычный Effect, поэтому транзакционный код композится с любым другим.

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

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.