четверг, 14 апреля 2016 г.

[prog.thoughts] Чуть подробнее про разработку приложений с использованием акторов

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

Итак, одно из самых распространенных заблуждений, с которыми доводится сталкиваться, рассказывая про использование акторов -- это проецирование акторов на операции ввода-вывода. И, соответственно, главный вопрос: "У меня Asio (libuv, libev, ACE_Reactor/Proactor, you-name-it) нормально разруливает N коннектов и с каждым коннектом у меня связано свое собственное состояние, Asio (libuv, libev,...) сам управляет рабочими контекстами, предоставляет таймеры и т.д. и т.п. Так зачем мне ваши акторы?"

Вопрос правильный. И ответ на него прост: не нужны акторы для операций ввода-вывода. А раз такой вопрос возник, значит есть непонимание, где и как акторы могут применяться.

На мой взгляд, акторы в C++ нужны для вещей, которые реализуются на более высоких уровнях абстракции, нежели ввод-вывод.

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

Есть задачи работы с сетевым подключением (подключиться к брокеру, проконтролировать жизнеспособность соединения, прочитать данные из соединения, записать данные в соединение).

Есть ряд задач, связанных с конкретным протоколом.

На низком уровне сериализация и десериализация PDU.

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

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

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

Возможно, в каких-то простых случаях всю эту кухню можно делать прямо на уровне I/O-хендлеров Asio (libuv, libev, ...). Скажем, вычитали из сокета очередной блок данных, тут же попытались его распарсить. Если удалось, то выделили из блока PDU, проанализировали PDU, выделили из него прикладное сообщение. Разобрались с тем, что это за сообщение, как его нужно обрабатывать, выполнили обработку. Сформировали ответ, сериализовали его, упаковали в PDU, преобразовали PDU в блок бинарных данных, записали в канал. Перешли в режим чтения.

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

И вот тут-то у разработчика появляется выбор, а значит и головная боль. Вроде как не есть хорошо выполнять работу разных логических уровней на одном рабочем контексте. Грубо говоря, если I/O-хендлер вычитал из канала N байт, распарсил их, преобразовал в PDU и выделил из PDU прикладное сообщение, то не следует на этом же рабочем контексте пытаться записать прикладное сообщение в БД. Т.к. эта операция займет какое-то время, а время это можно было бы потратить на вычитывание очередных N байт.

Следовательно, имеет смысл разделить работу между несколькими независимыми сущностями, у каждой из которых будет свой рабочий контекст (что из себя будет представлять данный контекст -- это отдельный вопрос).

А раз так, то возникает вопрос: как одна сущность будет передавать информацию другой сущности?

Т.е. как I/O-хендлер передаст PDU менеджеру сессии? Как затем менеджер сессии передаст прикладное сообщение конкретному получателю?

Можно пойти дедовским способом: через разделяемую память, защищенную мутексом. Т.е. I/O-хэндлер захватывает мутекс, запихивает PDU (или указатель на PDU) в некий массив, после чего освобождает мутекс. Менеджер сессий захватывает мутекс, забирает PDU из этого массива, освобождает мутекс. И т.д. и т.п.

Проблема в том, что такой подход чреват ошибками (как и любая рукопашная работа с голыми нитями, мутексами, условными переменными и пр. примитивами). Кроме того, вот эти вот массивы с мутексами -- это ad-hoc реализация очередей сообщений. Посему получается, что достаточно сделать один логический шаг и предоставить сущностям для взаимодействия тот или иной вариант очередей сообщений. И жизнь станет легче: I/O-хендлер помещает сообщение в очередь, менеджер сессий извлекает сообщения из очереди, низкоуровневые детали упрятаны под капот реализации очередей.

Как только мы приходим к передаче данных между сущностями посредством очередей сообщений, у нас появляется нечто вроде стадийности обработки. А сама обработка может выглядеть как конвейер. I/O-хендлер сформировал PDU и передал его на следующую стадию. Там менеджер сессий разобрался с тем, что это за PDU и, например, выделил из PDU прикладное сообщение, после чего передал его на следующую стадию. Там менеджер подписок разобрался с тем, кто должен получить это прикладное сообщений и передал сообщение дальше -- прикладному обработчику. Прикладной обработчик взял сообщение, понял, что с ним нужно сделать и, скажем, сохранил сообщение в БД. После чего трансформировал сообщение и передал следующему обработчику. И т.д. и т.п.

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

Ну и тут есть ряд мелких вопросиков, которые не кажутся принципиальными каждый сам по себе. Но их кумулятивный эффект довольно силен.

Например, какой рабочий контекст будет у каждой из этих сущностей? Будет ли сущность отдельной нитью или короутиной? Кто будет предоставлять этот рабочий контекст? Кто будет стартовать сущность? Кто и как будет давать команды на завершение ее работы? Кто будет контролировать, что сущность свою работу завершила и контекст можно утилизировать?

Как работает сущность и что она из себя представляет? Это функция, внутри которой работает цикл с вызовами receive_message с последующей обработкой извлеченных сообщений? Или это объект, у которого кто-то дергает методы, когда для агента приходит сообщение?

Есть ли у этой сущности какая-то собственная очередь сообщений? Или же очереди сообщений живут сами по себе?

Являются ли очереди сообщений ограниченными по типу (т.е. могут содержать сообщения только одного-единственного типа) или нет (т.е. внутри очереди могут быть сообщения разных типов)? Поддерживает ли receive_message семантику selective receive (т.е. извлечение только тех сообщений, которые удовлетворяют определенным условиям, с сохранением в очереди всех остальных сообщений)? Существует ли поддержка широковещательной рассылки сообщений?

Существуют ли какие-нибудь инструменты интроспекции и/или мониторинга сущностей и их очередей?

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

Акторные фреймворки предлагают свой набор ответов. Каждая независимая сущность будет является актором. У актора будет своя очередь сообщений. Для взаимодействия акторы должны знать друг для друга (ну или про очереди сообщений друг друга). Стартовать и останавливать акторов будет сам фреймворк. Фреймворк же будет определять на каком контексте актор будет работать. Будет ли этот контекст выделен только одному актору (актор является активным объектом) или же на этом контексте смогут работать разные акторы. И т.д.

В принципе, фреймворк, предоставляющий функциональность CSP-каналов в купе с короутинами (т.е. каждая сущность -- это короутина с циклом вызовов receive_message внутри), делает тоже самое, но несколько по другому. На высоких уровнях абстракции между акторами и CSP-процессами разницы практически-то и нет. Зато эта разница может играть заметную роль на более низких уровнях, там, где абстракции начинают течь и детали реализации напоминают о себе.

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

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

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

Ну вот вкратце получается как-то вот так. Если кого-то эта тема заинтересовала, то можно ее развить и пообсуждать, как именно в акторах мог бы выглядеть описанный выше пример с MQ-шным каналом.

Комментариев нет: