gen_server: главный строительный блок

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

gen_server — обобщённый сервер OTP: процесс с состоянием, отвечающий на синхронные (call) и асинхронные (cast) запросы через ваши callback-функции.

Если вы освоите только одно поведение OTP, пусть это будет gen_server. Большинство процессов в реальных системах — это именно gen_server: кэши, счётчики, обёртки соединений, менеджеры ресурсов. Удобно думать о нём как об объекте из мира объектно-ориентированного программирования, но живущем в своём отдельном процессе: у него есть приватное состояние, которое снаружи не видно, и набор «методов» — запросов, через которые с ним можно взаимодействовать. Разница в том, что этот «объект» полностью изолирован, работает конкурентно с остальными и общается только сообщениями. Поэтому gen_server одновременно решает две задачи: инкапсулирует состояние и сериализует доступ к нему. Раз процесс обрабатывает запросы строго по одному, вам не нужны мьютексы и блокировки — гонок за общим состоянием просто не возникает, ведь общего состояния нет, есть только сообщения к одному владельцу.

Анатомия gen_server

Вы реализуете несколько callback-функций. Главные три: init задаёт начальное состояние, handle_call отвечает на синхронные запросы (с ответом), handle_cast — на асинхронные (без ответа). Есть и четвёртый, который вы встретите почти сразу, — handle_info: он обрабатывает «прочие» сообщения, пришедшие процессу не через call или cast, а напрямую, например уведомления {'DOWN', ...} от мониторов или сработавшие таймеры. Обратите внимание на структуру модуля ниже: он чётко делится на две зоны. Сверху — клиентский API (start_link, increment, value): это обычные функции, которые вызывает остальной код, и они прячут за собой детали обмена сообщениями. Снизу — callbacks, которые вызывает уже сама OTP. Такое разделение — общепринятый стиль: пользователь модуля никогда не пишет gen_server:call вручную, он зовёт понятную функцию value(), а та внутри сама знает, кому и как послать запрос.

-module(counter).
-behaviour(gen_server).

-export([start_link/0, increment/0, value/0]).
-export([init/1, handle_call/3, handle_cast/2]).

%% --- Клиентский API ---
start_link() ->
    gen_server:start_link({local, ?MODULE}, ?MODULE, 0, []).

increment() -> gen_server:cast(?MODULE, increment).

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

%% --- Callbacks ---
init(InitialCount) ->
    {ok, InitialCount}.

handle_cast(increment, Count) ->
    {noreply, Count + 1}.

handle_call(value, _From, Count) ->
    {reply, Count, Count}.

call против cast

Свойствоcallcast
Ответесть, клиент ждётнет, «выстрелил и забыл»
Поведение клиентаблокируется до ответане блокируется
Callbackhandle_call/3handle_cast/2
Когданужен результат или подтверждениерезультат не важен

call по умолчанию имеет тайм-аут 5 секунд: если сервер не ответит, клиент получит ошибку, а не зависнет навсегда. Это разумная защита, но и ловушка: если вы знаете, что операция может быть долгой, явно укажите больший тайм-аут пятым аргументом gen_server:call, иначе под нагрузкой начнут сыпаться загадочные ошибки тайм-аута. Выбор между call и cast — не просто вопрос «нужен ли ответ». call даёт ещё и обратное давление (backpressure): клиент ждёт, а значит не может завалить сервер быстрее, чем тот успевает обрабатывать. cast такой защиты не даёт — забрасывая сервер асинхронными сообщениями быстрее, чем он их разгребает, можно неограниченно растить его почтовый ящик и в пределе исчерпать память. Поэтому, как ни парадоксально, синхронный call часто безопаснее для системы в целом, хотя и медленнее для отдельного клиента.

Возвращаемые значения callbacks

Каждый callback возвращает кортеж, говорящий OTP, что делать дальше. Для handle_call это обычно {reply, Ответ, НовоеСостояние}, для handle_cast{noreply, НовоеСостояние}. Состояние, которое вы возвращаете, OTP передаст в следующий вызов — так живёт состояние сервера. Здесь важно прочувствовать главную особенность: состояние в Erlang неизменяемо, поэтому сервер не «правит» свою переменную, а на каждом запросе вычисляет новую версию состояния и возвращает её OTP. Общая часть бережно хранит это значение между вызовами и передаёт его в следующий callback третьим (или вторым) аргументом. Получается аккуратный цикл без какого-либо изменяемого хранилища: вход — старое состояние и запрос, выход — ответ и новое состояние. Есть и другие варианты кортежей: например, {stop, Reason, NewState} просит сервер корректно завершиться, а форма {reply, ..., ..., Timeout} позволяет задать тайм-аут бездействия. Но базовых reply/noreply хватает для подавляющего большинства задач.

start_link и имена

Функция запуска неслучайно называется start_link, а не просто start: суффикс _link означает, что новый процесс сразу связывается с тем, кто его запустил. Почти всегда запускающим оказывается супервизор, и связь нужна, чтобы он немедленно узнал о падении сервера. Первый аргумент {local, ?MODULE} просит зарегистрировать процесс под локальным именем, равным имени модуля, — тогда клиентам не нужно знать PID, они обращаются по имени. Запись ?MODULE — это макрос, разворачивающийся в имя текущего модуля; его удобно использовать и как имя регистрации, и как имя callback-модуля, чтобы не дублировать строку и не ошибиться при переименовании. Если регистрировать процесс под именем не нужно, первый аргумент опускают, и start_link вызывают с меньшим числом параметров, работая дальше по возвращённому PID.

Как работает под капотом

Под капотом gen_server — это тот самый рекурсивный приёмный цикл, что мы писали вручную, но «промышленного качества». gen_server:call отправляет серверу сообщение с уникальной ссылкой и ставит монитор, затем ждёт ответ именно с этой ссылкой — поэтому ответы не путаются. gen_server:cast просто шлёт сообщение и не ждёт. Системные сообщения (отладка, завершение, обновление кода) общая часть обрабатывает сама, вызывая ваши callbacks лишь для прикладных запросов.

Частые ошибки

  • Делать долгую работу прямо в handle_call. Сервер однопоточный — он не ответит другим, пока занят. Тяжёлое выносите в отдельные процессы.
  • Использовать call, когда хватило бы cast. Лишняя блокировка клиента.
  • Забыть вернуть новое состояние. Возвращаемый кортеж обязан содержать актуальное состояние.

Итоги

  • gen_server — сервер с состоянием; вы пишете init, handle_call, handle_cast.
  • call синхронный (клиент ждёт ответ), cast асинхронный (без ответа).
  • Callbacks возвращают кортеж с новым состоянием, которое OTP несёт в следующий вызов.
  • Тяжёлую работу не делают внутри callbacks — сервер обрабатывает запросы по одному.
Проверьте себя
1. Чем call отличается от cast в gen_server?
AНичем
Bcall синхронный — клиент ждёт ответа; cast асинхронный — без ответа
Ccast блокирует сервер навсегда
Dcall не возвращает результат
2. Что обычно возвращает callback handle_call?
AТолько новое состояние
BКортеж {reply, Ответ, НовоеСостояние}
CАтом ok
DPID процесса
3. Почему нельзя выполнять долгую работу прямо в handle_call?
AЭто запрещено компилятором
BСервер обрабатывает запросы по одному и не ответит другим клиентам, пока занят
Chandle_call не умеет считать
DЭто вызовет утечку памяти