Hotreload в Rust

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

Хабр не позволяет встраивать mp4 видео по ссылке, поэтому тут просто картинка из демки. Видео демо hotreload’а можно увидеть в README репозитория.
Динамические библиотеки
В этом разделе кратко объясняется, что такое динамическая библиотека и как она работает. Можете пропустить если уже знаете.
Динамическая библиотека - это набор скомпилированного кода, хранящийся в файле на диске. Приложение может загрузить этот код во время выполнения с помощью функций ОСи. Вы передаёте путь к файлу динамической библиотеки, и она загружает его в память процесса, возвращая дескриптор ОСного объекта. Затем вы ищете в динамической библиотеке нужную функцию по её символу. Символ - это просто уникальное имя, представленное строкой байтов. В Rust его можно задать явно с помощью атрибута export_name:
#[unsafe(export_name = "my_function_symbol")]
pub fn my_function() {}
Загрузить динамическую библиотеку в Rust можно с помощью крейта libloading, он прост в использовании:
use libloading::*;
let lib = Library::new("/path/to/liblibrary.so").unwrap();
let func: Symbol<unsafe extern "C" fn() -> u32> = lib.get(b"my_function").unwrap();
unsafe { func() }; // функция, загруженная из динамической библиотеки
Чтобы крейт собирался в динамическую библиотеку, нужно указать в Cargo.toml тип крейта dylib или cdylib:
[lib]
crate-type = ["dylib"]
# или
crate-type = ["cdylib"]
dylib предназначен для использования другими крейтами Rust и использует (пока) нестабильный ABI Rust. cdylib предназначен для использования из других языков и даёт библиотеки меньшего размера, потому что из них удаляются метаданные Rust. Подробнее о типах крейтов можно прочитать в разделе линковки справочника Rust Reference.
Перезагрузка кода через динамическую библиотеку
Один из самых простых способов сделать горячую перезагрузку в Rust - обновлять код функции через динамические библиотеки. Идея простая: вместо вызова статически слинкованной функции мы загружаем динамическую библиотеку и вызываем функцию из неё. После изменения кода мы выгружаем старую библиотеку и загружаем обновлённую версию. Выглядит все это как-то так:
let path = "path/to/dynamic/library";
let lib = libloading::Library::new(path);
let func = lib.get<fn() -> i32>(b"my_function");
let first_result = func();
// ...
// ждём обновления динамической библиотеки
// ...
drop(lib); // выгружаем предыдущую динамическую библиотеку
let lib = libloading::Library::new(path);
let func = lib.get<fn() -> i32>(b"my_function");
let second_result = func(); // используем изменённую функцию
assert_ne!(first_result, second_result);
В этом в общем то и вся идея, однако у неё есть ряд ограничений:
Загрузка и использование динамической библиотеки должны выполняться в бинарном или отдельном исполняемом крейте.
my_functionнужно поместить в крейт типаdylib: мы будем загружать нашу библиотеку на Rust только из Rust, поэтомуcdylibне нужен.Ещё одно ограничение динамических библиотек - через них нельзя экспортировать шаблонные функции вроде
fn foo<T>(t: T) {}
Почему обобщённые функции нельзя экспортировать из динамических библиотек?
Потому что одного определения функции компилятору недостаточно, чтобы сгенерировать реальный код: ему также нужно точно знать, как эта функция используется. Например, если написать:
fn string_value<T: ToString>(t: &T) -> String {
t.to_string()
}
string_value(&1);
string_value("quo vadis");
компилятор сгенерирует две реализации string_value: одну для i32 и одну для &str. При статической линковке он знает все способы использования string_value. Но при сборке динамической библиотеки мы не можем заранее знать, как будет использоваться экспортируемая функция. Один из обходных путей - использовать трейт-объекты и динамическую диспетчеризацию:
pub fn string_value(t: &dyn ToString) -> String {
t.to_string()
}
Другой способ - создать обёртки для конкретных типов:
pub fn string_value_i32(value: &i32) -> String {
string_value(value)
}
Дальше мы сосредоточимся на деталях реализации: 1. как хранить динамическую библиотеку, 2. как следтиь за её изменением и 3. как упростить её использование.
Хранение динамической библиотеки
В коде динамическая библиотека представлена структурой libloading::Library, которая оборачивает дескриптор объекта ОС. Этот дескриптор должен оставаться живым, пока хотя бы один поток выполняет код из библиотеки. Выгрузка библиотеки во время работы одной из её функций приводит к UB.
Защита от выгрузки во время использования
Чтобы случайно не выгрузить библиотеку, которая ещё используется, можно хранить её в указателе со счётчиком ссылок и создать обёртку для функции, которая держит библиотеку живой. Подойдёт Arc, потому что к библиотеке могут обращаться из нескольких потоков:
struct FnGuard<F> {
f: F,
_lib_arc: Arc<libloading::Library>,
}
unsafe fn get_fn<F>(lib: Arc<libloading::Library>, fn_name: &[u8]) -> FnGuard<F> {
let fn_ptr = lib.get::<fn()>(fn_name).unwrap().try_as_raw_ptr().unwrap();
let f = core::mem::transmute_copy(&fn_ptr.cast::<()>());
let result = FnGuard { f, _lib_arc: lib.clone() };
return result;
}
// чтобы можно было вызывать так:
// let fn_guard = get_fn(...);
// fn_guard();
impl<F> Deref for FnGuard<F> {
type Target = F;
fn deref(&self) -> &Self::Target {
&self.f
}
}
// использование
let lib = Arc::new(libloading::Library::new(...));
let f = lib.get_fn<fn() -> i32>(b"my_function_symbol");
f();
Когда мы вызываем функцию, загруженную из динамической библиотеки через get_fn, guard гарантирует, что хотя бы одна ссылка на библиотеку останется живой до завершения вызова. Это не даёт выгрузить библиотеку, пока она ещё используется.
Теперь ускорим функцию get_fn.
Ускорение поиска функций
Поиск символа может быть довольно дорогим. К счастью, его результат можно кэшировать, потому что адрес символа в загруженной динамической библиотеке не меняется. Получив указатель на символ в первый раз, мы можем сохранить его в словаре с ключом по имени символа. Для этого можно засунуть libloading::Library в обёртку, которая владеет библиотекой, предоставляет метод get_fn и кэширует указатели на символы:
struct LibraryWrapper {
inner: libloading::Library,
symbols_map: RwLock<HashMap<&'static [u8], *mut core::ffi::c_void>>,
}
pub unsafe fn get_fn<F>(lib: Arc<RwLock<Self>>, symbol_name: &'static [u8]) -> FnGuard<F> {
let fn_ptr =
if let Some(existing) = lib.symbols_map.read().unwrap().get(symbol_name) {
*existing
} else {
let mut write = lib.symbols_map.write().unwrap();
let raw_fn_ptr = lib
.inner
.get::<fn()>(symbol_name)
.unwrap()
.try_as_raw_ptr()
.unwrap();
write.insert(symbol_name, raw_fn_ptr);
raw_fn_ptr
};
let f = core::mem::transmute_copy(&fn_ptr.cast::<()>());
let result = FnGuard { f, _lib_arc: lib.clone() };
return result;
}
// использование
let lib = LibraryWrapper::new(...); // те же аргументы, что и для `libloading::Library::new`
let f = lib.get_fn<fn() -> i32>(b"my_function_symbol");
f();
Теперь можно хранить динамическую библиотеку в удобной структуре LibraryWrapper, которая держит библиотеку живой и быстро ищет функции.
Обновление динамической библиотеки
Мы хотим, чтобы приложение автоматически перезагружало динамическую библиотеку при каждой её пересборке. Для этого нужно следить за изменениями скомпилированного файла библиотеки. Это можно сделать с помощью крейта notify, который предоставляет кроссплатформенные наблюдатели за файловой системой. Наблюдатель работает в отдельном потоке и вызывает колбек, когда целевой файл меняется - например, когда он создан, удалён или изменён. Используется просто:
let mut watcher = notify::recommended_watcher(|res: Result<notify::Event, notify::Error>| {
match res {
Ok(event) => {
println!("Changed: {:?}", event.paths);
println!("Event details: {:?}", event.kind);
}
Err(e) => println!("Watch error: {:?}", e),
}
}).unwrap();
watcher.watch("path/to/file/or/directory".as_ref(), RecursiveMode::Recursive).unwrap();
Колбек вызывается при каждом изменении целевого файла или любого файла в отслеживаемой папке. В реальном проекте скорее всего понадобится наблюдатель с дебаунсингом (notify-debouncer-full), потому что Cargo может записывать файл несколько раз, но для простоты ограничимся обычной версией. Можно запустить его для отслеживания файла нашей динамической библиотеки и передать Arc в колбек. Но ведь через Arc нельзя изменять библиотеку?
Наивное решение - положить Mutex внутрь Arc, чтобы колбек наблюдателя мог залочить библиотеку, выгрузить старую и загрузить новую.
Лучше будет использовать RwLock, так как мы часто читаем из библиотеки (каждый раз, когда получаем указатель на функцию через get_fn, т.е. при каждом её вызове), а обновляем её только при пересборке.
А ещё лучше - использовать ArcSwap из крейта arc-swap. Он хранит Arc, который можно атомарно заменить, и с ним можно перезагрузить библиотеку вообще без блокировок!
Работает это так: допустим, мы вызвали функцию foo из динамической библиотеки. Пока она ещё выполняется, наблюдатель сообщает, что библиотека изменилась. Мы загружаем новую версию, не выгружая старую, потому что первый вызов foo всё ещё её использует. Затем мы сохраняем новую библиотеку в ArcSwap, и последующие вызовы foo используют новую версию. Arc старой библиотеки удаляется из ArcSwap, поэтому новые вызовы уже не достучатся до старой версии функции, но сама библиотека остаётся загруженной, пока выполняется первый вызов. Когда он завершается, его FnGuard удаляет последний Arc на старую библиотеку, и она автоматически выгружается.
Вместе с наблюдателем за файлом код выглядит так:
// main - однократная настройка
let path = "path/to/dynamic/library";
let lib = Arc::new(ArcSwap::from_pointee(LibraryWrapper::new(path)));
let lib_for_callback = lib.clone();
let mut watcher = notify::recommended_watcher(|res| {
let event = res.unwrap();
if event.paths.contains(path) {
let new_lib = LibraryWrapper::new(path);
lib_for_callback.store(Arc::new(new_lib)); // сохраняем новую библиотеку
}
}).unwrap();
watcher.watch(path, notify::RecursiveMode::NonRecursive).unwrap();
// использование
let func = lib
.load_full() // метод `ArcSwap`
.get_fn<fn() -> i32>(b"my_function");
let result = func();
Остался последний важный шаг: нужно копировать динамическую библиотеку во временный файл, чтобы LibraryWrapper не блокировал результат сборки. Это можно реализовать в LibraryWrapper::new:
struct LibraryWrapper {
inner: libloading::Library,
symbols_map: RwLock<HashMap<&'static [u8], *mut core::ffi::c_void>>,
lib_path: PathBuf // добавляем это поле, чтобы помнить, откуда
// загружена библиотека (`libloading::Library`
// не хранит путь к файлу своей библиотеки)
}
impl LibraryWrapper {
pub fn new(path: PathBuf) -> Self {
let copy_path = temp_path();
std::fs::copy(&path, ©_path).unwrap();
let inner = libloading::Library::new(copy_path);
Self {
inner,
symbols_map: Default::default(),
lib_path: copy_path
}
}
}
impl Drop for LibraryWrapper {
fn drop(&mut self) {
// не забываем удалить копию динамической библиотеки
self.inner.close().unwrap();
std::fs::remove_file(&self.lib_path);
}
}
Ну и наконец, пора сделать всю эту инфраструктуру проще в использовании.
Упрощение использования
Сейчас для hotreload’а нужно постоянно выполнять нескольких шагов: инициализировать библиотеку, запустить наблюдатель, вручную загружать функции перед вызовом и так далее. Всё это можно заменить одним атрибутным макросом:
// к чему мы стремимся
#[hotreload]
fn foo() {}
Для этого сначала упростим инициализацию библиотеки и настройку наблюдателя.
Ленивая инициализация динамической библиотеки
Можно хранить динамическую библиотеку в LazyLock в статической переменной и инициализировать её при первом вызове одной из hotreload функций:
static DYNAMIC_LIBRARY: LazyLock<Arc<ArcSwap<LibraryWrapper>>> =
LazyLock::new(initialize_dynamic_library);
fn initialize_dynamic_library() -> Arc<ArcSwap<LibraryWrapper>> {
let library_file_name = "your_library_name.so";
let library_source_path = std::env::current_exe()
.unwrap()
.parent()
.unwrap()
.join(library_file_name);
let shared_library_wrapper = Arc::new(ArcSwap::from_pointee(LibraryWrapper::new(
library_source_path.clone(),
)));
spawn_library_watcher(
library_source_path,
shared_library_wrapper.clone(),
);
return shared_library_wrapper;
}
// использование
let f = (*DYNAMIC_LIBRARY).get_fn<fn() -> i32>(b"my_function");
f();
Теперь заменим вот это:
#[unsafe(export_name = "my_function")]
fn my_function() {}
let f = (*DYNAMIC_LIBRARY).get_fn<fn() -> i32>(b"my_function");
f();
на это:
#[hotreload] // процедурный макрос
fn my_function() {}
my_function();
Так мы сможем вызывать функции с горячей перезагрузкой не меняя места их вызова. Для этого нужно включить и статическую, и динамическую линковку нашего крейта-библиотеки; это делается указанием обоих типов крейта, rlib и dylib, в Cargo.toml:
[lib]
crate-type = ["rlib", "dylib"]
Так компилятор Rust сгенерирует и my_library.rlib, и my_library.so. Файл .rlib содержит объектный код (.o) и метаданные Rust и используется для статической линковки библиотеки, чтобы её функции можно было вызывать прямо из кода бинаря.
Далее наш макрос должен преобразовать исходную функцию так, чтобы вызовы из статически слинкованного кода загружали динамическую библиотеку, а вызовы через динамическую библиотеку выполняли настоящее тело функции. Это можно реализовать двумя способами:
Сгенерировать две версии функции: первая будет иметь то же имя и служить точкой входа для статических вызовов, которая загружает динамическую библиотеку; вторая будет содержать настоящее тело функции и export name, чтобы её можно было загрузить из динамической библиотеки. Этот подход превращает это:
#[hotreload] fn my_function(v: i32) -> i32 { v + 1 }в вот это:
// точка входа для статических вызовов fn my_function(v: i32) -> i32 { let f = (*DYNAMIC_LIBRARY).get_fn<fn(i32) -> i32>(b"__hotreload_my_function"); f(v) } // настоящая реализация, доступная только через динамическую библиотеку #[unsafe(export_name = "__hotreload_my_function")] fn __hotreload_my_function(v: i32) -> i32 { v + 1 }Однако этот подход не работает для функций внутри реализаций трейтов, потому что он создаёт новую функцию, которая не является частью трейта, из-за чего мы получим ошибку компиляции.
Другой подход - добавить в каждую функцию с горячей перезагрузкой проверку, находимся ли мы сейчас в статически или динамически слинкованной функции. Если мы внутри статически слинкованной функции, нужно загрузить динамическую библиотеку и вызвать функцию из неё. Если мы внутри динамически слинкованной функции, просто выполняем инструкции самой функции. С таким подходом этот код:
#[hotreload] fn my_function(v: i32) -> i32 { v + 1 }превращается в такой:
#[unsafe(export_name = "__hotreload_my_function")] fn my_function(v: i32) -> i32 { if is_outside_dynamic_library() { let f = (*DYNAMIC_LIBRARY).get_fn<fn(i32) -> i32>(b"__hotreload_my_function"); return f(v); } // тело исходной функции v + 1 }Этот подход работает с любой функцией, потому что не меняет её сигнатуру и не создаёт новых синтаксических элементов; он меняет только тело функции. Поэтому, в отличие от первого подхода, его можно использовать и внутри реализаций трейтов:
impl Trait for Struct { #[hotreload] fn work(&self) { self.do_something(); } }
Сначала я использовал в hotcode первый подход, но меня не устраивало, что макрос нельзя применить везде. Поэтому я придумал второй подход, который мы и будем использовать.
Проверка, находимся ли мы внутри динамической библиотеки
Эта проверка - ядро всей системы. Нужно спросить у ОС, какой модуль содержит адрес текущей функции, а затем сравнить путь этого модуля с std::env::current_exe(). Если они совпадают, то это значит что мы находимся в статически слинкованной функции; иначе - в динамической библиотеке. Псевдокод этой проверки выглядит примерно так:
fn is_outside_dynamic_library() -> bool {
let current_fn_ptr = is_outside_dynamic_library as *const _;
let current_module_name = os::get_module_name_from_ptr(current_fn_ptr);
return current_module_name != std::env::current_exe();
}
Обратите внимание: нам не нужно передавать указатель на функцию, которую мы hotreload’им. Указателя на is_outside_dynamic_library достаточно, чтобы определить текущий модуль, потому что эта функция тоже существует в двух версиях: статически слинкованной и динамически слинкованной.
В Linux get_module_name_from_ptr использует функцию libc dladdr. В Windows - функции ОС GetModuleHandleExW и GetModuleFileNameW. Выполнять эту проверку при каждом вызове функции с горячей перезагрузкой может быть слишком дорого на горячих путях. К счастью, результат можно закэшировать в лениво инициализируемой статической переменной типа bool:
fn is_outside_dynamic_library() -> bool {
*IS_OUTSIDE_DYNAMIC_LIBRARY
}
static IS_OUTSIDE_DYNAMIC_LIBRARY: LazyLock<bool> =
LazyLock::new(slow_is_outside_dynamic_library);
fn slow_is_outside_dynamic_library() -> bool {
let current_fn_ptr = is_outside_dynamic_library as *const _;
let current_module_name = os::get_module_name_from_ptr(current_fn_ptr);
return current_module_name != std::env::current_exe();
}
Это работает, потому что у каждой динамической библиотеки свой набор статических переменных. В основном процессе, который вызывает функции с горячей перезагрузкой, IS_OUTSIDE_DYNAMIC_LIBRARY кэширует false; в каждой загруженной динамической библиотеке - true. Результат проверки можно навсегда сохранить, потому что адрес функции не сдвинется в памяти процесса. В итоге накладные расходы на вызов функций с горячей перезагрузкой становятся пренебрежимо малыми.
Поддержка нескольких библиотек
Можно сделать ещё одно улучшение. Допустим, ваш бинарник использует несколько библиотек, функции которых вы хотите перезагружать. Текущая реализация запаникует на unwrap’е результата libloading::Library::get_fn, если вы попытаетесь загрузить функцию из второй библиотеки после того, как первая библиотека уже инициализировала статическую переменную DYNAMIC_LIBRARY. Это решается хранением RwLock<HashMap<String, Arc<ArcSwap<LibraryWrapper>>>> в DYNAMIC_LIBRARY - словаря, связывающего каждую отдельную библиотеку с её LibraryWrapper:
pub static DYNAMIC_LIBRARIES_MAP: LazyLock<RwLock<HashMap<String, Arc<ArcSwap<LibraryWrapper>>>>> =
LazyLock::new(Default::default);
// новая функция, которая выдаёт обёртку библиотеки для конкретного крейта
pub fn provide_library_wrapper(library_file_name: String) -> Arc<LibraryWrapper> {
let library = if let Some(existing) = DYNAMIC_LIBRARIES_MAP.read().unwrap().get(&library_file_name) {
existing
} else {
let mut write = DYNAMIC_LIBRARIES_MAP.write().unwrap();
let library_source_path = std::env::current_exe()
.unwrap()
.parent()
.unwrap()
.join(library_file_name);
let shared_library_wrapper = Arc::new(ArcSwap::from_pointee(LibraryWrapper::new(
library_source_path.clone(),
)));
library_watcher::spawn(
library_file_name.to_owned(), // <- также передаём наблюдателю имя библиотеки,
library_source_path, // потому что нужен отдельный наблюдатель
shared_library_wrapper.clone(), // для каждой перезагружаемой библиотеки
);
write.insert(library_file_name, shared_library_wrapper);
shared_library_wrapper
}
return library.load_full();
}
Имя крейта, в котором используется #[hotreload], можно получить из переменной окружения CARGO_PKG_NAME: std::env::var("CARGO_PKG_NAME").unwrap(). Это имя можно передавать вместо захардкоженного "your_library_name.so" из примеров выше.
Результат
Теперь можно перезагружать любую функцию навесив на неё один атрибут:
#[hotreload]
fn my_function() {}
impl Struct {
#[hotreload]
fn my_function(&self) {}
#[hotreload]
fn my_static_function() {}
}
impl Trait for Struct {
#[hotreload]
fn my_function(&self) {}
#[hotreload]
fn my_static_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. это перевод моего же поста с английского, ссылка на оригинал. Не стал отмечать статью как перевод потому что я мог бы написать её с нуля и на русском 😃
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.