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

Файберы

middle~20 мин

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

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

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

Файбер

Файбер это квитанция, которую запустили отдельной дорожкой. Слово значит «волокно»: тонкая нить исполнения, гораздо легче потока операционной системы. В Гексе файбер это муравей. Исполнитель Effect бегает между файберами и даёт каждому сделать несколько шагов; как он выбирает, разберём в уроке 70.

Ты уже запускал файберы, не называя их: Effect.runFork в уроке 45 и runMain в уроке 58 запускали главный файбер программы. Внутри квитанции файбер запускают через Effect.forkChild:

export const beat: Effect.Effect<never, never, Journal> = Effect.gen(function* () {
  const journal = yield* Journal;
  for (let count = 1; ; count++) {
    yield* Effect.sleep('1 second');
    yield* journal.write(`пульс ${count}`);
  }
});

Пульс это бесконечный цикл: успеха у него нет, поэтому первый параметр never. Если запустить его как обычный шаг, yield* beat, смена до следующего шага не дойдёт никогда. Его запускают рядом:

export const withHeartbeat = <A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R | Journal> =>
  Effect.gen(function* () {
    const pulse = yield* Effect.forkChild(beat);
    return yield* self.pipe(Effect.ensuring(Fiber.interrupt(pulse)));
  });

yield* Effect.forkChild(beat) запускает пульс и сразу отдаёт его файбер, Fiber<never, never>. Смена идёт дальше, не дожидаясь пульса. Когда смена кончилась, чем бы ни кончилась, ensuring из урока 57 прерывает пульс.

const night = Effect.as(Effect.sleep('2500 millis'), 'ночь прошла');
const journal = await Effect.runPromise(
  Effect.gen(function* () {
    const result = yield* withHeartbeat(night);
    yield* Effect.sleep('2 seconds');
    return [result, ...(yield* Journal.use((journal) => journal.entries))];
  }).pipe(Effect.provide(Journal.layer)),
);
[ 'ночь прошла', 'пульс 1', 'пульс 2' ]

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

Перевод с Ук

Первая попытка, без звёздочки:

const pulse = Effect.forkChild(beat);
return yield* self.pipe(Effect.ensuring(Fiber.interrupt(pulse)));
ex61c.ts(9,61): error TS2345: Argument of type 'Effect<Fiber<never, never>, never, Journal>' is not assignable to parameter of type 'Fiber<unknown, unknown>'.
  Type 'Effect<Fiber<never, never>, never, Journal>' is missing the following properties from type 'Fiber<unknown, unknown>': id, currentOpCount, getRef, context, and 7 more.

Effect.forkChild(beat) это квитанция, которая запустит пульс и вернёт файбер. Пока её не исполнили через yield*, никакого файбера нет, есть только запись «запусти». Fiber.interrupt хочет сам файбер, а получил запись. Вторая строка перечисляет, чего не хватает: у квитанции нет id, нет context и ещё семи полей файбера. Лечится звёздочкой: const pulse = yield* Effect.forkChild(beat).

Что умеет файбер

У файбера четыре главные операции, и все они квитанции.

  • Fiber.join(fiber) ждёт конца и отдаёт результат, а провал файбера становится провалом того, кто ждёт: Effect<A, E>.
  • Fiber.await(fiber) ждёт конца и отдаёт Exit значением, никогда не падая: Effect<Exit<A, E>>.
  • Fiber.interrupt(fiber) прерывает и ждёт, пока прерванный закроет свои финализаторы.
  • fiber.pollUnsafe() спрашивает без ожидания: undefined, если ещё работает, иначе Exit. Нужен в тестах, в программе обычно ждут.

Исход файбера одним словом башня теперь умеет говорить сама, через exitWord склада из урока 55:

export const outcomeOf = <A, E>(fiber: Fiber.Fiber<A, E>): Effect.Effect<string> =>
  Effect.map(Fiber.await(fiber), exitWord);
const fiber = Effect.runFork(Effect.as(Effect.sleep('100 millis'), 42));
console.log(await Effect.runPromise(Fiber.join(fiber)));
const sleeper = Effect.runFork(Effect.never);
await Effect.runPromise(Fiber.interrupt(sleeper));
console.log(await Effect.runPromise(outcomeOf(sleeper)));
42
interrupt

Дерево файберов

Файбер, запущенный через forkChild, это ребёнок того, кто его запустил. У ребёнка могут быть свои дети. Выходит дерево, и прерывание идёт по нему вниз: прервали смену, остановились пульс, кадры и ответы, которые запустили кадры. Вверх и вбок прерывание не идёт: прервали ответ на кадр 5, смена и соседний кадр работают дальше.

Почему ребёнок умирает с родителем, и бывает ли иначе, разберём в следующем уроке. Пока хватит правила: forkChild запускает работу, которая не переживёт того, кто её начал.

Под капотом

Наш Гекс из урока 33 исполнял одну квитанцию: брал инструкцию, исполнял, клал продолжение на стек. Файбер Effect устроен так же, только стеков много, по одному на файбер, и исполнитель переключается между ними. Переключение дешёвое: это не поток операционной системы, а объект в памяти со своим стеком продолжений. Поэтому файберов можно завести тысячи. Делать работу одновременно в смысле «на двух ядрах сразу» они не умеют: JavaScript исполняет один шаг за раз. Они умеют не ждать: пока один спит или ждёт провода, другой работает.

Где тут дыра

const shift = Effect.gen(function* () {
  Effect.runFork(beat);
  yield* Effect.sleep('1500 millis');
  log.push('смена кончилась');
});

await Effect.runPromise(shift);
await new Promise((resolve) => setTimeout(resolve, 2100));
console.log(log);
[ 'пульс', 'смена кончилась', 'пульс', 'пульс' ]

Смена кончилась, а пульс бьётся дальше. Effect.runFork внутри квитанции это ещё один край программы посреди программы, как runPromise Ринцвальда из урока 45. Файбер ничей: его не прервёт ни конец смены, ни сигнал из урока 58, и тест, который его запустил, оставит пульс биться в следующих тестах. Ещё он не видит контекста: журнал, провод и тестовые часы туда не попадут.

Лечится тем, что внутри квитанции файберы запускают через forkChild, и они становятся частью дерева.

Башня, этап 26

У башни теперь есть пульс. Он бьётся в фоне раз в секунду, пока идёт работа, и гаснет вместе с ней, чем бы она ни кончилась. А исход любого файбера башня называет одним словом, как склад.

Практика

Дальше

Рыдкулл читает утренний журнал и хмурится.

Чьи это дети и кто из них может пережить родителя, разберём в следующем уроке.