GenServer: сервер с состоянием
GenServer — самое используемое поведение OTP. Это процесс с состоянием и аккуратным API: синхронные запросы (call), асинхронные команды (cast) и чистое разделение клиента и сервера.
Где в других языках вы завели бы объект с полями и методами, в Elixir вы заводите GenServer: его состояние живёт в процессе, а методы — это колбэки.
GenServer состоит из клиентского API (обычные функции, которые зовёт остальной код) и серверных колбэков (что делать при сообщении). Вот счётчик целиком:
defmodule Counter do
use GenServer
# --- Клиентский API ---
def start_link(initial), do: GenServer.start_link(__MODULE__, initial, name: __MODULE__)
def value, do: GenServer.call(__MODULE__, :get) # синхронно
def inc, do: GenServer.cast(__MODULE__, :inc) # асинхронно
# --- Серверные колбэки ---
def init(initial), do: {:ok, initial}
def handle_call(:get, _from, count), do: {:reply, count, count}
def handle_cast(:inc, count), do: {:noreply, count + 1}
end
Разница принципиальна: call синхронен — клиент ждёт ответ ({:reply, answer, new_state}). cast асинхронен — «выстрелил и забыл» ({:noreply, new_state}).
{:ok, _} = Counter.start_link(0)
Counter.inc() # cast, вернётся мгновенно
Counter.inc()
Counter.value() # call, ждёт ответ => 2
Как работает под капотом (BEAM)
Под капотом GenServer — это всё тот же процесс с циклом receive, но цикл реализован в OTP. GenServer.call отправляет серверу сообщение с уникальной ссылкой и блокирует клиента, ожидая ответ с этой ссылкой (по умолчанию до 5 секунд, иначе — таймаут-краш). GenServer.cast просто кладёт сообщение в mailbox и сразу возвращается. Сервер обрабатывает сообщения строго по одному, последовательно — поэтому внутри GenServer не бывает гонок за его состояние: оно всегда меняется атомарно, один запрос за раз. Это бесплатная сериализация доступа к состоянию.
call (синхронно):
клиент --{ref, :get}--> [GenServer] --{ref, ответ}--> клиент
клиент ЖДЁТ ответ (блокируется до таймаута)
cast (асинхронно):
клиент --:inc--> [GenServer mailbox] клиент идёт дальше сразу
Та же идея на Python ▶
Покажем разницу call/cast и последовательную обработку, защищающую состояние.
from collections import deque
class Counter:
def __init__(self, initial=0):
self.state = initial
self.mailbox = deque()
def cast(self, msg): # асинхронно: положил и ушёл
self.mailbox.append(("cast", msg))
def call(self, msg): # синхронно: ждём ответ
self._drain() # обработать накопленное по одному
if msg == "get":
return self.state # {:reply, count, count}
def _drain(self): # обработка строго по одному -> нет гонок
while self.mailbox:
_, msg = self.mailbox.popleft()
if msg == "inc":
self.state += 1 # {:noreply, count + 1}
c = Counter(0)
c.cast("inc")
c.cast("inc")
print(c.call("get")) # 2 — состояние менялось атомарно
Частые ошибки
- Долгая работа в handle_call. Сервер обрабатывает сообщения по одному; тяжёлый запрос блокирует всех клиентов. Выносите работу или используйте cast.
- cast там, где нужно подтверждение. cast не возвращает результат и не сообщает об ошибке — для гарантий нужен call.
- Забыть про таймаут call. По умолчанию 5 секунд; долгий сервер уронит клиента таймаутом.
Best practices
- Прячьте
GenServer.call/castза чистым клиентским API — остальной код не должен знать о деталях. - Держите состояние компактным; крупные данные выносите в ETS или хранилище.
- Используйте call для запросов с ответом и cast — для команд «без ответа», осознавая разницу.
Итог. GenServer — это процесс-с-состоянием с понятным API и бесплатной сериализацией доступа. Это рабочая лошадь OTP. Но что поднимет его, если он упадёт? Супервизор — наша следующая тема.
Init, тяжёлая инициализация и проектирование API
Колбэк init/1 заслуживает внимания: он выполняется в процессе сервера до того, как тот начнёт принимать запросы, и блокирует старт. Поэтому тяжёлую инициализацию (загрузку данных, установку соединений) не делают прямо в init — иначе супервизор будет долго ждать старта ребёнка. Идиома — вернуть из init базовое состояние и отправить себе сообщение {:continue, :load}, обработав долгую работу в handle_continue уже после того, как процесс «ожил».
Не менее важно проектирование клиентского API. Хорошее правило: весь внешний код общается с сервером только через ваши публичные функции (Counter.inc(), Counter.value()), а не через сырые GenServer.call(pid, ...) вразброс по кодовой базе. Это даёт единую точку, где видно все возможные взаимодействия, упрощает рефакторинг и позволяет добавить, скажем, валидацию или логирование в одном месте. Сервер становится чёрным ящиком с аккуратным фасадом — ровно тем, чем в других языках был бы объект с инкапсулированным состоянием, только здесь это безопасно конкурентно.