0x00 开篇
大家好久不见,最近一段时间工作比较繁忙,公众号停更了一段时间,接下来应该会回归正常,感谢大家的不离不弃!!
上一篇文章介绍了 Mutex(互斥量/互斥锁),但是它不能区分获取锁的读者或者写者,因此会阻塞任何等待锁变为可用的线程。本篇文章将介绍在多线程编程中的另一个概念——读写锁。读写锁在没有写者持有锁时,允许任意数量的读者获取锁。本篇文章阅读时长 5 分钟。
0x01 定义
读写锁是一种同步机制,可在多线程环境中控制共享数据的访问。读写锁允许多个线程同时读取数据,但只允许一个线程写入数据,在 Rust 中使用 RwLock
来操作读写锁。对于这种锁,写入部分通常允许对底层数据进行修改(独占访问),而读取部分通常只允许进行只读访问(共享访问)。读写锁的优先级策略取决于底层操作系统的实现,并且该类型不保证使用任何特定的策略。
先来看一个示例:
fn main() {
let rust = "hello rust!".to_string();
let lock = RwLock::new(rust);
{
let read1 = lock.read().unwrap();
let read2 = lock.read().unwrap();
println!("read1: {}, read2: {}", read1, read2);
} // 读锁在这里被释放
// 仅能持有一个写锁
{
let mut write = lock.write().unwrap();
(*write).push_str(" hello world!");
println!("write: {}", *write);
} // 写锁在这里被释放
}
//
首先,创建一个不可变的字符串 hello rust!
,然后通过 RwLock::new
创建一个包含字符串 "hello rust!"
的 RwLock
对象 lock
。在第一个代码块内部,我们获取了两个读锁(read1
和 read2
),同时对数据进行只读访问。在第二个代码块内部,我们获取了一个写锁来修改数据。写锁是独占的,只能有一个线程或进程持有。然后将 lock.write()
返回的结果作为可变引用来向字符串追加文本 " hello world!"
。
还有需要注意的一点,在第一个代码块和第二个代码块之间,读锁被释放。这是因为 RwLock
允许多个线程同时持有读锁,但是在持有写锁时,读锁将会被阻塞。这保证了在有写锁时,不会有并发的读操作。最后,随着第二个代码块的结束,写锁也被释放。
0x02 多线程中使用读写锁
下面是一个在多线程中使用读写锁的示例:
fn main() {
// 使用`Arc`和`RwLock`创建一个共享数据结构
let shared_data = Arc::new(RwLock::new(0));
// 创建读线程
let reader_shared_data = shared_data.clone();
thread::spawn(move || {
// 获取读锁
let reader = reader_shared_data.read().unwrap();
// 在共享数据上执行只读操作
println!("读取: {}", *reader);
// 当`reader`离开作用域时,读锁会被释放
}).join().unwrap();
// 创建一个写线程
let writer_data = shared_data.clone();
thread::spawn(move || {
// 获取写锁
let mut writer = writer_data.write().unwrap();
// 在共享数据上执行写操作
*writer += 1;
println!("写入: {}", *writer);
thread::sleep(Duration::from_secs(2));
// 当`writer`离开作用域时,写锁会被释放
}).join().unwrap();
// 再次创建读线程
thread::spawn(move || {
// 获取读锁
let reader = shared_data.read().unwrap();
// 在共享数据上执行只读操作
println!("读取: {}", *reader);
// 当`reader`离开作用域时,读锁会被释放
}).join().unwrap();
}
在使用 Rust 读写锁时,最好将锁封装到 Arc(原子引用计数)中。这样可以确保多个线程可以共享读写锁的所有权。
首先,通过Arc
的clone
方法创建了两个共享数据的副本,分别用于读线程和写线程。Arc
(原子引用计数)是一种多线程安全的引用计数智能指针,可以在多个线程之间共享数据所有权。
然后,创建了读线程,并使用 thread::spawn
方法和 move
关键字传递了一个闭包作为线程的执行体。闭包内部首先获取了读锁,然后对共享数据执行只读操作(这里是简单地打印出共享数据的值)。最后,当闭包结束时,读锁会被自动释放。join
方法用于等待线程的执行结束。
随后,创建了写线程,其过程类似于上面的读线程。写线程获取写锁后,对共享数据执行写操作(这里是将共享数据加1并打印出结果),并在之后睡眠2秒,模拟执行写操作需要的时间。当写线程结束时,写锁会被自动释放。
最后,再次创建了一个读线程,并重复之前的读操作。这次读线程会读取之前写线程修改后的共享数据。
整个程序通过读写锁实现多线程对共享数据的并发访问和操作,保证读操作之间不会发生冲突,而写操作和读操作之间互斥。这样可以同时进行读操作,而在写操作时保证其他线程无法读取或写入数据,从而避免了数据竞争和并发访问的问题。
0x03 RwLock 源码解读
一起来看下 RwLock
的源码:
RwLock
结构体有三个字段:
-
inner
:表示底层的操作系统级别的读写锁实现。它是一个sys::RwLock
类型的变量,该类型是平台相关的,即每个平台都有自己的实现方式。 -
poison
:这个字段在上一篇文章的Mutex
已经认识过它,是一个标记字段。用于标记读写锁是否处于"中毒"状态,即被线程异常终止或出现了某些错误。 -
data
:是T
类型数据的包装器,其中包含一个UnsafeCell
类型的变量。通过UnsafeCell
可以在并发环境下安全地进行内部可变性。PS: 在第一个示例中,我们创建了一个不可变的字符串
hello rust!
,但是当我们获取读写锁的时候可以修改它,这里就是通过UnsafeCell
实现的,这也是前面所说的 Rust 的内部修改能力。
0x04 扩展阅读: UnsafeCell 和RefCell 、Cell的区别
上面提到了 UnsafeCell
,那这与前面介绍的 RefCell
和Cell
有什么区别呢?
UnsafeCell
: UnsafeCell
是 Rust 内部可变性的核心原语是,它是一种提供了对数据进行内部可变操作的安全抽象的类型,可以进行更低级别的内存操作。UnsafeCell
本身是一个零成本的抽象,它的存在不会引入任何开销,只是提供了一个原始的可变性接口。使用 UnsafeCell
时,程序员需要自行保证线程安全性。使用 UnsafeCell
需要特别小心,因为它可能导致数据竞争和未定义行为。
RefCell
、Cell
: 它们内部对 UnsafeCell
进行了包裹,它们提供了一种在单线程环境下进行内部可变性的方式。通过使用内部可变性模式,允许在不借用整个结构体的情况下,在不可变引用或可变引用之间切换。它们内部在运行时进行了借用检查,而不是在编译时进行,这使得在运行时进行更灵活的借用规则检查。
总的来讲就是,UnsafeCell
提供了一种更低级别的原始内部可变性,需要程序员自己确保线程安全;而 RefCell
和 Cell
则提供了一个安全的运行时内部可变性抽象,它在运行时借用检查的帮助下实现了线程安全。
0x05 小结
RwLock
(读写锁)是一种并发原语,用于保护共享数据的并发访问。在多线程环境中,允许多个线程同时读取共享数据,但只允许一个线程写入数据。
RwLock
保证了以下关键点:
- 写锁具有互斥性,当写锁被持有时,其他线程无法同时持有写锁或读锁,确保了数据的一致性。
- 读锁与其他读锁之间并发无阻塞,可以同时持有多个读锁,提高了读操作的并发性能。
- 读锁与写锁之间具有互斥性,当读锁被持有时,其他线程无法同时持有写锁,保证了写操作的独占性。
总的来讲,RwLock
在多线程环境中提供了一种简单、安全和高效的方式来保护共享资源的并发访问。