Ніжна дружба агентів і виключень в SObjectizer

Рано чи пізно в програмі щось йде не так. Не відкрився файл, не створилася робоча нитка, не виділилася пам'ять… І з цим треба якось жити.
В невеликому однопоточному додатку досить просто: можна перервати всю роботу і рестартовать. Це один із факторів, завдяки якому Erlang здобув собі заслужену популярність, адже ідеологія fail fast є одним з наріжних каменів Erlang-а з його легковажним процесами. Якщо ж додаток велике, складне і багатопотокове, то не розумно рестартовать усі додаток, якщо лише одна з його ниток зіткнулася з проблемами. Ще гірше в ситуації з реалізацією Моделі Акторів, у яких сотні тисяч акторів можуть працювати на десятках робочих ниток. Проблема одного актора навряд чи повинна позначатися на всіх інших актора.
У даній статті ми розповімо, як ми підійшли до обробки помилок у своєму фреймворку SObjectizer.
Винятків – так, кодами повернення – ні!
Коли SObjectizer-4 з'явився в 2002-му році, ми зробили велику помилку – зволіли використовувати коди повернення винятків. І весь подальший досвід розробки на SObjectizer-4 знову і знову переконував у однієї простої істини: якщо помилка може бути прогнорирована розробником, то вона буде ним проігнорована. Тому при створенні SObjectizer-5 ми вирішили використовувати виключення для інформування про помилки.

Це був правильний вибір. У спорах «виключення проти кодів повернення» до цих пір ламаються списи, але наш досвід показує, що розробка тільки виграє, якщо не можна випадково пропустити, наприклад, помилку підписки агента або реєстрації кооперації агентів.
Отже, SObjectizer-5 викидає виключення, якщо не може виконати ту чи іншу операцію. Найчастіше ці операції виконуються вже зареєстрованими в SObjectizer-е агентами. Що ж робити агенту, якщо він зіткнувся з виключенням?
Нормальний агент не повинен випускати виключення назовні!
Це головне правило, яке існує для агентів щодо винятків. Якщо агент при обробці свого події отримує виключення (не важливо, викинув виняток SObjectizer чи хтось інший), то агент не повинен випускати це виняток назовні.
Пояснення просте. Агент в SObjectizer не володіє власним робочим контекстом. Грубо кажучи, агент не володіє робочою ниткою, на якій він працює. Робочий контекст надається диспетчером, до якого прив'язаний агент, на час обробки чергового події, а потім може бути наданий іншому агенту. Коли якийсь агент випускає виняток назовні, то це виняток потрапить до диспечеру, выделившему робочий контекст. Якщо додаток не хоче, щоб диспетчер вирішував, чи вбити додаток або дозволити продовжити роботу, то агентам цього додатка варто самим поміркувати над обробкою виключень.
В ідеальному випадку це означає, що події агентів повинні бути noexcept-методами. Але це в ідеальному випадку. Та й механізм noexcept в C++ – це річ хороша, але вона лише гарантує, що виключення з noexcept-методу не прилетить. При цьому вилетіти воно може, компілятор ж не б'є по руках, якщо в noexcept-методи викликаються не-noexcept-методи. І якщо виключення таки вилітає, то приводить прямо до std::terminate(). Що не завжди нас влаштовує.
Як же бути в тому неідеальному світі, в якому ми живемо?
SObjectizer-у можна підказати, як реагувати на вылетевшее з агента виключення
Так як shit таки happens час від часу, то навіть коли ми беремося забезпечувати no exception guarantee для агентів, то можемо помилитися і виключення все-таки піде назовні. Його зловить диспетчер і буде вирішувати, що ж робити далі.
Для цього диспетчер викличе у проблемного агента віртуальний метод so_exception_reaction(). Цей метод повинен повертати одне з наступних значень:
  • so_5::abort_on_exception. Що приведе до виклику std::abort() і переривання роботи всієї програми;
  • so_5::shutdown_sobjectizer_on_exception. Це значення означає, що агент забезпечує basic exception guarantee (тобто відсутність витоків ресурсів і/або псування чого-небудь), але продовжувати далі роботу сенсу немає. Тому агент переводиться в спеціальний стан, в якому агент не може обробляти ніяких подій, а робота SObjectizer Environment завершується нормальним чином, без виклику std::abort(). При цьому належним чином дерегистрируются всі зареєстровані кооперації, що дає можливість іншим агентам нормально завершити свою роботу і почистити ресурси. Зазначимо, що в програмі одночасно може працювати кілька SObjectizer Environment. У разі shutdown_sobjectizer_on_exception завершується робота тільки того SObjectizer Environment, в якому виняток було перехоплено;
  • so_5::deregister_coop_on_exception. Це значення означає, що агент забезпечує basic exception guarantee і додаток може продовжити свою роботу без цього агента і його кооперації. Тому агент переводиться в спеціальний стан, а його кооперація звичайним чином дерегистрируется (що дає можливість іншим агентам кооперації нормально завершити свою роботу);
  • so_5::ignore_exception. Це значення означає, що агент забезпечує strong exception guarantee (тобто немає витоків ресурсів і/або псування чого-небудь + агент залишається в коректному стані) і може продовжувати свою роботу. Тому диспетчер просто ігнорує виняток, як ніби його й не було.
Наявність такого варіанту, як ignore_exception може здатися дивним після того, як було заявлено, що нормальні агенти не повинні випускати виключення назовні. Однак, на практиці наявність такого значення зручно для агентів з дуже простими обробниками подій. Наприклад, агент отримує повідомлення типу M1 і перетворює його в повідомлення типу M2. При перетворенні може виникнути виняток, але воно мало на що впливає: стан агента не порушено, повідомлення M2 загубилося, ну так повідомлення можуть губитися по тим або іншим причинам. В таких випадках простіше дозволити винятків вилітати з простих агентів щоб диспетчер проігнорував їх, ніж включати в кожен оброблювач подій блок try-catch.
Таким чином, програміст може сам вирішити, який варіант краще всього підходить для його агента, перевизначити метод so_exception_reaction() і тим самим проінформувати SObjectizer про те, як бути після перехоплення винятку:
using namespace so_5;
// Простий агент, який отримує повідомлення M1 mbox-а src і перетворює його
// повідомлення M2 mbox-а dest. Винятки, які можуть бути випущені
// назовні, ігноруються.
class my_simple_message_translator final : public agent_t {
public :
my_simple_message_translator( context_t ctx, mbox_t src, mbox_t dest )
: agent_t( ctx ) {
so_subscribe( src ).event( [dest]( const M1 & msg ){ send< M2 >( dest, ... );} );
}
// Вказуємо SO-5, що винятки можуть бути проігноровані.
virtual exception_reaction_t so_exception_reaction() const override {
return ignore_exception;
}
};

Реакція на виключення на рівні кооперації
Штатна реалізація agent_t::so_exception_reaction() смикає метод exception_reaction() в кооперації, в яку входить агент. Тобто за замовчуванням агент успадковує реакцію на виняток у своїй кооперації. А цю реакцію можна задати при реєстрації кооперації. Наприклад:

// Реєструємо кооперацію і вказуємо, що при вильоті виключення
// з будь-якого з агентів потрібно скасувати реєстрацію всю кооперацію.
env.introduce_coop( []( coop_t & coop ) {
coop.set_exception_reaction( deregister_coop_on_exception );
coop.make_agent< some_agent >(...);
...
} );

Таким чином, у SObjectizer реакцію на виключення можна вказати на рівні агента, а якщо це не було зроблено, то використовується реакція на виняток, задана для кооперації агента.
Але що відбувається, якщо при створенні кооперації метод set_exception_reaction() не викликається (а в більшості випадків він не викликається)?
Якщо програміст не викликав coop_t::set_exception_reaction() явно, то coop_t::exception_reaction() поверне спеціальне значення – so_5::inherit_exception_reaction. Це значення вказує, що кооперація успадковує реакцію на виняток у своїй батьківського кооперації. Якщо ця батьківська кооперація є, то SObjectizer викличе exception_reaction() для неї. Якщо і батьківська кооперація поверне значення so_5::inherit_exception_reaction, то SObjectizer викличе exception_reaction() для батьків батьківського кооперації і т. д.
врешті-решт може виявитися, що немає черговий батьківського кооперації. У цьому випадку SObjectizer викличе exception_reaction() для всього environment_t. А вже environment_t::exception_reaction() поверне значення so_5::abort_on_exception. Що і призведе до краху всього додатка через виклик std::abort().

Однак, програміст може задати реакцію на виключення для всього SObjectizer Environment. Це робиться через налаштування властивостей SObjectizer-а при запуску:
so_5::launch( []( environment_t & env ) {...},
[]( environment_params_t & params ) {
params.exception_reaction( shutdown_sobjectizer_on_exception );
...
} );

Невелике проміжне резюме
Отже, якщо агент випускає назовні виняток, то SObjectizer перехоплює його і питає у агента, що робити з виключенням через виклик agent_t::so_exception_reaction(). Якщо програміст не переопределял so_exception_reaction(), то реакцію на виключення визначає кооперація, в яку входить агент.
Зазвичай кооперація говорить SObjectizer-у, що вона успадковує реакцію на виключення від свого батька. І SObjectizer буде питати батьківську кооперацію. Потім батька батьківського кооперації і т. д. А коли батьки закінчаться, SObjectizer запитає реакцію на виняток у environment_t, всередині якого працює проблемний агент. За замовчуванням environment_t скаже, що роботу додатка потрібно перервати через виклик std::abort().
Таким чином, програміст може впливати на виникнення винятків на різних рівнях:
  • в самому агента, перехоплюючи всі винятки всередині подій агента або ж перевизначаючи so_exception_reaction();
  • кооперації агента або в батьківських кооперації;
  • SObjectizer Environment, всередині якого працюють агенти і кооперації.
Як зреагувати на дорегистрацію кооперації?
Як показано вище, SObjectizer може реагувати на випущені з агента виключення по-різному. Може, наприклад, скасувати реєстрацію тільки проблемну кооперацію. Але який сенс в цій реакції? Адже кооперація вирішувала якусь прикладну задачу в додатку, а якщо б не вирішувала, то її б і не було. І тут ця кооперація раптово зникає… Як про це дізнатися і як на це зреагувати?
SObjectizer дозволяє отримати повідомлення про те, що якась кооперація виявилася дерегистрирована. Чимось цей механізм нагадує можливість моніторингу процесів в Erlang: наприклад, можна викликати erlang:monitor(process, Pid) і, якщо процес Pid завершується, то приходить повідомлення {'DOWN',...}.
У SObjectizer є можливість «повісити» нотіфікатор на подію дерегистрации. Нотіфікатор – це функтор, який SObjectizer викличе автоматично, коли завершить дорегистрацію кооперації. В цей функтор SObjectizer передасть та ім'я дерегистрированной кооперації, та причину її дерегистрации. Цей функтор може робити те, що потрібно додатком. Наприклад, можна відіслати якомусь зацікавленій агенту повідомлення про зникнення кооперації. А можна просто перереєструвати кооперацію:
// Простий приклад демонструє автоматичну перереєстрацію кооперації
// якщо вона була дерегистрирована через виникнення виключення.
#include < iostream>

#include <so_5/all.hpp>

void start_coop( so_5::environment_t & env )
{
env.introduce_coop( [&]( so_5::coop_t & coop ) {
struct raise_exception : public so_5::signal_t {};

// Єдиний агент в даній кооперації.
// Буде випускати назовні виняток через секунду після старту.
auto agent = coop.define_agent();
agent.on_start( [agent] {
so_5::send_delayed< raise_exception >( agent, std::chrono::seconds(1) );
} )
.event< raise_exception >( agent, [] {
throw std::runtime_error( "Just a test exception" );
} );

// Приписуємо SObjectizer-у скасувати реєстрацію кооперацію у разі виключення.
coop.set_exception_reaction( so_5::deregister_coop_on_exception );

// Вішаємо нотіфікатор, який перевірить причину дерегистрации кооперації
// та, якщо було виключення, створює цю ж кооперацію заново.
coop.add_dereg_notificator(
[]( so_5::environment_t & env,
const std::string & coop_name,
const so_5::coop_dereg_reason_t & why )
{
std::cout << "Deregistered: " << coop_name << ", reason: "
<< why.reason() << std::endl;

if( so_5::dereg_reason::unhandled_exception == why.reason() )
start_coop( env );
} );
} );
}

int main()
{
so_5::launch( []( so_5::environment_t & env ) {
// Створюємо кооперацію в перший раз. 
start_coop( env );
// Даємо деякий час для роботи.
std::this_thread::sleep_for( std::chrono::seconds( 5 ) );
// І зупиняємося.
env.stop();
} );
}

Готової системи супервізорів, як в Erlang-е, SObjectizer-е ні. Якось виходило обходитися без неї. Але, якщо для прикладної задачі вона потрібна, то можна зібрати щось схоже на базі нотифікаторів.
Невелике філософське зауваження наостанок
C++ – це небезпечний мову. І написання коду, який забезпечує хоча б базові гарантії безпеки винятків, вимагає певних зусиль від розробника. Тому, при реалізації акторів в C++, потрібно обачно ставитися до використання принципу fail fast. Це в Erlang-е добре – якщо в процесі виявили якусь проблему, то просто вбили процес, після чого Erlang VM почистила всі за ним, а відповідний супервізор виконав запуск нового процесу замість роботу.
В C++ всі агенти живуть в рамках одного процесу. Тому, якщо якийсь з агентів реалізований недостатньо якісно, допускає витік ресурсів і/або псування чого-небудь в пам'яті процесу, то його дерегистрация і подальше створення нового агента натомість дерегистрированного, може виявитися не рішенням, а ще більшою проблемою.
Саме з-за цього в SObjectizer за замовчуванням робота всього додатки переривається, якщо якийсь з агентів випускає назовні виняток. Якщо програміста це не влаштовує і він збирається змінити реакцію на якусь іншу (особливо на реакцію ignore_exception), то слід кілька разів гарненько подумати і ретельно перевірити код агентів на предмет забезпечення exception safefy.
Висновок
Мабуть, цією статтею ми закриваємо розповідь про основні відмітні особливості SObjectizer-а. Наступні статті про SObjectizer ми збираємося випускати, коли буде з'являтися що-небудь новеньке. Ну або якщо будуть потрапляти цікаві запитання, дати вичерпну відповідь на які в коментарях важко.
Заодно, користуючись нагодою, запрошуємо відвідати конференцію Corehard C++ Autumn 2016, яка відбудеться 22-го жовтня в Мінську. І на якій буде доповідь про Моделі Акторів стосовно до C++. В тому числі і про SObjectizer.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.