自旋锁、互斥器、条件变量及读写锁
分类:技术
自旋锁(spinlock)很好理解。对自旋锁加锁的操作,你可以认为是类似这样的:
while (抢锁(lock) == 没抢到) {
}
只要没有锁上,就不断重试。显然,如果别的线程长期持有该锁,那么你这个线程就一直在 while
while
while
地检查是否能够加锁,浪费 CPU 做无用功。
仔细想想,其实没有必要一直去尝试加锁,因为只要锁的持有状态没有改变,加锁操作就肯定是失败的。所以,抢锁失败后只要锁的持有状态一直没有改变,那就让出 CPU 给别的线程先执行好了。这就是互斥器(mutex)也就是题目里的互斥锁(不过个人觉得既然英语里本来就不带 lock,就不要称作锁了吧)。对互斥器加锁的操作你可以认为是类似这样的:
while (抢锁(lock) == 没抢到) {
本线程先去睡了请在这把锁的状态发生改变时再唤醒(lock);
}
操作系统负责线程调度,为了实现「锁的状态发生改变时再唤醒」就需要把锁也交给操作系统管理。所以互斥器的加锁操作通常都需要涉及到上下文切换,操作花销也就会比自旋锁要大。
以上两者的作用是加锁互斥,保证能够排它地访问被锁保护的资源。
不过并不是所有场景下我们都希望能够独占某个资源,很快你可能就会不得不写出这样的代码:
// 这是「生产者消费者问题」中的消费者的部分逻辑
// 等待队列非空,再从队列中取走元素进行处理
加锁(lock); // lock 保护对 queue 的操作
while (queue.isEmpty()) { // 队列为空时等待
解锁(lock);
// 这里让出锁,让生产者有机会往 queue 里安放数据
加锁(lock);
}
data = queue.pop(); // 至此肯定非空,所以能对资源进行操作
解锁(lock);
消费(data); // 在临界区外做其它处理
你看那个 while
,这不就是自己又搞了一个自旋锁么?区别在于这次你不是在 while
一个抽象资源是否可用,而是在 while
某个被锁保护的具体的条件是否达成。
有了前面自旋锁、互斥器的经验就不难想到:「只要条件没有发生改变,while
里就没有必要再去加锁、判断、条件不成立、解锁,完全可以让出 CPU 给别的线程」。不过由于「条件是否达成」属于业务逻辑,操作系统没法管理,需要让能够作出这一改变的代码来手动「通知」,比如上面的例子里就需要在生产者往 queue
里 push_back
后「通知」!queue.isEmpty()
成立。
也就是说,我们希望把上面例子中的 while
循环变成这样:
while (queue.isEmpty()) {
解锁后等待通知唤醒再加锁(用来收发通知的东西, lock);
}
生产者只需在往 queue
中 push_back
数据后这样,就可以完成协作:
触发通知(用来收发通知的东西);
// 一般有两种方式:
// 通知所有在等待的(notifyAll / broadcast)
// 通知一个在等待的(notifyOne / signal)
这就是条件变量(condition variable),也就是问题里的条件锁。它解决的问题不是「互斥」,而是「等待」。
至于读写锁(readers-writer lock),看英文可以顾名思义,在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥。读写锁不需要特殊支持就可以直接用之前提到的几个东西实现,比如可以直接用两个 spinlock 或者两个 mutex 实现:
void 以读者身份加锁(rwlock) {
加锁(rwlock.保护当前读者数量的锁);
rwlock.当前读者数量 += 1;
if (rwlock.当前读者数量 == 1) {
加锁(rwlock.保护写操作的锁);
}
解锁(rwlock.保护当前读者数量的锁);
}
void 以读者身份解锁(rwlock) {
加锁(rwlock.保护当前读者数量的锁);
rwlock.当前读者数量 -= 1;
if (rwlock.当前读者数量 == 0) {
解锁(rwlock.保护写操作的锁);
}
解锁(rwlock.保护当前读者数量的锁);
}
void 以写者身份加锁(rwlock) {
加锁(rwlock.保护写操作的锁);
}
void 以写者身份解锁(rwlock) {
解锁(rwlock.保护写操作的锁);
}
如果整个场景中只有一个读者、一个写者,那么其实可以等价于直接使用互斥器。不过由于读写锁内部是至少需要用一把锁来保护当前读者数的,所以,如果你的临界区很小,读写锁相比一般的锁并不能带来很大的优势,甚至可能性能更低。
另一方面,读写锁要真正发挥效能,条件也比较麻烦。比如实际的读写锁通常不用例子里两把锁的实现,而是用一把锁、一个条件变量来实现,好处是可以缓解写者饥饿的情况(一旦有写者在等锁,后续读者都需要等写者离开后才能继续),但这样一来,如果读者的临界区没有明显小于写者的临界区,阻塞情况可能会变得比较不理想……
所以你可以认为读写锁是针对某种特定情景的「优化」。不是说不要用读写锁,而是读写锁往往没有看上去那么理想。个人建议是可以优先用 mutex,如果遇到瓶颈后可以选择替换为读写锁,看看能否带来性能提升。
以上。