понедельник, 23 февраля 2015 г.

[prog.c++.sobjectizer] О сбоящих агентах или почему в версии 5.5.4 не будет изменена схема реакции на исключения

Непохожесть агентной модели в SObjectizer на модель акторов в Erlang/Akka/CAF имеет как хорошие, так и не очень хорошие стороны :) Одна из них, непонятно какого знака, -- это отсутствие в SObjectizer-е механизма супервизоров. Не знаю, описывался ли этот механизм где-то до Erlang-а, но я узнал о нем именно при знакомстве с Erlang-ом.

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

Дабы не витать в каких-то высоких материях, можно попробовать продемонстрировать суть идеи на более приближенном к жизни примере.

Предположим, что у нас есть группа акторов, которые совместно решают следующую задачу: вычитывают из какой-то MQ-шной очереди поток сообщений и пушат их на удаленный сервер через RESTful-интерфейс. Причем, поскольку удаленный RESTful-сервер может принимать траффик ограниченной интенсивности (скажем, не более 100 запросов в секунду), а поток из MQ идет эпизодическими "пачками" (скажем, по 10K за раз, после чего может наступить тишина на 10 минут), то нужно иметь возможность сглаживать пики входящего трафика и не грузить RESTful-сервер сверх его скромных возможностей. Так же канал к RESTful-серверу, как и сам RESTful-сервер, не отличается надежностью, поэтому HTTP-взаимодействие с RESTful может обрываться по тем или иным причинам. А запрос, который не был подтвержден, должен быть со временем запушен на RESTful-сервер повторно (будем считать, что в нашем случае это безопасно, т.к. каждое сообщение/запрос имеет уникальный идентификатор, по которому можно понять, что запрос пришел повторно из-за того, что предыдущее подтверждение не было получено инициатором запроса).

В решении такой задачи может потребоваться несколько типов агентов. Как минимум:

  • агент, который будет работать с MQ-шной очередью;
  • агент, который будет работать с СУБД для промежуточного хранения сообщений, пришедших из MQ, но не ушедших еще на RESTful-сервис;
  • агент для выполнения RESTful-взаимодействия (возможно, таких агентов будет много, по одному на каждое HTTP-подключение).

В Erlang/Akka/CAF, как мне представляется, создавался бы родительский актор (процесс в Erlang-е), который бы запускал дочерних акторов (MQ-актора, DB-актора, HTTP-актора), после чего становился бы для них супервизором.

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

В случае SObjectizer-а вряд ли я бы стал вводить родительского агента. Просто для решения этой задачи создавалась бы кооперация, в которую входили бы MQ-агент, DB-агент и HTTP-агент(ы). Соответственно, если бы какой-то из агентов упал бы, то была бы убита, как минимум, вся кооперация. Но скорее всего, если разработчик не даст SObjectizer-у некоторых специальных указаний, будет убито все приложение посредством обращения к std::abort(). И, думается мне, для C++ это вполне обосновано.

Попробую объяснить.

Начать можно издалека, от SObjectizer-4. В котором исключения не поддерживались вообще. Т.е. совсем. Сам SObjectizer информировал о результатах своих действий посредством кодов ошибок. И требовал, чтобы исключения не выпускались из обработчиков событий агентов.

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

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

Зато вот с требованием к агентам не выпускать наружу исключения, оказалось все не так тривиально. Требование это возникло как естественная реакция на возможности SObjectizer-4. Но время показало, что это не глупость и/или жестокость, как может показаться на первый взгляд. Поэтому для прикладных агентов в SObjectizer-5 основная рекомендация, в принципе, такая же: исключения наружу лучше не выпускать.

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

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

private static SupervisorStrategy strategy =
   new OneForOneStrategy(10, Duration.create("1 minute"),
      new Function<Throwable, Directive>() {
         @Override
         public Directive apply(Throwable t) {
            if (t instanceof ArithmeticException) {
               return resume();
            } else if (t instanceof NullPointerException) {
               return restart();
            } else if (t instanceof IllegalArgumentException) {
               return stop();
            } else {
               return escalate();
            }
         }
      });

то могу лишь грустно улыбнуться, пожать плечами и сказать что-то вроде: "Хорошо вам, ловите ArithmeticException или NullPointerException и можете продолжать спокойно работать". В C++ над кросс-платформенным перехватом исключения об обращении по нулевому указателю еще нужно покорпеть. Да и будет ли толк от этого перехвата? Если уж разработчик обратился по указателю с некорректным значением, то какова вероятность, что это значение будет нулевым? Может этот указатель уже смотрит на данные, принадлежащие другому агенту?

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

Во-вторых, если отвлечься от специфики C++ и поговорить об исключениях вообще, то исключения можно разделить на три категории:

  1. Исключения, которые весьма вероятно произойдут. Например, в нашем примере может порваться связь с MQ. Может отвалиться коннект к БД. Может произойти какая-нибудь фигня с HTTP-соединением.
  2. Исключения, о которых программист, в принципе, знает, но защищаться от которых ну очень грустно и муторно. Например, в большинстве случаев на серьезном сервере у нас вряд ли закончится свободная память и мы словим исключение bad_alloc. Тем не менее, рядом с нашей кооперацией могут запустить еще какие-то кооперации, которые возьмут и безжалостно выжрут все, что только можно. Либо же выяснится, что вместо 16-ядерного сервера с 1TB RAM на борту наше приложение будет запускаться на встроенной железячке со старым одноядерным ARM-ом и 256Mb памяти. И мы запросто сможем столкнуться с ситуацией, когда безобидная с виду операция добавления еще одного идентификатора сообщения в небольшой std::vector упадет из-за нехватки памяти.
  3. Исключения, о которых программист даже не подозревает, но которые таки могут выскочить. Например, при определенном стечении обстоятельств библиотека для работы с БД может выбросить исключение о том, что ей не хватает ресурсов для инициализации. Хотя это будет следствие всего лишь несовпадения языка ОС и языка DLL-ки с ODBC-драйвером.

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

Ну действительно, представим, что связь с СУБД порвалась при выполнении транзации и DB-агент выпустил наружу исключение о том, что текущая операция над БД сломалась с таким-то кодом ошибки. И что?

Если агенту нужен рестарт для возобновления работы, то почему тогда он сам не мог сделать этот рестарт переинициализировав свои внутренние структуры данных (вспоминаем, например, про идиому pimpl)? Почему операция рестарта должна перекладываться на какого-то супервизора, как будто супервизор будет лучше понимать, что делать с тем объемом данных, который оказался в подвешенном состоянии из-за обрыва транзакции?

Да и, по моему мнению, такой рестарт -- это не самая тривиальная операция. Например, потому, что если связь с СУБД порвалась, то не факт, что следующий реконнект произойдет быстро. Скорее потребуется какое-то время и несколько неудачных попыток переконнекта, прежде чем связь будет восстановлена. Что в это время будут делать MQ- и HTTP-агенты? Далеко не праздный вопрос, однако. И, на мой взгляд, логично, если правильный ответ на него найдет сам разработчик DB-агента, реализовав в агенте соответствующую логику по взаимодействию с MQ- и HTTP-агентами в ситуации, когда связи с СУБД нет. А если такая логика слишком сложна и накладна, то не проще ли тогда грохнуть сразу всех агентов кооперации, дабы не мучаться?

Если мы говорим об исключениях второй и третьей категории, то вопрос упирается в то, какой уровень безопасности исключений мы готовы (и способны) обеспечивать при написании кода своих прикладных агентов. Никто не признает себя плохим программистом, но если говорить откровенно, то как давно мы всерьез задумывались о том, чтобы обеспечить в прикладном(!!!), а не библиотечном, коде строгую гарантию? Т.е. чтобы при возникновении исключения в каком-то событии прикладного агента, состояние агента автоматически откатывалось к исходному (т.е. к тому, которое было на момент вызова события агента).

И ладно, если речь идет только о состоянии самого агента. Но ведь он до возникновения исключения мог еще и повоздействовать на внешний мир. Например, DB-агент сохранил новую пачку сообщений в БД, отослал новые запросы HTTP-агентам, но на отсылке подтверждения MQ-агенту возникло исключение bad_alloc. Ну вот не хватило памяти и все. MQ-агент через какое-то время попытался доставить неподтвержденные сообщения до DB-агента еще раз, но у него так же обломалась операция send. И что дальше?

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

В связи с этим примеры супервизоров из документации к Akka напоминают мне не что иное, как "заплатки" или, по-буржуински, "workaround"-ы. Т.е. нам достался написанный кем-то агент. Эта редиска Этот нехороший агент время от времени выкидывает наружу какое-то собственное исключение. И поделать с этим мы ничего не можем, т.к. агент писан не нами и поработать над ним напильником мы не можем. Все, что нам остается, -- это повесить на агента супервизора и при поимке уже знакомого нам исключения мы можем проигнорировать его, понимая, что в самом агенте ничего не сломалось и он будет продолжать работать и дальше. Хотя хорошим решением такой подход назвать все равно сложно.

Тут можно поговорить о том, а что в этом плане позволяет сделать SObjectizer.

Когда SObjectizer ловит выпущенное агентом наружу исключение, он вызывает у агента метод so_exception_reaction(). Этот метод возвращает значение, которое должно сказать SObjectizer Run-Time о том, какое действие должно быть предпринято.

По умолчанию возвращается значение so_5::rt::inherit_exception_reaction. Это означает, что SObjectizer будет вызывать метод exception_reaction() у кооперации, которой принадлежит агент. Если и кооперация вернет inherit_exception_reaction, то метод exception_reaction() будет вызван у родительской кооперации и т.д. До тех пор, пока дело не дойдет до кооперации, у которой нет родителя. Тогда метод exception_reaction() будет вызван у самого SObjectizer Environment, внутри которого работает проблемный агент. А там уже может быть получено одно из следующих значений:

  • so_5::rt::abort_on_exception. Работа всего приложения будет оборвана посредством вызова std::abort();
  • so_5::rt::shutdown_sobjectizer_on_exception. Агент переводится в специальное состояние, в котором он ничего не обрабатывает. Инициируется завершение работы SObjectizer Environment без вызова std::abort();
  • so_5::rt::deregister_coop_on_exception. Агент переводится в специальное состояние, в котором он ничего не обрабатывает. Дерегистрируется кооперация, в которую входит агент (а так же все ее дочерние кооперации);
  • so_5::rt::ignore_exception. Исключение игнорируется, а агент продолжает работать как ни в чем не бывало.

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

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

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

Нотификатор дерегистрации можно написать самому, а можно использовать стандартный, который уже есть в SObjectizer-е. Этот стандартый нотификатор присылает сообщение so_5::rt::msg_coop_deregistered, в котором содержится информация о дерегистрированной кооперации. В том числе и причина дерегистрации. Если кооперация была дерегистрирована из-за исключения, то причиной будет значение so_5::rt::dereg_reason::unhandled_exception.

Что дает возможность, при большом желании, написать супервизора своими руками посредством использования нотификатора дерегистрации кооперации. Простейший пример того, как это можно сделать, показан в штатном примере coop_notifications, описание которого можно найти в Wiki проекта (в том числе со ссылками на другие сопутствующие материалы).

Однако, описанный подход к работе с необработанными исключениями в SObjectizer-5 (включая версию 5.5.3), теоритически, может иметь следующие проблемные места:

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

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

Однако, если кто-то покажет, что механизм обработки исключений нуждается в тех или иных модификациях/доработках/переделках, то вполне можно будет этим заняться. Только нужно понимать, что это реально нужно в C++проектах. А не потому, что в Erlang или Akka есть, а вот в SObjectizer нет.

Ну и, кстати говоря, я здесь наговорил много довольно-таки радикальных соображений на счет обработки исключений (да и сбоев вообще, по большому-то счету). Будет интересно услышать и противоположные точки зрения. Может я искренне заблуждаюсь и все совсем не так? :)

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