gen_server – сервер внутри сервера
21 июня 2014
Доклад для 1-й встречи Belarus Erlang User Group.
- Внутренности gen_server
- Timeout и hibernate
- Отладка с помощью модуля sys
- Оптимизация производительности
- Некоторые рекомендации по использованию gen_server
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(…)

На этой схеме и последующих:
- Левые квадраты, верхний и нижний – это callback-модуль, написанный программистом.
- Правые квадраты, верхний и нижний – это код OTP фреймворка (модули gen_server, gen, proc_lib).
- Верхние квадраты, правый и левый – это код, который выполняется в потоке клиента (или родителя).
- Нижние квадраты, правый и левый – это код, который выполняется в потоке сервера.
Начинаем отсюда:
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_call', {self(), Mref}, Request};
- блокируется в receive, ожидая ответного сообщения;
- ответ матчится по Mref монитора;
- при получении ответа монитор снимается, ответ возвращается клиенту;
- обрабатывается падение серверного процесса, возвращается ошибка;
- обрабатывается timeout, возвращается ошибка.
Здесь же обрабатывается и 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:
- {reply, Reply, NewState}
- {reply, Reply, NewState, Timeout}
- {reply, Reply, NewState, hibernate}
3 noreply:
- {noreply, NewState}
- {noreply, NewState, Timeout}
- {noreply, NewState, hibernate}
2 stop:
- {stop, Reason, Reply, NewState}
- {stop, Reason, NewState}
Про 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 варианта ответа:
- {noreply, NewState}
- {noreply, NewState, Timeout}
- {noreply, NewState, hibernate}
- {stop, Reason, NewState}
Тоже самое, что и 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 инфраструктурой:
- хранит дополнительную информацию о родительском процессе и его предках;
- поддерживает отладку через модуль sys;
- обрабатывает системные сообщения, приходящие от 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