Как я осознал, что такое распределенные системы

https://github.com/rkuhn/blog/blob/master/01_my_journey_towards_understanding_distribution.md
  • Перевод
Привет, Хабр!

В скором времени у нас выходит изысканная новинка для разработчиков высшего класса — "Реактивные шаблоны проектирования".

Автор книги Роланд Кун — звезда первой величины в области распределенных систем, один из разработчиков Akka. Под катом предлагаем перевод его программной статьи о распределенных системах и акторной модели, размещенной на сайте GitHub

Когда меня спрашивают, чем мне нравится акторная модель, я обычно отвечаю: «Она точно моделирует распределенность». Это означает, что она конкретно описывает, что такое распределенные вычисления, в ней нет мишуры, и не замалчиваются важные характеристики. В этой статье я хотел рассказать, что узнал о распределенных моделях; надеюсь, читателей она также заинтересует.

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

## Начало: нетипизированные акторы Akka


Я участвовал в доработке Akka между версиями 1.3 и 2.0. За этот период мы внесли некоторые фундаментальные изменения и в устройство инструментария, и в набор гарантий, которые он дает. При всех изменениях пользовались золотым правилом: «если нельзя гарантировать, что эта фича непременно будет работать в распределенном контексте, то мы ее не делаем». Мы интуитивно понимали, что такое распределенность, в духе:

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

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

Как может быть устроен пользовательский API, а также отдельные возможности в условиях столь строгих ограничений? В акторной модели определяются три характеристики и подразумевается четвертая (к этой подразумеваемой характеристике мы вернемся ниже):

  • отправка сообщений
  • изменение поведения по мере поступления сообщений (т.e. последовательная обработка)
  • создание дополнительных акторов

Akka реализует все три эти возможности, однако по-своему обеспечивает гарантии доставки сообщений; вместо надежной доставки организуется опциональная; мы предпочитаем, чтобы пользователь сам решал, какой уровень надежности ему требуется. То есть, мы считаем, что без (избыточной) сохраняемости доставка не будет по-настоящему надежной, поскольку достаточно одного сбоя в электропитании – и о надежности не будет и речи. Однако, требовать долговременного хранилища всего лишь для запуска нескольких локальных акторов – в самом деле, слишком тяжело. Здесь существует важное ограничение: пользовательский API один к одному соответствует операционной семантике, т.е., ActorRef всегда должен действовать одинаково, невозможно обеспечить сколь-нибудь существенную надежность одними лишь средствами конфигурации, так как никакая надежность в выражении ref ! msg не угадывается.

Вот какие еще возможности предлагает Akka

  • обязательный родительский контроль (в частности, срок жизни дочернего актора ограничен сроком жизни родительского)
  • мониторинг жизненного цикла a.k.a. DeathWatch

Выйти за пределы акторной модели не так просто – для этого требуется добиться определенной согласованности в пределах кластера узлов, где хостятся акторы. Лишь достигнув консенсуса по поводу того, какую ситуацию считать фатальным отказом узла, можно гарантировать, что эти возможности так и будут работать с одинаковой семантикой во всех условиях. Когда я читаю мою рассылку, не остается сомнений, что с такими проблемами шутить нельзя. Постоянно всплывает вопрос: почему узлы выбиваются из кластера, и почему их впоследствии не вернуть обратно (объясняю: как только узел объявлен мертвым, отменяется весь надзор за ним и «death watch», поэтому при воскрешении такого узла из мертвых его акторы начнут безобразничать как зомби.

## Первая остановка: Akka Typed


Взаимодействия между акторами Akka нетипизированы, и меня это с самого начала раздражало. Отправка сообщения опосредуется оператором !, в сущности, это функция «от общего к частному» — совершенно неограниченно и без какой-либо обратной связи. Отсутствие обратной связи – это уступка, на которую приходится идти при моделировании распределенной системы, поскольку любой обмен информацией обходится дорого. Но ограничения, связанные с типизацией, по-видимому, оказались упущены случайно, и мы наблюдаем все ту же проблему, рассматривая, как определяется актор: это частичная функция, действующая от общего к частному. Таким образом, любой актор превращается в черный ящик, который может сработать, а может и не сработать, когда вы отправите ему сообщение. Поэтому акторы получают значительную свободу действий, но судить о них в отрыве от динамики событий становится сложно: мы как будто впрыскиваем пузырь с JavaScript в типобезопасный мир Java.

Поскольку версия 2.4 Akka поставляется с Akka Typed, уточню мое желание в третий раз таким образом: хотелось бы ограничить типы сообщений, принимаемых актором, позволив компилятору с порога отклонять явно некорректные программы. В сущности, теперь определение актора представляет собой цельную функцию, описывающую путь от входного сообщения некоторого типа к тому или иному поведению (ограниченному этим же типом). Соответственно, теперь можно и оправданно параметризовать ссылку на актор тем же самым типом, отклоняя неподходящую входную информацию.

// синтаксис псевдо-Scala с расширениями для dotty 
type ActorRef[-T] = T => Unit
type Behavior[T]  = (T | Signal) => Behavior[T] // естественно, этот код цикличный, так что здесь нужен типаж

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

case class Authenticate(token: Token, replyTo: ActorRef[AuthResponse])

sealed trait AuthResponse
case class AuthSuccess(session: ActorRef[SessionCommand]) extends AuthResponse
case class AuthFailure(reason: String) extends AuthResponse

Если моделировать протокол именно таким образом и предоставлять клиентам лишь, ActorRef[Authenticate], то мы не только помешаем им отправлять сообщения совершенно неверного типа, но и выразим зависимость сеанса от успешной аутентификации. Ведь, не имея ActorRef[Sessioncommand], компилятор не позволит отправлять такие сообщения.

## Смежная тема: протоколы


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

Чтобы сладить с этими проблемами, необходимо описывать многоэтапные протоколы в контексте их структуры. Один перспективный подход называется «сеансовые типы», но здесь даны ответы не на все вопросы. Например, по-прежнему проблематично выразить линейность операций (то есть, невозможность вернуться назад во времени и воспользоваться ранее утилизированной информацией) в языке программирования – тогда как для человека такая ретроспектива кажется совершенно логичной. Одно из приближений такого рода – библиотека Ichannels на Scala от Alceste.

## Путь к компонуемости


Формулировка концепции акторов сознательно подразумевает, что для всех сообщений должна существовать единая входная точка. Такую входную точку можно сделать и на нетипизированной Akka при помощи context.become(...), либо организовать, чтобы каждое сообщение обрабатывалось на Erlang или Akka Typed. Если мы составляем акторы из разнотипных поведений (то есть, акторы работают по-разному в зависимости от посредника), то все сообщения, поступающие через такую входную точку, должны демультиплексироваться – только так они будут поступать к корректному месту назначения в соответствии с внутренней логикой. Такая ситуация слегка раздражает при работе с нетипизированными акторами, но в Akka Typed может превратиться в сущее мучение: здесь потребуются операции приведения, формулирующие такое поведение, которое принимает и Authenticate, и SessionCommand, но, к примеру, публично предоставляет лишь первый вариант. При сильно типизированной логике требуются строгие правила компоновки, по-видимому, это универсальная истина, идет ли речь о компоновке чистых функций или распределенных вычислений.

Алекс Прокопец в своей презентации на конференции ScalaDays 2016 в Берлине буквально открыл мне глаза, указав на выход из этой дилеммы. Сама природа акторной модели такова, что она требует присваивать различные версии акторов (ссылки на них) для разных целей. Из них можно собрать более крупный объект, который может общаться по разным протоколам с каждым из посредников. При создании независимых акторов сталкиваемся со следующим недостатком: теряется внутренняя согласованность. Акторы превращаются в одиночные досмотренные островки в море распределенного хаоса. Поэтому фокус в том, чтобы виртуализовать актор и сделать в нем несколько входных точек, каждую со своим идентификатором. Алекс пользуется семантикой потоковой обработки, примерно как в RxJava, но меня сразу привлекла идея организовать внутреннюю компоновку таких составных акторов при помощи π-исчисления.

Первую версию своеобразного сеансового DSL поверх Akka Typed быстро создали на основе монадического описания последовательной и конкурентной компоновки примитивных действий и вычислений.

val server = toBehavior(for {
  backend ← initialize
  server ← register(backend)
} yield run(server, backend))

private def initialize: Process[ActorRef[BackendCommand]] = {
  val getBackend = channel[Receptionist.Listing[BackendCommand]](1)
  actorContext.system.receptionist ! Receptionist.Find(BackendKey)(getBackend.ref)
  for (listing ← readAndSeal(getBackend)) yield {
	if (listing.addresses.isEmpty) timer((), 1.second).map(_ ⇒ initialize)
	else unit(listing.addresses.head)
  }
}

...

Основная абстракция – это Процесс, который в конечном итоге вычисляет значение указанного типа. Для последовательной компоновки используется flatMap или map, а действие fork(process) используется для создания конкурентных потоков выполнения. Полный результирующий процесс вычисляется в рамках единственного актора (функция toBehavior обертывает его в подходящий интерпретатор). Он реагирует на ввод по мере поступления, и вот что он должен делать: операция readAndSeal приостанавливает процесс до тех пор, пока не появится доступное сообщение в канале getBackend, создаваемом в рамках процесса initialize.
Примитивы, предлагаемые в черновом варианте этой библиотеки, соответствуют действиям и композиционным возможностям π-исчисления:

  • создание канала
  • отправка
  • получение
  • последовательность
  • выбор
  • распараллеливание

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

## Глубокое (да, глубокое) разочарование


Мои грезы рухнули, как только я задался следующим вопросом. Что будет, если важное сообщение – такое, которое открывает для другого элемента Процесса путь к следующему протоколу – по какой-то причине потеряется. Обеспечение надежной доставки не устраняет следующей проблемы: другие акторы могут отказать совершенно «независимо» от первого, и, если записывающий конец канала соответствует ActorRef, то логично, что прогресс всей нашей работы зависит от удаленных систем, ведь прозрачность местоположения – очень мощный семантический феномен. Чтобы исправить проблему, можно было бы задать верхний предел времени ожидания для операции получения, но в таком случае нам необходимо дождаться отказа локального процесса. Исходя из того, что локальные процессы также будут координировать работу через каналы, мы подразумеваем, что каналы будут висящими. Это проблема из области безопасности, решаемая при помощи (распределенной) сборки мусора. (Естественно, каналы могут оказываться висящими и из-за ошибки программиста, поэтому, в любом случае, представляется благоразумным перестраховаться от фатальной утечки ресурсов).

Также я осознал, что, выражая π-исчисление на бумаге, можно вычленить и устранить в таком выражении мертвые процессы, которые, пожалуй, уже никуда не сдвинутся: ведь они ожидают отправки или получения в таком канале, который не известен ни одному другому процессу. Такая операция устранения мертвых процессов практически недостижима, если реализация основана на нечетких замыканиях Scala.

Но наибольшая сложность связана с тем, что определяющая черта π-исчисления, а именно – возможность обмениваться каналами – на практике реализуется очень сложно. На стороне отправителя проблема решается тривиально: канал попросту предоставляется как ActorRef. Однако, на стороне получателя потребуется добиться, чтобы отправленное сообщение доставлялось к множеству адресатов, обладающих всего одной определяющей характеристикой: в настоящий момент у каждого из них есть ссылка на канал, причем, адресат готов получать информацию. Нам потребуется гарантировать, чтобы на практике сообщение получил один и только один адресат.

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

## Вверх по склону


Использование каналов можно было бы «поправить», не допуская сериализуемости получателя (то есть, не позволяя пересылать его по сети) и выбрасывая исключение, если операция получения предпринимается из контекста актора, который для этого не подходит. Такое решение было бы уродливо, не только потому, что решительно противоречит π-исчислению, но и потому что не рекомендуется предлагать не всюду определенные функции в качестве пользовательских API, чья функция зависит от обстоятельств, не просматривающихся на уровне кода или типов.

Человеку кажется удобным выбирать, с какого канала считывать: ведь мы так и общаемся. Идем куда-нибудь и говорим с разными людьми в том порядке, который требуется для достижения целей, которые мы перед собой ставим. Акторы вынуждены иметь дело с тем сообщением, которое поступит следующим, в реальной жизни это означало бы «все время отвлекаться». К сожалению, выше мы убедились, что именно возможность свободно выбирать канал наиболее проблематична, так что давайте рассмотрим альтернативные подходы.

Одну из альтернатив предложил Алекс, описавший Реакторы. API канала позволяет прикреплять в потоковом режиме преобразования и другие реакции. Это уже лучше, н сохраняется возможность, что ссылка на канал будет передана другому реактору, и в системе воцарится хаос из-за получения информации из неверного контекста.

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

Другая альтернатива – это… акторы. Каждый канал создается на базе поведения, описывающего, как он будет реагировать на входящие сообщения; в частности, он может менять поведение от сообщения к сообщению. Таким образом, мы продолжаем диалог с другим актором, создавая канал с продолжением и отправляя его обратно посреднику. Именно так Карл Хьюитт и Гал Ага представляли себе эту концепцию и пропагандировали с самого начала, хотя, и не рассматривали конкурентность в качестве неотъемлемой части этой модели.

## Вторая остановка: cуб-акторы


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

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

## И это все?


В самом начале я упомянул, что кое-то понял об утверждении «Акторы точно моделируют распределенность». Просто я осознал, как именно эта модель работает при решении задач: по-видимому, акторная модель во всех ее деталях и семантика распределенных систем сочетаются практически в одно целое. Так, в статье Википедии об алгебре процессов утверждается, что π-исчисление и акторная модель — фактически двойники, но я так больше не считаю. На мой взгляд, π-исчисление предлагает слишком большой набор свойств, чтобы допустить создание бесконечно масштабируемых реализаций. По этому поводу появились и некоторые новые исследования, в частности, работа Криса Мейкледжона о выразительности асинхронного π-исчисления. Также я нашел очень полезный FAQ по π-исчислению, описывающий, для чего его следует применять. Полагаю, что инструменты π-исчисления интересны для формального описания и верификации протоколов – причем, в данном случае выразительность этого исчисления используется не в полной мере – и что на основе такого исчисления (без адаптации) нельзя писать API для конечного пользователя.

С другой стороны, я не нашел, как полноценно формализовать акторную модель в виде исчисления (в том смысле, что не удается математически адекватно перевести ее в другие виды исчисления и так соблюсти все отношения эквивалентности и конгруэнтности, чтобы продолжали выполняться все красивые теоремы). Вполне возможно, что нам потребуется не такая формализация, а ограничения на поведения акторов, описываемые во внешних протоколах, поднимая их на уровень исходников при помощи генерации кода (или кодируя весь сеанс, как это делается в Alceste, или генерируя должным образом связанные классы сообщений, в зависимости от того, какой уровень безопасности достижим на конкретном API конечного пользователя). Либо нужно извлекать поведения акторов в абстрактное дерево поведений, которые могут быть представлены в виде процессов π-исчисления и анализироваться за пределами дерева. Мне показалось, что с концептуальной точки зрения сложнее всего следующее: примитивное действие ненадежной отправки сообщения с произвольной доставкой соответствует нетривиальному π-процессу, что приводит к комбинаторным взрывам, резко сокращающим количество возможностей; но я (пока?) не готов отказаться от основной цели: добиться, чтобы наиболее примитивный конструкт можно было эффективно реализовать даже в бесконечно масштабируемых системах.

Мой текущий вывод – сначала нужно напрактиковаться с компонуемыми суб-акторами и с любыми другими, мыслимыми в такой модели. А в дальнейшем – развивать модель.
Поделиться публикацией
Похожие публикации
Комментарии 7
  • 0
    Ок.
    Scala в рунете есть.
    Паттерны реактивные будут.
    Akka,akka stream,akka http — нету.
    • 0

      Опять гугл книжечки или нормальная версия будет?

      • +1

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


        Пожалуй, не буду дальше читать, предпочитаю учиться самостоятельно.

        • 0
          Сегодня день выхода книги. Издательство Питер, порадуйте нас традиционным купоном для Хаброжителей!
          • 0
            Пожалуйста — 7ft.
            20% скидка.
            • 0
              Спасибо))) очень приятно!
            • 0
              Купил.Тяжело очень читается.Очень много воды.Примеры встречаются на java,scala и(erlang?).

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое