记录一些编程中锁相关的名词解释
为什么需要锁?
多个线程使用同一个资源时,加锁保证原子性。如果不加锁可能导致逻辑(一致性)问题,也就是线程安全问题。
-
常见的加锁场景:
- 单 CPU、多线程、一系列操作需要原子执行。如果不加锁,原子操作中的变量可能中途被改变。在多 CPU 下更需要加锁。
- 多 CPU、多线程、小容量变量的变更。
由于每个 CPU 有自己的多级缓冲、寄存器,一个最基本的操作可能被拆分为多部执行,
比如
x++ <=> x = x+1
,这就无法保证逻辑一致性。 小容量变量都加锁,大容量变量如结构体或多个变量就更应该加锁了。
-
下面两种特殊情况可以不加锁:
- 如果小容量值(例如 32 位 CPU 下的 int32)的修改、单 CPU 情况下,可以不加锁。 因为这种情况真实运行的线程只能有一个,并且小容量变量的修改是原子性的,不会有线程安全问题。
- 如果是单 CPU、变量前用 volatile 修饰的,变量整体赋值,可以不加锁。volatile 关键字确保编译时不优化。
-
为什么读数据也需要锁? 通常数据都不能正好一个 CPU 周期内读取,比如一个 32 位的 CPU 读取 int64 时就需要两步 32 位的操作实现,这时中间如果被修改了,就可能脏读。 而往往我们保存的数据要远远大于 32 位,比如一个结构体对象,所以也要读锁保证原子性。
死锁
多个线程对不同资源加锁,进入并保持相互等待的状态。
发生条件
- 加锁顺序不一致
- 各自占有部分锁不释放
应对方案
针对上面发生的条件,只要打破条件就能避免死锁
- 所有线程获得资源时,保持一致的加锁顺序。这样前面资源未获得的也不会对后面资源加锁
- 一次获得所有资源的总锁,然后执行。简单粗暴,可以避免死锁,但并发性差
- 占有锁后,如果无法获得别的锁,则超时释放资源、稍后再试。类似乐观锁逻辑
分析方法
一般会有工具,可以列出发生死锁时的线程和资源锁状况,知道哪些线程获得了哪些锁、哪些线程在等待哪些锁, 这样就可以画出一个依赖关系图。而关系图中出现了循环,说明发生了死锁。只要打破循环就能解锁。
常用锁结构
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 && 线程号和当前线程一致
,则锁定次数++
,获得锁
- 如果
数据库
范围
行锁、表锁、间隙锁、临键锁
操作目的
读锁、写锁、意向锁(意向读、意向写)
- 意向锁的使用场景是可以快速判定整个表是否可以锁定,而不必轮询所有行