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
| Свойство | call | cast |
| Ответ | есть, клиент ждёт | нет, «выстрелил и забыл» |
| Поведение клиента | блокируется до ответа | не блокируется |
| Callback | handle_call/3 | handle_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 — сервер обрабатывает запросы по одному.