互斥锁、条件锁、读写锁以及自旋锁

 

自旋锁(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 后「通知」!queue.isEmpty() 成立。
也就是说,我们希望把上面例子中的 while 循环变成这样
while (queue.isEmpty()) {
解锁后等待通知唤醒再加锁(用来收发通知的东西, lock);
}
生产者只需在往 queue 中 push 数据后这样,就可以完成协作:
触发通知(用来收发通知的东西);
// 一般有两种方式:
// 通知所有在等待的(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.保护写操作的锁);
}
如果整个场景中只有一个读者、一个写者,那么其实可以等价于直接使用互斥器。不过由于读写锁需要额外记
录读者数量,花销要大一点。
你可以认为读写锁是针对某种特定情景的「优化」。但个人还是建议忘掉读写锁,直接用互斥器.
 

 

自旋锁:如果进线程无法取得锁,进线程不会立刻放弃CPU时间片,而是一直申请CPU时间片轮询自旋锁,直
到获取为止,一般应用于加锁时间很短(1ms左右或更低)的场景。

至于实现机制,以linux的自旋锁为例(kernel 2.6.23,asm-i386/spinlock.h),嵌入的汇编源码如下:

preview

line 4: 对lock->slock自减,这个操作是互斥的,LOCK_PREFIX保证了此刻只能有一个CPU访问内存
line 5: 判断lock->slock是否为非负数,如果是跳转到3,即获得自旋锁
line 6: 位置符
line 7: lock->slock此时为负数,说明已经被其他cpu抢占了,cpu休息一会,相当于pause指令
line 8: 继续将lock->slock和0比较,
line 9: 判断lock->slock是否小于等于0,如果判断为真,跳转到2,继续休息
line 10: 此时lock->slock已经大于0,可以继续尝试抢占了,跳转到1
line 11: 位置符
重点是LOCK_PREFIX前缀对CPU独占访问内存的保证,实现方式和cpu架构有关,在x86架构上,早期的
x86CPU通过总线锁来实现这一点,现在的x86CPU通过缓存一致性协议来实现(只有一个cache可以对内存进
行最终写入)。
另外一个例子,Intel DPDK里的自旋锁

previewpreview

这里没有LOCK前缀,但用到了xchg指令来交换寄存器和内存间的内容,用了cmpl来对比sl->locked,组合
在一起,这段汇编其实就是用来实现OS里的CAS(compare and swap)原语,在x86架构上,同样是通过缓
存一致性协议来确保CAS操作的原子性。
互斥锁:无法获取琐时,进线程立刻放弃剩余的时间片并进入阻塞(或者说挂起)状态,同时保存寄存器和程
序计数器的内容(保存现场,上下文切换的前半部分),当可以获取锁时,进线程激活,等待被调度进CPU并
恢复现场(上下文切换下半部分)
上下文切换会带来数十微秒的开销,不要在性能敏感的地方用互斥锁
读写锁:写锁被占用时,所有申请读锁和写锁的进线程都会被阻塞,读锁被占用时,申请写锁的进线程被阻
塞,其他申请读锁的进线程不会。
读写自旋锁:同时集成自旋锁和读写锁的优点,tbb库提供该锁
 

本文摘自知乎问答,https://www.zhihu.com/question/66733477  如有侵权,请联系撤回。谢谢。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值