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

Противодавление

middle~25 мин

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

Липвиг описал противодавление из урока 68: медленный конец говорит быстрому «подожди», и работа не копится там, где её некуда положить. В уроке 68 его давала ограниченная очередь. У тянущего потока из прошлого урока оно встроено, и сегодня видно, где оно работает само, где его надо настроить и где его не бывает вовсе.

Даром

Провод, из которого берут, и башня, которая пишет каждую строку секунду:

let reads = 0;
const wire = Stream.fromEffectRepeat(Effect.sync(() => ++reads));

const night = wire.pipe(
  Stream.take(3),
  Stream.runForEach((line) => Effect.andThen(Effect.log(`пишу ${line}`), Effect.sleep('1 second'))),
);

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

Пока шаги тянут друг у друга, так работает любая цепочка: map, filter, mapEffect никогда не берут у шага перед собой больше, чем у них попросили.

Буфер: читать вперёд

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

Stream.buffer ставит такую стопку между двумя шагами:

const ahead =
  (capacity: number) =>
  <A, E, R>(self: Stream.Stream<A, E, R>): Stream.Stream<A, E, R> =>
    Stream.buffer(self, { capacity });

Ниже буфера всё по-прежнему тянет. Выше него свой файбер тянет из провода и складывает в очередь на capacity мест, пока они есть. Возьми тот же провод с ahead(4): пока башня пишет первую строку, reads равно пяти. Одна строка у башни в руках, четыре лежат в буфере, а шестую провод не отдаст, пока место не освободится. Противодавление осталось, только отодвинулось на capacity строк.

Подвигай скорости и размер буфера, как в уроке 68:

Backpressure: producer 12/s, consumer 5/s, hwm = 4
producer
flowing
buffer
0/4
consumer
0
0 produced0 consumed0 paused0 tps

Подними producer выше consumer, буфер заполняется. Когда длина достигает hwm, producer переходит в paused: это и есть backpressure. Опустится буфер ниже половины hwm, producer возвращается в flowing. Это упрощённая модель: в Node hwm, это порог для возврата false у write(), в Web Streams похожая логика реализована через возврат промиса из pull.

Когда стопка полна

Полный буфер может делать три вещи, и выбор это strategy:

Stream.buffer(statuses, { capacity: 2 }); // suspend: провод ждёт
Stream.buffer(statuses, { capacity: 2, strategy: 'dropping' }); // новое выпадает
Stream.buffer(statuses, { capacity: 2, strategy: 'sliding' }); // старое выпадает

Быстрый провод из десяти строк, Stream.range(1, 10), и башня, которая пишет строку в секунду:

suspend   [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]
dropping  [ 1, 2 ]
sliding   [ 9, 10 ]

suspend стоит по умолчанию и ничего не теряет: провод ждёт. dropping держит первые и выбрасывает всё, что пришло, пока стопка полна. sliding держит последние: новое вытесняет старое. Для писем годится только первая. Для сводки «как дела у соседней башни» лучше последняя: башне нужна свежая сводка, а не та, что пришла десять минут назад. Это и есть freshest в задаче.

Линия, которая не умеет ждать

Всё выше работало, потому что провод можно попросить подождать. С линией, которая толкает сама, так нельзя. Обработчик onLine(line) вызывают, когда строка пришла. Ответить «приходи позже» он не может: линия не спрашивает.

Stream.callback из прошлого урока прячет это за очередью, и по умолчанию очередь без предела. Пока башня успевает, разницы нет. Когда не успевает, строки копятся в памяти всю ночь, и к утру башня падает, не потеряв ни строки. Почтмейстер, который берёт всё, что суют в окошко, кончает так.

Значит, очередь надо ограничить, и тогда снова встаёт вопрос, что делать, когда она полна:

Stream.callback<string>(subscribe, { bufferSize: 3, strategy: 'dropping' });

Тут есть подвох, и в документации он записан мелким шрифтом. Queue.offerUnsafe из обработчика синхронный: он не умеет ждать. Поэтому strategy: 'suspend' у толкающей линии не ждёт, а вежливо возвращает false, и строка пропадает. dropping делает то же самое, но хотя бы честно об этом говорит. sliding возвращает true и молча выбрасывает самую старую.

Для линии, которая толкает, честный выбор такой: ограничить очередь, выбрасывать новое и каждое выброшенное письмо считать:

export const fromBusyFeed = (
  feed: Feed,
  capacity: number,
  onDrop: (line: string) => void,
): Stream.Stream<string> =>
  Stream.callback<string>(
    (queue) =>
      Effect.acquireRelease(
        Effect.sync(() =>
          feed.listen(
            (line) => {
              if (!Queue.offerUnsafe(queue, line)) onDrop(line);
            },
            () => Queue.endUnsafe(queue),
          ),
        ),
        (unsubscribe) => Effect.sync(unsubscribe),
      ),
    { bufferSize: capacity, strategy: 'dropping' },
  );

onDrop здесь не украшение. Потеря, о которой знаешь, это беда, которую можно чинить: утром Липвиг попросит Магистраль прислать эти строки ещё раз. Потеря, о которой не знаешь, это клякса в отчёте. Лучше всего, конечно, не терять вовсе, и для этого у толкающей линии один способ: перестать быть толкающей. Если у источника есть «прочитай следующее», бери его, а не подписку. Вся защита этого урока нужна для тех, у кого такого нет.

Stream.fromEventListener для событий браузера устроен так же: у него есть bufferSize, и при полной очереди лишние события он отбрасывает. Об этом сказано в его документации, и теперь понятно почему.

Где тут дыра

const lines = Stream.callback<string>(
  (queue) =>
    Effect.acquireRelease(
      Effect.sync(() =>
        magistral.listen(
          (line) => Queue.offerUnsafe(queue, line),
          () => Queue.endUnsafe(queue),
        ),
      ),
      (unsubscribe) => Effect.sync(unsubscribe),
    ),
  { bufferSize: 3, strategy: 'suspend' },
);

Ночью линия тихая, башня успевает, очередь не наполняется ни разу, и Ринцвальд прав. Под утро линия присылает десять строк разом, a до j. В журнале башни a, b, c. Остальных семи нет нигде: ни в журнале, ни в ошибке, ни в логе. offerUnsafe вернул false семь раз, и никто этого не прочитал. Ждать умеет Queue.offer, эффект; обработчик линии эффект не исполняет. Ринцвальд написал «ждать» там, где ждать некому.

Магистраль, этап 11

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

Практика

Глубже в архиве

Противодавление в стримах Node и браузера разобрано в уроке про стримы раздела про асинхронность.

Дальше

К вечеру у башни есть стопка, сводка и счёт выброшенных писем. Ринцвальд долго смотрит на свою строку с suspend, потом на счётчик onDrop в журнале.

Две линии в одну башню ждут в следующем уроке.