В прошлой части мы освоили самые азы альтернатив. Познакомились с Alt, Alt.choose и коммитом. В это части мы научимся собирать сложные альтернативы на основе базовых, а также скрывать этап их подготовки при необходимости.

Alt after

Когда мы ожидаем однотипные сообщения из разных источников, выбор между ними не составляет особых трудностей. Однако зачастую есть несколько разнотипных источников, сообщения которых необходимо сводить к одному знаменателю при помощи дополнительных асинхронных операций.

В более простых случаях мы бы имели две функции Alt.bind и Alt.map. Но конкретно в нашем автор Hopac решил явно указать на наличие акта коммита в недрах Alt. Ну или как минимум на то, что добавляемая операция будет происходить после выполнения альтернативы.

В категориях нашей симуляции Alt (из первой части) речь идёт об операциях над type 'a CommittedAlt = 'a Job. То есть стадии после коммита. Очень важно, что она либо не будет произведена вовсе, либо к моменту её исполнения другие альтернативы уйдут со сцены.

Для этих целей в Hopac определены:

module Alt =
    // Как бы bind.
    val afterJob: ('x -> 'y Job) -> 'x Alt -> 'y Alt
    // map
    val afterFun: ('x -> 'y) -> 'x Alt -> 'y Alt

А также семейство операторов вида:

// Аналог Alt.afterJob. Как бы bind.
val (^=>) : 'x Alt -> ('x -> 'y Job) -> 'y Alt
// Аналог Alt.afterFun. Как бы map.
val (^->) : 'x Alt -> ('x -> 'y) -> 'y Alt
val (^=>.) : 'x Alt -> 'y Job -> 'y Alt
val (^->.) : 'x Alt -> 'y -> 'y Alt
val (^->!) : 'x Alt -> exn -> 'y Alt

В прошлой части мы разбирали пример сервера с двумя каналами сообщений.

// Канал с обычными сообщениями.
let msgs = Ch()
// Канал с важными сообщениями, обработка которых должна идти в приоритете.
let criticalMsgs = Ch()

start ^ Job.foreverServer ^ job {
    let! msg = 
        // Забираем первое доступное сообщение
        // с приоритетом за критическими сообщениями.
        Ch.take criticalMsgs <|> Ch.take msgs 
        // можно проще с тем же результатом 
        // criticalMsgs <|> msgs
    printfn "%A" msg
}

Тогда мы могли лишь забрать значение из приоритетного канала раньше, чем из обычного.
Но теперь, при помощи Alt.after_, мы можем доработать критически важные сообщения. Например, можно снабдить их соответствующим предупреждением:

start ^ Job.foreverServer ^ job {
    let! msg = Alt.choose [
        criticalMsgs |> Alt.afterFun ^ sprintf "AHTUNG!!! %s"
        msgs
    ]
    printfn "%s" msg
}

Кейс простой, ибо оба источника изначально имели одинаковый тип. Операция после criticalMsgs незначительна и серьёзного влияния на обработку не оказывает. Её можно было бы без ущерба реализовать при помощи Job.map или (>>-), но в этом случае мы потеряли бы информацию об альтернативе. Тем самым не смогли бы загрузить полученную Job в Alt.choose.

All You Need Is Die

Вернёмся к примеру из начала первой статьи (туда можно не бегать, здесь он представлен шире). К магии классического сервера. У нас есть разнотипные альтернативы, каждую из которых мы приводим к одному и тому же типу, чтобы позднее разобраться с каждым случаем через обычный match.

// : int Mailbox
let private msgs = Mailbox()
// : unit IVar
let private poisonPill = IVar()
// : unit IVar
let private terminated = IVar()

start ^ Job.iterateServer 0 ^ fun state -> job {
    let! msg = 
        poisonPill ^->. Message.PoisonPill
        <|> msgs ^-> Message.Delta
    match msg with
    | Message.PoisonPill ->
        printfn "Terminated with %i" state
        // Возможно, resources.Dispose()
        do! IVar.fill terminated ()
        return! Job.abort()
    | Message.Delta delta ->
        let newState = state + delta
        printfn "New state %i" newState
        return newState
}

module PublicApi =
    // int -> unit Job
    let sendMessage msg = Mailbox.send msgs msg
    // unit Job
    let kill = IVar.tryFill poisonPill ()
    // unit Alt
    let terminated = Alt.paranoid terminated

В данном случае мы просто гоняем актор-счётчик. Начинает он с 0. Ждёт сообщения с дельтами, прибавляет их к сохранённому значению и уходит на новый круг. Если он обнаруживает, что poisonPill заполнен (его содержимое не важно), то освобождает зависимые ресурсы и падает через Job.abort(). Данная ошибка будет прокинута выше до уровня сервера, после чего весь "цикл" навсегда прервётся.

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

start ^ Job.iterateServer 0 ^ fun state -> Alt.choose [
    // poisonPill |> Alt.afterJob ^ fun _ -> job {
    poisonPill ^=>. job {
        printfn "Terminated with %i" state
        // Возможно, resources.Dispose()
        do! IVar.fill terminated ()
        do! Job.abort()
    }
    // msgs |> Alt.afterFun ^ fun delta ->
    msgs ^-> fun delta ->
        let newState = state + delta
        printfn "New state %i" newState
        newState
]

Порядок обработки альтернатив (точнее, их передачи в Alt.choose) играет роль. В текущей записи, при обнаружении пилюли, актор прекратит работу моментально.

poisonPill ^=>. ...
msgs ^-> ...

(Если мне не изменяет память, то так работает Akka).

Если мы сменим порядок на:

msgs ^-> ...
poisonPill ^=>. ...

То актор будет забирать сообщения, пока они есть. И лишь по исчерпанию их, проверит PoisonPill. Приблизительно так действует MailboxProcessor, когда вы пытаетесь убить его через Dispose.

При подобном поведении имеет смысл усложнить sendMessage. Например, можно заблокировать добавление сообщений, если обнаружено, что poisonPill заполнен.

let sendMessage msg = job {
    if poisonPill.Full 
    then do! Job.abort() // Или Job.unit(), если не заинтересованы в отдаче.
    else do! Mailbox.send msgs msg
}

Потребуется изолировать msgs от внешних наблюдателей, чтобы все пользователи прошли под ярмом sendMessage. Именно из-за подобных поворотов сюжета рекомендуется не выставлять Mailbox, Ch и IVar в публичный доступ. Ибо высок риск последующей ломки API. Если же полная изоляция невозможна, то надо хотя бы остаться в границах props. То есть скармливать Mailbox, Ch и IVar лишь в конструкторы и фабрики.

Наконец, стоит заметить, что порядок и состав альтернатив можно менять между итерациями в зависимости от состояния актора. Возможность естественным образом проистекает из устройства языка. Но берётся на вооружение с некоторым запозданием.

Статистически большинство наращиваний Alt приходится на конструкции вида:

Alt.choose [
    a ^-> fun a -> ...
    b ^=> fun b -> job { ... }
    c ^=>. job { ... }
]

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

Особенности оператора (^=>)

В F# операторы, начинающиеся с (^), имеют другой приоритет вычисления. Сначала будет разрешён правый аргумент, потом левый. Благодаря этому довольно часто стандартный оператор конкатенации строк val (^) : str -> str -> str заменяется на:

let inline (^) f x = f x

Его используют, чтобы экономить на скобочках.

Этим же свойством обладает семейство операторов (^_>). Доподлинно неизвестно, чем определялся состав и порядок знаков для данного семейства.
> -- можно отнести к направлению, слева направо.
= и - -- стандартная для Hopac метка, указывающая на наличие или отсутствие Job в аутпуте.

Но с происхождением ^ наблюдаются проблемы. Особенно в свете того, что a ^-> b ^-> c будет раскладываться как a ^-> (b ^-> c), а не (a ^-> b) ^-> c. Это сбивает новичков, ибо более привычное им семейство (>>-) работает как обычно, слева направо. Радует, что смена порядка ломает компиляцию. Не пропустишь.

Лично для себя я объясняю это следующим образом. Раз уж выбор ветвей похож на match, то имеет смысл сохранить дихотомию условия и реакции. В случае match ... with для этих целей есть компилятор. Он не даст вам выстроить реакции в цепочку посредством нескольких (->). Вполне возможно, что здесь предполагалось воспроизвести то же ограничение ограниченными средствами, что выдал <choir sing="Hallelujah!">светоч наш Сайм</choir>.

Решение довольно радикальное, но, к сожалению, не тотальное. Ибо по своему опыту я тоже считаю нежелательным, многократный after в рамках choose-match-а.

В любом случае все эти операторы находятся в модуле Hopac.Infixes. И если вашу команду они напрягают, вы вольны их не использовать. Либо определить и открывать собственный набор операторов на локальном участке.

Prepare Alt

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

Предположим, есть актор, что по внешнему запросу может выдать часть внутреннего состояния. Конкретно здесь сервер будет по очереди обрабатывать запросы из Mailbox. Один из запросов получает состояние сущности по идентификатору. И идентификатор, и методику ответа актор обнаруживает в самой посылке.

start ^ Job.foreverServer ^ job {
    match! mbx with
    | Query.GetState (id, reply : _ -> _ Job) ->
        do! reply ^ localState.TryFind id
    | _ -> ...
}

В этом случае внешнему наблюдателю для создания Alt-запроса требуется произвести подготовительные работы. Если мы выделим фабрику явно, то теряем возможность бесшовно использовать Alt:

// 'reply Alt Job
let getState id = job { 
    let reply = IVar()
    do! Mailbox.send mbx ^ Query.GetState (id, IVar.tryFill reply)
    return reply // NB: Передаётся без !.
}

Вместо:

let state = run ^ getState someId

Придётся писать:

let state = run ^ Job.join ^ getState someId

А когда потребуется стравить альтернативы, ситуация ещё ухудшится.
Вместо:

// : Case Alt
let case = 
    someAlt
    <|> getState someId ^-> Case.State

Придётся писать:

// : Case Alt Job
let case = job { 
    let! preparedGetState = getState someId
    return
        someAlt
        <|> preparedGetState ^-> Case.State
}

При этом в случае новых обёртываний в Alt.choose потребуется аналогичным образом протягивать _ Alt Job дальше по стеку.

Если же мы откажемся от фабрики, то потеряем информацию об альтернативе, после чего дальнейшие смешивания будут невозможны:

// : 'reply Job
let getState id = job { 
    let reply = IVar()
    do! Mailbox.send mbx ^ Query.GetState (id, IVar.tryFill reply)
    // Возвращаем результат альтернативы.
    return! reply
}

Для подобных задач в Hopac завезли:

module Alt =
    val prepare: Job<#Alt<'a>> -> 'a Alt
    val prepareFun: (unit -> #Alt<'a>) -> 'a Alt
    val prepareJob: (unit -> #Job<#Alt<'a>>) -> 'a Alt

Запрос из примера выше при помощи prepare можно переписать так:

let getState id = Alt.prepare ^ job { // : _ Alt
    let reply = IVar()
    do! Mailbox.send mbx ^ Query.GetState (id, IVar.tryFill reply)
    return reply // NB: Передаётся без !.
}

Этот код любовно сохраняет кишочки Alt и позволяет стороннему наблюдателю проводить выбор, опираясь на API альтернативы.

Совсем невыразимых ранее фич prepare не несёт. По факту, это ещё один способ скормить Alt.choose фабрику и её результат одним пакетом. При этом фабрики будут запускаться экономно. Если какая-то альтернатива разрешится раньше, то фабрики последующих альтернатив останутся недвижимы.

After vs Prepare

Предположим, у нас есть два AltbAfterA и aPrepareB. Их получили через композиции двух абстрактных Alt-ов a и b:

let a : _ Alt = ...
let b : _ Alt = ...

let bAfterA = 
    a ^=>. b 
    // a |> Alt.afterJob ^ fun _ -> b

let aPrepareB = 
    Alt.prepare (a >>-. b)
    // Alt.prepare ^ job {
    //     do! a
    //     return b
    // }

Можно ли утверждать, что по содержанию bAfterA = aPrepareB? Зависит от контекста использования.

Если вызывать run bAfterA или run aPrepareB, то, безусловно, "Да". Но когда данные альтернативы попадают в Alt.choose, обнаруживаются различия. Они имеют одинаковый набор операций, но при этом переходят в состояние ready в разных частях конвейера.

В данном вопросе следует обратить внимание на сигнатуры применяемых функций. Можно заметить, что при сборке bAfterA оператор (^=>.) (или функция Alt.afterJob) знал о том, что a -- это Alt, а вот b потреблялся как Job. В aPrepareB всё наоборот. a исполняется как обычный Job, а b передаётся именно как Alt.

Альтернатива, что была взята за основу, управляет состоянием целого. В случае когда и a, и b корректно отработают, разница будет лишь в различном времени коммита. Важность данного фактора -- вопрос спорный, так что в общем случае при позитивном сценарии они опять будут идентичны.

Мы никак не ограничивали содержимое a и b. Предположим, что одна из этих альтернатив содержит неразрешимый Alt.never. То есть альтернативу, которая никогда не выдаст результат и солнцу не откроет сердцевину.

Если проблемной альтернативой будет a, то ни bAfterA, ни aPrepareB не достигнут стадии готовности. Поэтому Alt.choose разрешится через другие альтернативы.

Аналогичным образом будет себя вести aPrepareB, если проблема окажется в b. Но bAfterA не просто зависнет, а ещё повесит весь Alt.choose. Так как a, достигнув стадии ready, заблокирует другие альтернативы. После чего вся система будет бессмысленно ждать завершения незавершающегося остатка в виде b.

Это следует учитывать при проектировании. Альтернатива, что "закусилась", более не откатывается. Если такой сценарий возможен, с ним должен разбираться разработчик, а не Hopac. Hopac лишь даст необходимые инструменты для механизма компенсации.

Оператор (<+>)

Пока мы не отошли далеко, имеет смысл сказать об ещё одном операторе Hopac. Он берёт две альтернативы и возвращает новую с кортежем из их результатов.

val (<+>): Alt<'x> -> Alt<'y> -> Alt<'x * 'y>

Мы уже знаем, что взаимная увязка двух Alt возможна несколькими способами. Поэтому естественным образом возникает вопрос о том, где конкретно находится коммит в данной композиции, в левой или в правой части. На самом деле и там, и там. Дело в том, что (<+>) создаёт под капотом две альтернативы, после чего стравливает их друг с другом.

let (<+>) (xA : 'x Alt) (yA: 'y Alt) : Alt<'x * 'y> =
    (xA ^=> fun x -> yA ^-> fun y -> (x, y))
    <|> 
    (yA ^=> fun y -> xA ^-> fun x -> (x, y))

В первом случае рассматривается вариант, когда первым разрешится xA. После чего альтернатива дождётся окончания yA и выдаст общий результат. На последней строке дано такое же определение, но xA и yA поменяли местами. Из этого следует, что нам без разницы, какая из альтернатив разрешится первой.

Итоговая альтернатива затребует коммит, когда его затребует хотя бы один из аргументов. Реализации двойного синхронного коммита здесь нет. Написать его подобие самостоятельно возможно, но не имеющимися средствами.
(Необходимый инструментарий будет затронут в следующей части.)

Сорок четыре оператора

В модуле Hopac.Infixes определено аж 44 кастомных оператора. Даже по абсолютным цифрам, с точки зрения F#-сообщества, это перебор. Несмотря на это, в документации можно встретить вот такие пассажи:

Note that the above can be expressed more concisely using *<+->-.

Это жесть.
Поэтому для начинающих F#-истов я обязан дать несколько комментариев.

Наследие

Исследования не проводил, но устная традиция говорит следующее:

polytypic (Vesa Karvonen) написал Hopac практически в одиночку в 2014-2016 годах. Нужен он был ему для геймдева, преимущественно в серверной части. В качестве образца он ориентировался на Concurrent ML. Так что в действительности Hopac -- это подмножество CML, приправленное синтаксическими плюшками F#.

Karvonen -- довольно сильный разраб, но для F#-комьюнити человек пришлый. Имея образец перед глазами, он не имел необходимости в контактах со смертными. Что привело если не к профдеформации, то к немного специфическому взгляду на код. Так появились 44 оператора. (А также убийственные, вводящие в заблуждение отступы в исходниках.)

Позднее Karvonen, следуя за своим работодателем, мигрировал куда-то ещё. И весь проект остался на попечении F#-сообщества. Если быть точным, то он находится в руках в haf-а (Henrik Feldt). При этом править там особо нечего, так что либа может находиться в коматозном состоянии вечно без потери актуальности.

Так 44 оператора остались без своего господина. И сейчас уже никто не в силах их выпилить без серьёзных последствий. Часть операторов, например, таких, как (<|>), никто не отдаст.
По другой части будут довольно жаркие дискуссии.

Поэтому отсеивание операторов переходит в разряд административных решений на конкретном проекте.

Этимология

Не все операторы в Hopac являются общепризнанной классикой. Часто им вообще нет аналогов. Поэтому в первую очередь сверяемся с докой или сигнатурой, а потом уже с личным опытом.

Далее, следует обратить внимание, на "семейственность" операторов. В этот момент я обычно достаю таблицу коррелятивных местоимений на Эсперанто.
Вот это поворот!

Согласно этой схеме, в Эсперанто из двух групп (5 "корней" и 9 "окончаний") механическим "произведением" получается 45 местоимений на все случаи жизни.

  • Iu iras ie. -- Кто-то ходит где-то.

  • Iu iras tie. -- Кто-то ходит там.

  • Iu iras ĉie. -- Кто-то ходит везде.

Сверх этого, полученные слова подчиняются стандартным правилам Эсперанто для соответствующих частей речи. Например, -n обозначает винительный падеж, а -j -- множественное число.

  • Ĉiu vidas tiun. -- Каждый видит это.

  • Ĉiuj vidas tiun. -- Все видят это.

  • Ĉiuj vidas ĉiujn. -- Все видят всех.

Схожая система пусть и в гораздо меньших масштабах воспроизведена в Hopac.
Например существуют несколько групп "корней":

  • >>_ -- означает действие после Job, т. е. Job<'x> -> ... -> Job<'y>

  • ^_> -- означает действие после Alt, т. е. Alt<'x> -> ... -> Alt<'y>

  • >_> -- композицию над Job (>>), т. е. ('x -> Job<'y>) -> ... -> ('x -> Job<'z>)

  • и т.д.

Параллельно им существует несколько суффиксов:

  • = -- когда в правой части возвращается Job, т. е. ... -> (x -> Job<'y>) -> ...

  • - -- в правой части возвращается не Job, т. е. ... -> (x -> 'y) -> ...

  • * -- необходимо завернуть результат в Promise, т. е. -> Promise<_>

  • . -- игнорируем вход и возвращаем готовый результат, т. е.:

    • ... -> Job<'y> -> ... для =;

    • ... -> 'y -> ... для -.

  • ! -- игнорируем вход и бросаем исключение, т. е. ... -> exn -> ...

Их совмещение даёт итоговый оператор.
Например:

val ( >>=*  ): Job<'x> -> ('x -> #Job<'y>) -> Promise<'y>
  • >> -- добавляем операцию после Job, т. е. Job<'x> -> ... -> Job<'y>

  • = -- результат функции будет Job, т. е. ... -> ('x -> #Job<'y>) -> ...

  • * -- результат необходимо мемоизировать, т. е. ... -> Promise<'y> вместо ... -> Job<'y>

Не скажу, что система проста, но она по возможности предсказуема. Если вы не злоупотребляете операторами передачи сообщений (одноименная категория в доке), то вы сравнительно быстро научитесь моментально устанавливать смысл неизвестной или забытой версии по её базе и суффиксам.

Чаще проблема возникает не при чтении, а при написании кода. Я очень редко использую одновременно и мемоизацию, и константные значения. Поэтому забываю, в каком порядке они должны перечисляться. Т.е. правильный (>>-*.) может перепутаться с несуществующим (>>-.*).

Границы использования

Глядя на предыдущий параграф, вы должны оценить стоимость входа в мир операторов Hopac. Если Hopac для вас является лишь инструментом разовых акций, когда нужно "намудрить" что-то, что не далось стандартными средствами, то я бы отложил операторы подальше.

На моих проектах Hopac является базой вместо Async и Task. Думаю, что я уже вряд ли буду когда-либо пилить что-то без его использования. Слишком уж угнетает бессмысленность происходящего в его отсутствие. Поэтому даже кодоген у нас генерит API сразу на Hopac, и здесь операторы начинают играть существенную роль. Настолько, что появляется смысл в монстрах типа *<+=>-. В "рукописном" коде мы их вообще не используем, но в кодогене игры с приоритетом выполнения экономят силы на производство генераторов. А читать результат генерации будут, только если она сломается.

При больших объёмах в сжатом пространстве максимально возможное использование операторов начинает выигрывать по читаемости. Дело в том, что в реальности операторы и имена распадаются на два взаимосвязанных, но параллельных графа. Первые отвечают за маршруты, вторые -- за наполнение. Статистически операторы меняются крайне редко, обычно лишь при необходимости адаптироваться под свершившиеся изменения в "именной" части. Поэтому возникает желание максимально спрятать словесные описания. Нечто схожее можно испытать при столкновении с бухгалтерией XV-XVI века.

... в тысячу рублев шестьдесят рублев с рублем да десять алтын с денгою ...

Подводя итог: для тех, кто хочет оказаться на золотой середине, рекомендую ограничиться >>_, ^_> и <_>.
Желательно в безсуффиксных версиях. И с заменой всех * на memo.

Ну, и наконец, напоминаю, что F# позволяет определять операторы локально. Очень вероятно, что когда вы захотите массово заюзать особо редкого динозавра, вы сможете вместо этого определить свой оператор в границах ближайшего списка. Так вы проконтролируете его приоритет выполнения и ассоциативность. А также сможете убить на корню недопонимание со стороны сопартийцев и будущего себя.

Промежуточный итог

В этой части мы научились наращивать базовые Alt до осмысленных объёмов и скрывать их фабрики внутри их самих. Для относительно чистых предметных областей этого хватит за глаза. В следующей части мы будем разбираться с негативными сценариями и исключениями.

Автор статьи @kleidemos


НЛО прилетело и оставило здесь промокод для читателей нашего блога:
— 15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS

Комментарии (0)