Контекст и модели: 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.