Горячая перезагрузка (hotreload) позволяет менять код работающего приложения, не перезапуская его после каждого изменения. Она также сохраняет состояние программы, поэтому не нужно повторять подготовительные шаги: подключаться к БД, открывать и редактировать тестовые файлы или расставлять машины для симуляции столкновения в физическом движке.

Сегодня я хочу рассказать как выполнить горячую перезагрузку в Rust с помощью динамических библиотек и как я реализовал её в своём крейте hotcode.

Хабр не позволяет встраивать mp4 видео по ссылке, поэтому тут просто картинка из демки. Видео демо hotreload’а можно увидеть в README репозитория.

Что такое динамическая библиотека?

В этом разделе кратко объясняется, что такое динамическая библиотека и как она работает. Можете пропустить если уже знаете.

Динамическая библиотека — это набор скомпилированного кода, хранящийся в файле на диске. Приложение может загрузить этот код во время выполнения с помощью функций ОС. Вы передаёте путь к файлу динамической библиотеки, и она загружает его в память процесса, возвращая дескриптор объектного файла ОС. Затем вы ищете в динамической библиотеке нужную функцию по её символу. Символ — это просто уникальное имя, представленное строкой байтов. В Rust его можно задать явно с помощью атрибута export_name.

Загрузить динамическую библиотеку в Rust можно с помощью крейта libloading, он прост в использовании.

Чтобы крейт собирался в динамическую библиотеку, нужно указать в Cargo.toml тип крейта dylib или cdylib:

dylib предназначен для использования другими крейтами Rust и использует (пока) нестабильный ABI Rust. cdylib предназначен для использования из других языков и даёт библиотеки меньшего размера, потому что из них удаляются метаданные Rust. Подробнее о типах крейтов можно прочитать в разделе линковки справочника Rust Reference.

Перезагрузка кода через динамическую библиотеку

Один из самых простых способов сделать горячую перезагрузку в Rust — обновлять код функции через динамические библиотеки. Идея простая: вместо вызова статически слинкованной функции мы загружаем динамическую библиотеку и вызываем функцию из неё. После изменения кода мы выгружаем старую библиотеку и загружаем обновлённую версию. Выглядит всё это как-то так.

В этом в общем-то и вся идея, однако у неё есть ряд ограничений:

  • Загрузка и использование динамической библиотеки должны выполняться в бинарном или отдельном исполняемом крейте. my_function нужно поместить в крейт типа dylib: мы будем загружать нашу библиотеку на Rust только из Rust, поэтому cdylib не нужен.
  • Ещё одно ограничение динамических библиотек — через них нельзя экспортировать шаблонные функции вроде fn foo(t: T) {}. Потому что одного определения функции компилятору недостаточно, чтобы сгенерировать реальный код: ему также нужно точно знать, как эта функция используется. Например, если написать fn string_value(t: T) -> String { t.to_string() }, компилятор сгенерирует две реализации string_value: одну для i32 и одну для &str. При статической линковке он знает все способы использования string_value. Но при сборке динамической библиотеки мы не можем заранее знать, как будет использоваться экспортируемая функция. Один из обходных путей — использовать трейт-объекты и динамическую диспетчеризацию.

Другой способ — создать обёртки для конкретных типов.

Дальше мы сосредоточимся на деталях реализации: 1) как хранить динамическую библиотеку, 2) как следить за её изменением и 3) как упростить её использование.

В коде динамическая библиотека представлена структурой libloading::Library, которая оборачивает дескриптор объекта ОС. Этот дескриптор должен оставаться живым, пока хотя бы один поток выполняет код из библиотеки. Выгрузка библиотеки во время работы одной из её функций приводит к неопределённому поведению (UB).

Чтобы случайно не выгрузить библиотеку, которая ещё используется, можно хранить её в указателе со счётчиком ссылок и создать обёртку для функции, которая держит библиотеку живой. Подойдёт Arc, потому что к библиотеке могут обращаться из нескольких потоков.

Когда мы вызываем функцию, загруженную из динамической библиотеки через get_fn, guard гарантирует, что хотя бы одна ссылка на библиотеку останется живой до завершения вызова. Это не даёт выгрузить библиотеку, пока она ещё используется.

Поиск символа может быть довольно дорогим. К счастью, его результат можно кэшировать, потому что адрес символа в загруженной динамической библиотеке не меняется. Получив указатель на символ в первый раз, мы можем сохранить его в словаре с ключом по имени символа. Для этого можно засунуть libloading::Library в обёртку, которая владеет библиотекой, предоставляет метод get_fn и кэширует указатели на символы.

Теперь можно хранить динамическую библиотеку в удобной структуре LibraryWrapper, которая держит библиотеку живой и быстро ищет функции.

Мы хотим, чтобы приложение автоматически перезагружало динамическую библиотеку при каждой её пересборке. Для этого нужно следить за изменениями скомпилированного файла библиотеки. Это можно сделать с помощью крейта notify, который предоставляет кроссплатформенные наблюдатели за файловой системой. Наблюдатель работает в отдельном потоке и вызывает колбек, когда целевой файл меняется — например, когда он создан, удалён или изменён. Используется просто.

Колбек вызывается при каждом изменении целевого файла или любого файла в отслеживаемой папке. В реальном проекте скорее всего понадобится наблюдатель с дебаунсингом (notify-debouncer-full), потому что Cargo может записывать файл несколько раз, но для простоты ограничимся обычной версией. Можно запустить его для отслеживания файла нашей динамической библиотеки и передать Arc в колбек. Но ведь через Arc нельзя изменять библиотеку?

Наивное решение — положить Mutex внутрь Arc, чтобы колбек наблюдателя мог залочить библиотеку, выгрузить старую и загрузить новую. Лучше будет использовать RwLock, так как мы часто читаем из библиотеки (каждый раз, когда получаем указатель на функцию через get_fn, т.е. при каждом её вызове), а обновляем её только при пересборке. А ещё лучше — использовать ArcSwap из крейта arc-swap. Он хранит Arc, который можно атомарно заменить, и с ним можно перезагрузить библиотеку вообще без блокировок!

Работает это так: допустим, мы вызвали функцию foo из динамической библиотеки. Пока она ещё выполняется, наблюдатель сообщает, что библиотека изменилась. Мы загружаем новую версию, не выгружая старую, потому что первый вызов foo всё ещё её использует. Затем мы сохраняем новую библиотеку в ArcSwap, и последующие вызовы foo используют новую версию. Arc старой библиотеки удаляется из ArcSwap, поэтому новые вызовы уже не достучатся до старой версии функции, но сама библиотека остаётся загруженной, пока выполняется первый вызов. Когда он завершается, его FnGuard удаляет последний Arc на старую библиотеку, и она автоматически выгружается.

Вместе с наблюдателем за файлом код выглядит так.

Остался последний важный шаг: нужно копировать динамическую библиотеку во временный файл, чтобы LibraryWrapper не блокировал результат сборки. Это можно реализовать в LibraryWrapper::new.

Ну и наконец, пора сделать всю эту инфраструктуру проще в использовании.

Сейчас для hotreload’а нужно постоянно выполнять несколько шагов: инициализировать библиотеку, запустить наблюдатель, вручную загружать функции перед вызовом и так далее. Всё это можно заменить одним атрибутным макросом.

Для этого сначала упростим инициализацию библиотеки и настройку наблюдателя.

Можно хранить динамическую библиотеку в LazyLock в статической переменной и инициализировать её при первом вызове одной из hotreload функций.

Так мы сможем вызывать функции с горячей перезагрузкой не меняя места их вызова. Для этого нужно включить и статическую, и динамическую линковку нашего крейта-библиотеки; это делается указанием обоих типов крейта, rlib и dylib, в Cargo.toml.

Так компилятор Rust сгенерирует и my_library.rlib, и my_library.so. Файл .rlib содержит объектный код (.o) и метаданные Rust и используется для статической линковки библиотеки, чтобы её функции можно было вызывать прямо из кода бинаря.

Далее наш макрос должен преобразовать исходную функцию так, чтобы вызовы из статически слинкованного кода загружали динамическую библиотеку, а вызовы через динамическую библиотеку выполняли настоящее тело функции. Это можно реализовать двумя способами:

  1. Сгенерировать две версии функции: первая будет иметь то же имя и служить точкой входа для статических вызовов, которая загружает динамическую библиотеку; вторая будет содержать настоящее тело функции и export name, чтобы её можно было загрузить из динамической библиотеки. Этот подход превращает это:
  2. fn my_function(v: i32) -> i32 { v + 1 }

    в

    // точка входа для статических вызовов
    let f = (*DYNAMIC_LIBRARY).get_fn i32>(b"__hotreload_my_function");
    // настоящая реализация, доступная только через динамическую библиотеку
    #[export_name = "__hotreload_my_function"]
    fn __hotreload_my_function(v: i32) -> i32 { v + 1 }
  3. Другой подход — добавить в каждую функцию с горячей перезагрузкой проверку, находимся ли мы сейчас в статически или динамически слинкованной функции. Если мы внутри статически слинкованной функции, нужно загрузить динамическую библиотеку и вызвать функцию из неё. Если мы внутри динамически слинкованной функции, просто выполняем инструкции самой функции. С таким подходом этот код остаётся совместимым с любыми функциями, потому что не меняет их сигнатуру и не создаёт новых синтаксических элементов; он меняет только тело функции. Поэтому, в отличие от первого подхода, его можно использовать и внутри реализаций трейтов:
  4. fn work(&self) { self.do_something(); }

Сначала я использовал в hotcode первый подход, но меня не устраивало, что макрос нельзя применить везде. Поэтому я придумал второй подход, который мы и будем использовать.

Эта проверка — ядро всей системы. Нужно спросить у ОС, какой модуль содержит адрес текущей функции, а затем сравнить путь этого модуля с std::env::current_exe(). Если они совпадают, то это значит что мы находимся в статически слинкованной функции; иначе — в динамической библиотеке. Псевдокод этой проверки выглядит примерно так.

Обратите внимание: нам не нужно передавать указатель на функцию, которую мы hotreload’им. Указателя на is_outside_dynamic_library достаточно, чтобы определить текущий модуль, потому что эта функция тоже существует в двух версиях: статически слинкованной и динамически слинкованной.

В Linux get_module_name_from_ptr использует функцию libc dladdr. В Windows — функции ОС GetModuleHandleExW и GetModuleFileNameW. Выполнять эту проверку при каждом вызове функции с горячей перезагрузкой может быть слишком дорого на горячих путях. К счастью, результат можно кэшировать в лениво инициализируемой статической переменной типа bool.

Это работает, потому что у каждой динамической библиотеки свой набор статических переменных. В основном процессе, который вызывает функции с горячей перезагрузкой, IS_OUTSIDE_DYNAMIC_LIBRARY кэширует false; в каждой загруженной динамической библиотеке — true. Результат проверки можно навсегда сохранить, потому что адрес функции не сдвинется в памяти процесса. В итоге накладные расходы на вызов функций с горячей перезагрузкой становятся пренебрежимо малыми.

Можно сделать ещё одно улучшение. Допустим, ваш бинарник использует несколько библиотек, функции которых вы хотите перезагружать. Текущая реализация запаникует на unwrap’е результата libloading::Library::get_fn, если вы попытаетесь загрузить функцию из второй библиотеки после того, как первая библиотека уже инициализировала статическую переменную DYNAMIC_LIBRARY. Это решается хранением RwLock>>> в DYNAMIC_LIBRARY — словаря, связывающего каждую отдельную библиотеку с её LibraryWrapper.

Имя крейта, в котором используется #[hotreload], можно получить из переменной окружения CARGO_PKG_NAME: std::env::var(«CARGO_PKG_NAME»).unwrap(). Это имя можно передавать вместо захардкоженного «your_library_name.so» из примеров выше.

Теперь можно перезагружать любую функцию, навесив на неё один атрибут:

#[hotreload]
fn my_function(...) { ... }

При этом производительность почти не пострадала. В худшем случае выполняются три относительно дорогие операции: загрузка библиотеки с диска, вызов функции ОС для проверки, находимся ли мы внутри динамической библиотеки, и поиск нужной функции в загруженной библиотеке. А в лучшем и наиболее частом случае мы просто читаем значения из двух LazyLock и берём существующую запись из HashMap.

Результаты бенчмарков можно посмотреть в документации hotcode.

Вообще этот подход применим не только в Rust: горячую перезагрузку можно реализовать в любом языке программирования, который умеет компилироваться в динамическую библиотеку и загружать её. Однако должен отметить, что система типов и модель памяти Rust заметно упростили разработку этого проекта.

Мне было очень интересно разрабатывать этот проект, но это не первая попытка добавить горячую перезагрузку в Rust. Вот ещё несколько проектов, мне они показались интересными:

  • hot-lib-reloader — этот крейт тоже использует динамические библиотеки для горячей перезагрузки, но требует гораздо больше настройки.
  • subsecond — решение от Dioxus, которое использует hotpatching и собственный линкер. Требует Dioxus CLI и оборачивания перезагружаемого кода в колбеки для subsecond::call.
  • inline_tweak — решение без зависимостей, которое перезагружает литеральные значения. Легко использовать с помощью одного макроса.

Надеюсь, что теперь ваши итерации станут быстрее!

Ссылка на репозиторий с исходным кодом hotcode — https://github.com/alordash/hotcode

P.S. это перевод моего же поста с английского, ссылка на оригинал. Не стал отмечать статью как перевод потому что я мог бы написать её с нуля и на русском 😃

Claude за полчаса разгадал загадку АНБ, которой больше 25 лет. Ответ был у криптографов под носом

Четверть века вокруг одного из главных стандартов современной криптографии держалась почти детективная интрига. В 2000 году американский институт стандартов NIST утвердил набор эллиптических кривых от P-192 до P-521 — сегодня на P-256 держатся подписи сертификатов сайтов, токены входа и защищённые чипы телефонов. Параметры этих кривых выведены из начальных значений (seed), которые Агентство национальной безопасности США передало в стандарт без всяких объяснений. 16 октября 2026 года команда Anthropic сообщила, что её внутренняя исследовательская модель Claude меньше чем за полчаса нашла происхождение этих чисел, а криптограф Филиппо Вальсорда, объявивший за разгадку награду, подтвердил правильность решения.

Как я выгружал 1,8 TB с Яндекс Диска после аварии в дата-центрах

Anthropic забанила мой Claude и отдала в экспорте только телефон. Теперь мне отвечает Grok в парике.