一文了解 Rust 中的 Cell 和内部可变性
Cell 并没有一个合适的中文翻译,你把它翻译成单元格、内存单元、可变单元都随意,本质上表示一个值。但是这是一个特殊的值,拥有内部可变性(Interior Mutability)的一个值。
为什么需要 Cell
在所有权系统中,你无法改变一个不可变的引用,例如:
fn try_set(value: &str) {
// Cannot assign a new value to an immutable variable more than once
value = "modified";
}
但是有些情况下,我希望它可以修改这个不可变引用,比如:
1. 结构体中需要可变字段,但是整个结构体需要是不可变的
2. 在不可变上下文中需要修改状态,比如说回调函数
3. 实现内部计数器、缓存或者延迟计算,而不暴露可变性
Cell 的实际例子
我们先来说第一种情况,比如我要统计网页的访问量:
use std::cell::Cell;
struct WebPage {
title: String,
path: String,
pv: Cell<i64>,
uv: Cell<i64>
}
如果你不使用 Cell<T>
结构的话,在 Rust 的借用规则下,同一时间只能存在一个可变引用(&mut T
)或者任意数量的不可变引用(&T
),以此来防止数据竞争和其他内存安全的问题。
如果我需要同时更新 pv
和 uv
的值,那么我需要持有 WebPage
的可变引用同时修改多个字段。使用了 Cell 字段的话,可以允许多个组件通过不可变引用来并发(不是并行)修改这些字段。
使用了Cell<T>
我就可以用 WebPage
的不可变引用修改的某个字段了。本质上,它是通过 Copy 来实现的,所以 Cell<T>
中的 T
当调用 get()
、clone()
方法的时候需要实现 Copy trait, 但是调用 set()
、replace()
等方法则不需要:
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>
是非线程安全的,解决是单线程环境下的并发问题,而不是多线程环境里的并行问题。
我们再来说第二种情况,就是在回调函数中更新状态。比如我写了一个下载文件的功能:
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();
}
}
}
在下载文件的同时,我还需要计算文件下载的数量:
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
本身是线程不安全的。
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 的:
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,但是出于成本考虑,就不适合在大型数据、自定义数据结构中使用:
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、替换实现的,返回的是值,而不是引用。如果是大型的数据(比如 Vec
、String
或者自定义结构或者没有实现 Copy 的类型),使用 RefCell
会比较合适。其在运行时去检查是否符合借用规则,不符合则抛出 Panic, 存在运行时开销。
比如你要去实现一个 HTTP 协议的框架,需要存储会话数据,就可以使用 thread_local!
宏结合 RefCell
来实现:
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
, 或者说只初始化一次,类似于单例模式。比如说在一个请求中初始化配置文件:
use once_cell::sync::Lazy;
static CONFIG: Lazy<String> = Lazy::new(|| {
println!("初始化 CONFIG!");
"全局配置".to_string()
});
fn main() {
println!("{}", *CONFIG); // 第一次访问,初始化
println!("{}", *CONFIG); // 之后访问,复用
}
此外,数据库连接等场景也非常适用。
总结
通过这篇文章,你能分别 Cell
、RefCell
、OnceCell
了吗?通过这些结构,可以更加深入的理解借用、内部可变性等概念。
本作品采用《CC 协议》,转载必须注明作者和本文链接
推荐文章: