Pull to refresh
300.55
FirstVDS
Виртуальные серверы в ДЦ в Москве и Амстердаме

Грань выбора. Учимся строить временные петли на F# при помощи Hopac.Alt. Часть 1. Развилка

Reading time15 min
Views2K

Hopac -- самостоятельный асинхронный движок, написанный специально под F#.
Он стоит на 4 китах, одним из которых является перенаправление потоков вычисления через явное противопоставление конкурирующих задач. Конкурирующие задачи (или ветки) реализуются через концепцию альтернатив (или Alt), которую я хочу осветить в этом цикле из трёх статей.

Я не буду объяснять Hopac с нуля. Несложно найти гайды по азам в англоязычном сегменте сети. Я, конечно, дам пояснения по большинству понятий, чтобы подстраховать и себя, и читателя. Но в первую очередь я хочу решить проблему тех, кто сталкивался с Hopac или даже писал что-то в прод, однако остался неудовлетворённым своим уровнем осознания происходящего. Как мне кажется, доля таких F#-истов довольно большая. Ибо Hopac при наличии адекватных задаче примеров позволяет выдавать результат сильно раньше, чем приходит понимание происходящего под ковром.

Нужно подчеркнуть, что у Hopac есть:

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

  • В первой части мы ознакомимся с самыми примитивными понятиями. Коснёмся готовых Alt-ов, что были предоставлены самим Hopac. Научимся их противопоставлять. И разберёмся с понятием коммита, где он находится и для чего он нужен на практике.

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

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

Пара предупреждений о коде

Я ориентируюсь на версию 0.5.0 (на момент написания последней была 0.5.1).
Тем, кто собирается держать REPL сессию под рукой:

#r "nuget: Hopac, 0.5.0"

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

open Hopac
open Hopac.Infixes

Я адепт "крышечки".
Этот оператор экономит некоторое количество скобочек за счёт игр с приоритетом выполнения.
Крышечки нет в Hopac.Infixes, хотя она и используется в исходниках Hopac.
Для того, чтобы код работал, вам потребуется определить:

let inline (^) f x = f x

Что есть Alt

Каждый F#-ист к моменту чтения статьи должен представлять себе "изкоробочный" 'a Async.
Поэтому он легко сможет усвоить, что:

  • 'a Job -- это базовая единица асинхронной операции в Hopac.

  • Она обладает идентичным с 'a Async-ом поведением, за исключением неявного CancellationToken. Его нет, работайте руками.

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

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

  • реагировать на выбор других альтернатив;

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

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

Откуда берутся Alt-ы

Имеется следующая иерархия типов:

Схема может выглядеть довольно увесисто, если сравнивать с аналогами. Но типы под Alt автономны и не содержат каких-либо ссылок друг на друга (как минимум в публичном API).
Благодаря этому можно осваивать типы, отталкиваясь от существующей кодовой базы, игнорируя параллельные ветви. Так что даже сейчас я очень поверхностно представляю природу Latch и Proc. Этого же подхода я буду придерживаться и по ходу статьи. Информация о данных типах будет поступать по мере необходимости.

Каждый из этих типов имеет одноимённый модуль, в котором обязательно есть "основная" функция, которая преобразует их в обычный Alt. Имя её зависит от семантики типа. Например, IVar.read или Mailbox.take. Каждый из этих типов может неявно каститься в Alt.
Но сущностно, данный каст будет вызывать "основную" функцию из модуля. Следующие альтернативы будут идентичны по наполнению.

let read : _ Alt = IVar.read ^ IVar()
let alt : _ Alt = IVar()

Оба варианта сохранят под "капотом" информацию об исходном типе. Так что на самом деле IVar.read и ему подобные нужны не для реального создания Alt, а для вывода типа компилятором.

(IVar.read ^ IVar<int>()) :? IVar<int> // true
(IVar<int>() :> _ Alt) :? IVar<int> // true

Для сокрытия данной информации есть функция Alt.paranoid : 'a Alt -> 'a Alt. Она создаёт действительно новую альтернативу на базе предыдущей. Её задача -- исключить риск обратного каста к исходному типу.

let paranoid : _ Alt = Alt.paranoid ^ IVar()

(Alt.paranoid ^ IVar<int>()) :? IVar<int> // false

При проектировании типов и модулей на основе Hopac вы вряд ли будете светить конкретные типы в публичном API. Можно сказать, что на каждые 10 публичных упоминаний типов из представленной иерархии у вас будет:

  • 5 упоминаний Job.

  • 1 упоминание Alt.

  • 2 упоминания Promise (это как lazy, но на базе Job, т.е. memo ^ job { return 42 } вместо lazy(42)).

Оставшиеся 2 могут касаться особых случаев (если они есть), порождённых конкретным доменом. В моих кодовых базах это, скорее всего, будет IVar (ячейка, которую можно заполнить только один раз и навсегда) и Mailbox. В чужих же репах я наблюдаю лидерство за Ch (от Channel, родня System.Threading.Channels).

Серьёзное влияние на данное распределение оказывает наличие или отсутствие 'a Hopac.Stream. Наиболее близкой его аналогией будут Rx, AsyncSeq, IAsyncEnumerable и System.Threading.Channels (ещё раз), с явным преимуществом за Stream. Присутствие Stream зачастую обессмысливает использование Ch и Mailbox. А также сводит наше распределение типов к дихотомии Job vs Promise. (Замечу, что сам Stream, а точнее сопутствующая ему машинерия, основаны на Alt.)

Малое число упоминаний в публичном поле не тождественно низкой частоте использования Alt. Из пачки альтернатив можно собрать хороший раздражитель для конвейера, что будет работать автономно в своём режиме. Всё его бытие можно будет описать в виде реакций на конкретные события во внешней системе (кстати, в CML предок Alt известен как Event).
Hopac позволяет довольно легко писать акторо-подобные сущности, запущенные в пустоту.

(Акторы без акторной системы некорректно называть акторами, поэтому в терминологии Hopac они называются серверами. В моей ойкумене термин сервер не особо прижился, и если рядом в тексте/коде не используется какой-нибудь Job.foreverServer, мы используем термин "актор".)

Если ваш актор просто совершает рутинные операции над потоком сообщений, то вполне возможно, что вы спокойно ограничитесь Job. Однако любая нелинейная обработка сообщений будет построена на конфликте различных альтернатив. В большинстве случаев первой вашей альтернативой обработчику сообщений по умолчанию будет команда на остановку обработчика. Так называемый Poison Pill, который встречается в различных акторных системах. В MailboxProcessor, Akka и т. п. реализациях убийство актора часто сопровождается приключениями, что приводит к бесконечному потоку гайдов по подводным камням. Hopac этой проблемы избежал через использование другой сетки абстракций, системообразующим элементом которой является Alt. И поэтому мы сбацали ещё один гайд...

Немного примеров

Отложенная инициализация

Мне необходимо писать приложения под конечных пользователей на различных устройствах.
Это означает, что часть имплементаций будет дана за пределами ядра, на конкретных платформах. Если речь идёт о мобилках, то реализации может вообще не быть. Иногда реализация может задерживаться на минуты. Для всего выше перечисленного часто используется IVar.

  • IVar.fill заполнит ячейку в зоне старта приложения.

  • IVar.read / Alt.paranoid позволит привязаться к операции заполнения на стороне ядра. Читатель получит значение, как только оно будет заполнено. Если у него нет желания или возможности ждать, то он сможет решить проблему через композицию (к середине второй статьи станет ясно как).

В самом простом случае мы имеем дело с обычной незащищённой ячейкой let instance = IVar<Api>(). И конечный пользователь будет работать с ней напрямую. Однако, если инициализация будет сопровождаться какими-то дополнительными операциями или предваряться валидацией, то возможно разделение на read / write блоки.

let private instance = IVar<Api>()
module PublicApi =
    // jobResult импортирован из FsToolkit.ErrorHandling.JobResult
    let tryInit props = jobResult {
        do! validate props ...
        do! IVar.fill instance
    }
    let wait = Alt.paranoid instance // : Api Alt

Актор

Акторы/сервера -- чуть более сложный случай. Рычагов и прочих крутилок у них заметно больше. Есть контроль жизненного цикла. Есть очередь входных сообщений, иногда не одна.
Изредка отдельно выносят управление параметрами обработки и т. п. Даже в минимальной версии потребуется изолировать:

  • Почтовый ящик, чтобы, кроме нас, из него никто ничего не забрал.

  • Флаг о том, что актор окончательно умер, чтобы его ложно не поднял кто-то другой.

  • Флаг о том, что актор попросили умереть, чтобы его не спутали с terminated.

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

start ^ ... // Какая-то server магия.

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

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

Вырожденные случаи

Отталкиваясь от приведённых примеров, можно заметить, что для Hopac характерно раннее деструктурирование сложных объектов. В отличие от C# здесь вам вряд ли выдадут интерфейс с 10 методами. Либа подталкивает использовать CQRS подход. Зачастую команды и запросы будут перекидываться по системе в качестве самостоятельных элементов. В отрыве от сущностей, которым они принадлежат.

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

  • Alt.never : unit -> 'a Alt -- никогда не завершается, тянет резину вечно.

  • Alt.always : 'a -> 'a Alt -- всегда по первому требованию выдаёт указанный результат.

  • Alt.once : 'a -> 'a Alt -- в первый раз выдаст указанный результат, а потом превратится в never.

  • Alt.fromAsync : 'a Async -> 'a Alt -- строит Alt на базе Async, и если потребуется, воспользуется внутренним CancellationToken-ом.

  • Alt.fromTask: (CancellationToken -> 'a Task) -> 'a Alt -- то же самое для 'a Task, но с явной передачей токена. Есть Alt.fromUnitTask для необобщённого Task.

  • timeOutMillis : int -> unit Alt -- аналог Async.Sleep, есть timeOut для TimeSpan.

  • Редкие кейсы типа Alt.raises, Alt.unit, Alt.zero и что-то ещё по закоулкам.

Alt как Job

Все особенности Alt-а раскрываются во взаимодействии с другими Alt-ами. И запуск отдельно стоящего экземпляра ничего сверх Job не даст.

let ivar = IVar()
...

// Будет ждать, пока кто-то не заполнит значение ivar в другом потоке.
let foo = run ivar

Это касается всех случаев употребления Alt как Job. Следует запомнить, что данная деградация является билетом в один конец (если не касаться ручных кастов). Как только система потеряет информацию об альтернативе, фарш прокрутить назад будет невозможно.
Из этого следует, что лучше преобразовывать Alt-ы в Alt-ы как можно дольше, не скатываясь в Job.

Интересно, что некоторые из Alt могут сообщать остальным, что их ждать бесполезно.
Например, Alt.never (или Alt.once после своей разрядки) будут очень активно перенаправлять выбор от себя. Это даёт некоторый бонус по производительности. Однако на это свойство нельзя опираться при работе с ними как с обычными Job. Вы никогда не дождётесь окончания run ^ Alt.never().

Alt vs Alt

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

Упорядоченный выбор

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

val (<|>) : `a Alt -> `a Alt -> `a Alt

(<|>) -- наиболее распространенный хелпер (канонического названия не обнаружил, в локальных обсуждениях упоминается как "или"). Запускает один или оба Alt и возвращает результат того, что реализуется быстрее. Позволяет без особого ущерба для производительности сворачивать целые цепочки Alt-ов в один.

let uber = // : _ Alt
    a <|> b <|> c <|> d ...

Этот оператор опрашивает альтернативы слева направо. Это не особо влияет, если на момент запроса обе альтернативы не имеют готового результата. Однако этого хватает, чтобы детерминированно разрешать мемоизированные кейсы (Promise). Именно на этом принципе построен Hopac.Stream.

В целом рекомендовал бы чтение исходников Stream.fs для познания Hopac-дзена. С некоторой поправкой на любовь автора Hopac к очень длинным кастомным операторам и ужасным отступам.

Если альтернатив становится много, или их общее число неизвестно на этапе компиляции, то используются:

module Alt =
    val choose: seq<#Alt<'a>> -> Alt<'a>
    val choosy: array<#Alt<'a>> -> Alt<'a>

В обеих функциях: чем ближе к началу окажется входная альтернатива, тем больший у неё шанс стать "той самой" за счёт более раннего старта. Разница между двумя функциями лишь в области перфоманса. Alt.choosy немного быстрее на больших объёмах. Однако дельта столь мала, что меня эта проблема никогда особо не беспокоила, так что Alt.choose -- one love.

Случайный выбор

val (<~>) : `a Alt -> `a Alt -> `a Alt

Аналог (<|>), но опрос производится случайным образом.

module Alt =
    val chooser: seq<#Alt<'x>> -> Alt<'x>

Аналог Alt.choose с рандомным выбором. "Ускоренной" версии типа Alt.choosy не завезли.

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

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

Alt.choose vs Task.WaitAny

К этому моменту, в голове читателя, Alt из этого очень условного определения:

type 'a Alt = 'a Job

Мог эволюционировать до следующего, не менее условного:

type 'a Alt = 'a option Job

В любом случае выдуманная опорная версия choose должна была выглядеть как-то так:

let choose alts = job {
    let result = IVar()
    for alt in alts do
        do! Job.queue ^ job {
            match! alt with
            | None -> ()
            | Some res -> do! IVar.tryFill result res
        }
    return! result
}

Глядя на эту симуляцию, может возникнуть вопрос, в чём принципиальная разница между Alt.choose и каким-нибудь Task.WaitAny. (Выдёргивание результата по индексу пропустим.)
Во-первых, выбор итоговой альтернативы может происходить до того, как она успела доделать все необходимые операции. Это возможно при условии, что данная альтернатива дала гарантии своего завершения. Во-вторых, надлежит вспомнить, что попытка выполнить Alt не является строго однонаправленной операцией. И этот выбор сопровождается актом коммита. В ходе него:

  1. Alt.choose (или его аналог) фиксируется на определённой альтернативе, о чём ей и сообщает.

  2. Альтернативы, что не были запущены до коммита, не запускаются вовсе.

  3. Оставшиеся альтернативы, что были запущены до коммита, получают извещение о том, что в их результате больше нет необходимости. Реакция на данное извещение -- дело конкретных имплементаций Alt.

После этого Alt.choose "доделывает" выбранную альтернативу.

Если облечь необходимые нам узлы в код, то потребуется приблизительно такая конструкция:

type 'a CommittedAlt = 'a Job
type 'a ReadyAlt = 'a CommittedAlt Job
type 'a StartedAlt = {
    Abort : unit Job
    Wait : 'a ReadyAlt option Job
}
type 'a Alt = 'a StartedAlt Job

Мне сложно воспринимать подобные цепочки типов в отрыве от дела. Так что сразу сосредоточимся на применении:

let choose (alts : 'a Alt seq) = job {
    let firstReady = IVar() // : 'a ReadyAlt IVar
    let committedId = IVar() // : Id IVar
    
    for alt in alts do
        if not firstReady.Full then
            do! Job.start ^ job {
                let! started = alt // : 'a StartedAlt
                let thisId = genId ()
                do! Job.queue ^ job {
                    let! committedId = committedId
                    if committedId <> thisId then
                        do! started.Abort
                }
                match! started.Wait with
                | None ->
                    do ()
                | Some ready -> 
                    do! IVar.tryFill firstReady (ready, thisId)
            }

    let! (ready, sourceId) = firstReady // 'a ReadyAlt * Id
    let! afterCommit = ready // 'a CommittedAlt
    do! IVar.fill committedId sourceId
    return! afterCommit
}

Этот код не похож на тот, что содержится в исходниках. В них вы найдёте крайне запутанную систему написанную на C# (sic!), с использованием Interlocked-ов на километровых расстояниях. Но данный пример хорошо отражает общий ход операции, и его придётся понять, ибо он определяет категориальный аппарат на оставшуюся часть статьи.

Здесь у нас есть 2 "глобальные" точки, важные для всех альтернатив.

  • firstReady.Full -- факт готовности первой альтернативы. Если это произошло, то дальнейший запуск альтернатив прекращается.

  • committedId -- известие с идентификатором закоммиченной альтернативы. Оно провоцирует прекращение вычисления всех альтернатив, оставшихся не у дел.

В рамках данного примера firstReady и committedId можно было бы объединить. Но это возможно лишь благодаря тому, что наш choose возвращает Job, а не Alt, как должен был. В реальности существует вероятность, что firstReady будет не востребована из-за того, что внешняя альтернатива справится быстрее. В этом случае над всеми присутствующими, включая firstReady, будет вызван abort.

Если идти до конца, то потребуется одну большую неостановимую Job распилить на 4, две из которых способны откатываться к своему началу. Система обработки исключений сюда тоже не попала. Как и сложности подготовки коммита. Я счёл вредным заходить так далеко в рамках этой статьи.

Иногда можно проще

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

type 'a Alt = 'a Job option Job

Грубо, игнорирует большую часть из сказанного в данном цикле, но на макроуровне это будет работать почти всегда.

Ценность коммита

Ранее я упоминал про Alt.once.

Alt.once : 'a -> 'a Alt -- в первый раз выдаст указанный результат, а потом превратится в never.

Согласно этому определению, следует ожидать следующего поведения:

let once = Alt.once 42

// Выведет: First result: 42
printfn "First result: %i" ^ run once

// Зависнет навечно.
printfn "Second result: %i" ^ run once

Ошибочно можно было предполагать следующую имплементацию:

let once = 
    let mutable committed = false
    job {
        if committed 
        then return! Job.never()
        else return 42
    }
    |> магия, превращающая Job в Alt

И она даже пройдёт "тест" из примера выше.

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

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

let one = Alt.once 1
let two = Alt.once 2
let total = one <|> two <|> Alt.always 42

for index in Seq.initInfinite id do
    // Выведет: 
    // #1 result: 1
    // #2 result: 2
    // #3 result: 42
    // #4 result: 42
    ...
    printfn "#%i result: %i" index ^ run total

Здесь мы бесконечно запускаем один и тот же total : int Alt и выводим его результат в консоль. Он состоит из двух Alt.once, каждый из которых должен отстреляться ровно по одному разу, и одного Alt.always, который будет затыкать дыры после разрядки остальных.
Если бы любой запуск Alt.once приводил к изменению состояния, то мы бы получили чуть менее разнообразный вывод. Так как two рисковал бы потерять своё содержимое уже при первом запуске total.

#1 result: 1
#2 result: 42 // vs #2 result: 2
#3 result: 42
#4 result: 42

(Может быть полезно в контексте GC: Alt.once теряет ссылку на контент после первого коммита.)


Если взять 'x Ch (далее Ch), как наиболее полный модельный пример Alt, то обе операции отправки (Ch.give) и получения (Ch.take) сообщения являются Alt-ами. Для коммита альтернативы потребуется синхронизация отправителя и получателя в одной точке. Только в этом случае они произведут атомарную операцию передачи.

Поэтому запись вида:

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

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

  • Ни одно сообщение не будет потеряно из-за конкурентной гонки в рамках данного сервера.

  • Пока в criticalMsgs будут сообщения, только они и будут забираться.

  • msgs сможет вклиниться в процесс только после полного опорожнения criticalMsgs.

Все это запрограммировано на "физическом уровне" за счёт наличия акта коммита.
Ибо он производит необратимую операцию лишь над одной альтернативой.

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

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

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

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


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

Tags:
Hubs:
Total votes 2: ↑2 and ↓0+2
Comments2

Articles

Information

Website
firstvds.ru
Registered
Founded
Employees
51–100 employees
Location
Россия
Representative
FirstJohn