正文
可是,这带来了一个显而易见的问题:我们的 KV db 成为了一个共享状态,它在多个线程之间共享数据。这是并发处理的第一种范式:共享状态的并发(Shared-State Concurrency)。
既然引入了共享状态,那么我们需要在访问它的时候做妥善的保护 —— 这个访问和操作共享状态的代码区域叫临界区(Critical Section)。如果你还记得操作系统课程的内容,你会知道,最基本的操作是使用互斥量(Mutex)来保护临界区。
互斥量本质是一种二元锁。当线程获得锁之后,便拥有了对共享状态的独占访问;反之,如果无法获得锁,那么将会在访问锁的位置阻塞,直到能够获得锁。在完成对共享状态的访问后(临界区的出口),我们需要释放锁,这样,其它访问者才有机会退出阻塞状态。一旦忘记释放锁,或者使用多把锁的过程中造成了死锁,那么程序就无法响应或者崩溃。rust 的内存安全模型能够避免忘记释放锁,这让开发变得非常轻松,并且最大程度上解决了(不同函数间)死锁问题。
但任何语言的任何保护都无法避免逻辑上的死锁,比如下面这个显而易见的例子:
use std::sync::Mutex;
fn main() {
let data = Mutex::new(0);
let _d1 = data.lock();
let _d2 = data.lock();
}
互斥锁往往锁的粒度太大,在很多场景下效率太低。于是我们在此基础上分离了读写的操作,产生了读写锁(RwLock),它同一时刻允许任意数量的共享读者或者一个写者。读写锁的一个优化是顺序锁(SeqLock),它提高了读锁和写锁的独立性 —— 写锁不会被读锁阻塞,读锁也不会被写锁阻塞。,但写锁会被写锁阻塞。
读写锁适用于读者数量远大于写者,或者读多写少的场景。在我们这个场景下,读写的比例差别可能并不是特别明显,从 Mutex 换到 RwLock 的收益需要在生产环境中具体测试一下才能有结论。
v3:锁的优化
但即使我们无法通过使用不同实现的锁来优化对共享状态访问的效率,我们还是有很多方法来优化锁。无论何种方法,其核心思想是:
尽可能减少锁的粒度
。比如,对数据库而言,我们可以对整个数据库管理系统加锁,也可以对单个数据库的访问加锁,还可以对数据表的访问加锁,甚至对数据表中的一行或者一列加锁。对于我们的 KV db 而言,我们可以创建 N 个 hashmap(模拟多个数据库),然后把 Key 分散到这 N 个 hashmap 中,这样,不管使用什么锁,其粒度都变成之前的 1/N 了。
新的 KV db 的定义,以及添加 / 访问数据的代码:
use std::collections::{hash_map::DefaultHasher, HashMap};
use std::hash::{Hash, Hasher};
use std::sync::{Arc, RwLock};
struct KvDb(Arc>>>>);
impl KvDb {
pub fn new(len: usize) -> Self {
let mut dbs: Vec>>> = Vec::with_capacity(len);
for _i in 0..len {
dbs.push(RwLock::new(HashMap::new()))
}
Self(Arc::new(dbs))
}
pub fn insert(&self