[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,仅能通过函数访问得到所需的安全的内容

其他

相关推荐
simper_zxb20 小时前
【rust实战】rust博客系统3_项目目录结构及文件目录引入
rust
好看资源平台21 小时前
深入理解所有权与借用——借用与生命周期管理
开发语言·算法·rust
勇敢牛牛_1 天前
【Rust笔记】Rocket实现自定义的Responder
开发语言·笔记·rust
好看资源平台1 天前
Cargo 的工作机制
开发语言·rust
陈序缘1 天前
Rust实现Kafka - 前言
开发语言·分布式·后端·职场和发展·rust·kafka
喜欢打篮球的普通人1 天前
2024 Rust现代实用教程:1.3获取rust的库国内源以及windows下的操作
开发语言·windows·rust
TYYJ-洪伟1 天前
Rust 程序设计语言学习——高级特性
rust··指针·函数指针·闭包·不安全
陈序缘1 天前
Rust 力扣 - 48. 旋转图像
开发语言·后端·算法·leetcode·职场和发展·rust
小宇学编程1 天前
M1 Pro MacBook Pro 上的奇遇:Rust 构建失败,SIGKILL 惊魂记
后端·rust·编译问题·编译失败·cargo build
老猿讲编程2 天前
Rust语言的优缺点以及学习建议
开发语言·学习·rust