跳转到内容

内部可变性

我们了解了 Rust 严苛的所有权与借用规则——数据要么是独占可变的 (&mut),要么是共享不可变的 (&). 但对于一些全局访问或者需要异步处理的资源,在保持 Rust 语义检查的情况下,我们需要引入内部可变性 (Interior Mutability) 这个概念.

通常,当你持有一个不可变引用 &T 时,Rust 禁止你修改它指向的数据. 这是外部可变性 (Inherited Mutability) 变量的可变性由它的引用类型 (可变引用或不可变引用) 决定.

内部可变性允许你在拥有不可变引用时修改数据. 这听起来不太行,但它是安全的,因为我们将借用规则的检查从编译期 (Compile time) 推迟到了运行时 (Runtime),或者通过原子操作保证了安全性。

所有内部可变性类型的底层核心都是 UnsafeCell<T>. 它是 Rust 语言中唯一被编译器特殊对待的类型,它明确告诉编译器,“即使你持有这个类型的共享引用,其内部数据也可能被修改,请不要做激进的优化”.

如果你有一个引用 &T,通常 Rust 编译器会基于 &T 指向不可变数据这一认知进行优化. 通过别名或通过将 &T 转换为 &mut T 来改变该数据被视为未定义行为. UnsafeCell 取消了 &T 的不可变性保证,共享引用 &UnsafeCell 可能指向正在被修改的数据. 这被称为内部可变性.

所有我们使用的安全封装,诸如 Cell, RefCell, Mutex 内部都包裹着 UnsafeCell.

在单线程环境下,如果编译器无法静态确认借用安全性,我们可以请求运行时检查.

Cell<T> 适用于实现了 Copy trait 的简单类型.

Cell<T> 不返回数据的引用,它没有 get(&self) -> &T 这种方法,仅提供值.

  • 它通过 set() 将新值移动 (Move/Copy) 进去覆盖旧值.
  • 它通过 get() 将内部值拷贝 (Copy) 出来.

因为永远拿不到内部数据的引用 &T,所以永远不会发生“同时拥有可变引用和不可变引用”的情况. 由此,指针别名问题被物理隔离了.

use std::cell::Cell;

struct ConsoleState {
    is_busy: Cell<bool>,
    written_bytes: Cell<usize>, 
}

fn main() {
    let state = ConsoleState {
        is_busy: Cell::new(false),
        written_bytes: Cell::new(0),
    };

    // 即使我们只有 &ConsoleState,我们依然可以修改内部字段
    // 编译器自动创建了一个引用 &state.is_busy 传给 set 方法
    state.is_busy.set(true);
    
    let current = state.written_bytes.get(); 
    state.written_bytes.set(current + 1);
    
    println!("Bytes: {}", state.written_bytes.get());
}

对于复杂类型助如 VecString,使用引用 (或者说指针) 可以减少频繁拷贝的代价.

RefCell<T>Cell<T> 几乎是一样的,正如其名,主要差异在于 RefCell<T> 提供引用,而 Cell<T> 提供值,另外 RefCell<T> 有 panic 的风险.

RefCell 在内部维护了一个运行时的借用计数器 (borrow counter). 它把编译器的借用检查逻辑搬到了程序运行时执行. RefCell 通过 borrowborrow_mut 等方法来访问值.

  • borrow(): 借用计数 +1. 如果发现当前已有 &mut 借用,则 Panic.
  • borrow_mut(): 检查计数是否为 0,如果不为 0,说明已有 &&mut,则 Panic;否则标记为独占.
use std::cell::RefCell;

// 即使 data 是不可变,我们也能通过 RefCell 修改内部的 Vec
let data = RefCell::new(vec![1, 2, 3]);

{
    let mut v = data.borrow_mut(); 
    v.push(4);
} // v 离开作用域,借用归还

println!("{:?}", data.borrow());

OS 内核是天然的多线程程序,我们需要线程安全的内部可变性. 这里涉及两个关键的 Trait:

  • Send: 只要 T 是 Send 的,就允许将 T 的所有权在线程间传递.
  • Sync: 只要 T 是 Sync 的,就允许将引用 &T 在线程间传递. 这也意味着 T 支持并发访问.

Rust 的 Mutex<T> 互斥锁作为数据的容器,用户无法直接访问 T,而是调用 .lock() 来阻塞当前线程直到获取锁,.lock() 返回一个 MutexGuard<T> 智能指针. 用户通过 MutexGuard 访问 T,当 MutexGuard Drop 时,自动释放锁.

use std::sync::Mutex;
static GLOBAL_QUEUE: Mutex<Vec<i32>> = Mutex::new(Vec::new());

fn push_item(i: i32) {
    // lock() 阻塞等待,得到 guard
    let mut guard = GLOBAL_QUEUE.lock().unwrap();
    guard.push(i);
} // 函数结束,guard 被 Drop,锁被释放

Rc (Reference Count) 和 Arc (Atomic Reference Count) 是关于引用计数智能指针. 如果说 Mutex 致力于解决谁修改的问题,那么 Arc 在于解决谁持有的问题.

在单线程中,我们可以使用 Rc<T> 来让多个变量拥有同一个数据. 但在多线程环境中,Rc 是不安全的,因为它的引用计数操作不是原子的. 如果两个线程同时克隆 Rc,会导致计数错误,Arc 是更加线程安全的版本.

Arc<T> 目的在于实现共享状态. Arc 使用 CPU 的原子指令来增减引用计数,保证了线程安全. 原子操作通常比普通的内存读写要慢.

在内核开发中,你几乎总会看到 ArcMutex 成对出现为 Arc<Mutex<T>>. 这是 Rust 处理并发共享状态的标准模式:

  • Arc 提供多所有权,让多个线程都能持有一个指向数据的指针.
  • Mutex 提供内部可变性,确保同一时刻只有一个线程能修改数据.
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        // 克隆 Arc 只会增加引用计数,不会拷贝底层数据
        let counter_clone = Arc::clone(&counter);
        
        let handle = thread::spawn(move || {
            let mut num = counter_clone.lock().unwrap();
            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

主线程依然持有 counter,可以读取最终结果.

static 意味着一个变量有固定的内存地址,如果它有初始值,它会被硬编码进 .data 段, 如果它未初始化或为零,它会在 .bss 段占据一个位置.

Rust 要求 static 变量必须在编译期初始化. 但这对于需要堆分配的类型诸如 Vec, Mutex<T>)往往不可能,所以我们引入 lazy_static! 或者 once_cell 来将初始化推迟到运行时.

lazy_static! 在于,虽然变量声明为 static,但其实际的初始化逻辑被延迟到了第一次被访问的时候执行. 其实现内部使用原子状态机 (如 std::sync::Once) 确保初始化函数只被执行一次,而且是线程安全的.

use lazy_static::lazy_static;
use std::sync::Mutex;

lazy_static! {
    // GLOBAL_MAP 在第一次被使用时才会进行堆分配
    static ref GLOBAL_MAP: Mutex<HashMap<u32, String>> = Mutex::new(HashMap::new());
}

在 Rust 中,你不能简单地声明一个全局可变变量. 你需要组合使用这些工具来构建安全的全局状态:

  • RefCell<T> 提供引用,而 Cell<T> 提供值,解决数据修改的问题.
  • Arc<T> 用引用计数提供多线程间的共享所有权.
  • Mutex<T> 提供跨线程的内部可变性以及访问控制.
  • lazy_static! 解决全局变量的运行时初始化问题.