ElixirMiddleTechnical

Что такое Elixir Agents и когда их использовать вместо GenServer?

Agent — обёртка над GenServer для хранения состояния без кастомной логики сообщений. Когда нужны кастомные handle_call/handle_cast, таймауты, handle_info или сложный lifecycle — используйте GenServer напрямую.

Agent vs GenServer: в чём разница

Agent — это тонкая абстракция над GenServer, предназначенная исключительно для управления состоянием. Он скрывает шаблонный код: вам не нужно объявлять init/1, handle_call/3 и handle_cast/2 — достаточно передать функцию-трансформер состояния. GenServer даёт полный контроль над протоколом сообщений, lifecycle и обработкой ошибок.

Agent: когда и как

Используйте Agent, когда процесс — это просто хранилище данных с простыми операциями чтения/записи.

defmodule Counter do
  def start_link(initial \\ 0) do
    Agent.start_link(fn -> initial end, name: __MODULE__)
  end

  def increment do
    Agent.update(__MODULE__, &(&1 + 1))
  end

  def value do
    Agent.get(__MODULE__, & &1)
  end
end

# Использование
{:ok, _pid} = Counter.start_link(0)
Counter.increment()
Counter.increment()
IO.inspect(Counter.value())  # => 2

Agent.get/2 — синхронный вызов (call), Agent.update/2 тоже синхронный. Есть Agent.cast/2 для асинхронных обновлений без ожидания ответа. Функция, переданная в Agent, выполняется внутри процесса-агента — длинные вычисления заблокируют всех вызывающих.

GenServer: когда и как

Используйте GenServer, когда нужны:

  • Кастомные сообщения через handle_info/2 (например, :timeout, сигналы от других процессов)
  • Разные типы запросов с разной логикой (handle_call vs handle_cast)
  • Инициализация с побочными эффектами (подключение к БД, подписка на события)
  • Тонкая обработка ошибок: {:stop, reason, state}
  • Совместимость с :sys.get_state/1 для отладки
defmodule RateLimiter do
  use GenServer

  @window_ms 60_000
  @max_requests 100

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  def allow?(client_id) do
    GenServer.call(__MODULE__, {:check, client_id})
  end

  ## Callbacks

  @impl true
  def init(_opts) do
    # Периодически сбрасываем счётчики
    schedule_reset()
    {:ok, %{}}
  end

  @impl true
  def handle_call({:check, client_id}, _from, state) do
    count = Map.get(state, client_id, 0)
    if count < @max_requests do
      {:reply, true, Map.put(state, client_id, count + 1)}
    else
      {:reply, false, state}
    end
  end

  @impl true
  def handle_info(:reset, _state) do
    schedule_reset()
    {:noreply, %{}}  # Сбрасываем все счётчики
  end

  defp schedule_reset do
    Process.send_after(self(), :reset, @window_ms)
  end
end

Здесь handle_info(:reset, ...) обрабатывает сообщение от таймера — это невозможно реализовать через Agent.

Правило выбора

КритерийAgentGenServer
Только хранение состоянияизбыточно
Кастомные сообщения / handle_info
Инициализация с побочными эффектами
Разные типы запросов
Простой счётчик / кэш / аккумулятор

На практике: если через месяц понадобится добавить handle_info — придётся переписывать Agent в GenServer. Если логика уже нетривиальна, сразу пишите GenServer.

Supervision

Оба модуля одинаково хорошо живут под Supervisor. Agent.child_spec/1 генерируется автоматически, для GenServer — тоже, если определён start_link/1:

children = [
  Counter,           # Agent
  RateLimiter        # GenServer
]
Supervisor.start_link(children, strategy: :one_for_one)

Подводные камни

  • Долгие вычисления в Agent.get/2 — функция выполняется в процессе агента, блокируя других вызывающих. Для тяжёлых операций используйте Agent.get_and_update/2 или переходите на GenServer с явным копированием состояния на сторону клиента.
  • Agent не поддерживает handle_info — сообщения от Process.send_after/3, мониторов или связанных процессов будут проигнорированы или упадут с ошибкой.
  • Отладка Agent сложнее:sys.get_state/1 работает, но нет возможности подключить кастомный format_status/2 для структурированного вывода.
  • Анонимные агенты теряются — если не передать name:, ссылку на PID нужно хранить самостоятельно. После рестарта супервизора PID меняется.
  • Гонка в get + update — последовательность Agent.getAgent.update не атомарна. Используйте Agent.get_and_update/2 для атомарных операций «прочитать и изменить».
  • Переусложнённый Agent — признак неправильной абстракции — если в Agent накапливается бизнес-логика, это сигнал перейти на GenServer с явными callback-функциями.
  • Не путайте с Elixir TaskTask для одноразовых асинхронных вычислений, Agent для долгоживущего состояния. Нередко новички используют Agent там, где достаточно Task.async/await.
  • ETS как альтернатива — для высококонкурентного чтения (тысячи процессов одновременно) :ets с режимом :read_concurrency быстрее, чем любой агент, поскольку исключает bottleneck единственного процесса.

Common mistakes

  • Сводить agents vs genserver к названию метода без lifecycle и failure path.
  • Игнорировать модель runtime: Elixir компилируется в BEAM bytecode и наследует процессы, message passing, supervision и hot-code friendly модель Erlang VM.
  • Не отделять validation, authorization, transaction boundary и business logic.
  • Менять похожие API местами без учёта семантики ошибок и ownership.

What the interviewer is testing

  • Объясняет agents vs genserver через конкретную точку lifecycle в Elixir.
  • Приводит корректный минимальный пример без вымышленных методов или callbacks.
  • Называет edge cases: пустые значения, ошибки, транзакции, безопасность или concurrency.

Sources

Related topics