Модель Акторов и C++: что, зачем и как?

habralogo.jpg

Данная статья является доработанной текстовой версией одноименного доклада с конференции C++ CoreHard Autumn 2016, которая проходила в Минске в октябре прошлого года. Желание сделать эту статью возникло под впечатлением о том, что в мире C++ разработчики как бы делятся на два больших и не пересекающихся лагеря. В первом лагере находятся матерые спецы, которые все видели, все знают и все умеют, за плечами у которых десятки собственноручно написанных реализаций Модели Акторов, внутрях у которых хитрые, конечно же самостоятельно сделанные, lock-free очереди и state-of-the-art механизмы обслуживания сообщений. Такие проффи сами часами могут рассказывать про тонкости многопоточного программирования (только почему-то редко это делают). Во втором лагере — зеленые новички, которых волею судьбы занесло в мир C++, которые пока слабо представляют себе различия между unique_ptr и shared_ptr, про шаблоны только слышали, а в области многопоточности имеют поверхностное впечатление только о std: thread, std: mutex и, может быть, std: condition_variable. Для людей из первого лагеря я вряд ли что-нибудь интересное расскажу, а вот разработчикам из второго лагеря попробую вкратце рассказать о том, что Модель Акторов в C++ — это нормально. И что есть ряд готовых инструментов, на примере которых можно увидеть, что же это такое.


Введение

Разговор пойдет о Модели Акторов и о том, стоит ли ее использовать в программах на языке C++ и, если таки стоит, то чем можно воспользоваться, чтобы не изобретать собственный велосипед. Говорить о Модели Акторов будем применительно к решению проблем многопоточности, поэтому следует сузить контекст, дабы не возникало разночтений.


Многопоточность, как инструмент, используется в двух сильно разных направлениях. Первое — это parallel computing. Многопоточность здесь нужна для параллельного выполнения одних и тех же операций над разными блоками данных. Тем самым сильно сокращая время решения конкретной вычислительной задачи. Например, перекодирование видеофайла в один поток может занять час. А перекодирование в четыре параллельных потока — всего 15 минут.


Второе направление — это concurrent computing. Т.е. одновременное выполнение множества разных операций. Например, многопоточный сервер СУБД, который одновременно принимает запросы, строит планы их выполнения, производит операции ввода-вывода, отдает результаты запросы клиентам, обновляет статистику и т.д. Многопоточность здесь нужна для обеспечения действительно параллельного выполнения различных операций. Хотя, по большому счету, обеспечивать concurrency можно даже и на одном потоке (т.н. квазипараллелизм).


Так вот дальше речь пойдет о многопоточности применительно к concurrent computing. Ибо именно в этом направлении использование Модели Акторов полностью себя оправдывает.


Что же такого сложного в многопоточном программировании?


Одна из самых больших сложностей в многопоточности — это мутабельное разделяемое состояние.


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


Как упростить себе жизнь?


Ничего не разделять. Принцип shared nothing, который широко известен в узких кругах.


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


Замечательно, но что, если потоку X потребовалась какая-то информация, которая есть у потока Y? Или если поток Y хочет, чтобы поток Z обновил какие-то данные у себя?


Значит, потоки должны каким-то образом взаимодействовать между собой. Каким образом?


На первый взгляд кажется, что вариантов два:


Либо синхронно.
Либо асинхронно.


Однако, синхронное взаимодействие не вариант. Синхронное взаимодействие независимых потоков — это так же самая работа с разделяемыми данными. Только в качестве разделяемых данных выступают сами потоки.


Остается асинхронное взаимодействие.


Подходим к Модели Акторов издалека…


Как у нас можем происходить общение рабочих потоков на сообщениях? Например, вот так:


  • поток X отсылает сообщение-запрос потоку Y;
  • поток Y когда-то получает запрос потока X и отсылает потоку X ответ;
  • поток Y отсылает сообщение-обновление потоку Z;
  • поток Z когда-то получит сообщение-обновление от потока Y и обновит те данные, которые принадлежат потоку Z.

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


Если же потоку X нужно что-то от другого потока Y, то поток X помещает сообщение во входящую очередь потока Y.


Приятное дополнение: если сообщения переносят копию данных, а не ссылку на исходные данные где-то в разделяемой памяти, то получается внезапный бонус — прозрачный переход к распределенности.


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


Вот мы на пальцах и показали часть основных принципов Модели Акторов.


Собственно, Модель Акторов. В двух-трех словах

Модель Акторов появилась в 1973-ем году благодаря работам Карла Хьюитта (Carl Hewitt), а затем была развита в 1981-ом году Уильямом Клингером (William Clinger) и в 1985-ом Гулом Агха (Gul Agha).


Модель Акторов несколько раз привлекала к себе широкое внимание. Последняя волна известности, по субъективному впечатлению, началась подниматься где-то лет 10–12 назад. Сначала этому способствовал язык программирования Erlang. Затем фреймворк Akka.


Желающие погрузиться в теоретическую часть Модели Акторов могут начать со следующих обзорных статей в Wikipedia и далее по ссылкам:


History of the Actor Model
Actor Model
Actor Model Theory


Однако, погружение в теорию Модели Акторов — это чистой воды прыжок в Computer Science. Но я не ученый, а инженер-программист в прошлом, менеджер в нынешнем, поэтому позволю себе сконцентрироваться только на практических аспектах.


Модель Акторов «на пальцах»


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


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

Принципы простые. И, когда к ним привыкнешь, очевидные. Тем не менее, один важный момент нужно затронуть отдельно. Этот момент очень важен, т.к. он поясняет, почему реализации Модели Акторов могут выглядеть по-разному и очень сильно отличаются друг от друга.


Больше акторов, хороших и разных!


Актор — это некоторая сущность.


Модель акторов не говорит о том, как именно эта сущность должна быть реализована.


Актором может быть отдельный процесс. Например, так происходит в Erlang, где каждый легковесный процесс внутри Erlang VM может считаться актором.


Актором может быть отдельный поток (OS thread, «green» thread, fiber, etc…). Например, goroutines в языке Go так же можно рассматривать как акторы (с натяжкой).


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


С чем же ассоциируется Модель Акторов сейчас?

Erlang


Прежде всего — это Erlang.


Ирония в том, что я никогда не встречал, чтобы Джо Армстронг говорил, что на Erlang повлияла Модель Акторов.

Erlang сам по себе. А так же попытки создать более удобные для разработчиков языки на базе Erlang VM. Например, Elixir.


История Erlang началась в далеком 1986-м году в одной из лабораторий компании Ericsson. Джо Армстронг экспериментировал с Prolog-ом для написания программ для телефонии. В результате этих экспериментов появился Erlang.


В 1995-ом году в Ericsson-е была закрыта неудачная разработка нового телефонного свича AXE-N (кстати, на C++). В новой разработке в качестве основного языка использовался Erlang. Итогом стал успешный программно-аппаратный продукт AXD301, внутри которого было порядка миллиона (!) строк кода на Erlang.


Правда далее история Erlang-а в Ericsson-е развивалась парадоксальным образом. Вскоре после создания AXD301 использование Erlang-а в разработке новых продуктов внутри Ericsson-а было запрещено. Джо Армстронг ушел из Ericsson-а, основал свою компанию, язык Erlang вышел в OpenSource.


Через несколько лет, когда Erlang доказал свою состоятельность находясь в «свободном» плавании, запрет в Ericsson-е был снят. В 2004-ом году Армстронг возвращается в Ericsson.


За последние 15 лет Erlang доказал свою состоятельность более чем убедительно.
Огромное количество продуктов разработано на Erlang, ряд компаний использует Erlang в качестве ключевого инструмента.


Например, WhatsApp.


Многие компании убеждаются в преимуществах Erlang-а и начинают использовать его у себя. Например, Wargaming сформировал у себя серьезную команду Erlang-разработчиков (возможно, самую серьезную в СНГ) и потихоньку переучивает Python-истов на Erlang.


Давайте посмотрим маленький пример простейшей программы на Erlang-е. Классический для Модели Акторов пример — «пинг-понг»:


-module(tut15).
-export([start/0, ping/2, pong/0]).

ping(0, Pong_PID) ->
    Pong_PID ! finished,
    io:format("ping finished~n", []);

ping(N, Pong_PID) ->
    Pong_PID ! {ping, self()},
    receive
        pong ->
            io:format("Ping received pong~n", [])
    end,
    ping(N - 1, Pong_PID).

pong() ->
    receive
        finished ->
            io:format("Pong finished~n", []);
        {ping, Ping_PID} ->
            io:format("Pong received ping~n", []),
            Ping_PID ! pong,
            pong()
    end.

start() ->
    Pong_PID = spawn(tut15, pong, []),
    spawn(tut15, ping, [3, Pong_PID]).

Akka


Вторая по известности «икона» Модели Акторов — это фреймворк Akka для языков Scala и Java.


Историю Akka можно начать с 2006-го года, когда Филипп Холлер (Philipp Haller) разработал реализацию Модели Акторов для языка Scala. Эта реализация вошла в стандартную библиотеку Scala.


Через несколько лет, когда Scala и акторы из ее стандартной библиотеки доказали свою состоятельность, Джонес Бонер (Jonas Bonér) в 2008-м приступил к созданию фреймворка Akka, первая публичная версия которого вышла в 2010-ом. Одним из существенных отличий Akka от акторов из стандартной библиотеки Scala стало то, что Akka поддерживала как Scala, так и Java. В этом смысле примечательно то, что компания Lightbend (бывший TypeSafe), которая стоит за разработкой Akka и оказывает коммерческую поддержку Akka, заявила о сдвиге своего фокуса со Scala в пользу Java.


Akka широко используется в области Web-а и онлайн-сервисов (например, Twitter, LinkedIn). Люди, стоящие за Akka, причастны к таким современным buzz-word-ам, как Reactive Manifesto и Microservices.


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


import akka.actor._

case object PingMessage
case object PongMessage
case object StartMessage
case object StopMessage

class Ping(pong: ActorRef) extends Actor {
  var count = 0
  def incrementAndPrint { count += 1; println("ping") }
  def receive = {
    case StartMessage =>
        incrementAndPrint
        pong ! PingMessage
    case PongMessage => 
        incrementAndPrint
        if (count > 99) {
          sender ! StopMessage
          println("ping stopped")
          context.stop(self)
        } else {
          sender ! PingMessage
        }
  }
}

class Pong extends Actor {
  def receive = {
    case PingMessage =>
        println("  pong")
        sender ! PongMessage
    case StopMessage =>
        println("pong stopped")
        context.stop(self)
  }
}

object PingPongTest extends App {
  val system = ActorSystem("PingPongSystem")
  val pong = system.actorOf(Props[Pong], name = "pong")
  val ping = system.actorOf(Props(new Ping(pong)), name = "ping")
  // start them going
  ping ! StartMessage
}

В чем сила, брат?

Почему же Erlang, Akka и другие похожие на них инструменты получили такую популярность?


Тут уместно было бы привести цитату из Джо Армстронга (создателя языка Erlang):


I also suspect that the advent of true parallel CPU cores will make programming parallel systems using conventional mutexes and shared data structures almost impossibly difficult, and that the pure message-passing systems will become the dominant way to program parallel systems.

Что в грубом пересказе, но с сохранением смысла сказанного, может звучать как:


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


Отмечу несколько ключевых факторов, которые объясняют успех Erlang и Akka:


  • простота разработки. Использование асинхронного обмена сообщениями сильно упрощает жизнь когда приходится иметь дело с concurrent computing;
  • масштабирование. Модель Акторов позволяет создавать огромное количество акторов, каждый из которых отвечает за свою частную задачу. Принцип shared nothing и асинхронный обмен сообщениями позволяет строить распределенные приложения, горизонтально масштабируясь по мере надобности;
  • отказоустойчивость. Сбой одного актора может отлавливаться другими акторами, которые предпринимают соответствующие действия для восстановления ситуации (например, механизм супервизоров из Erlang-а).

Но это все безопасные языки и управляемые среды.


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


Так что в том, что в последние годы Модель Акторов стала популярна в первую очередь в безопасных языках, есть свои объективные факторы.


А есть ли смысл в реализациях Модели Акторов для языка C++?


Чтобы ответить на этот вопрос, нужно сперва ответить на другой вопрос:, а нужен ли сейчас вообще C++?


Язык C++ — это старый язык с очень длинной историей. Даже если считать от момента официального релиза (осень 1985-го), то ему уже больше тридцати лет. Само название C++ появилось в 1983-ем, а работа над языком началась в 1979-ом. Т.е. скоро можно будет говорить о сорокалетней истории языка.


За это время С++ вобрал в себя множество новшеств и заимствований. Но сохранил, при этом, совместимость с изрядным подмножеством языка C.


Т.е. стал настоящим монстром. Вряд ли в мире найдется больше пары сотен человек, про которых можно сказать, что они знают C++.


Использовать C++ сложно. C++ часто критикуют. Очень часто критикуют заслуженно.


Развитие таких языков, как Java/Scala/C# с одной стороны, рост популярности функциональных языков (вроде OCaml и Haskell) с другой, а также появление новых и «современных» альтернатив, вроде Go, вытеснило C++ из многих прикладных ниш, в которых он волею судьбы оказался в 1980-х и 1990-х годах.


Поэтому последние лет 15-ть язык C++ регулярно хоронят. Я сам лет 8-мь назад всерьез считал, что у C++ нет будущего.


Тем не менее, C++ здесь. Жив-здоров. Успешно развивается. Становится еще большим монстром, чем был. Но, что удивительно, чем более сложным языком C++ становится, тем проще его использовать в повседневной работе.


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


Этот язык — C++.


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


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

Так что хотим мы того или нет, но C++ пока что здесь. И, по всей видимости, будет здесь еще долго.


А раз так, и раз на C++ разрабатываются большие и сложные программы, в том числе и с использованием многопоточности, то почему бы не упростить себе жизнь за счет применения Модели Акторов?


Что есть готового для C++?


Давайте посмотрим, что для C++ есть готового из реализаций Модели Акторов. Дабы не переизобретать велосипед и иметь возможность взять что-то существующее, вместо того, чтобы дать что-то свое с нуля. Ну или убедиться в том, что нужной для вас реализации нет и имеет смысл убить пару-тройку человеко-лет на создание еще одного решения.


Вообще-то говоря, готовых реализаций Модели Акторов не так уж много. Один из списков можно найти в Wikipedia: Actor Libraries and Frameworks. Но там, к сожалению, перечислено не все и часть проектов уже не подает признаков жизни.


Ниже мы рассмотрим несколько реализаций, которые явно живы, здоровы, не просто подают признаки жизни, но и эволюционно развиваются. Кроме того, эти проекты могут похвастаться переносимостью между разными платформами. Например, по этой причине в обзор не включена Asynchonous Agents Library от Microsoft, которая доступна в Microsoft Visual Studio. Так же в обзор не попали OOSMOS (заточенность в первую очередь под чистый C, а не под C++) и actor-zeta (пока еще находящийся на ранней стадии своего развития).


QP/C++


Начнем с библиотеки QP/C++.


QP/C++ — это зрелый (более 15 лет развития) программный продукт под двойной лицензией, предназначенный для разработки встраиваемого ПО, в том числе и систем реального времени. В том числе и систем, которые могут работать прямо на голом железе. В том числе QP/C++ частично соответствует MISRA C++2008. Из всего этого и проистекает его специфичность. Так же это единственный фреймворк в обзоре, которому достаточно C++98.


Акторы в QP/C++ называются активными объектами и представляю из себя иерархические конечные автоматы. Код акторов можно набирать в виде обычных C++ных классов. А можно нарисовать актора в специальном инструменте для визуального моделирования и его код будет сгенерирован автоматически.


Активные объекты в QP/C++ работают на контексте, который им выделяет QP. В зависимости от окружения активные объекты могут работать каждый на своей нити или же они могут разделять общий рабочий контекст.


В качестве иллюстрации посмотрим на один из примеров из состава QP, в котором заставляют периодически мигать светодиод на каком-то устройстве.


Исходный файл blinky.h, в котором декларируется актор и все, что с ним связано:

#ifndef blinky_h
#define blinky_h

using namespace QP;

enum BlinkySignals {
    DUMMY_SIG = Q_USER_SIG,
    MAX_PUB_SIG,  // the last published signal

    TIMEOUT_SIG,
    MAX_SIG       // the last signal
};

extern QMActive * const AO_Blinky; // opaque pointer

#endif // blinky_h

Файл main.cpp, в котором инициируется работа актора:


#include "qpcpp.h"
#include "bsp.h"
#include "blinky.h"

int main() {
    static QEvt const *blinkyQSto[10]; // Event queue storage for Blinky

    BSP_init(); // initialize the Board Support Package
    QF::init(); // initialize the framework and the underlying RT kernel

    // instantiate and start the active objects...
    AO_Blinky->start(1U,                            // priority
                 	blinkyQSto, Q_DIM(blinkyQSto), // event queue
                 	(void *)0, 0U);                // stack (unused)

    return QF::run(); // run the QF application
}

Ну и файл blinky.cpp, в котором актор реализован:


#include "qpcpp.h"
#include "bsp.h"
#include "blinky.h"

class Blinky : public QActive {
private:
    QTimeEvt m_timeEvt;

public:
    Blinky();

protected:
    static QState initial(Blinky * const me, QEvt const * const e);
    static QState off(Blinky * const me, QEvt const * const e);
    static QState on(Blinky * const me, QEvt const * const e);
};

Blinky l_blinky;

QMActive * const AO_Blinky = &l_blinky; // opaque pointer

Blinky::Blinky()
  : QActive(Q_STATE_CAST(&Blinky::initial)),
    m_timeEvt(this, TIMEOUT_SIG, 0U)
{}

QState Blinky::initial(Blinky * const me, QEvt const * const e) {
    (void)e; // unused parameter

    // arm the time event to expire in half a second and every half second
    me->m_timeEvt.armX(BSP_TICKS_PER_SEC/2U, BSP_TICKS_PER_SEC/2U);
    return Q_TRAN(&Blinky::off);
}

QState Blinky::off(Blinky * const me, QEvt const * const e)
{
    QState status;
    switch (e->sig) {
        case Q_ENTRY_SIG: {
            BSP_ledOff();
            status = Q_HANDLED();
            break;
        }
        case TIMEOUT_SIG: {
            status = Q_TRAN(&Blinky::on);
            break;
        }
        default: {
            status = Q_SUPER(&QHsm::top);
            break;
        }
    }
    return status;
}

QState Blinky::on(Blinky * const me, QEvt const * const e)
{
    QState status;
    switch (e->sig) {
        case Q_ENTRY_SIG: {
            BSP_ledOn();
            status = Q_HANDLED();
            break;
        }
        case TIMEOUT_SIG: {
            status = Q_TRAN(&Blinky::off);
            break;
        }
        default: {
            status = Q_SUPER(&QHsm::top);
            break;
        }
    }
    return status;
}

Just: Thread Pro: Actors Edition


Следующий инструмент — Just: Thread Pro: Actors Edition.


Платная библиотека от очень известного в C++ном мире Энтони Уильямса (Anthony Williams). Автора книги «C++ Concurrency in Action».


Собственно, достоинства библиотеки на этом и заканчиваются :)


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


В качестве иллюстрации посмотрим на классический пример ping-pong.


#include 
#include 
#include 

int main()
{
    struct pingpong {
        jss::actor_ref sender;

        pingpong(jss::actor_ref sender_): sender(sender_) {}
    };
    jss::actor pp1( 
        []{
            for(;;)
            {
                jss::actor::receive().match(
                    [](pingpong p){
                        std::cout<<"ping\n";
                        p.sender.send(pingpong(jss::actor::self()));
                    });
            }
        });

    jss::actor pp2(
        []{
            for(;;)
            {
                jss::actor::receive().match(
                    [](pingpong p){
                        std::cout<<"pong\n";
                        p.sender.send(pingpong(jss::actor::self()));
                    });
            }
        });

    pp1.send(pingpong(pp2));

    std::this_thread::sleep_for(std::chrono::seconds(2));
    pp1.stop();
    pp2.stop();
}

C++ Actor Framework


Следующий инструмент — это C++ Actor Framework. Он же CAF, он же libcppa в недавном прошлом.


OpenSource проект под BSD-лицензией.


Если можно говорить о самой известной реализации Модели Акторов для C++, то это про CAF. Пожалуй, более распиаренной библиотеки на эту тему для C++ нет.


CAF копирует Erlang в C++ настолько близко, насколько это возможно. Поэтому если вы знаете Erlang, вам нравится Erlang, но вам нужно вести разработку на C++ и вы хотели бы писать на C++ как на Erlang, то вам прямиком в CAF.


Ценой за мимикрию под Erlang являются высокие требования CAF-а к уровню поддержки стандартов в C++ в компиляторе. Из-за этого разработчики CAF-а никогда не рассматривали Windows и VC++ в качестве одной из значимых платформ для своей разработки, ограничиваясь Linux-ом, FreeBSD MacOS, а также самыми свежими версиями компиляторов gcc и clang. Кроме того, авторы CAF несколько раз заявляли, что они и впредь будут ориентироваться прежде всего на самые новые возможности языка C++ и будут переходить на фичи из новых стандартов так быстро, как это возможно.


Отметим, что CAF предлагает готовые инструменты для создания распределенных приложений. Для этого в CAF есть свой протокол для общения удаленных агентов и реализация этого протокола посредством Boost: Asio.


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


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


Ну и разработчики CAF-а позиционируют его как очень шустрый фреймворк, хотя на этот счет у некоторых есть обоснованные сомнения ;)


В качестве иллюстрации можно привести код примера fixed_stack из состава самого CAF-а.


#include 
#include 
#include 
#include "caf/all.hpp"

using std::endl;
using namespace caf;

namespace {

using pop_atom = atom_constant;
using push_atom = atom_constant;

enum class fixed_stack_errc : uint8_t { push_to_full = 1, pop_from_empty };

error make_error(fixed_stack_errc x) {
  return error{static_cast(x), atom("FixedStack")};
}

class fixed_stack : public event_based_actor {
public:
  fixed_stack(actor_config& cfg, size_t stack_size)
      : event_based_actor(cfg),
        size_(stack_size)  {
    full_.assign(
      [=](push_atom, int) -> error {
        return fixed_stack_errc::push_to_full;
      },
      [=](pop_atom) -> int {
        auto result = data_.back();
        data_.pop_back();
        become(filled_);
        return result;
      }
    );

    filled_.assign(
      [=](push_atom, int what) {
        data_.push_back(what);
        if (data_.size() == size_)
          become(full_);
      },
      [=](pop_atom) -> int {
        auto result = data_.back();
        data_.pop_back();
        if (data_.empty())
          become(empty_);
        return result;
      }
    );

    empty_.assign(
      [=](push_atom, int what) {
        data_.push_back(what);
        become(filled_);
      },
      [=](pop_atom) -> error {
        return fixed_stack_errc::pop_from_empty;
      }
    );
  }

  behavior make_behavior() override {
    assert(size_ < 2);
    return empty_;
  }

private:
  size_t size_;
  std::vector data_;
  behavior full_;
  behavior filled_;
  behavior empty_;
};

void caf_main(actor_system& system) {
  scoped_actor self{system};
  auto st = self->spawn(5u);
  // fill stack
  for (int i = 0; i < 10; ++i) self->send(st, push_atom::value, i);
  // drain stack
  aout(self) << "stack: { ";
  bool stack_empty = false;
  while (!stack_empty) {
    self->request(st, std::chrono::seconds(10), pop_atom::value).receive(
      [&](int x) {
        aout(self) << x << "  ";
      },
      [&](const error&) {
        stack_empty = true;
      }
    );
  }
  aout(self) << "}" << endl;
  self->send_exit(st, exit_reason::user_shutdown);
}
} // namespace 
CAF_MAIN()

SObjectizer


Ну и четвертый фреймворк, на котором мы остановимся чуть подробнее — это SObjectizer.


OpenSource-проект под лицензией BSD.


Проект развивается с 2002-го года, хотя базируется на идеях, которые были выработаны и проверены еще в середине 90-х при разработке небольшой объектно-ориентированной SCADA-системы (развитие которой, к сожалению, завершилось в 2000-ом).


SObjectizer никогда не были экспериментальным проектом, он создавался специально для того, чтобы упростить разработку многопоточного софта на C++. До сих пор в эксплуатации находятся программные системы, написанные на разных версиях SObjectizer.


Поэтому в SObjectizer огромное внимание уделяется совместимости. Например, осенью 2014-го года вышла версия SObjectizer-5.5.0. С тех пор в рамках версии 5.5 прошло более двадцати релизов, последняя стабильная версия имеет номер 5.5.18, но ломающих изменений не было. Так что SObjectizer — это проект с весьма долгой историей и трепетным отношением к совместимости между версиями.


Акторы в SObjectizer называются агентами. Просто по историческим причинам.


Как и в QP/C++ агенты в SObjectizer — это, как правило, экземпляры отдельных C++ных классов. Так же, как и в QP/C++ агенты представляют из себя иерархические конечные автоматы (включая вложенные состояния, deep- и shallow-историю, обработчики входа-выхода, временные лимиты).


Так же, как и в QP/C++ рабочий контекст агентам предоставляет фреймворк. Для этого в SObjectizer есть такое понятие, как диспетчер: специальная сущность, которая выполняет диспетчеризацию событий агентов. В состав SObjectizer входит восемь типов диспетчеров, доступных разработчику «из коробки». Среди них есть такой интересный диспетчер, как adv_thread_pool, который позволяет параллельно запускать на разных рабочих нитях обработчики событий одного и того же агента, если эти обработчики помечены как thread-safe.


В чем SObjectizer сильно отличается от перечисленных выше проектов, так это симбиозом моделей Акторов, Publish-Subscribe и Comminicating Sequential Processes.


В SObjectizer сообщения отсылаются не напрямую агентам-получателям, а в mbox-ы (почтовые ящики). А уже из mbox-а сообщения доставляются тем агентам, которые на него подписаны. Таким образом mbox-ы в SObjectizer работают как Topic-и в модели Publish-Subscribe. Отсылка сообщения в mbox — это как операция Publish. Агенты же должны выполнить операцию Subscribe для получения интересующих их сообщений.


Вот чего сейчас SObjectizer не предоставляет, так это готовых средств построения распределенных приложений. Подобные средства были в ранних версиях SObjectizer-а, но со временем по ряду объективных причин от них отказались и начиная с 2010-го года подобных инструментов в ядре SObjectizer-а нет. Пользователь сам выбирает, какой коммуникационный слой ему удобнее использовать — будь это REST, MQTT, CoAP, AMQP или что-то еще.


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


#include 

#include 

class fixed_stack final : public so_5::agent_t
{
  state_t st_empty{ this },
          st_filled{ this },
          st_full{ this };
 
  const size_t m_max_size;
  std::vector< int > m_stack;
 
public :
  class empty_stack final : public std::logic_error
  {
  public :
    using std::logic_error::logic_error;
  };

  struct push { int m_val; };
  struct pop : public so_5::signal_t {};

  fixed_stack( context_t ctx, size_t max_size )
    : so_5::agent_t( ctx )
    , m_max_size( max_size )
  {
    this >>= st_empty;
 
    so_subscribe_self()
      .in( st_empty )
      .in( st_filled )
      .event( &fixed_stack::on_push );
 
    so_subscribe_self()
      .in( st_filled )
      .in( st_full )
      .event( &fixed_stack::on_pop_when_not_empty );
 
    so_subscribe_self()
      .in( st_empty )
      .event( &fixed_stack::on_pop_when_empty );
  }

private :
  void on_push( const push & w )
  {
    m_stack.push_back( w.m_val );
    this >>= ( m_stack.size() == m_max_size ? st_full : st_filled );
  }
 
  int on_pop_when_not_empty( mhood_t< pop > )
  {
    auto r = m_stack.back();
    m_stack.pop_back();
    this >>= ( m_stack.empty() ? st_empty : st_filled );
    return r;
  }
 
  int on_pop_when_empty( mhood_t< pop > )
  {
    throw empty_stack( "empty_stack" );
  }
};  

int main() {
  try {
    so_5::launch( []( so_5::environment_t & env ) {
      so_5::mbox_t stack;
      env.introduce_coop( [&stack]( so_5::coop_t & coop ) {
        stack = coop.make_agent< fixed_stack >( 5u )->so_direct_mbox();
      } );

      for( int i = 0; i < 10; ++i )  so_5::send< fixed_stack::push >( stack, i );

      std::cout << "stack { ";
      try {
        for(;;)
          std::cout << so_5::request_value< int, fixed_stack::pop >( stack, std::chrono::seconds(10) ) << " ";
      }
      catch( const fixed_stack::empty_stack & ) {}
      std::cout << "}" << std::endl;

      env.stop();
    } );
    return 0;
  }
  catch( const std::exception & x )  {
    std::cerr << "Oops! " << x.what() << std::endl;
  }
  return 2;
}

Заключение

Модель Акторов — это очень удобный инструмент в случаях, где использование этой модели уместно. Это уже неоднократно доказывалось успешным применением таких инструментов, как Erlang и Akka в самых разнообразных проектах. Да и вообще за асинхронным обменом сообщений между независимыми сущностями будущее. Прислушайтесь к Джо Армстронгу, он плохого не посоветует.


Но не верьте рекламе: использование Модели Акторов уместно не всегда.


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


И размер кошелька, конечно же. В коммерческом проекте за QP/C++ и за Just: Thread Pro придется заплатить. За SObjectizer и CAF — нет. По крайней мере сразу не придется.


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


Неблагодарное это дело. Проверено на людях. Лучше все-таки взять что-то готовое.


И пусть кто-нибудь другой весело бегает по граблям многопоточности :)

Комментарии (0)

© Habrahabr.ru