Состояние, регистрация и серверный цикл
Превращаем процесс в полезный сервис с памятью и именем.
Серверный цикл — процесс, который в бесконечной хвостовой рекурсии принимает сообщения и передаёт обновлённое состояние следующему витку.
Процессы изолированы и неизменяемы — как же тогда хранить меняющееся состояние, например счётчик или список клиентов? Ответ изящен: состояние передаётся аргументом рекурсивной функции. На первый взгляд это звучит парадоксально: язык построен на неизменяемых данных, а мы хотим что-то менять во времени. Разрешение парадокса в том, что меняется не переменная, а то, какой именно вызов функции сейчас активен. Каждый виток цикла — это новый вызов с новым значением аргумента, и хотя ни одна конкретная переменная не переписывается, со стороны процесс выглядит как объект, хранящий и обновляющий своё состояние.
Этот приём объединяет почти всё, что мы изучили. Хвостовая рекурсия из третьего раздела позволяет циклу крутиться вечно, не пожирая память. Pattern matching разбирает входящие команды. Изоляция процессов гарантирует, что к состоянию никто не подберётся в обход сообщений. А аккумулятор, который раньше копил сумму списка, теперь превращается в живое состояние сервера. Получается, что серверный цикл — не новая магия, а естественная кульминация всех предыдущих идей курса.
Состояние через аргумент цикла
Сделаем процесс-счётчик. Текущее значение он держит в аргументе своей цикл-функции. Получив сообщение, он вычисляет новое значение и вызывает себя с ним. Состояние тут — простое число, но точно так же на его месте мог бы быть список, кортеж или словарь с десятками полей; принцип не меняется от сложности данных.
start() ->
spawn(fun() -> loop(0) end).
loop(Count) ->
receive
increment ->
loop(Count + 1);
{get, From} ->
From ! {count, Count},
loop(Count);
reset ->
loop(0)
end.
Каждое сообщение «изменяет» состояние, создавая новый виток цикла с новым значением Count. Переменная по-прежнему неизменяема — просто следующий вызов получает другое число. Это и есть способ Erlang моделировать изменяемое состояние без изменяемых переменных. Обратите внимание на тонкую, но важную деталь: поскольку состояние обрабатывает один-единственный процесс строго по одному сообщению за раз, никакой синхронизации не нужно в принципе. Параллельных обращений к Count просто не существует — сообщения выстраиваются в очередь в почтовом ящике и разбираются по очереди. Так процесс становится естественной точкой сериализации доступа к данным, заменяя собой замки и мьютексы из других языков.
Из этого следует красивый архитектурный принцип: если к каким-то данным должны иметь доступ многие, отдайте эти данные «во владение» одному процессу, а всем остальным дайте общаться с ним сообщениями. Тогда данные защищены самим фактом изоляции, а порядок изменений предсказуем. Счётчик из примера — простейший представитель целого семейства таких «процессов-хранителей состояния», на которых держится типичная система на Erlang.
Регистрация под именем
Запоминать PID неудобно, особенно для долгоживущих сервисов. Процесс можно зарегистрировать под атомом-именем — тогда сообщения шлют по имени. Это похоже на разницу между «позвонить по конкретному номеру телефона» и «позвонить в справочную по её общеизвестному названию»: имя — стабильная, заранее известная точка входа, не зависящая от того, какой PID достался процессу при запуске.
Pid = spawn(fun() -> loop(0) end),
register(counter, Pid).
% Теперь вместо PID используем имя
counter ! increment,
counter ! {get, self()}.
Имя — это глобально доступная в узле метка. Если процесс с этим именем падает и перезапускается, клиенты продолжают слать сообщения на то же имя, не зная нового PID. Это особенно ценно в связке с отказоустойчивостью: упавший сервис подменяется новым процессом, который заново занимает прежнее имя, и для всех остальных подмена проходит почти незаметно. Регистрацию под именем обычно приберегают для немногочисленных долгоживущих сервисов-«синглтонов» — реестр, журнал, главный диспетчер; плодить именованные процессы для каждой мелочи не стоит, ведь имя в узле должно быть уникальным.
Прячем протокол за функциями
Хороший тон — не заставлять клиента вручную слать сообщения, а обернуть протокол в функции API. Сырые сообщения — это деталь реализации, и просить каждого пользователя сервиса помнить точную форму кортежей и порядок «отправь запрос, потом жди ответ» — путь к ошибкам. Гораздо лучше дать наружу понятные функции, а всю переписку спрятать внутри них.
increment() -> counter ! increment.
get_count() ->
counter ! {get, self()},
receive {count, N} -> N end.
Теперь снаружи это выглядит как обычные вызовы функций, а сообщения скрыты внутри. Именно эту идею доводит до совершенства поведение gen_server из OTP, которое мы изучим дальше. По сути, написав вручную пару «серверный цикл плюс функции-обёртки», вы своими руками воспроизвели зародыш того, что в промышленном Erlang давно стандартизировано и вынесено в библиотеку. Когда вы позже встретите gen_server, он не покажется чёрной магией: вы будете точно знать, что у него внутри спрятан тот же цикл с состоянием в аргументе и тот же разбор сообщений, просто аккуратно оформленные и снабжённые готовой обработкой ошибок, тайм-аутов и надзора.
В этом и состоит педагогическая ценность ручной реализации: прежде чем брать мощный готовый инструмент, полезно один раз собрать его упрощённый аналог самому. Тогда абстракция перестаёт быть непрозрачной коробкой и становится понятным сокращением для кода, который вы и сами способны написать. Большинство «серверов» в реальных системах на Erlang — это именно такие процессы с состоянием за фасадом из функций, доведённые до зрелости средствами OTP.
Как работает под капотом
Серверный цикл — это бесконечная хвостовая рекурсия, поэтому он не растит стек и живёт сколько угодно. Состояние существует только как аргумент текущего вызова: его нельзя «подсмотреть» извне, нельзя изменить гонкой. Реестр имён — таблица в BEAM, отображающая атом в PID; register упадёт, если имя занято, а при смерти процесса имя автоматически освобождается.
Частые ошибки
- Забыть передать состояние в рекурсивный вызов. Тогда оно «сбросится» или процесс завершится.
- Регистрировать имя, которое уже занято.
registerбросит исключение. - Не отвечать клиенту на синхронный запрос. Клиент в
receiveзависнет.
Итоги
- Состояние процесса живёт как аргумент его рекурсивного цикла.
- Каждое сообщение порождает новый виток цикла с обновлённым состоянием.
registerдаёт процессу имя-атом, по которому шлют сообщения вместо PID.- Протокол удобно прятать за функциями API — это прообраз
gen_server.