[Rust] 最终我还是选择了 UnsafeCell

前言

在我最初学 Rust 的时候,我十分严格的要求自己不用 RefCell,不写 unsafe,我坚信一切事物均应该按照函数式思想,一切都应该是纯函数,一切副作用从上向下传递,但随着我逐渐的使用,我最终还是选择了在我的 Rust 代码中引入 UnsafeCell

探索

阶段 1,全部可变

一切的缘由都开始于我想对值进行懒加载,于是我参考其他语言中 lazy,借助可变引用实现了懒加载:

注,目前 Rust 1.80+ 已经引入了更方便的 lazy cell 来实现这个诉求,或者在 1.70+ 也可以使用 once cell 或其他第三方库来实现诉求,这里只是展示个人的想法过程。

rust 复制代码
struct Lazy<T> {
    value: Option<T>,
    producer: fn() -> T,
}

impl<T> Lazy<T> {
    fn new(producer: fn() -> T) -> Self {
        Lazy { value: None, producer }
    }

    fn get(&mut self) -> &T {
        if self.value.is_none() {
            self.value = Some((self.producer)());
        }
        self.value.as_ref().unwrap()
    }
}

我很轻松的设计出了这样的一个 api,如果未初始化,那么值将会被计算并返回 &T,一切看起来很美好,但是当我们真正使用时就会发现问题:

rust 复制代码
fn main() {
    let mut lazy = Lazy::new(|| 1);
    let a = lazy.get();
    let b = lazy.get();
    println!("{a}, {b}");
}

下面的代码会在 b 的 lazy 获取处报错:

cannot borrow lazy as mutable more than once at a time

造成这个问题的原因是因为只能持有一个可变引用,这是 Rust 的借用规则,而这里 a 和 b 均借用了可变引用 &mut lazy,同时持有了多个从可变引用 &mut lazy 得到的不可变引用 (&T),也相当于同时间接持有了多个对 lazy 的可变引用。

不过,可以多次借用可变引用,只不过不允许同时持有他们,我们只需要返回 owned T 而不是 &T 就可以解决这个问题,一个可能的方式是通过智能指针 Rc 实现,通过 Rc 我们能够获得直接指向一个对象智能指针,且每次返回 clone 后的 owned Rc,不需要担心返回的引用被 mut& 的借用机制传染:

rust 复制代码
struct Lazy<T> {
    value: Option<Rc<T>>,
    producer: fn() -> T,
}

impl<T> Lazy<T> {
    fn get(&mut self) -> Rc<T> {
        if self.value.is_none() {
            self.value = Some(Rc::new((self.producer)()));
        }
        Rc::clone(self.value.as_ref().unwrap())
    }
}

再次编译上方的 main 测试代码,则不再会报错,同时我们也可以在需要时对 Rc 解引用得到 &T

阶段 2,RefCell

但上面的代码是比较理想的,但其有这样的缺点:

  1. 全部链路都需要是可变的,Rust 中,不可变的 struct 其内部的 struct 理论上也是不可变的
  2. 遇到需要同时持有多个可变引用中的不可变引用时,往往需要通过 Rc 等方式记录智能指针
  3. 从外部使用者的视角来看,给定特定的 Lazy 总能得到所需的 &T,始终是一样的,似乎没必要传入 &mut self,同时也由于 &mut 的限制比常规引用大,外部使用者也更难以拿到对 self 的可变引用。

但从库设计者的角度来说,这似乎又应该是合理的诉求,因为在内部对 value 进行了 write,当然要使用 &mut。

因此 Rust 有 RefCell,它可以实现内部可变性,使得你能够使用不可变引用执行可变操作,继续以上面 Lazy 为例,我们使用 RefCell 又对其进行了改造

rust 复制代码
struct Lazy<T> {
    value: RefCell<Option<T>>,
    producer: fn() -> T,
}

impl<T> Lazy<T> {
    fn new(producer: fn() -> T) -> Self {
        Lazy { value: RefCell::default(), producer }
    }

    fn get(&self) -> Ref<T> {
        if self.value.borrow().is_none() {
            let value = (self.producer)();
            *self.value.borrow_mut() = Some(value);
        }
        Ref::map(self.value.borrow(), |v| v.as_ref().unwrap())
    }
}

此时,get 不再需要可变引用,也不再需要 Rc,可变被隐藏在了实现内部,不过,你无法对外部隐藏你使用了 RefCell,因为你需要返回 Ref 而不是普通的 &T。

额外插一句,Rust 官方之所以不希望返回 &T(否则悬垂指针)的原因,一方面是希望不对外隐藏使用了 RefCell 的事实,另一方面是 Ref 和 RefMut 也会记录借用次数并在 new 和 drop 时检验借用规则,在不满足借用规则的情况下,则会运行时 panic 或返回 result (try_borrow)。

阶段 3,可重置

上面的代码实现简单的 lazy 是足够了,不过我们来看看一个真实诉求:

在我们访问一个服务时,通常要先获取其 access token(懒获取),token 往往具有时效性,因此我们希望在 token 到期后去初始化新的 token,于是写出了这样的代码:

rust 复制代码
struct TokenCenter<T> {
    value: RefCell<Option<(u64, T)>>, // (last_update time, value)
    producer: fn() -> T,
}

impl<T> TokenCenter<T> {
    fn get(&self) -> Ref<T> {
        let value = self.value.borrow();
        let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
        if value.is_none() || now - value.as_ref().unwrap().0 > 1000 {
            let new_value = (self.producer)();
            *self.value.borrow_mut() = Some((now, new_value));
        }
        Ref::map(self.value.borrow(), |v| &v.as_ref().unwrap().1)
    }
}

和之前 lazy 类似,不过加上了超时,token 到期 get 会返回新 Ref,不过,这样的代码事实上非常危险,一旦你运行它,则一定 panic,原因在于我们 borrow 出的 value: Ref 在还没 drop 时被后续 borrow_mut 可变的借用了,一般情况下,drop 会在退出 value 所在的 block(即 get 函数体)时才会执行 drop,修复方法就是我们主动指定其 drop 时机,确保不会同时持有 Ref 和 RefMut

rust 复制代码
fn get(&self) -> Ref<T> {
    let value = self.value.borrow();
    let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
    if value.is_none() || now - value.as_ref().unwrap().0 > 1000 {
        drop(value);
        let new_value = (self.producer)();
        *self.value.borrow_mut() = Some((now, new_value));
    }
    Ref::map(self.value.borrow(), |v| &v.as_ref().unwrap().1)
}

阶段 4,UnsafeCell

但 RefCell 也不是万能良方,它也有缺点:

  1. 由于 Ref 和 RefMut 不能同时持有,而我们还将 Ref 的所有权交给了外部的调用者,这导致当我们想对其进行改变时,我们必须要保证所有持有 Ref 的均已经 drop,否则 borrow_mut 则会发生 panic,但对于所有权怎么使用是使用者的事,完全不受我们的控制,甚至即使你使用 try_borrow_mut 也只是能兜住这个问题,而不能真正解决这个问题。
  2. 借用检查似乎是不必要的,我可以通过合理代码逻辑设计来避免这个问题发生,只要保证对外提供 API 足够安全即可,我希望得到最为极致的性能,更何况 RefCell 的设计下 borrow 也是危险操作,这意味着我总应该小心行事,运行时去模拟借用检查在我看来并不是一个很好的策略。

UnsafeCell 是安全的 API,只是名字看着吓人,但其内部函数都是 safe 的,为什么 safe 呢?因为获取一个裸指针是安全的,只有访问裸指针才是不安全的,而 UnsafeCell 提供的 api 都是获取裸指针,而不是访问裸指针。

我们希望使用 UnsafeCell 来得到更自由的能力,同时我们也能够避免借用检查,获得更优越的性能,于是写出了这样的代码:

rust 复制代码
struct TokenCenter<T> {
    value: UnsafeCell<Option<(u64, T)>>, // (last_update time, value)
    producer: fn() -> T,
}

impl<T> TokenCenter<T> {
    fn get(&self) -> &T {
        let value_ptr = self.value.get();
        let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
        let value_ref = unsafe { &mut *value_ptr };
        if value_ref.is_none() || now - value_ref.as_ref().unwrap().0 > 1000 {
            let value = (self.producer)();
            *value_ref = Some((now, value));
        }
        &value_ref.as_ref().unwrap().1
    }
}

在上面的例子中:

  1. 获取 value_ptr 是裸指针,但获取其是安全的
  2. value_ref 是通过裸指针得到的可变引用,解引用裸指针需要 unsafe
  3. 由于没有 RefCell 的介入,因此这里不会有额外的借用检查,性能是最好的,同时也能直接返回 &T,使用方便,不用担心外部控制了对不可变引用所有权而导致内部的可变操作 panic

这样的 api 站在使用者视角来看简直是完美,但却是暗藏隐患的,因为 value 没有借用规则的约束,因此返回的 &T 指向的内存所存放的值是会变化的,这意味着如果过一段时间之后再次访问这个 &T,可能得到完全不一样的结果,更可怕的是这样的结果也很可能影响编译器的判断,编译器会根据不可变特性在编译阶段对代码进行更激进的优化,从而产生未定义的结果,下面看一个例子:

rust 复制代码
fn simple_token_center() {
    let token_center = TokenCenter::new(|| random::<u8>());
    let a: &u8 = token_center.get();
    let b: &u8 = token_center.get();
    println!("{a}, {b}"); // 92, 92

    sleep(Duration::from_secs(2000000));
    let a: &u8 = token_center.get();
    println!("{a}, {b}"); // 216, 216
}

在执行上面的代码后我们能发现,a 和 b 始终是相同的,因为两行之类调用之间没有其他指令,但在 sleep 了相当长一段时间后,缓存过期,此时再去 get 时我们发现不仅 a 变了,b 甚至都变了,但明明 b 被声明为了不可变引用,这一行为是相当危险的。

经过上面的过程,我们发现:

  • RefCell 将所有权交给调用方,导致 get 方法对调用者如何使用 Ref 无感知
  • UnsafeCell 导致了 &T 可能引用到事实上是可变的变量

两者都不完美,更好的使用方式是配合智能指针 Rc 来让被持有的对象在无引用时自动回收:

rust 复制代码
struct TokenCenter<T> {
    value: UnsafeCell<Option<(u64, Rc<T>)>>, // (last_update time, value)
    producer: fn() -> T,
}

impl<T> TokenCenter<T> {
    fn get(&self) -> Rc<T> {
        let value_ptr = self.value.get();
        let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
        let value_ref = unsafe { &mut *value_ptr };
        if value_ref.is_none() || now - value_ref.as_ref().unwrap().0 > 1000 {
            let value = (self.producer)();
            *value_ref = Some((now, Rc::new(value)));
        }
        Rc::clone(&value_ref.as_ref().unwrap().1)
    }
}

经过了这样的修改,测试代码也得到了正确的结果,我们发现此时得到的 b 就不再发生变更了:

rust 复制代码
fn simple_token_center() {
    let token_center = TokenCenter::new(|| random::<u8>());
    let b = token_center.get();
    println!("{}, {b}", token_center.get()); // 248, 248
    sleep(Duration::from_secs(200000));
    println!("{}, {b}", token_center.get()); // 182, 248
    sleep(Duration::from_secs(200000));
    println!("{}, {b}", token_center.get()); // 86, 248
}

总结

从一开始全部显式暴露可变,到使用 RefCell 实现内部可变性避免可变引用的传染性,再到可重置 Lazy 显式 drop 来保证借用规则符合要求,最终选择了 UnsafeCell 来实现更好的 API 暴露和更好的性能。

但我仍为自己制定了额外的规则:

  • 不对外传播不安全的引用,如 Ref,MutRef 以及 unsafe 产生的 &
  • 仅在结构体内部谨慎维护可变性,不对外暴露 RefCell、UnsafeCell 等 API,仅能通过函数访问得到所需的安全的内容

其他

相关推荐
WMYeah1 小时前
【无标题】
前端·rust·抽奖程序·跨平台抽奖程序
楼兰公子14 小时前
buildroot 在编译rust时裁剪平台类型数量的方法
开发语言·后端·rust
Rust研习社20 小时前
开源项目里的 deny.toml 是什么?
后端·rust·编程语言
铭毅天下1 天前
当搜索引擎遇上 Rust——深度解读下一代实时搜索引擎 INFINI Pizza
开发语言·后端·搜索引擎·rust
咸甜适中1 天前
rust语言学习笔记Trait之Default(默认值)
笔记·学习·rust
容智信息2 天前
AI Agent(智能体)的输出格式应该从 Markdown 转向 HTML吗?
前端·人工智能·rust·编辑器·html·prompt
Rust研习社2 天前
Rust Clippy 实用指南:写出更优雅、安全的 Rust 代码
后端·rust·编程语言
yangyongdehao302 天前
两天用AI+rust撸了一款本地批量去水印软件,30MB,效果能打
ai作画·rust
nudt_qxx2 天前
NVIDIA 正式开源cuda-oxide!Rust 编写 CUDA 内核新范式!
rust