ElixirMiddleTechnical

В чём разница между send/receive и передачей сообщений через GenServer?

send/receive — низкоуровневые примитивы обмена сообщениями между процессами. GenServer — высокоуровневый OTP behaviour поверх них, добавляющий синхронные call (с таймаутом), асинхронные cast, обработку системных сообщений и интеграцию с Supervisor.

send/receive — низкоуровневая передача сообщений

send(pid, message) помещает сообщение в mailbox указанного процесса и немедленно возвращает управление. receive do ... end извлекает первое совпавшее сообщение из mailbox текущего процесса. Это примитив BEAM VM — минимальный уровень абстракции:

parent = self()

worker = spawn(fn ->
  receive do
    {:compute, x, from} ->
      send(from, {:result, x * x})
  end
end)

send(worker, {:compute, 7, parent})

receive do
  {:result, val} -> IO.puts("Got #{val}")  # => Got 49
after
  3000 -> IO.puts("Timeout")
end

GenServer — высокоуровневая абстракция

GenServer — OTP behaviour, построенный поверх send/receive. Он стандартизирует паттерны: синхронный call (запрос + ответ с таймаутом), асинхронный cast (только запрос), обработку системных сообщений (:EXIT, :DOWN, рестарт Supervisor), логирование и трассировку.

defmodule Cache do
  use GenServer

  # Клиентский API
  def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
  def put(key, val), do: GenServer.cast(__MODULE__, {:put, key, val})
  def get(key), do: GenServer.call(__MODULE__, {:get, key})

  # Callbacks
  @impl GenServer
  def init(state), do: {:ok, state}

  @impl GenServer
  def handle_cast({:put, key, val}, state) do
    {:noreply, Map.put(state, key, val)}
  end

  @impl GenServer
  def handle_call({:get, key}, _from, state) do
    {:reply, Map.get(state, key), state}
  end
end

Cache.start_link([])
Cache.put(:user, "Alice")
Cache.get(:user)  # => "Alice"

Ключевые отличия

  • Синхронность: GenServer.call блокирует вызывающий процесс до получения ответа (с таймаутом 5000 мс по умолчанию); send всегда неблокирующий.
  • Обработка ошибок: GenServer перехватывает исключения в колбэках и завершает процесс аккуратно, уведомляя Supervisor; receive не имеет встроенной защиты.
  • Системные сообщения: GenServer автоматически обрабатывает :system-сообщения (трассировка, горячая замена кода, смена режима), которые в ручном receive нужно обрабатывать самостоятельно.
  • Интеграция с OTP: GenServer совместим с Supervisor, :sys.trace, :observer; сырой spawn + receive — нет.
  • Сложность реализации: send/receive — 3-5 строк для простых задач; GenServer — ~20 строк, но масштабируется без рефакторинга.

Когда использовать send/receive напрямую

  • Одноразовые задачи через Task.async/await (Task использует send/receive внутри).
  • Простые сигналы между тесно связанными процессами (например, родитель-дочерний в тестах).
  • Реализация кастомного цикла сообщений в специализированных серверах (rare case).

handle_info для внешних send

Если кто-то отправил сырое сообщение через send в GenServer-процесс, оно обрабатывается через handle_info/2:

@impl GenServer
def handle_info({:DOWN, ref, :process, pid, reason}, state) do
  # монитор упавшего воркера
  {:noreply, cleanup(state, ref)}
end

@impl GenServer
def handle_info(:tick, state) do
  Process.send_after(self(), :tick, 1000)
  {:noreply, do_work(state)}
end

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

  • send в GenServer в обход API — прямой send(pid, {:put, key, val}) в GenServer вместо cast обходит типизацию колбэков и может привести к необработанному сообщению в mailbox, если нет handle_info.
  • GenServer.call дедлок — если GenServer вызывает call сам в себя синхронно, это гарантированный дедлок; используйте cast или send(self(), ...) + handle_info.
  • Таймаут call по умолчанию 5000 мс — при превышении вызывающий процесс получает exit, а GenServer продолжает обрабатывать запрос; ответ придёт в mailbox как «мусорное» сообщение.
  • Необработанные сообщения в receive — паттерны в receive не покрывающие все типы сообщений оставят «мусор» в mailbox навсегда; в GenServer это решает handle_info(msg, state) -> {:noreply, state} как фолбэк.
  • cast без подтвержденияcast не гарантирует доставку или обработку; если процесс умер до обработки, сообщение потеряно молча.
  • Сериализация через call — все запросы к GenServer обрабатываются последовательно в одном потоке; при высоком RPS это узкое место; рассмотрите пул процессов через :poolboy или partitioned Registry.

Common mistakes

  • Сводить send receive 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

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

Sources

Related topics