суббота, 28 марта 2020 г.

[prog.actors] Почему я не считаю упомянутых в статье на Хабре акторов настоящими акторами

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

К сожалению, получилось как в анекдоте: "Вот нутром чую, что 0.5 плюс 0.5 будет литр, а математически выразить не могу". Т.е. ощущение "должно было быть попроще" присутствует, но вот чтобы выяснить, что именно и как можно (и нужно бы) упростить... На это не хватает времени и терпения.

Это вообще достаточно сложная проблема, с которой я регулярно сталкивался будучи тимлидом. Делаешь code review подчиненного и явно ощущаешь, что реализация переусложнена, что можно проще. Говоришь "здесь что-то слишком сложно, должно быть проще", а на встречный вопрос "А как проще?" сходу ответ дать не можешь. Потому что для того, чтобы дать такой ответ, нужно самому сесть и плотно поработать над этой задачей. А это значит, что ты мало того, что будешь вынужден переделать работу, которую должен сделать твой подчиненный, но еще и не успеешь сделать что-то другое, что ты никому не можешь делегировать.

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

Но данный пост я решил написать на другую тему, связанную с этой же статьей.

Дело в том, что в статье автор повсюду упоминает акторов и, как мне показалось, пребывает в уверенности, что использует "акторный подход". Хотя я (пока еще) убежден, что это не так. И в данном посте попробую объяснить, почему мне кажется, что использованные в статье "акторы" на самом деле "акторами" не являются.

Почему же "акторы" не акторы?

Объяснять буду на пальцах. Ибо когда-то пробовал погрузиться в формальное описание акторов от Карла Хьютта и Ко, но у меня это не получилось. Т.к. с математикой и математическими абстракциями у меня проблемы начались еще во время учебы в универе. А за 25 лет после выпуска из ВУЗа все стало многократно хуже.

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

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

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

Из чего происходит важная особенность акторов, а именно: если актору B нужно что-то от актора A, то актор B отсылает сообщение актору A, а уже актор A решает, как именно он будет реагировать на сообщение от актора B. И будет ли реагировать вообще. Т.е. актор -- это автономная сущность, которая самостоятельно решает, что делать с полученным сообщением. Причем решение это принимается в момент извлечения сообщения из очереди.

Это как раз то, чего нет у акторов из обсуждаемой статьи. Там, по сути, происходит внешнее директивное управление акторами. Так, актор B захотел получить значение 'a' от актора A и выставил задачу "вызвать у A метод GetA" соответствующему диспетчеру задач.

Получается, что у актора A нет никакой самостоятельности, нет никакой свободы выбора. Актор B решает, что и как будет делать актор A.

Следует ли докапываться до способа возврата ответа от одного актора другому?

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

Еще один момент, который очень сильно смущает в обсуждаемой статье, -- это способ передачи ответа от целевого актора A инициировшему операцию актору B.

Исходя из того, что в Модели Акторов акторы взаимодействуют через асинхронный обмен сообщениями, можно было бы ожидать, что актор A среагировав на сообщение отошлет некое сообщение ValueA актору B.

Т.е. по "классике жанра" следовало бы ожидать что-то вроде вот такого:

// Логика работы актора A.
while(!terminate) {
  m = mailbox.receive();
  if(m is GetA) { // У нас запрашивают значение 'a'.
    // Отсылаем текущее значение отправителю, адрес которого лежит в сообщении.
    send(m.sender, ValueA(a));
  }
  ...
}

// Где-то внутри актора B.
send(actorA, GetA(this)); // Послали запрос. Ссылку на себя передали в запросе.
m = mailbox.receive();
if(m is ValueA) { // Получили значение 'a'.
  ...
}

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

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

Но тут хочется обратить внимание читателя на две вещи.

  • во-первых, это и есть один из источников искусственной сложности, о которой я говорил выше. Т.е. вместо базовой для акторного подхода операции отсылки сообщения мы видим какую-то странную инкапсуляцию, которая все равно приводит к той же самой отсылке сообщения у себя под капотом. А раз так, то эта инкапсуляция не нужна, т.к. без нее можно обойтись и полезных свойств у такой инкапсуляции не наблюдается;
  • во-вторых, опять же возникает вопрос о "свободе воли" актора A. Вот он получил входящее сообщение. Вроде как он должен иметь возможность выбрать способ реакции на него. Но, по факту, реакция только одна: вызов жестко заданного коллбэк-функтора. И все. Опять приходим к тому, что "акторы" из статьи никакие не самостоятельные сущности, обладающие собственным поведением, т.е. не акторы в традиционном понимании.

А как же можно охарактеризовать то, что описано в статье?

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

Т.е. по сути мы имеем что-то вроде:

DataObjectA A; // Это "актор" A.

thread_executor contextA; // Контекст для операций над A.

schedule(contextA, [&] {
   auto a = A.getA();
   schedule(contextA, [a, &A, &contextA] {
      auto b = A.getB();
      schedule(contextA, [a, b, &A, &contextA] {
         A.saveAB(a - b, a + b);
         schedule(contextA, [&A, &contextA] {...});
      });
   });
});

Только в этом псевдокоде мне приходится протаскивать ссылки на A и contextA в списке захвата лямба-функций явным образом. А автор попытался упрятать ссылку на contextA прямо в объект A.

В общем-то ничего не запрещает мне придумать некую сущность вроде объект-с-данным-привязанный-к-конкретному-контексту-исполнения и переписать этот же фрагмент более компактно:

thread_executor contextA; // Контекст для операций над A.
ExecutionContextBoundDataObjectA A(contextA); // Это "актор" A.

schedule(A, [&A] {
   auto a = A.getA();
   schedule(A, [a, &A] {
      auto b = A.getB();
      schedule(A, [a, b, &A] {
         A.saveAB(a - b, a + b);
         schedule(A, [&A] {...});
      });
   });
});

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

Так что я придерживаюсь мнения, что в обсуждаемой статье использован task-based подход, а вовсе не Модель Акторов.


Для тех, кто хочет прочувствовать разницу между несколькими подходами, дам ссылку на свою же статью "Текстовая версия доклада «Actors vs CSP vs Tasks...» с C++ CoreHard Autumn 2018". Статья не новая, но актуальности, как я вижу, не потеряла.

9 комментариев:

Alex комментирует...

Отличное определение акторов! Спасибо. Мало того, что толковое, понятное и не замороченное абстрактными сферическими конями, так ещё и наводящее на мысль: а не попробовать ли эти акторов в самом деле на практике. А то все читаешь, читаешь. Вроде интересно, но как-то далеко. А теперь как будто проснулся)))

eao197 комментирует...

@Alex

Если интересно, то могу сослаться на свое выступление на митапе в Питере: https://www.youtube.com/watch?v=c1qSVSHoMjU
Я там в начале постарался на пальцах рассказать о том, что такое Модель Акторов без матана и ухода в абстракции.

Alex комментирует...

Спасибо, посмотрю обязательно, когда доберусь до нормального инета.

Grigory Demchenko комментирует...

У автора в статье на хабре не акторы, а колбеки.

Твое определение акторов не является 100% акторами, а только теми "акторами", которые все почему-то используют и пишут "как в Эрланге", хотя до Эрланга там еще примерно столько же.

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

eao197 комментирует...

@Grigory Demchenko

Не понял пары вещей:

> Важный аспект, которые все забывают: принятие сообщений в любом месте, причем определенного рода сообщений.

Что под этим подразумевается?

> Именно поэтому акторы в Эрланге - это корутины.

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

С точки зрения C++ разработчика акторы в Эрланге -- это короутины. Тогда как с точки зрения Erlang VM -- это полноценные процессы в рамках данной VM.

Так что я не очень понимаю, что стоит за этой фразой.

Grigory Demchenko комментирует...

> Что под этим подразумевается?

Это когда можно написать:

receive {
msgA -> someAction
msgB -> someOtherAction
}

Т.е. помимо того, чтобы использовать send в любом месте, можно также использовать и receive в любом.

Говоря другим языком, так называемые "акторы" в 99.9% других реализаций используют лишь gen_server из Эрланга, который лишь микроскопический кусочек из OTP библиотеки.

> Так короутины -- это всего лишь механизм

Сопрогаммы имеют вполне конкретное определение, а именно: можно продолжить исполнение в месте выхода. Процессы в Эрланге называются процессами, т.к. данные изолированы, и их виртуальная машина вытесняет. А вытесняет она их в моменте receive, либо когда число тактов вышло. В этом смысле больше похоже на треды операционной системы, где операционная система - это Erlang VM. Но выглядит это как будто сопрограммы. Я бы даже сказал, что реализация больше похожа на stackless coroutines, т.к. новый процесс занимает не больше 100 байт, если я помню. Только без говна в виде co_async/co_await.

eao197 комментирует...

@Grigory Demchenko

> можно также использовать и receive в любом.

А откуда это проистекает и почему это важно?

Grigory Demchenko комментирует...

Это следует из Эрланга, который считается стандартом де-факто для акторной модели. Например, Scala Akka пытается быть похожим на Эрланг, введя оператор "!" прям как в Эрланге. При этом receive они забыли ввести, т.к. это просто невозможно в текущей модели.

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

eao197 комментирует...

@Grigory Demchenko

> Это следует из Эрланга, который считается стандартом де-факто для акторной модели.

Считается-то он считается, а вот является ли? ;)
Достаточно вспомнить, что Джо Армстронг вообще не упоминал акторов, рассказывая про историю и цели создания Erlang-а :)

Но это так, шутка юмора.

Если смотреть с практической точки зрения, то, возможно, ты и прав. Однако, нужно заметить, что для достижения подобного удобства в Erlang-е потребовалось сделать не простой receive, а selective receive. Без этого самого selective receive пользователям пришлось бы внучную решать проблему сообщений, которые им не нужны в данный момент времени, которые нельзя просто так отбросить.

И вот здесь уже практика несколько вступает в противоречие даже с минимальной формализации Модели Акторов, т.к. мне не доводилось встречать требование наличие "selective receive" для акторов.

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

1. Сменить свое состояние (поменять поведение).

2. Создать конечное количество новых акторов.

3. Отослать конечное количество сообщений.

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

Кроме того, синхронные взаимодействия, будучи нужными на практике, все-таки противоречат сути Модели Акторов, в которой акторы взаимодействуют только через посылку асинхронных сообщений. Что и позволяет избегать дедлоков. А пара из send с последующим receive "прямо по месту" как раз и ведет напрямую к получению дедлоков.

Так же мне кажется, что требование возможности вызова receive в произвольном месте резко ограничивает возможность адаптации Модели Акторов к различным условиям. Т.к. тогда акторы можно будет представить только в виде процессов (процессов ОС или процессов какой-то VM), полноценных нитей ОС или легковесных нитей (green threads, fibers). Т.е. в виде сущностей, которые могут быть приостановлены на вызовах типа send/receive и вытеснены с вычислительного ядра.

Между тем, как мне представляется, реализация Модели Акторов на базе объектов с коллбэками, где куча акторов может быть размещена на одной нити ОС или на общем пуле нитей ОС, так же должна иметь право на жизнь. Тем более, что в ряде прикладных областей, типа АСУТП или имитационного моделирования, такое представление акторов вполне естественно и удобно.