Lock

Lock

记录一些编程中相关的名词解释

为什么需要锁?

多个线程使用同一个资源时,加锁保证原子性。如果不加锁可能导致逻辑(一致性)问题,也就是线程安全问题。

  • 常见的加锁场景:

    • 单 CPU、多线程、一系列操作需要原子执行。如果不加锁,原子操作中的变量可能中途被改变。在多 CPU 下更需要加锁。
    • 多 CPU、多线程、小容量变量的变更。 由于每个 CPU 有自己的多级缓冲、寄存器,一个最基本的操作可能被拆分为多部执行, 比如x++ <=> x = x+1,这就无法保证逻辑一致性。 小容量变量都加锁,大容量变量如结构体或多个变量就更应该加锁了。
  • 下面两种特殊情况可以不加锁:

    • 如果小容量值(例如 32 位 CPU 下的 int32)的修改、单 CPU 情况下,可以不加锁。 因为这种情况真实运行的线程只能有一个,并且小容量变量的修改是原子性的,不会有线程安全问题。
    • 如果是单 CPU、变量前用 volatile 修饰的,变量整体赋值,可以不加锁。volatile 关键字确保编译时不优化。
  • 为什么读数据也需要锁? 通常数据都不能正好一个 CPU 周期内读取,比如一个 32 位的 CPU 读取 int64 时就需要两步 32 位的操作实现,这时中间如果被修改了,就可能脏读。 而往往我们保存的数据要远远大于 32 位,比如一个结构体对象,所以也要读锁保证原子性。

死锁

多个线程对不同资源加锁,进入并保持相互等待的状态。

发生条件

  1. 加锁顺序不一致
  2. 各自占有部分锁不释放

应对方案

针对上面发生的条件,只要打破条件就能避免死锁

  • 所有线程获得资源时,保持一致的加锁顺序。这样前面资源未获得的也不会对后面资源加锁
  • 一次获得所有资源的总锁,然后执行。简单粗暴,可以避免死锁,但并发性差
  • 占有锁后,如果无法获得别的锁,则超时释放资源、稍后再试。类似乐观锁逻辑

分析方法

一般会有工具,可以列出发生死锁时的线程和资源锁状况,知道哪些线程获得了哪些锁、哪些线程在等待哪些锁, 这样就可以画出一个依赖关系图。而关系图中出现了循环,说明发生了死锁。只要打破循环就能解锁。

常用锁结构

Mutex(互斥锁)

以 Go 的 Mutex 为例,关键技术是”CAS+Semaphore”。 其中 CAS(Compare And Swap) 通过自旋锁原子性轮询抢锁,自旋锁抢锁失败转为 Semaphore 通过队列等待唤醒。

RWLock(读写锁)

分布式锁

  • Redis(redlock)
  • Zookeeper(公平锁)

自旋锁

利用 CPU 原子操作 CAS(CompareAndSwap)进行轮询等待

  • 自旋锁往往要通过 CPU 的 pause 指令提高效率,避免 CPU 空转
  • 自旋锁是 Mutex 的基础结构之一

使用模式

悲观锁

一开始抢占锁,如果抢到则执行,执行完毕解锁;如果没有抢到则等待;

悲观锁性能较差,一般是因为用户态切换、网络延迟、轮询成本。

乐观锁

一开始读取版本号,然后准备要写入的数据,最后执行一次 CAS 操作,如果版本号发生了变化则前面的动作回滚,然后再重试。

乐观锁性能较好,但如果失败需要重试,逻辑较复杂。

锁特性

可重入锁

这里假设是线程级别的可重入锁。

  • 可重入锁的实现原理是给锁绑定线程号+锁定次数
    • 如果锁定次数 > 0 && 线程号和当前线程不一致,则等待
    • 如果锁定次数 == 0,则抢到锁
    • 如果锁定次数 > 0 && 线程号和当前线程一致,则锁定次数++,获得锁

数据库

范围

行锁、表锁、间隙锁、临键锁

操作目的

读锁、写锁、意向锁(意向读、意向写)

  • 意向锁的使用场景是可以快速判定整个表是否可以锁定,而不必轮询所有行