Передача сообщений: ! и 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задаёт тайм-аут, чтобы не ждать ответа вечно.