一文了解 Rust 中的 Cell 和内部可变性

Cell 并没有一个合适的中文翻译,你把它翻译成单元格、内存单元、可变单元都随意,本质上表示一个值。但是这是一个特殊的值,拥有内部可变性(Interior Mutability)的一个值。

为什么需要 Cell

在所有权系统中,你无法改变一个不可变的引用,例如:

rust 复制代码
fn try_set(value: &str) {
    // Cannot assign a new value to an immutable variable more than once
    value = "modified";
}

但是有些情况下,我希望它可以修改这个不可变引用,比如:

  1. 结构体中需要可变字段,但是整个结构体需要是不可变的
  2. 在不可变上下文中需要修改状态,比如说回调函数
  3. 实现内部计数器、缓存或者延迟计算,而不暴露可变性

Cell 的实际例子

我们先来说第一种情况,比如我要统计网页的访问量:

rust 复制代码
use std::cell::Cell;

struct WebPage {
    title: String,
    path: String,
    pv: Cell<i64>,
    uv: Cell<i64> 
}

如果你不使用 Cell<T> 结构的话,在 Rust 的借用规则下,同一时间只能存在一个可变引用(&mut T)或者任意数量的不可变引用(&T),以此来防止数据竞争和其他内存安全的问题。

如果我需要同时更新 pvuv 的值,那么我需要持有 WebPage 的可变引用同时修改多个字段。使用了 Cell 字段的话,可以允许多个组件通过不可变引用来并发(不是并行)修改这些字段

使用了Cell<T>我就可以用 WebPage 的不可变引用修改的某个字段了。本质上,它是通过 Copy 来实现的,所以 Cell<T> 中的 T 当调用 get()clone() 方法的时候需要实现 Copy trait, 但是调用 set()replace() 等方法则不需要:

rust 复制代码
impl WebPage {
    fn update_pv(&self) {
        self.pv.set(self.pv.get() + 1);
    }
    fn update_uv(&self) {
        self.uv.set(self.uv.get() + 1);
    }
}

当然,你需要注意: Cell<T> 是非线程安全的,解决是单线程环境下的并发问题,而不是多线程环境里的并行问题。

我们再来说第二种情况,就是在回调函数中更新状态。比如我写了一个下载文件的功能:

rust 复制代码
use std::cell::Cell;

struct Downloader {
    url: String,
    success: Option<Box<dyn Fn()>>,
}

impl Downloader {
    fn new(url: String) -> Self {
        Downloader {
            url,
            success: None
        }
    }
    fn on_success<F>(&mut self, handler: F)
    where
        F: Fn() + 'static
    {
        self.success = Some(Box::new(handler))
    }
    fn download(&self) {
        if let Some(handler) = &self.success {
            handler();
        }
    }
}

在下载文件的同时,我还需要计算文件下载的数量:

rust 复制代码
use std::cell::Cell;
use std::rc::Rc;

fn main() {
    let download_count = Rc::new(Cell::new(0));
    let mut downloader = Downloader::new("https://example.com/happy.jpg".to_string());

    let count_clone = Rc::clone(&download_count);
    downloader.on_success(move || {
        count_clone.set(count_clone.get() + 1);
    });
    downloader.download();
    downloader.download();
    println!("download_count: {}", download_count.get());
}

Cell<T> 解决了通过不可变引用修改内部的值的问题,Rc 则允许多个所有者共享同一个数据,通过引用计数的方式来管理资源的分发和回收。

Cell 的原理

我们来看一下 Cell 的定义, 透过这个定义,我们可以知道 T 可以是编译期间未知大小的(?Size), 但是 Cell 本身是线程不安全的(!Sync), 因为它内部使用了 UnsafeCell 来存储数据,而 UnsafeCell 本身是线程不安全的。

rust 复制代码
pub struct Cell<T: ?Sized> {
    value: UnsafeCell<T>,
}
#[repr(transparent)]
pub struct UnsafeCell<T: ?Sized> {
    value: T,
}
impl<T: ?Sized> !Sync for UnsafeCell<T> {}

我们接着来看一下 get(&self) -> *mut T 方法,从这个方法的实现来理解,为什么说它是需要T 是需要实现 Copy trait 的:

rust 复制代码
pub const fn get(&self) -> *mut T {
    // We can just cast the pointer from `UnsafeCell<T>` to `T` because of
    // #[repr(transparent)]. This exploits std's special status, there is
    // no guarantee for user code that this will work in future versions of the compiler!
    self as *const UnsafeCell<T> as *const T as *mut T
}

返回的 T 经过了 3 次指针转换, 最终转换为 *mut T,它是一个原始指针,并不受到借用检查器的管理,由用户来保证其内存安全。

注释中提到这种转换是取决于编译器的特殊处理的,这种写法不适合在用户代码中。

最后,也可以看到如果是 set(调用的是 replace)、replace 一个值,本质上是替换了整个值,而不是修改内部的值,所以不需要 T 实现 Copy trait,但是出于成本考虑,就不适合在大型数据、自定义数据结构中使用:

rust 复制代码
pub const fn replace(&self, val: T) -> T {
    // SAFETY: This can cause data races if called from a separate thread,
    // but `Cell` is `!Sync` so this won't happen.
    mem::replace(unsafe { &mut *self.value.get() }, val)
}

RefCell

如果你使用 Cell<T>,最好是用在小型数据、或者一些基本类型,因为其内部采用的是 Copy、替换实现的,返回的是值,而不是引用。如果是大型的数据(比如 VecString 或者自定义结构或者没有实现 Copy 的类型),使用 RefCell 会比较合适。其在运行时去检查是否符合借用规则,不符合则抛出 Panic, 存在运行时开销。

比如你要去实现一个 HTTP 协议的框架,需要存储会话数据,就可以使用 thread_local! 宏结合 RefCell 来实现:

rust 复制代码
use std::cell::{RefCell};
use std::thread;

struct Session {
    user_id: Option<i64>,
    is_logged: bool,
}

thread_local! {
    static SESSION: RefCell<Session> = RefCell::new(Session {
        user_id: None,
        is_logged: false,
    });
}

fn handle_request(user_id: i64) {
    SESSION.with(|session| {
        let mut session = session.borrow_mut();
        session.user_id = Some(user_id);
        session.is_logged = true;
    });
}
fn main() {
    for i in 0..10 {
        let handle = thread::spawn(move || {
            handle_request(i);
            SESSION.with(|session| {
                let session = session.borrow();
                println!("user_id: {:?}, is_logged: {}", session.user_id, session.is_logged);
            });
        });
        handle.join().unwrap();
    }
}

在这个案例中,thread_local! 实现了线程间数据隔离,而 RefCell 在实现了内部可变现。除了 Session 这个案例外,日志的上下文、资源的缓存等场景都可以适用。

OnceCell

OnceCell 就比较好理解了,惰性初始化 Cell, 或者说只初始化一次,类似于单例模式。比如说在一个请求中初始化配置文件:

rust 复制代码
use once_cell::sync::Lazy;

static CONFIG: Lazy<String> = Lazy::new(|| {
    println!("初始化 CONFIG!");
    "全局配置".to_string()
});

fn main() {
    println!("{}", *CONFIG); // 第一次访问,初始化
    println!("{}", *CONFIG); // 之后访问,复用
}

此外,数据库连接等场景也非常适用。

总结

通过这篇文章,你能分别 CellRefCellOnceCell 了吗?通过这些结构,可以更加深入的理解借用、内部可变性等概念。

相关推荐
疏狂难除10 小时前
【Tauri2】026——Tauri+Webassembly
rust·wasm·tauri2
Hello.Reader11 小时前
给你的 Rust 通用库“插上” WebAssembly 的翅膀
javascript·rust·wasm
yezipi耶不耶2 天前
Rust学习之实现命令行小工具minigrep(二)
开发语言·学习·rust
hboot2 天前
rust 全栈应用框架dioxus
前端·rust·全栈
muyouking113 天前
0.深入探秘 Rust Web 框架 Axum
开发语言·前端·rust
勇敢牛牛_3 天前
【Rust基础】使用Rocket构建基于SSE的流式回复
开发语言·后端·rust
muyouking113 天前
3.Rust + Axum 提取器模式深度剖析
前端·rust·github
pumpkin845143 天前
学习笔记十六——Rust Monad从头学
笔记·学习·rust
Hello.Reader3 天前
快速启动 Rust + WebAssembly 项目
开发语言·rust·wasm