gen_server – сервер внутри сервера

21 июня 2014

Доклад для 1-й встречи Belarus Erlang User Group.

gen_server хорошо известный и активно используемый паттерн. С другой стороны, он требует некоторых усилий для понимания.

Хороший подход к изучению gen_server – написать его самому. Такой подход выбрал и Joe Armstrong (Programming Erlang, глава 16), и Fred Hebert (LYSE, глава What is OTP?).

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

Внутренности gen_server

gen_server является базовым паттерном OTP, потому что все остальные: supervisor, gen_event, gen_fsm – реализованы аналогично, но узко специализированны.

Мы заглянем в код приложения stdlib, которое на моем компьютере находится здесь: /usr/local/lib/erlang/lib/stdlib-1.19.4 (а на вашем я не знаю, где :)

Нас будут интересовать модули: gen_server, proc_lib, sys и gen.

proc_lib предлагает функции обертки над стандартными функциями старта процессов erlang:spawn, erlang:spawn_link, erlang:spawn_opt, где совершаются дополнительные действия над процессами, чтобы вписать их в инфраструктуру OTP.

sys используется для отладки OTP-процессов.

gen (недокументированный) содержит общий код для gen_server, gen_fsm, gen_event и supervisor.

gen_server:start_link(…)

На этой схеме и последующих:

Начинаем отсюда:

start_link() ->
    gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).

Далее вызывается gen:do_spawn(…), где берется Timeout из Options если он там есть, иначе по дефолту задается infinity и вызывается proc_lib:start_link(…).

Дальше вызывается proc_lib:spawn_opt(…) где определяется родительский процесс и все его предки, вызывается erlang:spawn_opt(…), который и создает новый серверный процесс. После чего родительский процесс вызывает proc_lib:sync_wait(…), блокируется в receive с заданным timeout и ждет сообщение ack от серверного процесса.

Серверный процесс либо пришлет подтверждение, что он создан и у него все ок {ack, Pid, Return}, либо упадет при инициализации {'EXIT', Pid, Reason}, либо ничего не придет до истечения timeout. В последнем случае серверный процесс убивается, и возвращается ошибка.

Тем временем жизнь серверного процесса начинается с gen_server:init_it(…). Здесь настраиваются параметры отладки из агрумента Options, если есть, и от sys:debug_options(…).

Затем вызывается init нашего callback-модуля, где мы вольны инициализировать серверный процесс как нам угодно. Например, может создать для него структуру данных, хранящее состояние и вернуть ее {ok, State}.

Дальше посылается подтверждение клиентскому процессу proc_lib:init_ack(…). И, наконец, серверный процесс входит в основной цикл gen_server:loop(…).

gen_server:call(…)

Начинаем отсюда:

get_staff() ->
    gen_server:call(?MODULE, get_staff).

gen_server:call(Name, Request) ->
    case catch gen:call(Name, '$gen_call', Request) of
        {ok, Res} -> Res;
        {'EXIT', Reason} -> exit({Reason, {?MODULE, call, [Name, Request]}})
    end.

gen:call(…) разбирается, что такое Name – процесс, или список узлов; и разбрается с Timeout. В отличие от gen_server:start_link здесь timeout по умолчанию будет 5 секунд.

Затем gen:do_call(…) делает основную работу:

Здесь же обрабатывается и gen_server:multi_call(…) на удаленные узлы, так что код сложнее.

Между тем, серверный процесс, находясь в gen_server:loop(…) ждет сообщение. Ловит все подряд, обрабатывает в gen_server:decode_msg(…).

Могут приходить системные сообщения {system, From, Req}, они передаются для обработки в sys:handle_system_msg(…). Это могут быть сообщения shutdown от супервайзера, запросы на получение и замену состояния серверного процесса, используемые при отладке.

Если включена отладка, то входящие сообщения передаются в sys:handle_debug(…). Затем передаются дальше в gen_server:handle_msg(…), где отдельный клоз матчится на {'$gen_call', From, Msg}. Тут, наконец, вызывается handle_call нашего модуля, обрабатываются все варианты ответов и ошибки, после чего ответ посылается сообщением клиентскому процессу, а серверный опять входит в gen_server:loop(…).

Если включена отладка, то исходящие сообщения тоже передаются в sys:handle_debug(…).

gen_server:cast(…)

Ну тут ответ не нужен, так что все проще.

Начало аналогичное:

add_staff(Staff) ->
    gen_server:cast(?MODULE, {add_staff, Staff}).

gen_server:cast(Name, Request) ->
    case catch gen:call(Name, '$gen_cast', Request) of
        {ok, Res} -> Res;
        {'EXIT', Reason} -> exit({Reason, {?MODULE, call, [Name, Request]}})
    end.

Дальше тот же путь через gen:call(…), gen:do_call(…), gen_server:loop(…), gen_server:decode_msg(…) и, наконец, gen_server:handle_msg(…), но другой клоз, откуда сообщение передается на gen_server:dispatch(…). И тут для сообщений {'$gen_cast', Msg} вызывается handle_cast, для всех остальных handle_info нашего модуля.

Некоторые нюансы о callback-функциях

start_link

Для старта gen_server есть 4 функции start/3, start/4, start_link/3, start_link/4. Сперва о разнице между start и start_link. Второй вариант создает связь между родительским процессом и серверным. Первый вариант такой связи не создает. Второй вариант всегда должен использоваться в реальном коде, чтобы супервизор мог мониторить своих потомков. Первый вариант можно использовать в консоли, чтобы запускать разрабатываемый модуль для отладки.

C аргументами, я полагаю, все должно быть понятно, дублировать документацию не буду :) Единственное, чтобы понять последний аргумент Options, нужно читать документацию по erlang:spawn_otp. Там настройки работы с памятью и приоритета процесса. Впрочем, это чаще всего не нужно трогать.

init

init блокирует родительский процесс, причем с timeout = infinity по умолчанию. Желательно оставлять эту функцию легковесной, и возвращать управление родителю как можно быстрее.

Если инициализация сервера требует долгих действий (чтение файлов, установку соединения с базой и т.д.), то такие вещи лучше делать отложено.

Отложенную инициализацию можно сделать двумя способами:

Послать самому себе сообщение, поймать его в handle_info, и сделать тяжелую инициализацию там.

init(Args) ->
    State = ...
    self() ! heavy_init,
    {ok, State}.

handle_info(heavy_init, State) ->
    NewState = ...
    {noreply, NewState};

Или задать timeout = 0 в ответе init и в handle_info обработать сообщение timeout

init(Args) ->
    State = ...
    {ok, State, 0}.

handle_info(timeout, State) ->
    NewState = ...
    {noreply, NewState};

Про изначальный смыл {ok, State, Timeout} будет ниже.

А еще очень нежелательно в init крашится :) Такой краш обычно проявляется на старте приложения, а старт приложения обычно происходит на старте узла. Так что при этом весь узел падает, и с не очень понятными сообщениями в логе.

handle_call

handle_call имеет 8 вариантов ответа.

3 reply:

3 noreply:

2 stop:

Про Timeout и hibernate будет ниже, reply и stop понятны. noreply нужно объяснить.

Клиент в любом случае должен получить ответ на вызов gen_server:call. Если мы ответ не пошлем, то клиентский процесс упадет.

Другое дело, что ответ мы можем послать раньше, чем полностью отработает весь код в handle_call. Например, если обработка запроса займет некоторое время, и мы не хотим блокировать клиента на все это время, то мы можем дать ответ раньше, вызовом gen_server:reply(From, Reply). Затем выполнить обработку, затем вернуть noreply или stop без Reply.

handle_cast и handle_info

handle_cast и handle_info имеют 4 варианта ответа:

Тоже самое, что и handle_call, только отвечать клиенту не нужно.

format_status

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

Этот callback используется для формирования crash report – сообщения об ошибке при падении процесса. Там собирается информация о процессе, его родителях, инфа из sys:get_debug и, конечно, состояние процесса.

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

Вместо

[{data, [{"State", State}]}],

сделать

[{data, [{"State", get_important_part_of(State)}]},

Timeout и hibernate

init, handle_call, handle_cast и handle_info могут вернуть Timeout или hibernate.

Если задан Timeout, и в течение этого Timeout gen_server не получает никаких сообщений, то он сам себе генерирует сообщение timeout, и его можно обработать в handle_info. Например, можно сделать так, что если сервер 5 минут не получает никаких сообщений, то он отравляется в hibernate.

Ну а если сообщения поступают раньше, то они отменяют Timeout. А повторно его можно установить (или не устанавливать) по результатам обработки сообщений в соответствующем handle_call/handle_cast.

init(Args) ->
    State = ...,
    {ok, State, 5 * 60 * 1000}.

handle_info(timeout, State) ->
    io:format("~p no messages from clients, hibernate", [?MODULE]),
    {noreply, State, hibernate}.

hibernate – это особое состояние процесса, в котором он занимает минимум памяти. При этом отбрасывается стек, проводится сборка мусора, дефрагментируется heap.

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

Отладка с помощью модуля sys

gen_server и другие OTP модули уже имеет встроенные средства отладки.

Посмотрим некоторые функции модуля sys.

sys:trace(Name, Flag) позволяет включить-выключить вывод в консоль всех сообщений, которые проходят через gen_server:

> sys:trace(e_prof, true).
ok
> e_prof:add_action("some", 5).
*DBG* e_prof got cast {add_action,"some",5}
*DBG* e_prof new state {state,[{action_accum,[115,111,109,101],[5]}],[{action_stat_set,{16,15,4},[]},{action_stat_set,{16,14,4},[]},{action_stat_set,{16,13,4},[]},{action_stat_set,{16,12,4},[]},{action_stat_set,{16,11,4},[]}]}
ok
> e_prof:add_action("some", 15).
*DBG* e_prof got cast {add_action,"some",15}
*DBG* e_prof new state {state,[{action_accum,[115,111,109,101],[15,5]}],[{action_stat_set,{16,15,4},[]},{action_stat_set,{16,14,4},[]},{action_stat_set,{16,13,4},[]},{action_stat_set,{16,12,4},[]},{action_stat_set,{16,11,4},[]}]}
ok
> sys:trace(e_prof, false).
ok

sys:statistics(Name, Flag) собирает и показывает статистику работы серверного процесса:

> sys:statistics(e_prof, true).
ok
> sys:statistics(e_prof, get).
{ok,[{start_time,{{2014,6,18},{16,14,43}}},
     {current_time,{{2014,6,18},{16,17,10}}},
     {reductions,360},
     {messages_in,5},
     {messages_out,0}]}
> sys:statistics(e_prof, false).
ok

sys:get_state(Name) -> State позволяет получить состояние процесса:

> sys:get_state(e_prof).
{state,[],
       [{action_stat_set,{16,21,4},[]},
        {action_stat_set,{16,20,4},[]},
        {action_stat_set,{16,19,4},[]},
        {action_stat_set,{16,18,4},[]},
        {action_stat_set,{16,17,4},[]}]}

sys:get_status(Name) -> Status дает еще больше инфы о процессе:

> sys:get_status(e_prof).
{status,<0.136.0>,
        {module,gen_server},
        [[{'$ancestors',[e_prof_sup,<0.134.0>]},
          {'$initial_call',{e_prof,init,1}}],
         running,<0.135.0>,
         [{statistics,{{{2014,6,18},{16,14,43}},
                       {reductions,3590},
                       4,0}}],
         [{header,"Status for generic server e_prof"},
          {data,[{"Status",running},
                 {"Parent",<0.135.0>},
                 {"Logged events",[]}]},
          {data,[{"State",
                  {state,[],
                         [{action_stat_set,{16,16,4},
                                           [{action_stat,"some",10.0,15,...}]},
                          {action_stat_set,{16,15,4},[]},
                          {action_stat_set,{16,14,4},[]},
                          {action_stat_set,{16,13,4},[]},
                          {action_stat_set,{16,12,...},[]}]}}]}]]}

Очевидно, что эта отладка дает некоторый оверхед. Но все сделано по уму – отладка включается и выключается.

Оптимизация производительности

Ну а теперь подходим к самому интересному. Мы ведь полезли во внутренности OTP не просто так, а чтобы поискать, где можно выжать больше производительности. Всем нам хочется узнать, где и как можно похачить реализацию gen_server, чтобы работало быстрее :)

Понятное дело, что разработчики уже ходили этим путем. Например, этим занимался Луик Хоген (Loïc Hoguin), основатель компании 99s и автор Cowboy.

Есть его выступление на Erlang Factory 2013 Beyond OTP, где он рассказывает, про оптимизации, сделанные в Cowboy и Ranch. Там используются кастомные supervisor и gen_server, благодаря которым удалось на 10% увеличить количество запросов в секунду и на 20% снизить latency.

Там упрощенный supervisor, без child specs, только со стратегией temporary, но с дополнительным мониторингом и учетом дочерних процессов. Но этот специфический supervisor нам не очень интересен, а интересно, что можно сделать с gen_server.

Можно удалить поддержку удаленных узлов, и работать только с локальными процессами. gen_server:multi_call и gen_server:abcast работать не будут, и не надо :)

Можно убрать вызов proc_lib:sync_wait, не ждать сообщения {ack, Pid, Return}, и не обрабатывать падение серверного процесса при инициализации.

В gen_server:call можно убрать catch перед gen:call, не ловить возможные ошибки при отправке сообщения серверному процессу.

Также в gen:do_call можно отказаться от установки и снятия монитора на серверный процесс.

И можно отказаться от поддержки Timeout и hibernate в ответах сервера.

То есть, мы жертвуем надежностью ради 10-20% производительности. Стоит ли? :) Если вы решили, что стоит, тогда нужно начать с небольшой доки Sys and Proc_Lib, где описано, как создать свой special process

Special process – это процесс который дружит с OTP инфраструктурой:

В доке все толково и с примерами описано.

Некоторые рекомендации по использованию gen_server

Ну и напоследок некоторые рекомендации.

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

Если gen_server получает запрос, который не матчится ни с каким клозом в handle_ обработчиках, то он падает, перезапускается супервизором, и теряет свое состояние. Вместо этого лучше сделать, чтобы все handle_call, handle_cast, handle_info имели последний клоз "catch all", перехватывающий все неизвестные запросы и пишущий ошибку (или предупреждение) в лог.

Не нужно ставить timeout = infinity для gen_server:call. Если случится deadlock на запросе с таким timeout, то его очень сложно будет диагносцировать. Поэтому, если 5 секунд по умолчанию вам мало, то ставьте больше, но не infinity. И не слишком много, потому что о dead lock вы узнаете, когда истечет этот timeout.

Используйте call только тогда, когда вам действительно нужно получить ответ. cast работает быстрее и не блокирует клиентский процесс.

Посылать сообщения напрямую, в обход gen_server и обрабатывать их в handle_info не запрещено, но считается плохим стилем. Такой код сложнее проследить, кто и откуда делал вызов. Этот как в ООП программе обращаться к приватным методам в обход публичного АПИ.

Если init – аналог конструктора класса, то terminate – аналог деструктора. Тут нужно освобождать ресурсы. Если это требует времени, то нужно настроить адекватный timeout для terminate в супервизоре (параметр Shutdown в child spec).

spec для handle функций можно не писать. Они очень громоздкие и не несут никакой пользы, ни как документация, ни как опора для dialyzer. Если хотите писать, то можете воспользоваться моим рецептом, как сделать эти spec лаконичными.

comments powered by Disqus