Горячая замена кода без остановки
Уникальная способность Erlang: обновлять работающую систему на лету.
Горячая замена кода (hot code reloading) — обновление кода модуля в работающей системе без её остановки и без потери состояния процессов.
Для систем, которые не имеют права останавливаться (телеком, биржи, мессенджеры), обновление — больная тема. Перезапуск означает простой: разорванные звонки, потерянные сессии, окно недоступности, в которое не пройдёт ни один запрос. Обычный путь решает это избыточностью — поднимают вторую копию системы, переключают на неё трафик, гасят первую. Это работает, но требует балансировщиков, двойного железа и аккуратного слива соединений. Erlang предлагает дополнительный, более радикальный инструмент: код можно заменить прямо внутри работающего узла, и процессы продолжат выполняться, не теряя своего состояния и открытых соединений. Это уникальная для индустрии способность, родившаяся из требований телекома, где станция должна работать годами без перезагрузки.
Две версии модуля одновременно
Ключевая идея, которая делает горячую замену возможной без хаоса: BEAM держит в памяти две версии одного модуля — «текущую» (current) и «старую» (old). Когда вы загружаете новую версию, прежняя текущая становится старой, а новая — текущей. Процессы, уже выполняющие старый код, доигрывают его до конца своего текущего вызова, а новые вызовы идут в новую версию. Зачем держать сразу две? Потому что в момент загрузки в системе могут быть процессы, прямо сейчас исполняющие старую функцию. Резко выдернуть у них код — значит уронить их посреди работы. Две версии дают плавный переход: старый код живёт ровно столько, сколько нужно уже запущенным процессам, а всё новое сразу идёт по обновлённой логике. Это компромисс между «обновить мгновенно» и «не сломать работающее».
Загрузка новой версии:
[old] <- удаляется при следующей загрузке
[current] <- становится old
[new] <- становится current
Запущенные процессы доигрывают свою версию,
новые вызовы идут в current.
Как процесс «переходит» на новый код
Чтобы долгоживущий процесс (серверный цикл) перешёл на новую версию, он должен сделать полностью квалифицированный вызов — через имя модуля: ?MODULE:loop(State). Такой вызов всегда уходит в текущую версию модуля. Локальный вызов loop(State) остался бы в старой версии.
loop(State) ->
receive
Msg ->
NewState = handle(Msg, State),
?MODULE:loop(NewState) %% квалифицированный вызов
end.
Этот приём — причина, по которой в серверных циклах часто пишут ?MODULE:loop(...), а не просто loop(...). Разница тонкая, но принципиальная. Локальный вызов loop(State) компилятор связывает с той же версией модуля, в которой он находится: процесс, начавший крутиться в старом коде, так и останется в старом цикле до своей смерти, сколько бы новых версий вы ни загрузили. Полностью квалифицированный вызов ?MODULE:loop(State) (где ?MODULE — макрос, разворачивающийся в имя текущего модуля) разрешается заново при каждом обращении и всегда указывает на текущую версию. Поэтому на каждом витке цикла процесс «спрашивает» у code-сервера, какой код считается актуальным, и при следующем витке после загрузки бесшовно переходит на него. Долгоживущий процесс без такого вызова — это процесс, который никогда не обновится.
Загрузка нового кода
%% Перекомпилировать и загрузить модуль в работающую систему
c(my_server).
%% Или явно через code-сервер
code:load_file(my_server).
После загрузки следующий квалифицированный вызов попадёт в новую версию — и процесс продолжит работу уже по обновлённой логике, сохранив своё состояние.
OTP и upgrade состояния
Простой замены кода достаточно, когда меняется только логика, а формат состояния остаётся прежним. Но что, если новая версия хранит состояние иначе — добавилось поле в записи, изменилась структура карты? Старый процесс продолжает держать в памяти состояние в старом формате, а новый код ожидает новый. Здесь простой ?MODULE:loop уже не спасёт — нужно явно преобразовать данные.
OTP решает это аккуратно: поведения вроде gen_server имеют callback code_change/3, который рантайм вызывает при обновлении и который позволяет преобразовать структуру состояния под новую версию кода. Вы получаете на вход старое состояние и номер прежней версии, а возвращаете состояние в новом формате — например, дописываете недостающее поле значением по умолчанию. Эту миграцию OTP проводит во время апгрейда, до того как процесс начнёт обрабатывать новые запросы. Сами апгрейды OTP описывает формально, через так называемые релизы и appup/relup-файлы, где шаг за шагом перечислено, какие модули загрузить и каким процессам сменить код. Это превращает горячую замену из ручного фокуса в управляемую, повторяемую процедуру.
Когда это незаменимо
Горячая замена бесценна там, где простой недопустим: исправить баг в работающей телефонной станции, выкатить фикс на биржевой сервер прямо в торговую сессию, обновить мессенджер, не разрывая миллионы открытых соединений. В этих сценариях даже короткое окно перезапуска означает реальные потери — оборванные звонки, упущенные сделки, недовольных пользователей.
При этом честно признаем: горячая замена — мощный, но не бесплатный инструмент, и в современной практике её применяют избирательно. Многие команды, особенно работающие в облаке с оркестраторами и балансировщиками, предпочитают обычный деплой: поднять новые узлы с новым кодом, перевести на них трафик, погасить старые. Этот путь проще рассуждать и тестировать, а нулевой простой достигается избыточностью, а не сменой кода на лету. Корректные relup-апгрейды с миграцией состояния писать трудозатратно, и ошибка в них опаснее, чем чистый рестарт. Поэтому горячую замену держат для случаев, где она действительно незаменима, а в остальном опираются на перезапуск. Но сама возможность обновить работающую систему, не прерывая её, остаётся отличительной чертой платформы, которой большинство стеков просто не имеют.
Как работает под капотом
За код отвечает code-сервер BEAM. Он ведёт таблицу загруженных модулей и для каждого хранит до двух версий. Полностью квалифицированный вызов Module:fun разрешается через эту таблицу в момент вызова — поэтому он «видит» свежий код. Когда загружают третью версию, процессы, всё ещё выполняющие самую старую, принудительно завершаются (их уже некуда переселить) — это редкий, но важный нюанс.
Частые ошибки
- Локальный вызов
loop(...)в долгоживущем цикле. Процесс не перейдёт на новый код — нужен?MODULE:loop(...). - Менять формат состояния без
code_change. Старое состояние не подойдёт новому коду. - Грузить третью версию, пока процессы на самой старой. Они будут убиты при вытеснении старой версии.
Итоги
- Горячая замена обновляет код работающей системы без остановки.
- BEAM держит две версии модуля: current и old.
- Переход на новый код — через полностью квалифицированный вызов
?MODULE:fun. - OTP-callback
code_change/3преобразует состояние при обновлении.