Rust 所有权模型与 热更新 的思考
一开始我以为只是换个 .so
我最近在一个 Rust 小工具里试着做插件热更新。
一开始我的想法很朴素:插件编译成 cdylib,宿主启动时 libloading 打开,之后我只要把新的 .so 或 .dll 文件放进目录,然后重新加载一下,新的 update 函数就顶替旧的。
结果这玩意儿是真的难搞啊,得把一堆所有权问题想清楚。
我先做了一个最小示例。插件目录:
cargo new game_plugin --lib
插件的 Cargo.toml:
[lib]
crate-type = ["cdylib"]
插件代码里放一个更新函数:
use std::ffi::c_void;
pub struct World {
pub entities: Vec<i32>,
}
#[no_mangle]
pub extern "C" fn update(world: *mut c_void, dt: f32) {
let world = unsafe { &mut *(world as *mut World) };
for e in &mut world.entities {
*e += dt as i32;
}
}
宿主里加载它,然后调用:
use libloading::{Library, Symbol};
use std::ffi::c_void;
unsafe {
let lib = Library::new("./target/debug/libgame_plugin.so")?;
let update: Symbol<unsafe extern "C" fn(*mut c_void, f32)> = lib.get(b"update")?;
let mut world = World {
entities: vec![1, 2, 3],
};
update(
&mut world as *mut World as *mut c_void,
1.0 / 60.0,
);
}
不出问题的话,第一次调用是能跑通的。然后我就开始飘了,觉得这不就是换个文件再重新 load 一下的事吗?
结果真正开始热更新的时候,问题就来了。
我本来想:先保留旧插件,再加载新插件,等当前帧结束后切换。后来发现这个“当前帧结束”和“谁还能访问 World”比我想得麻烦多了。
可能是我瞎没找到吧,很多 Rust 插件示例都只演示“加载一次”,真正连续换、运行时换、换完还得回收旧状态,文档基本就不怎么管了。
Rust 所有权在这里不是故意刁难
后来我才慢慢理解,Rust 的所有权模型不是在故意阻止你做热更新,它是在提前逼问一个很现实的问题:
在换代码的那一瞬间,旧代码和新代码到底谁在碰这些状态?
热更新最容易踩坑的点,往往不是函数找不到,不是 .so 路径不对,而是 &mut World。
如果旧插件的 update 还在执行,它已经通过 unsafe 拿到了一个 &mut World。这时候你再加载新插件,并准备调用新插件的 update,本质上就变成两个函数都可能往同一个 World 里写。
普通 Rust 代码里这种情况会被借用检查器拦住。热更新时你当然可以用 unsafe 绕过它,但绕过不代表问题消失了,只是从编译错误变成了运行期 UB。
还有一个更阴间的点:函数能不能被保存下来。
比如我写:
pub fn make_update(world: &mut World) -> impl Fn(f32) {
move |dt| {
for e in &mut world.entities {
*e += dt as i32;
}
}
}
这个闭包会借用 world。
如果你想把这个闭包塞进某个全局表里,比如:
static mut SCRIPTS: Vec<Box<dyn Fn(f32)>> = Vec::new();
那 Rust 会非常直接地告诉你:你存不住。闭包持有的引用活不过那个 &mut World。你想热更,就必须保证函数对象的生命周期是合法的。
这其实就是在逼你重新设计状态归属。
你不能一边让插件函数借用 &mut World,一边又把这个函数长期保存下来。
我后来把问题拆成了两件事
折腾几次之后,我把“热更新”拆成了两件不同的事。
第一件是替换函数入口。
第二件是保证替换期间没有共享可变状态冲突。
这两件事如果不分开,最后就会变成“我明明换插件了,怎么崩在 Vec 里”这种很恶心的 bug。
对于替换函数入口,Rust 里常见的方式有几种:导出 C ABI 函数、传一个函数表、传 opaque handle,或者干脆让插件只返回数据,不直接拥有状态。
我后来比较能接受的一种写法,是把可热更函数统一成:
type UpdateFn = Arc<dyn Fn(&mut World, f32) + Send + Sync>;
然后函数表用 Arc 管理:
use std::sync::{Arc, RwLock, OnceLock};
static SCRIPT: OnceLock<RwLock<UpdateFn>> = OnceLock::new();
fn install(script: UpdateFn) {
*SCRIPT
.get_or_init(|| RwLock::new(Arc::new(|_w: &mut World, _dt| {})))
.write()
.unwrap() = script;
}
fn tick(world: &mut World, dt: f32) {
let f = {
let guard = SCRIPT.get().unwrap().read().unwrap();
guard.clone()
};
f(world, dt);
}
这个例子只是说明思路,不是可以直接上生产的完整实现。真项目里还得处理插件加载失败、旧插件销毁、新插件初始化、符号生命周期,以及多线程调用问题。
但这里能看出来,Arc 解决的是“函数对象可以被多个地方短期持有”的问题。
RwLock 解决的是“我要替换当前生效的函数”的问题。
而 tick 里的 &mut World 仍然是独占借用。
关键不在于能不能用 unsafe 绕过去,而在于:你不能一边热更新,一边让别人还拿着可变引用到处晃。
热更新真正卡住我的地方是“切换点”
后来我发现,热更新最需要的不是“动态加载”,而是“切换点”。
也就是在一个明确的时间点,旧代码不再被调用,新代码才可以被调用。
比如游戏主循环里,我大概会这样安排:
fn main_loop() {
let mut world = World {
entities: vec![1, 2, 3],
};
let dt = 1.0 / 60.0;
loop {
// 1. 处理输入
// 2. 如果收到 reload 请求,先等当前帧结束
// 3. 热替换脚本
// 4. 调用 update
// 5. 调用 draw
tick(&mut world, dt);
std::thread::sleep(std::time::Duration::from_millis(16));
}
}
这里有个很朴素的规则:
reload 不能在 tick 中间发生。
因为 tick 一旦把 &mut World 交给插件函数,你就得承认:在这个函数调用结束前,世界状态已经被借出去了。
如果你这时候强行换插件,或者让另一个线程也调 update,所有权模型就会变得很脏。
所以我后来更倾向于把热更新做成“帧边界替换”,而不是“随时替换”。
甚至可以配置一个切换策略:
[[plugin]]
path = "./plugins/damage"
entry = "update"
reload_policy = "frame_boundary"
timeout_ms = 250
这里面的 frame_boundary 是我个人比较推荐的方式。它意味着宿主会在当前帧执行完之后,再真正交换函数表。
这样至少 &mut World 的借用关系是清晰的。
旧插件不能随便被释放
还有一个问题:旧插件文件加载之后,什么时候可以真正 unload?
这个比想象中麻烦。
libloading 里的 Symbol 依赖 Library。如果 Library 被释放了,你手里那个函数指针可能还指向已经无效的加载内存。
所以不能一边还有旧闭包活着,一边就把旧 Library 丢了。
一种保守做法是用 Arc<Library> 把插件生命周期绑住:
struct PluginSlot {
lib: Arc<Library>,
update: UpdateFn,
}
static PLUGIN: OnceLock<RwLock<Arc<PluginSlot>>> = OnceLock::new();
然后 reload 时:
fn reload(path: &str) -> Result<(), Box<dyn std::error::Error>> {
let slot = PLUGIN.get().unwrap();
let old = slot.read().unwrap().clone();
let lib = unsafe {
Library::new(path)?
};
let update = unsafe {
// 这里只是示意,真实实现要看插件导出签名
old.update.clone()
};
*slot.write().unwrap() = Arc::new(PluginSlot {
lib: Arc::new(lib),
update,
});
Ok(())
}
当然这个代码只是说明“需要持有旧 Library”,不是完整可用实现。因为 update 从旧插件来还是新插件来,这个逻辑还没处理。
真做的时候会更像:
1. 创建新的 PluginSlot
2. 确保没有线程正在调用旧 update
3. 交换 PLUGIN.write()
4. 让旧 PluginSlot 自然 drop
5. 等所有 Arc 释放完,旧 Library 才会被释放
这其实就是 Rust 所有权模型在帮你管理生命周期。
你不能用一个“看起来差不多”的 unsafe 去赌它不会出问题。
热更新最好别碰数据布局
另一个坑是:插件可以热更,但数据结构不一定能热更。
比如第一版:
struct WorldV1 {
entities: Vec<i32>,
}
第二版插件假设:
struct WorldV2 {
entities: Vec<i32>,
flags: u32,
}
然后宿主里还拿着旧的 WorldV1 内存,新插件却按 WorldV2 解释。
这就是非常典型的布局不一致。
你只是热更了函数,不是给内存结构换了个壳。
如果要做真实热更新,至少要做到其中一种:
- 数据结构版本不变,插件只替换行为;
- 数据结构带版本号和字段偏移,运行时做迁移;
- 插件不直接碰内存,只通过宿主 API 读写;
- 状态序列化成稳定格式,换插件时重新解码。
我最后更倾向于第三种。也就是插件不直接 &mut World,而是拿一个受控 handle:
#[repr(C)]
struct HostApi {
get_entity_count: unsafe extern "C" fn(*mut c_void) -> usize,
set_entity: unsafe extern "C" fn(*mut c_void, usize, i32),
log: unsafe extern "C" fn(*const u8, usize),
}
插件函数通过 HostApi 来操作状态。
这样热更新的风险会小很多。因为插件不再依赖宿主内部结构,只依赖一个更稳定的 ABI。
这有点像 C 插件常用的 opaque pointer + function table 模型。
但这里有个很现实的点:
它本质上已经放弃了“直接共享 Rust 所有权类型”的热更新思路。
我最后得到的一个体会
做完这个尝试之后,我对 Rust 所有权的看法反而变了。
一开始我觉得它是在阻碍热更新。
后来我发现,它其实是在替我做一次“可变性审计”。
如果宿主、插件、旧代码、新代码、状态、函数闭包之间的关系,用 Rust 的所有权写不清楚,那大概率说明热更新架构本身就不稳定。
热更新不是“把代码换一下”这么简单。它背后是一连串问题:
谁拥有 World?
谁正在借用 World?
函数对象活多久?
插件文件什么时候释放?
状态能不能跨版本读取?
新代码什么时候才被允许执行?
旧代码什么时候彻底退出?
这些问题如果都含糊,最后就会变成非常玄学的崩溃。
所以我现在更倾向于把 Rust 的热更新分成几层:
配置热更,用 ArcSwap 或 RwLock<Arc<Config>>,这个相对简单。
脚本逻辑热更,用 Lua 或 Wasm,可能更稳。
Rust 动态库热更,要非常小心 ABI、Library 生命周期、借用冲突和数据结构版本。
如果是长期运行的生产系统,我不太建议直接热替换 Rust &mut 函数指针,除非你真的把边界设计得非常清楚。
最后我的结论是:
Rust 所有权模型和热更新并不是天然冲突。
冲突在于,你想热更新的时候,却还没有想清楚状态到底归谁。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。