小Cの已经记不起来的博客

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 解释。

这就是非常典型的布局不一致。

你只是热更了函数,不是给内存结构换了个壳。

如果要做真实热更新,至少要做到其中一种:

我最后更倾向于第三种。也就是插件不直接 &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 的热更新分成几层:

配置热更,用 ArcSwapRwLock<Arc<Config>>,这个相对简单。

脚本逻辑热更,用 Lua 或 Wasm,可能更稳。

Rust 动态库热更,要非常小心 ABI、Library 生命周期、借用冲突和数据结构版本。

如果是长期运行的生产系统,我不太建议直接热替换 Rust &mut 函数指针,除非你真的把边界设计得非常清楚。

最后我的结论是:

Rust 所有权模型和热更新并不是天然冲突。

冲突在于,你想热更新的时候,却还没有想清楚状态到底归谁。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放