Очередь из урока 106 помнит задания, но задания пишут в общие книги: журнал башни, счётчик писем, список принятых. Пока писарь один, всё сходится. Когда их два, каждый делает верные шаги, а вместе они теряют записи.
Беда Пондера знакома по уроку 65: прочитать значение и записать новое это два шага, а между ними может пройти кто угодно. Там её лечили Ref.update, одним шагом. Но журнал башни это не одно число. Письмо надо принять, проверить на дубль, записать, ответить отправителю, и всё это вместе. Одного атомарного шага на такую работу нет.
Почему замок не спасает
Первое, что приходит в голову: замок. Писарь берёт ключ от журнала, пишет, возвращает ключ. В Effect это семафор на одно место из урока 67, и он правда работает. Но у замка три беды, и все три вылезают, когда башен много.
- Замок надо не забыть. Любой код, который трогает журнал, обязан взять ключ. Один забыл, и потеря вернулась, а компилятор промолчит.
- Замок надо не перепутать. Писарь, который держит ключ от журнала и ждёт ключ от счётчика, пока второй держит счётчик и ждёт журнал, встанет навсегда.
- Замок живёт в одной башне. Семафор это значение в памяти одного процесса. Башня T-7 на другом конце города о нём не знает.
Есть другой порядок, старый, как почта. У журнала один хозяин, и только он к журналу прикасается. Остальные не пишут в журнал, а присылают письмо хозяину: «прими письмо номер семь». Хозяин читает письма по одному, в порядке прихода. Пока он пишет одно, остальные лежат в ящике и ждут. Это актор.
Ящик, состояние, одно письмо за раз
У актора три части: состояние, которое видит только он, почтовый ящик и цикл, который берёт из ящика письмо, обрабатывает и берёт следующее. Ответ отправителю идёт через Deferred из урока 66: отправитель кладёт в ящик письмо вместе с пустым конвертом и ждёт, пока актор положит в конверт ответ.
import { Deferred, Effect, Queue, Ref, type Scope } from 'effect';
export interface Actor<In, Out> {
readonly ask: (message: In) => Effect.Effect<Out>;
readonly tell: (message: In) => Effect.Effect<void>;
}
export const makeActor = <S, In, Out>(
initial: S,
handle: (state: S, message: In) => Effect.Effect<readonly [Out, S]>,
): Effect.Effect<Actor<In, Out>, never, Scope.Scope> =>
Effect.gen(function* () {
const state = yield* Ref.make(initial);
const mailbox = yield* Queue.unbounded<readonly [In, Deferred.Deferred<Out>]>();
yield* Effect.forkScoped(
Effect.forever(
Effect.gen(function* () {
const [message, reply] = yield* Queue.take(mailbox);
const [out, next] = yield* handle(yield* Ref.get(state), message);
yield* Ref.set(state, next);
yield* Deferred.succeed(reply, out);
}),
),
);
const post = (message: In) =>
Effect.flatMap(Deferred.make<Out>(), (reply) => Effect.as(Queue.offer(mailbox, [message, reply]), reply));
return {
ask: (message) => Effect.flatMap(post(message), Deferred.await),
tell: (message) => Effect.asVoid(post(message)),
};
});
Разберём.
handle(state, message)это вся логика актора: старое состояние и письмо на входе, ответ и новое состояние на выходе. Она не знает ни про ящик, ни про файберы.- Ящик это
Queue.unboundedиз урока 68. В нём лежат пары: письмо и конверт для ответа. - Цикл живёт в своём файбере (
Effect.forkScoped) и берёт письма по одному. Здесь стоятRef.getиRef.setдвумя шагами, те самые, что потеряли записи Пондера. Здесь они безопасны: этотRefтрогает только цикл, а цикл один. askкладёт письмо и ждёт ответа в конверте.tellкладёт и уходит: ответ ему не нужен.Scope.Scopeв типе говорит, что у актора есть время жизни. Закрылась область, файбер цикла прерван, актор замолчал. Это урок 62: фон не живёт дольше хозяина.
Журнал башни на акторе:
export type LedgerMessage = { readonly _tag: 'deliver'; readonly letter: number } | { readonly _tag: 'count' };
export const makeLedger = makeActor(0, (count: number, message: LedgerMessage) =>
Effect.succeed(message._tag === 'deliver' ? ([count + 1, count + 1] as const) : ([count, count] as const)),
);
Сто писем разом, без единого замка:
const night = Effect.gen(function* () {
const ledger = yield* makeLedger;
yield* Effect.forEach(
Array.from({ length: 100 }, (_, letter) => letter),
(letter) => ledger.ask({ _tag: 'deliver', letter }),
{ concurrency: 'unbounded' },
);
return yield* ledger.ask({ _tag: 'count' });
});
// Effect.runPromise(Effect.scoped(night)) отдаёт 100
Сто файберов бьются в ящик одновременно, а журнал пишется по одному письму. Сто писем, сто записей.
Актор не делает работу быстрее. Он делает её по очереди, и за это получает право не думать о гонках внутри. Если одно письмо пишется секунду, сто писем пишутся сто секунд. Это честная цена: актор это одно окошко, а не десять. Когда одного окошка мало, акторов делают много, каждому свой кусок работы. Как раздать куски сорока башням, разберём в следующем уроке.
Под капотом
Где тут дыра
const ledger = yield* Ref.make(0);
const deliver = Effect.gen(function* () {
const count = yield* Ref.get(ledger);
yield* Effect.yieldNow;
yield* Ref.set(ledger, count + 1);
});
Тест Ринцвальда звал deliver сто раз подряд, и каждый раз между чтением и записью никого не было. Соседний пример: те же сто писем через Effect.forEach с concurrency: 'unbounded'. Сто файберов читают 0, уступают друг другу (Effect.yieldNow стоит там, где у настоящего писаря запрос в базу или проверка дубля) и пишут 1. В журнале одно письмо из ста. Беда не в Ref и не в yieldNow, а в том, что состояние трогают двое. У актора его трогает один.
Линия, этап 2
У башни линии будет журнал, в который не нужно ставить замок. Новый модуль src/actor.ts: актор на очереди и Deferred и журнал башни на нём. Тесты шлют сто писем разом, следят, что обработка никогда не идёт в две руки, и проверяют порядок писем.
Всё в этом этапе стоит на стабильном ядре Effect: Queue, Deferred и Ref не поменяются в минорной версии.
Практика
Глубже в архиве
Почему один поток, который берёт задачи по очереди, обходится без замков, разобрано в уроке про колбэки и цикл событий раздела про асинхронность: цикл событий JavaScript это тоже один актор.
Дальше
К утру журнал T-1 сходится до письма. Липвиг смотрит на карту и не радуется.
Кольцо башен ждёт в следующем уроке.