Об очередном антипаттерне. Модель акторов.
От: LaPerouse  
Дата: 24.08.15 14:05
Оценка: 46 (5)
Речь идет разумеется не о том, что концепция акторов сама по себе является антипаттерном только потому, что это акторы, а о случаях их неправильного использования или чрезмерного использования. Проблема однако в том, что по моим наблюдениям 90 процентов случаев их реального применения на практике являются чрезмерными или неправильными. На волне популярности эрланга и всевозможных фреймворков навроде akka, концепция акторов сама по себе переоценена и выглядит в глазах некоторых как серебряная пуля, хотя и близко таковой не является. Приведу некоторые примеры, с которыми столкнулся за последние два года.

1. Использование акторов для parallel execution.

actor = new Actor();
actor.sendMessage(new CalculateSomething(1, 2, 3));
//Все, здесь ничего больше не делается, и если даже этот код
//является частью другого актора, в 99 процентах случаях он либо не
//получает других сообщений кроме CalculationResult,
//а если и получает, то ничего не может с ними сделать без CalculationResult


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

actor = new Actor();
actor.sendMessage(new CalculateSomething(1, 2, 3));
someHardCalculation();


это все равно останется антипаттерном, потому что потом кто-нибудь может добавить еще одно вычисление

actor = new Actor();
actor.sendMessage(new CalculateSomething(1, 2, 3));
actor.sendMessage(new CalculateSomething(4, 5, 6));
calc = someHardCalculation();


которое выполнится последовательно с первым вычислением (используется один актор). Потом приходится переписывать такой код с помощью Conditions или Future:

calc = new Calculator();
calc1Future = calc.calculate(1, 2, 3);
calc2Future = calc.calculate(4, 5, 6);
calc = someHardCalculation();
result = resultCalculation(calc, calc1Future.get(), calc2Future.get());


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

2. В предыдущем примере речь шла о явно ошибочном использовании акторов. Однако даже в тех случаях, когда использование акторов, казалось бы, оправдано, я бы 10 раз подумал, прежде чем их использовать. Приведу типичный пример — сервер, работающий по TCP, большое количество параллельных сессий, но время обработки запроса незначительное. Казалось бы, что может быть проще

sessionLock.lock();
try {
    commonDataLock.lock();
    try {
        //Непродолжительная обработка
        executeLogic(message, session, commonData);
    } finally {
      commonDataLock.unlock();
    }
} {
  sessionLock.unlock();
}


или на худой конец использовать актор, но только ОДИН (в смысле класс один, в котором будет вся обработка, а не instance). Однако, и здесь может найтись умник, который разнесет обработку запроса по двум, трем а то и более акторам — "так более масштабируемо"... В результате в системе порождается огромное количество сообщений между акторами, отлик падает, потому что 90 процентов тактов уходит на переключение контекста, вращение в спинлоках, чтение-копирование данных. Читаемость и поддерживаемость кода уже с тремя акторами с богатыми состояниеми и нетривиальными взаймодействиями радикально осложняется, об этом речь пойдет дальше.

3. Асинхронный код сложнее в поддержке, это все знают. Однако асинхронность асинхронности рознь. Одно дело, когда это асинхронный вызов с callback-ом, сохраняющий контекст вызова. И другое дело — актор, который каждый раз в момент прихода сообщения должен понять, в каком он состоянии находится. Городить на каждый чих FSM — удовольствие не из приятных. При добавлении одной дополнительной коммуникации или одного дополнительного контекста количество состояний способно комбинаторно взорваться. Бизнес-логика размазывается по всему коду актора на маленькие порции. Вместо одного сервисного метода вы ползаете по функциям перехода, проклиная автора за то, что тот не оставил excel-таблицу с описанием автомата. Это в том случае, если автор удосужился оформить актор именно в виде автомата с именованным признаком состояния, а не определяет контекст из лапши условий — последнее встречается чаще, тогда приходится брать бумагу и карандаш и рисовать граф с переходами, чтобы составить хотя бы приблизительное представление о том, что же делает эта мешанина кода. Спору нет, в некоторых случаях такой подход может быть эффективным с точки зрения производительности — в случае с TCP-сервером из предыдущего примера применение нескольких типов акторов может быть оправданным в случае достаточно тяжелых запросов. Однако в любом случае, прежде чем браться за акторы, я бы посмотрел на другие способы решения задачи. Например, shared memory что бы про него не говорили при соблюдении простейших правил и правильного проектирования совершенно безопасен, а код ясен и читаем. Акторы же, даже в тех случаях, когда их применение технически оправдано, принципиально тяжелы для восприятия — это их фунадментальная особенность в силу того, что каждый актор является машиной состояний.

activateState1();
actor1.sendMessage(new Request1(data1));
actor2.sendMessage(new Request2(data2));
state = STATE1_WAIT_FOR_RESPONSE1_RESPONSE2;


void response1Handler(Response1 resp) {
     this.resp1 = resp;
     if (state == STATE1_WAIT_FOR_RESPONSE1_RESPONSE2) {
         state = STATE1_WAIT_FOR_RESPONSE2;
     } else if (state == STATE1_WAIT_FOR_RESPONSE1){
       process(this.resp1, this.resp2);
       someCommonForState1State2Processing();
       state = STATE1_PROCESSED;
     } else if (state == STATE2_WAIT_FOR_RESPONSE1) {
       //Здесь другой контекст (STATE2) и совершенно другое вычисление
       doAnotherProcessing(this.resp1);
       someCommonForState1State2Processing();
       state = STATE2_PROCESSED;
     } else if (...){}//Обработка других комбинаций
}

void response2Handler(Response2 resp) {
     this.resp2 = resp;
     if (state == STATE1_WAIT_FOR_RESPONSE1_RESPONSE2) {
         state = STATE1_WAIT_FOR_RESPONSE1;
     } else if (state == STATE1_WAIT_RESPONSE2) {
       process(this.resp1, this.resp2);
       someCommonForState1State2Processing();
       state = STATE1_PROCESSED;
     }
}


Что может произойти с кодом выше при добавлении нового контекста STATE3 знает любой, кто хоть немного знаком с конечными автоматами — произойдет взрыв состояний — STATE12_WAIT_RESPONSE1_RESPONSE2, STATE23_WAIT_FOR_RESPONSE1_RESPONSE2 и т.д. Ситуацию может облегчить использование иерархических автоматов — тогда вместо подобной комбинации состояний будет дерево и состояния WAIT_FOR_RESPONSE1_RESPONSE2 будут дочерними по отношению к контекстам STATE1, STATE2, STATE3... Но иерархические автоматы никто в подобных случаях не использует (уже спасибо, если удосужились оформить машину состояний в виде обычного автомата), потому что для этого требуются либо дополнительные телодвижения, либо дополнительная библиотека. Также количество состояний можно сократить с использованием coroutines (в С# — yield return), но они не в каждом языке есть и не каждому разработчику знакомы. А акторы, черт возьми, знакомы уже многим (к сожалению).

Что касается технической стороны кода, приведенного выше, то он может быть оправдан в тех случаях, когда помимо Response1 и Response2 актор ожидает например еще SomeMessage, обработкой которого он потенциально может заняться, ожидая ответа на Request1 и Request2. Но анализ встретившихся мне случаев опять-таки говорит о том, что в большинстве случае никаких других сообщений кроме Response1 и Response2 актор не принимает (а если и принимает, то не может ничего сделать с ними без Response1 и Response2), и висит в спинлоке или мьютексе точно также, как висел бы в случае явной блокировки:

ProcessingResult process(data1, data2) {
      future1 = performer1.request(new Request1(data1));
      future2 = performer2.request(new Request2(data2));
      return process(future1.get(), future2.get());
}

ProcessingResult2 processAnother(data) {
      return performer1.request(new Request1(data));
}


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

Если заменить 95 процентов всех случаев использования акторов на shared memory/future/stm/однопоточный код, получится технически как минимум эквивалентное (а с учетом первого пункта о неправильным использовании — зачастую и превосходящее) решение, к тому же значительно выигрывающее по ясности и поддерживаемости. Из этого не следует, что акторы не нужны, но рядовому программисту они почти не нужны, объективно. Само собой, я не затрагиваю тех, кто пишет ПО для кластеров — в этой области это де-факто стандарт коммуникации.
Социализм — это власть трудящихся и централизованная плановая экономика.
 
Подождите ...
Wait...
Пока на собственное сообщение не было ответов, его можно удалить.