Передача сообщений: ! и receive

Как процессы общаются: отправляем и принимаем сообщения.

Почтовый ящик (mailbox) — очередь входящих сообщений процесса. Сообщения накапливаются в ней и извлекаются блоком receive.

Процессы изолированы, поэтому единственный способ их взаимодействия — сообщения. Разберём два инструмента: оператор отправки и блок приёма. Вместе они образуют полный «язык общения» процессов: одного достаточно, чтобы что-то сказать, второго — чтобы услышать. Всё богатство конкурентных программ на Erlang, вплоть до сложных распределённых систем, в основе сводится к этой простой паре операций.

Прежде чем погружаться в синтаксис, удержим в голове общую картину диалога. Один процесс кладёт сообщение в почтовый ящик другого и идёт по своим делам. Адресат, когда дойдут руки, заглядывает в свой ящик, находит подходящее сообщение и реагирует. Никто никого не перебивает и не лезет в чужую память — всё взаимодействие происходит через эти аккуратные «письма». Дальше мы посмотрим на каждую половину диалога отдельно.

Отправка: оператор !

Сообщение отправляется оператором ! (его называют «bang»). Слева — PID получателя, справа — любой терм (любое значение Erlang). То, что отправить можно любое значение — число, атом, кортеж, список, сколь угодно вложенную структуру, — очень удобно: сообщение само по себе может нести богатую информацию о том, что нужно сделать и какие данные при этом передать.

Pid ! {hello, "мир"}.
Pid ! 42.
Pid ! {self(), запрос}.   % self() — PID текущего процесса

Отправка асинхронная: ! кладёт сообщение в почтовый ящик получателя и тут же возвращает управление. Отправитель не ждёт, пока сообщение прочитают. Это принципиальное свойство: отправитель и получатель не обязаны быть «в фазе». Можно послать сообщение процессу, который сейчас занят чем-то другим, — оно спокойно полежит в ящике, пока до него дойдёт очередь. Распространённая идиома — вложить в сообщение собственный PID, полученный через self(), чтобы адресат знал, кому слать ответ. Так из односторонней отправки складывается полноценный диалог «запрос-ответ».

Поскольку прямого «ответа» от ! не приходит, синхронность — когда вам всё-таки нужно дождаться результата — приходится строить вручную: отправить запрос и затем встать на приём ответного сообщения. Эта пара «послал и жду ответа» встречается так часто, что позже её упаковывают в готовые абстракции OTP, но важно понимать, что под капотом там всё та же асинхронная отправка плюс ожидание входящего сообщения.

Приём: блок receive

Процесс забирает сообщения из почтового ящика блоком receive. Внутри — шаблоны, как в case: выбирается ветка, чей шаблон совпал с сообщением. Здесь снова окупается понимание pattern matching из третьего раздела: разбор входящих сообщений устроен ровно так же, как разбор любого другого значения. Обычно по форме сообщения и определяют его смысл — кортеж с меткой get означает запрос на чтение, кортеж с меткой set — команду на запись, и так далее.

loop() ->
    receive
        {hello, Name} ->
            io:format("Привет, ~s!~n", [Name]),
            loop();
        stop ->
            io:format("Останавливаюсь~n");
        _Other ->
            io:format("Непонятное сообщение~n"),
            loop()
    end.

Если в ящике нет подходящего сообщения, receive блокирует процесс — он «засыпает» до прихода нужного. Это не тратит процессор: спящий процесс ничего не потребляет. Это важнейшее отличие от наивного ожидания в цикле, который крутится впустую и жжёт процессорное время. В Erlang ожидающий процесс полностью снимается с исполнения и не отнимает ресурсов ровно до того момента, как ему придёт сообщение. Поэтому держать множество процессов, большую часть времени мирно спящих в ожидании событий, совершенно нормально и почти бесплатно.

Селективный приём

receive просматривает почтовый ящик и берёт первое сообщение, подходящее под любой шаблон, даже если оно не первое в очереди. Неподошедшие сообщения остаются в ящике и будут рассмотрены позже. Это называют селективным приёмом — он позволяет обрабатывать важные сообщения вне очереди. Благодаря ему процесс способен временно «прислушиваться» только к определённому виду сообщений, игнорируя прочие до поры. Например, ожидая конкретный ответ на свой запрос, он может пропустить мимо ушей посторонние уведомления, которые разберёт позже, когда вернётся в основной цикл.

У этого удобства есть и оборотная сторона, о которой полезно знать заранее. Если в ящик постоянно сыплются сообщения, которые ни один шаблон не разбирает, они там накапливаются, и каждый последующий receive вынужден просматривать всё больше «мусора», прежде чем найдёт нужное. Поэтому хорошо спроектированный процесс старается так или иначе разобрать любое приходящее сообщение — хотя бы запасной веткой, которая молча его выбрасывает, — чтобы ящик не разрастался.

Полный пример: эхо-процесс

start() ->
    spawn(fun() -> echo_loop() end).

echo_loop() ->
    receive
        {From, Msg} ->
            From ! {reply, Msg},
            echo_loop()
    end.
% Клиент: Pid ! {self(), "ау"}, потом ждёт {reply, "ау"}

Тайм-аут

Чтобы не ждать вечно, добавляют after с числом миллисекунд. Это спасательный круг на случай, когда ожидаемое сообщение может вообще не прийти — например, процесс-собеседник упал или ответ потерялся. Без тайм-аута процесс в такой ситуации завис бы навсегда, бессмысленно ожидая письма, которого нет. С after же у ожидания есть предел: по истечении срока выполняется ветка тайм-аута, и процесс получает шанс среагировать — повторить запрос, вернуть ошибку или просто продолжить работу.

receive
    {reply, Data} -> Data
after 5000 ->
    timeout
end.

Как работает под капотом

Почтовый ящик — это очередь в куче процесса. При receive BEAM перебирает сообщения от старых к новым, проверяя шаблоны. Подошедшее извлекается, остальные сохраняют порядок. Если ничего не подошло, процесс снимается с планировщика и не получает процессорного времени, пока не придёт новое сообщение, — отсюда дешевизна «спящих» процессов. Большой накопившийся ящик с непрочитанными сообщениями — известный антипаттерн: каждый receive вынужден перебирать всё.

Частые ошибки

  • Не вызвать loop() снова в ветке. Тогда процесс обработает одно сообщение и завершится.
  • Накапливать неразобранные сообщения. Растущий ящик замедляет каждый приём.
  • Забыть after там, где ответ может не прийти. Процесс зависнет навсегда.

Итоги

  • Сообщение отправляют оператором !; отправка асинхронная, отправитель не ждёт.
  • Блок receive извлекает сообщение из ящика по pattern matching.
  • Селективный приём берёт первое подходящее сообщение, не обязательно первое в очереди.
  • after задаёт тайм-аут, чтобы не ждать ответа вечно.
Проверьте себя
1. Что делает оператор ! в Erlang?
AОтрицание
BОтправляет сообщение процессу по его PID
CЗавершает процесс
DСравнивает значения
2. Что происходит, если в receive нет подходящего сообщения?
AПроцесс падает
BПроцесс блокируется и засыпает, не тратя процессор, до прихода нужного
CВозвращается ошибка
DБерётся любое сообщение
3. Что такое селективный приём?
AПриём только от одного процесса
Breceive берёт первое сообщение, подходящее под шаблон, даже если оно не первое в очереди
CУдаление всех сообщений
DПриём по таймеру