Архитектуры на акторах: монолиты
Архитектуры на акторах:
Вступление (Actors / Objects; Complexity; When to Distribute; Kinds of Actors)
Монолиты (Control/Data Flow; Reactor / Proactor / Half-Sync/Half-Async; Request State)
Простые системы (Notation for Diagrams; Sharding; Layers; Services / Pipeline)
Системы с моделью (Hexagonal / MVC; Blackboard / Message Bus; Microkernel / Plug-ins)
Фрагментированные системы (Mesh; SOA; Hierarchy; Design Space)
(1) Как нарисовать сову?
------- мы здесь -------
(2) Рисуем кружочки.
(3) Рисуем оставшуюся часть совы.
Продолжим наш core dump
Архитектуры для систем акторов не попадаются в литературе — вероятно, потому, что такие системы крайне неудобно отлаживать (недетерминизм и асинхронная логика). В результате, пока весь цивилизованный мир занимался паттернами для ООП, акторы жили на отшибе где-то в эмбедеде и телекоме (системы управления реального времени). Потом мода на ООП и паттерны прошла, старые известные вещи начали переизобретать и ребрендить, и все ушли в микросервисы, которые отличаются от классических акторов тем, что могут взаимодействовать как синхронно (RPC), так и асинхронно (event sourcing [MP], pub/sub [EIP, POSA1]). Сейчас в каждом мире свой набор паттернов с разными названиями, но примерно одинаковой сутью.
Что дает возможность хоть как-то подойти к попытке систематизировать информацию — это то, что обычно одной из основных целей архитектурных паттернов и в мире ООП, и в мире сервисов, было создание low coupling + high cohesion систем для минимизации сложности (затрат на поддержку), то есть — стремление разбить систему на не очень большое количество относительно независимых компонентов, каждый из которых делал бы одно дело, и делал его хорошо (Unix way [DDIA]). Именно такой цели служат объекты, но и акторы делают то же не хуже (как и (микро-)сервисы). Поэтому мы можем брать классические архитектурные паттерны из мира ООП и из мира сервисов, сравнивать их друг с другом, и пытаться применять к акторам. Чем и займемся.
Но сначала — посмотрим на разницу между control- и data- flow применением:
Control flow vs Data flow
Любая программа пережевывает входящую информацию и выплевывает исходящую.
Если программа ничего не производит — в ней нет смысла. Вот эта информация может быть данными (сигналы на шине в определенной последовательности) или событиями (сигналы на шине в определенное время). И у систем, ориентированных на эти два вида информации, разные свойства.
Сегодня запрос и ответ от пользователя (или входящие и исходящие данные) будем рисовать сверху, чтобы они не путались со взаимодействием логики самой программы с ОС. Хотя, на самом деле, общение с пользователем тоже происходит через ОС.
Возьмем фотик и могилку.
Смысл жизни фотика — в передаче потока/очереди данных с матрицы на экран или на флеш. Это может выглядеть так:
[Read from matrix to RAM] => [RGGB to RGB] => [Calculate image statistics] => [Apply brightness correction] => [Apply color correction] => [Apply noise reduction] => [Decrease color depth] => (1: [Write to LCD]) or (2: [Write to viewfinder]) or (3: [Compress to JPEG] => [Write to flash]).
Каждый кадр при включенном фотоаппарате проходит этот путь, и от кадра особо ничего не зависит. Часть фильтров в пайплайне обработки картинки может присутствовать или отсутствовать в зависимости от настроек фотоаппарата, но обычно настройки меняются раз в год, и пайплайн довольно стабилен — через один пайплайн проходят сотни кадров.
Это был data flow. Данные проходят кучу этапов, как в мясорубке, но сама схема не меняется.
Теперь берем мобилку. Когда пользователь нажимает клавишу 2, могут произойти следующие действия:
- если сейчас установлена аудио связь, в радиомодуль будет отослано сообщение с набранной цифрой, а также будет включен генератор DTMF для обратной связи.
- если сейчас на экране набор текста, и недавно была нажата клавиша 2, то изменяется последний набранный символ.
- иначе если на экране набор текста, к тексту добавляется буква A или a или цифра 2, в зависимости от состояния.
- если на экране меню, то будет выбран второй пункт меню.
- если на экране змейка, то она заворачивает вверх.
- если играет будильник, мерзкое пищание прекратится на 5 минут.
- если клавиатура заблокирована, и недавно не было нажато клавишей, на экране начнет воспроизводиться анимация о том, как разблокировать клавиатуру.
- иначе, если анимация уже воспроизводится, ничего не произойдет.
- если телефон выключен (на самом деле — он включен, но на пониженной частоте), то ничего не произойдет. Он ждет, пока нажмут кнопку питания, и не отпустят ее несколько тактов.
И еще вместе с этим может активироваться генератор гудков, если включен звук при нажатии клавишей.
Видим, что одно и то же событие (нажатие кнопки 2) может вызывать 100500 сценариев в зависимости от текущего состояния системы, либо игнорироваться. Некоторые сценарии меняют текущее состояние, и следующее нажатие клавиши 2 приведет уже к отличной реакции. Даже активированные модули железа будут различаться.
Это называется control flow. Событие ничего не значит без состояния системы, которое и является решающим фактором, реагировать ли, и каким образом, на входящее сообщение.
Да, у фотоаппарата есть менюшки, написанные в стиле control flow. Да, у телефона есть аудио обработка, выполненная как data flow. То есть, в жизни часто нужно поддерживать обе парадигмы. Но требования и, соответственно, архитектуры для них различаются. Если фотоаппарат может себе позволить не реагировать на кнопки во время сохранения кадра во флеш, то телефон, как реал-тайм устройство, обязан всегда давать возможность пользователю положить трубку (а это — сложный сценарий, который тоже запускается нажатием кнопки).
Data flow | Control flow | |
Цель | Обработка потока данных | Реакция на события |
Эффективность как | Пропускная способность | Время отклика |
Распространение сообщения по системе | Одинаково для каждого сообщения одного типа | Различается для каждого инстанса сообщения |
Сообщения меняют состояние системы | Обычно нет | Обычно да |
Размер интерфейса (количество типов сообщений) | Узкий (единицы или десятки) | Широкий (сотни или тысячи) |
Объем данных в сообщении | Обычно большой (пакет данных) | Обычно малый (нотификация о событии) |
Теперь мы готовы заняться собственно архитектурой.
Монолит
Сейчас в мире распределенных систем монолитом принято называть набор компонентов, который нужно вместе деплоить. Так было не всегда. В [POSA1] нет самого термина «монолит», но встречается «монолитная система», «монолитное приложение» как противоположность слоистым и распределенным архитектурам. В таком значении и будем использовать этот термин: назовем монолитом то, начинку чего не получается понять. Так как мы сейчас исследуем асинхронные (messaging) интерфейсы — разбитие системы на акторов — то синхронные куски (отдельные акторы) для нас более-менее похожи на монолиты. Будем к ним в дальнейшем так относиться. Итак: что же у нас внутри актора?
Монолит — самый простой и очевидный вариант, который классики [MP] рекомендуют к использованию, когда:
- не особо знакомый домен или
- проект не собирается вырасти до проблемных размеров по какому-либо измерению и
- нет специальных нефункциональных требований вроде малого времени отклика или высокой отказоустойчивости
Плюсы:
- Быстрый старт проекта.
- Простота дизайна и кода (не ругайтесь — на старте проекта).
- Простота дебага.
- Если падает, то все целиком — не нужно отслеживать подвисшие задачи, полуизмененные данные, и неотпущенные мютексы.
- Ноги еще не отстрелены — потом можно будет разбить на компоненты самым удобным образом, когда поймешь основные хотелки бизнеса.
- Максимально эффективное использование ресурсов (проц, память, диск, сеть).
Минусы:
- Весь код связан, и мы не можем добиться существенно разных свойств (нефункциональные требования [MP]) для разных частей программы (субдоменов).
- Код легко запутывается (poor evolvability).
- Если падает, то все целиком, и пользователь в шоке.
Забавно, что «падает все целиком» здесь и в плюшечках, и в какашечках. Как всегда с паттернами и с архитектурой — выбирай, что для тебя сейчас важнее. То же — с простотой дизайна: она дает легкий старт и быструю разработку вначале, но ценой тяжелой поддержки и очень медленных итераций, когда проект разрастется.
Общие названия: Big Ball of Mud, Hello World, KISS + YAGNI.
Классика: Reactor [POSA2], Proactor [POSA2], Half-Sync/Half-Async [POSA2].
Бэкенд: Monolith.
В широком понимании, здесь у нас любой неструктурированный (для нас сейчас — без асинхронных интерфейсов) код. Обычно это хорошо / просто, когда кода мало, и к нему нет особых требований.
Входящий запрос будет вызывать несколько операций с преобразованием данных (data flow) или выбором реакции (control flow) между операциями. Известны два основных подтипа: реактор и проактор, и как всегда — их можно смешивать:
Reactor (блокирующие вызовы)
(параллельные сценарии выделены цветом)
Один поток == одна задача. Каждый входящий запрос обрабатывается одним потоком, вызовы из кода наружу (в ОСь, сеть или библиотеки) блокирующие. Потоков-обработчиков можно наплодить много (Leader/Followers [POSA2] или Thread Pool (Master-Slave [POSA1])). Если обработчиков много, и они обращаются к общему состоянию — его нужно защищать мютексами. Код обработки простой, но большие проблемы, если нужно, чтобы один обработчик запроса мог повлиять на другие запросы, уже запущенные в выполнение. Многопоточный вариант недетерменистичен. Этот паттерн можно считать подходом по умолчанию для data flow систем (несложный бекенд). Многопоточный реактор — это шардинг (нарезка монолита — будет в следующей части статьи) на минималках.
Плюсы:
- Простота кода сценариев.
- Простота дебага.
Минусы (общие):
- Нет мультикаста (нельзя сделать наружу несколько параллельных запросов).
Минусы (для однопоточного варианта):
- Малая пропускная способность — только один сценарий в работе в любой момент времени.
Минусы (для многопоточного варианта):
- Недетерминизм (нельзя проиграть события и воспроизвести баг).
- Много потоков потребляют много ресурсов, и медленно переключаются.
- Если есть изменяемое состояние — его надо защищать мютексами, что дает дополнительные задержки, и даже шанс словить дедлок.
Обычно на бекенде применяют многопоточный реактор. Но, например, для работы с железом (или как адаптер диска / базы) нужен однопоточный вариант — мы не можем направить в железо одновременно две команды на выполнение. Здесь (ре)актор блокируется, пока железо отработает первую команду и пришлет ответ на нее, и только потом актор просыпается и берет в работу следующий запрос из очереди сообщений.
Proactor (асинхронные вызовы)
Многозадачность без многопоточности. Один поток обрабатывает все события, вызовы наружу асинхронные (поэтому поток ни на чем не блокируется и всегда готов обслужить клиента). Так как мы не плодим потоки, то ниже потребление ресурсов, и в большинстве случаев (если не загрузить систему под завязку) получается мгновенная реакция на входящее сообщение. Можно даже держать поток-обработчик прогретым (busy waiting). Состояние не нужно защищать мютексами (не от кого, поток же один!). Так как все события входят последовательно, любой сценарий на любом шаге выполнения может изменить состояние актора, таким образом меняя поведение для всех последующих событий — то есть, конкурентно выполняемые сценарии легко могут влиять друг на друга. Выполнение детерминистично — если записать все входящие сообщения, и потом их воспроизвести — результат совпадет с изначальным. Цена за всю эту радость — код для каждого сценария разорван на асинхронные куски, поэтому сценарий целиком нельзя нормально прочесть или отдебажить. Подход применим для control flow, когда важно мгновенно изменить поведение всей системы, в том числе — уже запущенных сценариев, и не так важна пропускная способность (используется только одно ядро процессора). Второй конец палки: жертвуем простотой и мощностью ради гибкости.
Плюсы:
- Сценарии легко могут влиять друг на друга и изменять друг друга на лету — меняя общее состояние.
- Нет задержек на переключении контекста.
- Минимальные затраты ресурсов на выполняемый запрос, то есть — максимально возможное количество запросов в параллельной обработке.
- Детерминизм (а значит — воспроизводимость багов при проигрывании событий).
- Мультикасты как два пальца в розетку — можно забросить одновременно кучу задач на периферию или в сеть, а потом собирать их результаты в порядке поступления — в коде нет привязки запроса к ответу.
Минусы:
- Очень неудобно читать и дебажить код сценариев, так как он разорван на кучу отдельных функций, которые вызываются внешними событиями — в коде нет привязки запроса к ответу.
- Использует только одно ядро проца. Если хотим запустить в несколько потоков — теряем большинство плюсов.
Half-Sync/Half-Async (реактор на корутинах)
(серым обозначены интервалы, когда процессор обслуживает другой стек)
На самом деле (и вот это вот «на самом деле» нас теперь будет преследовать — так всегда, когда говоришь об архитектуре) здесь у нас слои (будут в следующей части) с шардингом (там же). В верхнем слое живут реакторы, в нижнем — один проактор. Но все вместе оно работает как реактор, поэтому рассмотрим вотпрямщас.
Многопоточность без многопоточности. Сценарии работают синхронно как корутины. Корутина — это типа стек вызовов функций, но без потока. Один поток обрабатывает все корутины по очереди, переключаясь на стек каждой из них, чтобы она что-то там себе посчитала и опять зависла в блокирующей операции (await). Каждая корутина — это почти легковесный поток реактора, только без потока. И тот же один же поток выполнения на своем родном стеке обрабатывает все события, приходящие от внешнего мира — как проактор — только вместо того, чтобы самому просчитывать логику событий, он находит или создает корутину, ответственную за пришедшее сообщение, переключается на ее стек, и она уже дальше все разруливает, а потом корутина возвращает управление обратно обработчику событий.
Плюсы:
- Простота кода сценариев.
- Простота дебага.
- Относительно дешевое переключение контекста.
- Относительно небольшие затраты ресурсов на выполняемый запрос.
- Детерминизм (а значит — и воспроизводимость багов при проигрывании событий).
- Возможность для сценариев влиять друг на друга (вероятно — вбросом исключения вместо ответа от блокирующего синхронного вызова).
Минусы:
- Много служебного кода.
- Сценарии могут отменять друг друга, но им довольно сложно согласованно изменять поведение, так как здесь у сценариев активный, а не реактивный, подход.
- Использует только одно ядро проца. Если хотим запустить в несколько потоков — теряем детерминизм.
- Таки сложно с мультикастом.
- Не везде поддерживаются корутины (нужен хороший компилер и правильное железо).
Вот еще одна штука, которая нас будет преследовать — когда видим два варианта концов палки, то у этой палки обычно есть середина, которая ни к селу, ни к городу. Сложная, в чем-то хуже каждого из крайних вариантов, но временами применимая на практике. Как RPC — компромисс между месседжингом и синхронными вызовами.
Смесь
Часть вызовов наружу — быстрые синхронные (к флеш диску), часть — асинхронные (в сеть). Описан как оптимизация для уменьшения переключений контекста и упрощения кода. Где-то так же строится работа микросервисов (например, синхронно — с базой данных и асинхронно — с другими микросервисами). Еще это — очевидный вариант имплементации Content Enricher [EIP] или API Gateway [MP], когда мы получаем сообщение, добавляем в него данные из нашей базы (синхронное чтение) и переправляем обогащенное сообщение дальше кому-то более умному или ответственному (асинхронная посылка).
Состояние запроса
Если посмотреть, как хранятся состояния запросов у реактора и у вариантов проактора, получим следующую картинку:
- У (многопоточного) реактора состоянием запроса является стек потока, этот запрос обрабатывающего. В результате, один запрос фактически никак не может повлиять на другой, который в этот момент, вероятно, заблокирован на синхронном вызове в ОСь.
- Однопоточный проактор хранит все происходящее в явном виде — как структуры данных (по штуке на запрос) и как общее (статическое) состояние самого проактора. В результате — для запросов вообще нет проблем это состояние (общее или чужое) читать и менять, но код сильно фиговый, потому что асинхронный на колбеках — любой сценарий разбит на кучу несвязанных функций-обработчиков событий. Каждый колбек читает состояние, обновляет его полученными данными, решает, что делать дальше, и вызывает один или несколько асинхронных запросов для следующих шагов.
- Реактор на корутинах с одним потоком-обработчиком событий — это промежуточный вариант. Состояния запросов так же лежат в стеках корутин, но корутину можно разбудить вменяемыми методами. Код сценариев красивый, почти как у реактора, но за это пришлось заплатить дополнительной рукописной асинхронной прослойкой, роль которой в реакторе выполняет ОСь.
Итого:
- Мы посмотрели разницу между data flow и control flow
- Познакомились с внутренностями акторов, использующих блокирующие и асинхронные операции ввода/вывода
- Разобрали, где хранится состояние запросов, которыми актор сейчас занимается
Шарлотку приготовили — пора подумать, как ее нарезать. В следующей части статьи попробуем разрезать монолит messaging интерфейсами и смотреть на свойства получающихся систем.
Литература
[DDIA] Designing Data-Intensive Applications: The Big Ideas Behind Reliable, Scalable, and Maintainable Systems. Martin Kleppmann. O’Reilly Media, Inc. (2017).
[EIP] Enterprise Integration Patterns. Gregor Hohpe and Bobby Woolf. Addison-Wesley (2003).
[MP] Microservices Patterns: With Examples in Java. Chris Richardson. Manning Publications (2018).
[POSA1] Pattern-Oriented Software Architecture Volume 1: A System of Patterns. Frank Buschmann, Regine Meunier, Hans Rohnert, Peter Sommerlad and Michael Stal. John Wiley & Sons, Inc. (1996).
[POSA2] Pattern-Oriented Software Architecture Volume 2: Patterns for Concurrent and Networked Objects. Douglas C. Schmidt, Michael Stal, Hans Rohnert, Frank Buschmann. John Wiley & Sons, Inc. (2000).
Архитектуры на акторах:
Вступление (Actors / Objects; Complexity; When to Distribute; Kinds of Actors)
Монолиты (Control/Data Flow; Reactor / Proactor / Half-Sync/Half-Async; Request State)
Простые системы (Notation for Diagrams; Sharding; Layers; Services / Pipeline)
Системы с моделью (Hexagonal / MVC; Blackboard / Message Bus; Microkernel / Plug-ins)
Фрагментированные системы (Mesh; SOA; Hierarchy; Design Space)
31 коментар
Додати коментар Підписатись на коментаріВідписатись від коментарів