Раздел 46 · Effect: Тихая Академия

Одно письмо за раз: акторы

middle~20 мин

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

Очередь из урока 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 сходится до письма. Липвиг смотрит на карту и не радуется.

Кольцо башен ждёт в следующем уроке.