并发 - 各类锁的应用场景

本节主要介绍互斥锁、自旋锁、读写锁、悲观锁、乐观锁的应用场景。

C++互斥锁、自旋锁、读写锁、悲观锁、乐观锁的应用场景

最底层的两种就是会「互斥锁和自旋锁」,有很多高级的锁都是基于它们实现的。加锁的目的就是保证共享资源在任意时间里,只有一个线程访问,这样就可以避免多线程导致共享数据错乱的问题。

  • 互斥锁: mutex,用于保证在任何时刻,都只能有一个线程访问该对象。当获取锁操作失败时,加锁线程会释放CPU给其他线程,自己进入睡眠,等待锁释放时被唤醒。

    • 对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的。互斥锁加锁失败时,线程会从用户态陷入到内核态,让内核帮我们切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本。即存在两次线程上下文切换的成本
    • 当线程加锁失败时,内核会把线程的状态从「运行」状态设置为「睡眠」状态,然后把 CPU 切换给其他线程运行;
    • 当锁被释放时,之前「睡眠」状态的线程会变为「就绪」状态,然后内核会在合适的时间,把 CPU 切换给该线程运行。
      • 线程的上下文切换的是什么?当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据
    • 使用场景: 如果加锁时间过长,会主动释放CPU给其他线程处理,提高工作效率。
  • 自旋锁: spinlock,在任何时刻同样只能有一个线程访问对象。但是当获取锁操作失败时,不会进入睡眠,而是会在原地自旋,直到锁被释放。这样节省了线程从睡眠状态到被唤醒期间的消耗。

    • 使用场景: 在加锁时间短暂的环境下会极大的提高效率 。但如果加锁时间过长,则会非常浪费CPU资源`。
  • 自旋锁与互斥锁使用层面比较相似,但实现层面上完全不同: 当加锁失败时,互斥锁用「线程切换」来应对,自旋锁则用「忙等待」来应对。

  • 读写锁: rwlock,分为读锁和写锁。

    • 当「写锁」没有被线程持有时,多个线程能够并发地持有读锁,这大大提高了共享资源的访问效率,因为「读锁」是用于读取共享资源的场景,所以多个线程同时持有读锁也不会破坏共享资源的数据。
    • 但是,一旦「写锁」被线程持有后,读线程的获取读锁的操作会被阻塞,而且其他写线程的获取写锁的操作也会被阻塞。
    • 根据实现的不同,读写锁可以分为「读优先锁」和「写优先锁」。
      • 不管优先读锁还是写锁,对方可能会出现饿死问题,那么我们就不偏袒任何一方,搞个「公平读写锁」。
      • 公平读写锁比较简单的一种方式是: 用队列把获取锁的线程排队,不管是写线程还是读线程都按照先进先出的原则加锁即可,这样读线程仍然可以并发,也不会出现「饥饿」的现象。
    • 使用场景: 读写锁在读多写少的场景,能发挥出优势。
  • 乐观锁与悲观锁

    • 前面提到的互斥锁、自旋锁、读写锁,都是属于悲观锁。
      • 悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁。
    • 乐观锁做事比较乐观,它假定冲突的概率很低,它的工作方式是: 先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。
      • 在线文档。多人同时编辑,实际上是用了乐观锁,如果A,B用户同时编辑相同的文档,服务端要怎么验证是否冲突了呢?通常方案如下:
        • 由于发生冲突的概率比较低,所以先让用户编辑文档,但是浏览器在下载文档时会记录下服务端返回的文档版本号;
        • 当用户提交修改时,发给服务端的请求会带上原始文档版本号,服务器收到后将它与当前版本号进行比较,如果版本号一致则修改成功,否则提交失败。
      • 我们常见的 SVN 和 Git 也是用了乐观锁的思想,先让用户编辑代码,然后提交的时候,通过版本号来判断是否产生了冲突,发生了冲突的地方,需要我们自己修改后,再重新提交。
      • 使用场景: 只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁。