В прошлых частях мы познакомились с концепцией альтернатив. Потом научились их подготавливать и достраивать до полностью замкнутых систем. В этой части мы коснёмся "плохих", ну или как минимум сложных случаев. Научимся брать под контроль серую зону на периферии основного русла исполнения.
Wrap Abort
Лучше всего, когда альтернатива не совершает необратимых операций до своего коммита.
Иногда это правило нарушается, и нам приходится компенсировать последствия запуска, если коммит уходит к другой альтернативе.
Нужно это довольно редко, поэтому в Hopac
ограничились лишь двумя функциями:
module Alt =
val wrapAbortJob: Job<unit> -> Alt<'x> -> Alt<'x>
val wrapAbortFun: (unit -> unit) -> Alt<'x> -> Alt<'x>
Обе функции возвращают новую альтернативу, в которой скрывается реакция на abort
.
Описанный в прошлой части Alt.after_
разбирается с успешной веткой, которая происходит после коммита. В то время как Alt.wrapAbort_
отрабатывает негативный сценарий, когда коммит ушёл к другой альтернативе. Эти ветки и эти функции противопоставляются друг другу. Они взаимоисключающие.
alt
|> Alt.afterFun ^ fun msg ->
printfn "Alt закоммитился и двигается дальше."
msg
|> Alt.wrapAbortFun ^ fun () -> printfn "Alt был отменён."
В качестве аналогии можно вспомнить метод Register
у CancellationToken
. Этот метод позволял зарегистрировать реакцию на отмену токена. В зависимости от перегрузки это был либо Action
, либо Task
.
У меня сложилось впечатление, что добрая половина разработчиков о Register
даже не слышала. В Hopac
о wrapAbort
знают почти все из-за особенностей документации. Но используют так же редко, как и CancellationToken.Register
.
Важно, что wrapAbort
не имеет доступа к контексту Alt
. Реакция не получит на вход каких-либо данных, если вы не замкнёте их в рамках функции самостоятельно. Поэтому wrapAbort
можно условно разделить на 2 типа.
Первый тип работает с уже готовым Alt
. Когда мы не участвовали в его генерации и получили его в запечатанном виде. В этом случае сделать что-либо полезное очень сложно.
И задача wrapAbort
сводится к логированию и прочим сугубо диагностическим функциям.
Второй тип обращается к Alt
на этапе его создания, где мы имеем доступ ко всем параметрам. В большинстве своём это касается стадии до выхода из Alt.prepare_
.
let findNearest args =
Alt.prepare ^ job {
let result = IVar()
let finder = Finder()
do! Job.queue ^ job {
match! cache.TryFind args with
| None ->
let! nearest = finder.Find args
do! cache.AddFound args nearest
do! IVar.fill result nearest
| Some last ->
let! nearest = finder.FindBased(args, last)
do! cache.UpdateFound args nearest
do! IVar.fill result nearest
}
return
result
// Второй тип.
// Отсюда нам доступны любые объекты.
|> Alt.wrapAbortJob ^ job {
do! finder.Stop()
do! cache.TryUpdate args finder.Current
}
}
|> Alt.wrapAbortFun ^ fun () ->
// Первый тип.
// А здесь мы бессильны.
printfn "Альтернатива отменена"
With Nack
Nack
-- сокращение от Negative Acknowledgment
, что дословно переводится как "отрицательное подтверждение". Данный термин в документации означает знание того, что какая-то другая альтернатива заполучила коммит. Автор Hopac
начинает с Alt.withNack_
, а потом использует его для объяснения других, более простых концепций, типа Alt.wrapAbort_
и Alt.prepare
. Этот порядок удобен при проектировании Alt
, ибо сначала реализуется полная версия, а остальные хелперы дописываются на базе основной функции. Но последовательность от сложного к простому вводит в заблуждение новичков. Понять nack
с ходу они, как правило, не могут, а идти дальше бояться, т. к. ошибочно считают пробел в этой области критическим.
Однако мы к данному моменту ознакомились с большинством концепций и можем выйти на битву с боссом. В Hopac
есть две функции:
module Alt
val withNackFun : (unit Promise -> #Alt<'x>) -> 'x Alt
val withNackJob : (unit Promise -> #Job<#Alt<'x>>) -> 'x Alt
Их задача отрабатывать наиболее хардкорный вариант Alt.prepare
, что может возникнуть в разработке. Этим функциям скармливаются фабрики, которые, в свою очередь, принимают этот самый nack : unit Promise
.
Когда получаешь в руки unit Job / Alt / Promise
, не всегда понимаешь, является ли эта единица командой или запросом. То есть вызвав её, мы что-то спровоцируем или лишь присоединимся к другим ожидающим. В этом конкретном случае мы будем именно ждать.
Но в других ситуациях подобная неопределённость иногда оборачивается серьёзными проблемами, и я рекомендую для подобных сценариев определять конкретные типы с member this.Wait/Execute
.
Nack
разродится результатом, когда общий Alt
пройдёт стадию коммита. Безотносительно к тому, какая из альтернатив стала избранной. Это означает, что в отличие от Alt.wrapAbort_
, вы не можете реагировать на nack
как на взаимоисключающую ветвь.
Alt.withNackJob ^ fun nack -> job {
do! Job.queue ^ job {
do! nack
// Будет вызываться всегда и у всех альтернатив,
// включая успешную,
// как только произойдёт коммит.
printfn "NACK!"
}
return ...
}
Этот факт сбивает. И я не вижу ему объяснение в области логики построения API. Вполне возможно, что подобное решение продиктовано лишь выигрышем в производительности.
Nack
в первую очередь необходим для присоединения других акторов к текущему Alt.choose
. В обсуждении Alt.prepare
мы разбирали случай, когда внешний наблюдатель мог запросить часть внутренних данных у актора. Эта операция не имела сайд-эффектов, что позволяло игнорировать ситуацию, когда наблюдатель переставал нуждаться в информации.
// Сервер.
start ^ Job.foreverServer ^ job {
match! mbx with
| Query.GetState (id, reply : 'state option -> unit Job) ->
// Пробует заполнить reply, даже если в нём уже нет необходимости.
do! reply ^ localState.TryFind id
| _ -> ...
}
// Запрос.
let getState id = Alt.prepare ^ job { // : _ Alt
let reply = IVar()
do! Mailbox.send mbx ^ Query.GetState (id, IVar.tryFill reply)
return reply
}
Теперь разберём случай, когда внешний игрок хочет изменить внутреннее состояние актора.
Причём хочет либо сделать это, получив обратную связь, либо вовсе не делать. Операция не должна производить необратимых изменений, если внешний игрок по каким-то причинам переключился на другую задачу.
В качестве примера возьмём случай создания новой сущности в недрах сервера. Мы создаём новый экземпляр на основе обязательных входных данных, после чего регистрируем экземпляр в системе. Если написать работу сервера по аналогии с предыдущим примером, то мы увидим, что ни сервер, ни команда, не имеют альтернативных средств коммуникации, кроме однонаправленного IVar
:
// Сервер / актор.
start ^ Job.foreverServer ^ job {
match! mbx with
| Command.Create (required, reply : _ -> unit Job) ->
let entity = Entity.create required
// Сайд-эффект с укладыванием новой сущности в хранилище.
localState.Register required
// Отправка свежего экземпляра.
do! reply entity
| _ -> ...
}
// Команда.
let create required = Alt.prepare ^ job { // : _ Alt
let reply = IVar()
do! Mailbox.send mbx ^ Command.GetState (id, IVar.tryFill reply)
return reply
}
Если мы захотим создать сущность в течение секунды или не создавать вовсе, то у нас ничего не получится, т. к. после попадания в очередь команда становится неостановимой.
Например, следующий код при большой очереди может вернуть None
.
// Попытка создать сущность в течение 1 секунды.
timeOutMillis 1_000 ^->. None
<|> create dto ^-> Some
При этом сущность всё равно будет создана, но позднее. Чтобы этого избежать, можно передать флаг актуальности из запроса на сервер. Воспользуемся Alt.withNackJob
. В качестве флага будет работать nack.Full
, если он поднят, то дальнейшее движение бессмысленно, т. к. (<|>)
ушёл в другую ветку. Можно представить, что на месте nack
находится CancellationToken
, у которого необходимо проверить IsCancellationRequested
.
start ^ Job.foreverServer ^ job {
match! mbx with
| Command.Create (required, nack : unit Promise, reply : _ -> unit Job) ->
if not nack.Full then
let entity = Entity.create required
// Сайд-эффект с укладыванием новой сущности в хранилище.
localState.Register required
// Отправка свежего экземпляра.
do! reply entity
| _ -> ...
}
let create required = Alt.withNackJob ^ fun nack -> job { // : _ Alt
let reply = IVar()
do! Mailbox.send mbx ^ Command.GetState (id, nack, IVar.tryFill reply)
return reply
}
Такая реализация убережёт нас от создания сущностей сильно позже указанного времени.
Но чем ближе к границе таймаута окажется обработка команды на стороне сервера, тем выше шанс, что create
вернёт ложный None
. (Ну или сервер создаст ложную сущность.)
Чтобы этого избежать, необходимо противопоставить nack
и регистрацию сущности в рамках одного коммита на стороне сервера. Для этого пересылка ответа должна быть альтернативой и для команды (как получателя), и для сервера (как отправителя). Этим свойством обладает Ch
.
start ^ Job.foreverServer ^ job {
match! mbx with
| Command.Create (required, nack : unit Alt, reply : _ -> unit Alt) ->
let entity = Entity.create required
do! Alt.choose [
// Либо уже не надо.
nack
// Либо отправляем ответ.
reply entity ^-> fun () ->
// Сайд-эффект с укладыванием новой сущности в хранилище.
// Перенесён в фазу строго после отправки.
localState.Register required
]
| _ -> ...
}
let create required = Alt.withNackJob ^ fun nack -> job { // : _ Alt
let reply = Ch()
do! Mailbox.send mbx ^ Command.GetState (id, nack, Ch.give reply)
return reply
}
Как видим, кошерное использование Alt.withNack_
требует другого Alt.choose
где-то ещё.
Что задирает порог вхождения, т. к. по факту мы пытаемся создать один Alt
, используя свойства другого в очень нетривиальном взаимодействии на солидном расстоянии от первого. Именно поэтому я вообще против данного параграфа при первом знакомстве. Если читатель нашёл что-то полезное ранее, но намертво забуксовал здесь, то я бы рекомендовал сначала применить понятое в бою, а к данной теме вернуться позднее.
В отличие от приводимых мною, примеры из документации стараются ограничиться компактным иммутабельным состоянием в рамках одного Job.iterateServer
. Это хорошая практика, но конкретно в моих проектах нишу иммутабельных состояний плотно занимают Hopac.Stream
. А вот работа с внешними "мутабельными" ресурсами чаще ведётся именно при помощи серверов. Тем не менее nack
отлично ложится и на иммутабельное состояние:
start ^ Job.iterateServer State.empty ^ fun state -> job {
match! mbx with
| Command.Create (required, nack : unit Alt, reply : _ -> unit Alt) ->
let entity = Entity.create required
return! Alt.choose [
// Либо уже не надо.
nack ^->. state
// Либо отправляем ответ.
reply entity ^-> fun () ->
state.Register required
]
| _ -> ...
}
Достаточно явным образом указать на возвращение к существующему state
.
Если попытаться отранжировать все пройденные варианты создания Alt
по частоте использования, то получится такая последовательность:
Чаще всего используют
Alt
(и его наследников) напрямую.Реже их оборачивают в
Alt.paranoid
.Ещё реже готовят через
Alt.prepare
.Очень редко оборачивают в
Alt.wrapAbort
.И также редко создают через
Alt.withNack
.
Alt.paranoid
относится скорее к инкапсуляции, а не к сути альтернативы. Если исключить его, то имеет смысл предпочитать верхнюю строчку нижней. Разумеется, верхнюю планку должны определять требования к гарантиям выполнения, а не лень.
Исключения
В завершение надо коснуться обработки исключений. 'a Job
может иметь явный результат типа 'a
, и неявный, типа exn
. Если в какой-то момент вычисления Job
происходит выброс исключения, то оно сохраняется в соответствующее поле. (Что иногда можно встретить в дебаге.) После чего исключение по выделенной ветке экспрессом мчится к финишу.
На финише, если кто-то ждёт Job
через run
, то исключение будет выброшено ещё раз синхронным образом. Если же Job
парит в великом ничто, будучи запущенным через start
или какой-нибудь Job.foreverServer
, то Hopac
направит исключение в глобальный хендлер.
Тот выведет в консоль сообщение о неотловленном исключении. Остановить бег к финишу может самописный обработчик исключений. Подключается он либо через try ... with
внутри билдера, либо через Job.tryIn
, Job.tryWith
и т. п. В этом случае необходимо будет определить процедуру возвращения обратно в основной поток вычислений.
Всё это даёт ускоренную обработку ошибок. Особенно у тех, кто не пренебрегает использовать Job.raises
вместо raise
. Но при этом стек исключения сокращается до абсолютно бесполезных величин. Поэтому внутри Hopac
исключения рекомендуется навьючивать максимальным объёмом полезной информации, не отходя от кассы. Ибо другого шанса раскрыть контекст у вас не будет.
В совокупности Job
работает с исключениями как ROP-механизм, как Result<'a, exn>
. Но скрывает этот процесс от конечного пользователя, пока последний уверен в успехе. Alt
, в свою очередь, скрывает внутри себя фазы подготовки, коммита, завершения и отмены. При столкновении этих двух черных ящиков ROP
-механизм радикально усложняется.
С этого момента нам необходимо учитывать, в какой фазе произошло исключение. А также в какой именно альтернативе (избранной или побочной).
Проще всего с аутсайдерами. Те, кто не успел, сгинут во тьме. И от них не останется даже воспоминаний записей в глобальном логере. (В отличие от Job
.)
Если альтернатива упадёт после коммита (т. е. внутри Alt.after_
), то она механически передаст своё исключение наружу также, как это делает Job
. Никакого отката к другим альтернативам не будет, даже если они способны выдать нормальный результат.
Проблемы начинаются тогда, когда альтернатива выбрасывает исключение до того, как кто-то другой перейдёт в состояние ready
. Alt.choose
будет считать, что данная альтернатива пришла в состояние ready
самой первой, и произведёт операцию коммита именно с ней. Это может создать тонну проблем, если исключение возникает в чересчур плоских доменах, и при этом мы хотим дождаться корректной альтернативы. Возможна ситуация, когда у нас просто не хватит нужного слоя, чтобы разобраться с исключением до того, как оно навернёт чужой для себя актор (или систему).
Hopac
определяет:
module Alt =
val tryIn : Alt<'x> -> ('x -> #Job<'y>) -> (exn -> #Job<'y>) -> Alt<'y>
// Недостающие аналоги методов из модуля `Job` можно набросать самостоятельно на основе `Alt.tryIn`.
Эта штука действительно позволит отловить исключение на любом этапе. Но, как только это исключение будет выброшено, данная альтернатива затребует коммита. То есть можно считать, что отлов исключения окажется в фазе Alt.after_
. Часто необходимо проигнорировать все ошибочные альтернативы, сосредоточиться только на успешных вариантах. Для этого надо, чтобы исходная альтернатива не генерировала исключение. Сделать это не всегда возможно.
Если ошибка происходит на этапе подготовки альтернативы, т. е. в функциях Alt.prepare_
или Alt.withNack_
, то необходимо подготовить её отлов самостоятельно. После чего выдать Alt.never()
вместо нормальной альтернативы.
Alt.prepare ^ job {
try
// Вызов чего-то, что может спровоцировать выбор исключения.
let validateInput = validate input
let reply = IVar()
do! send ^ Query.Sample(validateInput, IVar.tryFill reply)
// Возвращаем корректный вариант.
return Alt.paranoid reply
with ex ->
// log
// Возвращаем альтернативу, которая ничего не вернёт.
return Alt.never()
}
Я видел пару раз, как люди уходили в бесконечное ожидание через Job.never()
внутри Alt.prepare
. Hopac
от этого точно не зависнет. Но я не могу утверждать, что Hopac
сможет адекватно пересчитать альтернативу и зачистить пространство после подобного хака. В подготовленной команде Alt.never()
быстро считывается и учитывается далее при проектировании.
Ещё следует обратить внимание на передачу reply
. В примере доступ к ней изолирован через IVar.tryFill
, поэтому на стороне сервера нельзя будет сделать что-либо ещё, кроме заполнения IVar
. Любое падение сервера не коснётся альтернативы из примера.
Однако если отправить reply
непосредственно, то сервер может воспользоваться:
module IVar =
val tryFillFailure : 'x IVar -> exn -> unit Job
В этом случае reply
будет заполнен исключением вместо корректного значения типа 'a
.
Приведённая выше обвязка никак не остановит выброс переданного исключения. А значит, и коммит может уйти не туда.
Остальные типы, как правило, не обладают функциями аналогичными IVar.tryFillFailre
. Но я бы рассматривал подобный риск как ещё один повод не отправлять во внешний мир необёрнутые типы.
Вместо заключения
Повествование о технических деталях Alt
закончилось. Если у меня получилось, то теперь основной проблемой читателя должна стать архитектура на базе Hopac
.
Hopac
даёт много новых средств в области лаконичности, производительности и т. п. Но ключевым преимуществом является возможность перевести асинхронные вычисления из области "необычайного и гадательного" в область жёстких детерминированных структур.Alt
легко склеивается с алгебраическими типами, что в большинстве случаев позволяет безболезненно смешивать асинхронность с чистым доменом без ущерба для последнего.
Поэтому длительное время после ввода Hopac
в игру, вы будете ощущать постоянное давление снизу.
Домен, что ранее обитал в своём изолированном мирке, будет непрерывно пробиваться наверх, ища новые пути и связки для взаимодействия с собратьями. По дороге он будет ломать привычные шаблоны проектирования. Большая часть из них ему будет просто не нужна. Какая-то часть будет творчески переработана. В любом случае весь этот процесс будет идти волнами. Суть которых будет заключаться в поэтапной смене парадигм.
Есть два типа людей, одни при нахождении в воде встают на ноги при малейшей возможности, другие также при малейшей возможности уходят по плечи в воду. Мой опыт хождения по малым горным рекам вызывает острое неприятие первых. Слишком уж часто они наворачивались в самых простых ситуациях. Особого вреда для здоровья они при мне не получали, пока всё ограничивалось ущербом для имущества, да 2--3 днями нытья. Но всякий раз, когда подобное происходит, трудно не раздражаться неспособностью опереться на поток воды. Если не брать совсем уж экстремальный сценарии, то почти всегда существует позиция, позволяющая без усилий не только оставаться на одном месте неограниченно долго, но и перемещаться в нужном направлении.
Эволюция кодовой базы при использовании Hopac
будет сопровождаться аналогичным конфликтом базисов. Будут люди, которые уже в воде, и будут странные персонажи, пытающиеся ходить по заиленным камням сквозь поток на своих двоих. Использовать в качестве примитива по умолчанию Async/Task
или Job
? Мутабельные или иммутабельные сущности? Изначально цельная сущность или лишь проекция-склейка существующих компонентов? Линейные коллекции или кучи в великом ничто? И т. д. и т. п. Вопросов много и потребность их задавать возникнет по мере роста владения Hopac
. Договориться раз и навсегда заранее не получится.
В отличие от большинства известных мне асинхронных абстракций, Hopac
-- это в первую очередь плод разума, а не следствие машинных ограничений или "привычных" практик. Это изначально математическая абстракция, которую лишь со временем облекли в код. Большая часть необходимых "богу-машине" жертв была уже давно принесена. Тонны нечитабельного кода под капотом уже давно упакованы в готовый пакет, осталось лишь освоить и пользоваться.
Автор статьи @kleidemos
НЛО прилетело и оставило здесь промокод для читателей нашего блога:
— 15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS