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::lockMutex::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,因为我们必须保证这个类型在线程之间发送是安全的,而编译器无法自动验证这一点。

通道要求

当且仅当TSend的时候,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>>

如果我们将ArcMutex搭配使用,我们最终可以得到一个这样的类型:

  • 可以在线程间发送,因为:如果 TSendArc 就是 Send,而如果 TSend,则 MutexSendTTicketTicketSend
  • 可以克隆,因为无论 T 是什么,Arc 都是 Clone 。克隆 Arc 会增加引用计数,但不会复制数据。
  • 可以用来修改它所封装的数据,因为 Arc 可以让我们获得对 Mutex<T> 的共享引用,而 Mutex<T>又可以用来获取锁。
    我们已经拥有了为我们的 ticket store 实现锁定策略所需的所有部件。

进一步学习

阅读剩余
THE END