Rust并发编程4 - 容器类并发原语

Rust 在并发编程方面有一些强大的原语,让你能够写出安全且高效的并发代码。最显著的原语之一是 ownership system,它允许你在没有锁的情况下管理内存访问。此外,Rust 还提供了一些并发编程的工具和标准库,比如线程、线程池、消息通讯(mpsc等)、原子操作等,不过这一章我们不介绍这些工具和库,它们会专门的分章节去讲。这一章我们专门讲一些保证在线程间共享的一些方式和库。

并发原语内容较多,分成两章,这一章介绍Cowbeef::CowBoxCellRefCellOnceCellLazyCellLazyLockRc。 我把它们称之为容器类并发原语,主要基于它们的行为,它们主要是对普通数据进行包装,以便提供其他更丰富的功能。

Cow

Cow不是🐄,而是clone-on-write或者copy-on-write的缩写。

Cow(Copy-on-write) 是一种优化内存和提高性能的技术,通常应用在资源共享的场景。

其基本思想是,当有多个调用者(callers)同时请求相同的资源时,都会共享同一份资源,直到有调用者试图修改资源内容时,系统才会真正复制一份副本出来给该调用者,而其他调用者仍然使用原来的资源。

Rust中的String和Vec等类型就利用了Cow。例如:

1
2
3
4
let s1 = String::from("hello");
let s2 = s1; // s1和s2共享同一份内存
s2.push_str(" world"); // s2会进行写操作,于是系统复制一份新的内存给s2

这样可以避免大量未修改的字符串、向量等的重复分配和复制,提高内存利用率和性能。

cow的优点是:

  • 内存利用率高,只有进行写时才复制
  • 读取性能高,多个调用者共享同一资源

缺点是:

  • 写时需要复制,有一定性能损失
  • 实现较复杂

需要根据实际场景权衡使用。但对于存在大量相同或相似资源的共享情况,使用cow可以带来显著性能提升。

标准库中std::borrow::Cow 类型是一个智能指针,提供了写时克隆(clone-on-write)的功能:它可以封装并提供对借用数据的不可变访问,当需要进行修改或获取所有权时,它可以惰性地克隆数据。

Cow 实现了Deref,这意味着你可以直接在其封装的数据上调用不可变方法。如果需要进行改变,则 to_mut 将获取到一个对拥有的值的可变引用,必要时进行克隆。

下面的代码将origin字符串包装成一个cow, 你可以把它borrowed成一个&str,其实也可以直接在cow调用&str方法,因为Cow实现了Deref,可以自动解引用,比如直接调用leninto

1
2
3
4
5
6
7
8
9
10
11
12
13
let origin = "hello world";
let mut cow = Cow::from(origin);
assert_eq!(cow, "hello world");
// Cow can be borrowed as a str
let s: &str = &cow;
assert_eq!(s, "hello world");
assert_eq!(s.len(), cow.len());
// Cow can be converted to a String
let s: String = cow.into();
assert_eq!(s, "HELLO WORLD");

接下来我们已一个写时clone的例子。下面这个例子将字符串中的字符全部改成大写字母:

1
2
3
4
5
// Cow can be borrowed as a mut str
let s: &mut str = cow.to_mut();
s.make_ascii_uppercase();
assert_eq!(s, "HELLO WORLD");
assert_eq!(origin, "hello world");

这里使用to_mut得到一个可变引用,一旦s有修改,它会从原始数据中clone一份,在克隆的数据上进行修改。

所以如果你想在某些数据上实现copy-on-write/clone-on-write的功能,可以考虑使用std::borrow::Cow

更进一步,beef库提供了一个更快,更紧凑的Cow类型,它的使用方法和标准库的Cow使用方法类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
pub fn beef_cow() {
let borrowed: beef::Cow<str> = beef::Cow::borrowed("Hello");
let owned: beef::Cow<str> = beef::Cow::owned(String::from("World"));
let _ = beef::Cow::from("Hello");
assert_eq!(format!("{} {}!", borrowed, owned), "Hello World!",);
const WORD: usize = size_of::<usize>();
assert_eq!(size_of::<std::borrow::Cow<str>>(), 3 * WORD);
assert_eq!(size_of::<beef::Cow<str>>(), 3 * WORD);
assert_eq!(size_of::<beef::lean::Cow<str>>(), 2 * WORD);
}

这个例子的上半部分演示了生成beef::Cow的三种方法Cow::borrowedCow::fromCow::owned,标准库Cow也有这三个方法,它们的区别是:

  • borrowed: 借用已有资源
  • from: 从已有资源复制创建Owned
  • owned: 自己提供资源内容

这个例子下半部分对比了标准库Cowbeef::Cow以及更紧凑的beef::lean::Cow所占内存的大小。可以看到对于数据是str类型的Cow,现在的标准库的Cow占三个WORD, 和beef::Cow相当,而进一步压缩的beef::lean::Cow只占了两个Word。

cow-utils针对字符串的Cow做了优化,性能更好。

Box

Box<T>,通常简称为box,提供了在 Rust 中最简单的堆分配形式。Box 为这个分配提供了所有权,并在超出作用域时释放其内容。Box 还确保它们不会分配超过 isize::MAX 字节的内存。

它的使用很简单,下面的例子就是把值val从栈上移动到堆上:

1
2
let val: u8 = 5;
let boxed: Box<u8> = Box::new(val);

那么怎么反其道而行之呢?下面的例子就是通过解引用把值从堆上移动到栈上:

1
2
let boxed: Box<u8> = Box::new(5);
let val: u8 = *boxed;

如果我们要定义一个递归的数据结构,比如链表,下面的方式是不行的,因为List的大小不固定,我们不知道该分配给它多少内存:

1
2
3
4
5
#[derive(Debug)]
enum List<T> {
Cons(T, List<T>),
Nil,
}

这个时候就可以使用Box了:

1
2
3
4
5
6
7
8
#[derive(Debug)]
enum List<T> {
Cons(T, Box<List<T>>),
Nil,
}
let list: List<i32> = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil))));
println!("{list:?}");

目前Rust还提供一个实验性的类型ThinBox, 它是一个瘦指针,不管内部元素的类型是啥:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
pub fn thin_box_example() {
use std::mem::{size_of, size_of_val};
let size_of_ptr = size_of::<*const ()>();
let box_five = Box::new(5);
let box_slice = Box::<[i32]>::new_zeroed_slice(5);
assert_eq!(size_of_ptr, size_of_val(&box_five));
assert_eq!(size_of_ptr * 2, size_of_val(&box_slice));
let five = ThinBox::new(5);
let thin_slice = ThinBox::<[i32]>::new_unsize([1, 2, 3, 4]);
assert_eq!(size_of_ptr, size_of_val(&five));
assert_eq!(size_of_ptr, size_of_val(&thin_slice));
}

Cell、RefCell、OnceCell、LazyCell 和 LazyLock

CellRefCell是Rust中用于内部可变性(interior mutability)的两个重要类型。

CellRefCell都是可共享的可变容器。可共享的可变容器的存在是为了以受控的方式允许可变性,即使存在别名引用。Cell 和 RefCell 都允许在单线程环境下以这种方式进行。然而,无论是 Cell 还是 RefCell 都不是线程安全的(它们没有实现 Sync)。

Cell

Cell<T>允许在不违反借用规则的前提下,修改其包含的值:

  • Cell中的值不再拥有所有权,只能通过getset方法访问。
  • set方法可以在不获取可变引用的情况下修改Cell的值。
  • 适用于简单的单值容器,如整数或字符。

下面这个例子创建了一个Cell, 赋值给变量x,注意x是不可变的,但是我们能够通过set方法修改它的值,并且即使存在对x的引用y时也可以修改它的值:

1
2
3
4
5
6
7
8
use std::cell::Cell;
let x = Cell::new(42);
let y = &x;
x.set(10); // 可以修改
println!("y: {:?}", y.get()); // 输出 y: 10

RefCell

RefCell<T> 提供了更灵活的内部可变性,允许在运行时检查借用规则,通过运行时借用检查来实现:

  • 通过borrowborrow_mut方法进行不可变和可变借用。
  • 借用必须在作用域结束前归还,否则会panic。
  • 适用于包含多个字段的容器。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use std::cell::RefCell;
let x = RefCell::new(42);
{
let y = x.borrow();
// 在这个作用域内,只能获得不可变引用
println!("y: {:?}", *y.borrow());
}
{
let mut z = x.borrow_mut();
// 在这个作用域内,可以获得可变引用
*z = 10;
}
println!("x: {:?}", x.borrow().deref());

如果你开启了#![feature(cell_update)], 你还可以更新它:c.update(|x| x + 1);

OnceCell

OnceCell 是 Rust 标准库中的一个类型,用于提供一次性写入的单元格。它允许在运行时将值放入单元格,但只允许一次。一旦值被写入,进一步的写入尝试将被忽略。

主要特点和用途:

  • 一次性写入:OnceCell 确保其内部值只能被写入一次。一旦值被写入,后续的写入操作将被忽略。
  • 懒初始化:OnceCell 支持懒初始化,这意味着它只有在需要时才会进行初始化。这在需要在运行时确定何时初始化值的情况下很有用。
  • 线程安全:OnceCell 提供了线程安全的一次性写入。在多线程环境中,它确保只有一个线程能够成功写入值,而其他线程的写入尝试将被忽略。

下面这个例子演示了OnceCell使用方法,还未初始化的时候,获取的它的值是None, 一旦初始化为Hello, World!,它的值就固定下来了:

1
2
3
4
5
6
7
8
pub fn once_cell_example() {
let cell = OnceCell::new();
assert!(cell.get().is_none()); // true
let value: &String = cell.get_or_init(|| "Hello, World!".to_string());
assert_eq!(value, "Hello, World!");
assert!(cell.get().is_some()); //true
}

LazyCell、LazyLock

有时候我们想实现懒(惰性)初始化的效果,当然lazy_static库可以实现这个效果,但是Rust标准库也提供了一个功能,不过目前还处于不稳定的状态,你需要设置#![feature(lazy_cell)]使能它。

下面是一个使用它的例子:

1
2
3
4
5
6
7
8
9
10
11
#![feature(lazy_cell)]
use std::cell::LazyCell;
let lazy: LazyCell<i32> = LazyCell::new(|| {
println!("initializing");
46
});
println!("ready");
println!("{}", *lazy); // 46
println!("{}", *lazy); // 46

注意它是懒初始化的,也就是你在第一次访问它的时候它才会调用初始化函数进行初始化。

但是它不是线程安全的,如果想使用线程安全的版本,你可以使用std::sync::LazyLock:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use std::collections::HashMap;
use std::sync::LazyLock;
static HASHMAP: LazyLock<HashMap<i32, String>> = LazyLock::new(|| {
println!("initializing");
let mut m = HashMap::new();
m.insert(13, "Spica".to_string());
m.insert(74, "Hoyten".to_string());
m
});
fn main() {
println!("ready");
std::thread::spawn(|| {
println!("{:?}", HASHMAP.get(&13));
}).join().unwrap();
println!("{:?}", HASHMAP.get(&74));
}

rc

Rc 是 Rust 标准库中的一个智能指针类型,全名是 std::rc::Rc,代表 "reference counting"。它用于在多个地方共享相同数据时,通过引用计数来进行所有权管理。

  • Rc 使用引用计数来追踪指向数据的引用数量。当引用计数降为零时,数据会被自动释放。
  • Rc允许多个 Rc 指针共享相同的数据,而无需担心所有权的转移。
  • Rc 内部存储的数据是不可变的。如果需要可变性,可以使用 RefCell 或 Mutex 等内部可变性的机制。
  • Rc 在处理循环引用时需要额外注意,因为循环引用会导致引用计数无法降为零,从而导致内存泄漏。为了解决这个问题,可以使用 Weak 类型。

下面这个例子演示了Rc的基本使用方法,通过clone我们可以获得新的共享引用。

1
2
3
4
5
6
7
8
9
10
use std::rc::Rc;
let data = Rc::new(42);
let reference1 = Rc::clone(&data);
let reference2 = Rc::clone(&data);
// data 的引用计数现在为 3
// 当 reference1 和 reference2 被丢弃时,引用计数减少

注意Rc 允许在多个地方共享不可变数据,通过引用计数来管理所有权。

如果还想修改数据,那么就可以使用上一节的Cell相关类型, 比如下面的例子,我们使用Rc<RefCell<HashMap>>类型来实现这个需求:

1
2
3
4
5
6
7
8
9
10
11
12
13
pub fn rc_refcell_example() {
let shared_map: Rc<RefCell<_>> = Rc::new(RefCell::new(HashMap::new()));
{
let mut map: RefMut<_> = shared_map.borrow_mut();
map.insert("africa", 92388);
map.insert("kyoto", 11837);
map.insert("piccadilly", 11826);
map.insert("marbles", 38);
}
let total: i32 = shared_map.borrow().values().sum();
println!("{total}");
}

这样我们就针对不可变类型Rc实现了数据的可变性。

注意Rc不是线程安全的,针对上面的里面,如果想实现线程安全的类型,你可以使用Arc,不过这个类型我们放在下一章进行再介绍。