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, ...) вразброс по кодовой базе. Это даёт единую точку, где видно все возможные взаимодействия, упрощает рефакторинг и позволяет добавить, скажем, валидацию или логирование в одном месте. Сервер становится чёрным ящиком с аккуратным фасадом — ровно тем, чем в других языках был бы объект с инкапсулированным состоянием, только здесь это безопасно конкурентно.

Проверьте себя
1. Чем GenServer.call отличается от GenServer.cast?
AНичем
Bcall синхронен — клиент ждёт ответ (с таймаутом); cast асинхронен — кладёт сообщение и сразу возвращается без ответа
Ccall асинхронен, cast синхронен
Dcast быстрее, потому что не использует mailbox
2. Почему внутри GenServer не бывает гонок за его состояние?
AИспользуются мьютексы
BСервер обрабатывает сообщения строго по одному, последовательно — состояние меняется атомарно
CСостояние хранится в файле
DГонки бывают