76、Mutex, Send and Arc
Locks, Send and Arc
上一节实现的补丁策略有个问题:就是它很racy。(不太清楚为什么用这个词)
如果两个客户端在同一时间为同一个ticket发送patches,服务器将会以任意顺序执行他们。而最后一个被处理的patch会覆盖掉上一个进行更改的patch。
版本号
我们可以尝试使用版本号(version number)来解决这个问题。每个ticket在创建时都会被分配一个版本号,设置为0.
无论客户端何时发送patch,它必须在所做更改中附加上当前的版本号。服务器只有在收到的版本号与服务器版本号一致时,才会应用patch。
在上述情况下,服务器将拒绝第二个补丁,因为版本号已被第一个补丁增加,因此与第二个客户端发送的版本号不匹配。
这种方法在分布式系统中相当常见(例如,当客户端和服务器不共享内存时),被称为乐观并发控制(optimistic concurrency control)。
我们的想法是,大多数情况下冲突不会发生,因此我们可以针对常见情况进行优化。
Locking
我们也可以引入lock来解决竞争条件。每当客户端想要更新ticket时,它必须事先获得一个锁。当锁处于激活状态时,其他客户端无法修改ticket。
Rust的标准库实现提供了两种不同的锁原语:Mutex<T>和RwLock<T>。
让我们从Mutex<T>开始,它代表的是互斥(mutual exclusion)。是最简单的一种锁:只允许一个线程访问数据,不管是读还是写。
Mutex<T>封装了它所保护的数据,它对数据类型是通用的。
我们不能直接访问数据:类型系统强制我们首先使用Mutex::lock或Mutex::try_lock获取一个锁。前者会阻塞进程直到获得锁,后者会在无法获得锁时立即返回错误信息。
这两种方法都会返回一个守卫(guard)对象,该对象解引用后可访问数据,从而允许你对其进行修改。当该守卫对象被drop时,锁会被自动释放。
use std::sync::Mutex;
// 受互斥锁保护的整数
let lock = Mutex::new(0);
// 获得mutex的锁
let mut guard = lock.lock().unwrap();
// 利用它的 `Deref` 实现,
// 通过 guard 变量访问数据
*guard += 1;
// 当 `data` 离开作用域时,锁被释放。
// 这可以通过放弃 guard 显式地完成
// 或在 guard 退出作用域时隐式释放
drop(guard)
锁的粒度(Locking granularity)
我们的Mutex应该封装什么?
最简单的方案是把整个TicketStore封装到单个Mutex中。
这虽然可行,但是会严重限制系统的性能:我们不能并行地读取tickets,因为每次读取都必须等待锁的释放。
这就是所谓的粗粒度锁(coarse-grained locking)。
最好使用细粒度锁(fine-grained locking),即每个ticket都有自己的锁来保护。这样,客户端就可以并行地处理ticket,只要不试图访问同一个ticket即可。
// 新的架构,每张票都有一个锁
struct TicketStore {
tickets: BTreeMap<TicketId, Mutex<Ticket>>,
}
这种方法效率更高,但是也有缺点:TicketStore必须意识到系统的多线程特性;截至目前,TicketStore一直无视多线程的存在。不管怎样还是试一下。
谁持有锁?
为了让整个方案有效,锁必须传递给想要修改ticket的客户端使用。
客户端可以直接修改ticket(就像他们有&mut Ticket一样),完成之后就释放锁。
这有一点棘手。
我们无法通过通道发送Mutex<Ticket>,因为Mutex没有实现Clone这个trait,我们无法将其移出TicketStore。我们能不能改成MutexGuard?
用一个小例子测试一下这个想法:
use std::thread::spawn;
use std::sync::Mutex;
use std::sync::mpsc::sync_channel;
fn main() {
let lock = Mutex::new(0);
let (sender, receiver) = sync_channel(1);
let guard = lock.lock().unwrap();
spawn(move || {
receiver.recv().unwrap();
});
// 尝试通过通道向另一个进程发送 guard
sender.send(guard);
}
编译器会报错:
error[E0277]: `MutexGuard<'_, i32>` cannot be sent between
threads safely
--> src/main.rs:10:7
|
10 | spawn(move || {
| _-----_^
| | |
| | required by a bound introduced by this call
11 | | receiver.recv().unwrap();
12 | | });
| |_^ `MutexGuard<'_, i32>` cannot be sent between threads safely
|
= help: the trait `Send` is not implemented for
`MutexGuard<'_, i32>`, which is required by
`{closure@src/main.rs:10:7: 10:14}: Send`
= note: required for `Receiver<MutexGuard<'_, i32>>`
to implement `Send`
note: required because it's used within this closure
MutexGuard<'_, i32> is not Send:这意味着什么?
Send
Send是一个标记trait,表示一个类型可以安全地从一个线程传递给另一个线程。
Send也是一个auto-trait,就像Sized一样;编译器会根据我们的类型定义自动实现它(或者不实现)。
我们也可以手动为类型实现Send,但是必须要求使用unsafe,因为我们必须保证这个类型在线程之间发送是安全的,而编译器无法自动验证这一点。
通道要求
当且仅当T是Send的时候,Sender<T>, SyncSender<T> 和 Receiver<T> 才是 Send。
这是因为他们用于在线程间发送值,如果值本身就不是Send,在线程间发送它将会变得不安全。
MutexGuard
MutexGuard不是Send,因为在某些平台上,Mutex用来实现锁的底层操作系统原语要求,锁必须由获取它的统一进程释放。
如果我们将MutexGuard发送给另一个线程,锁将会被另外一个线程释放,这就会导致未定义行为。
我们的挑战
总结一下
- 我们不能通过通道发送
MutexGuard。因此,我们不能在服务器端锁定,然后在客户端修改 ticket。 - 我们可以通过通道发送
Mutex,因为只要它保护的数据是Send的,它就是Send的,Ticket就是这种情况。与此同时,我们既不能将Mutex移出TicketStore,也不能克隆它。
如何解决这个难题?
我们需要从另一个角度看问题。要锁定一个Mutex,我们不需要自己拥有那个值。共享引用就足够了,因为Mutex使用内部可变性:
impl<T> Mutex<T> {
// `&self`, not `self`!
pub fn lock(&self) -> LockResult<MutexGuard<'_, T>> {
// Implementation details
}
}
因此,向客户端发送一个共享引用就足够了。
但我们不能直接这么做,因为引用必须是 'static 的,而事实并非如此。
某种程度上,我们需要一个 “自有的共享引用” 。事实证明,Rust有一种符合条件的类型:Arc。
Arc救援,小子
Arc代表原子引用计数(atomic reference counting)。
Arc封装一个值,并追踪这个值有多少个引用。当最后一个引用被drop的时候,该值即被释放。
Arc封装的值是不可变的:我们只能获取它的共享引用。
use std::sync::Arc;
let data: Arc<u32> = Arc::new(0);
let data_clone = Arc::clone(&data);
// `Arc<T>` 实现了 `Deref<T>`,
// 因此可以使用 deref coercion 将 `&Arc<T>` 转换为 `&T` 。
let data_ref: &u32 = &data;
看到这里,感觉挺熟悉就对了,Arc看起来很像与我们在讨论内部可变性时学习到的引用计数指针Rc非常相似。他们的区别在于线程安全:Rc不是Send,但是Arc是。
它可以归结为引用计数的实现方式:Rc使用一个普通的证书,而Arc用的是原子整数(atomic integer),可以安全地跨线程共享和修改。
Arc<Mutex<T>>
如果我们将Arc和Mutex搭配使用,我们最终可以得到一个这样的类型:
- 可以在线程间发送,因为:如果
T是Send,Arc就是Send,而如果T是Send,则Mutex是Send。T是Ticket,Ticket是Send。 - 可以克隆,因为无论
T是什么,Arc都是Clone。克隆Arc会增加引用计数,但不会复制数据。 - 可以用来修改它所封装的数据,因为
Arc可以让我们获得对Mutex<T>的共享引用,而Mutex<T>又可以用来获取锁。
我们已经拥有了为我们的 ticket store 实现锁定策略所需的所有部件。
进一步学习
- 这里不会详细学到原子操作的细节,但是我们可以在
std文档以及《Rust atomics and locks》一书中找到更多信息。
