В чём разница между 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.