原子性问题到底该如何解决
原子性问题的源头是线程切换,如果能够禁用线程切换那就能解决这个问题。而操作系统做线程切换是依赖 CPU 中断的,所以禁止 CPU 发生中断就能够禁止线程切换。
在早期单核 CPU 时代,这个方案的确是可行的,而且也有很多应用案例,但是并不适合多核场景。我们以 32 位 CPU 上执行 long 型变量的写操作为例来说明这个问题,long 型变量是 64 位,在 32 位 CPU 上执行写操作会被拆分成两次写操作(写高 32 位和写低 32 位)。
在单核 CPU 场景下,同一时刻只有一个线程执行,禁止 CPU 中断,意味着操作系统不会重新调度线程,也就是禁止了线程切换,获得 CPU 使用权的线程就可以不间断地执行,所以两次写操作一定是:要么都被执行,要么都没有被执行,具有原子性。
但是在多核场景下,同一时刻,有可能有两个线程同时在执行,一个线程执行在 CPU-1 上,一个线程执行在 CPU-2 上,此时禁止 CPU 中断,只能保证 CPU 上的线程连续执行,并不能保证同一时刻只有一个线程执行,如果这两个线程同时写 long 型变量高 32 位的话,那就有可能出现我们开头提及的诡异 Bug 了。
“同一时刻只有一个线程执行”这个条件非常重要,我们称之为互斥。如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核 CPU 还是多核 CPU,就都能保证原子性了。
简易锁模型
当谈到互斥,相信一定想到了那个杀手级解决方案:锁。
我们把一段需要互斥执行的代码称为临界区。线程在进入临界区之前,首先尝试加锁 lock(),如果成功,则进入临界区,此时我们称这个线程持有锁;否则呢就等待,直到持有锁的线程解锁;持有锁的线程执行完临界区的代码后,执行解锁 unlock()。
这个过程非常像办公室里高峰期抢占坑位,每个人都是进坑锁门(加锁),出坑开门(解锁),如厕这个事就是临界区。这样理解本身没有问题,但却很容易让我们忽视两个非常非常重要的点:我们锁的是什么?我们保护的又是什么?
改进后的锁模型
我们知道在现实世界里,锁和锁要保护的资源是有对应关系的,比如你用你家的锁保护你家的东西,我用我家的锁保护我家的东西。在并发编程世界里,锁和资源也应该有这个关系,但这个关系在我们上面的模型中是没有体现的,所以我们需要完善一下我们的模型。
首先,我们要把临界区要保护的资源标注出来,如图中临界区里增加了一个元素:受保护的资源 R;其次,我们要保护资源 R 就得为它创建一把锁 LR;最后,针对这把锁 LR,我们还需在进出临界区时添上加锁操作和解锁操作。另外,在锁 LR 和受保护资源之间做了关联,这个关联关系非常重要。很多并发 Bug 的出现都是因为把它忽略了,然后就出现了类似锁自家门来保护他家资产的事情,这样的 Bug 非常不好诊断,因为潜意识里我们认为已经正确加锁了。
Java 语言提供的锁技术:synchronized
锁是一种通用的技术方案,Java 语言提供的 synchronized 关键字,就是锁的一种实现。synchronized 关键字可以用来修饰方法,也可以用来修饰代码块。
Java 编译器会在 synchronized 修饰的方法或代码块前后自动加上加锁 lock() 和解锁 unlock(),这样做的好处就是加锁 lock() 和解锁 unlock() 一定是成对出现的,毕竟忘记解锁 unlock() 可是个致命的 Bug(意味着其他线程只能死等下去了)。
锁和受保护资源的关系
我们前面提到,受保护资源和锁之间的关联关系非常重要,他们的关系是怎样的呢?一个合理的关系是:受保护资源和锁之间的关联关系是 N:1 的关系。
互斥锁,在并发领域的知名度极高,只要有了并发问题,大家首先容易想到的就是加锁,因为大家都知道,加锁能够保证执行临界区代码的互斥性。这样理解虽然正确,但是却不能够指导你真正用好互斥锁。临界区的代码是操作受保护资源的路径,类似于球场的入口,入口一定要检票,也就是要加锁,但不是随便一把锁都能有效。所以必须深入分析锁定的对象和受保护资源的关系,综合考虑受保护资源的访问路径,多方面考量才能用好互斥锁。
synchronized 是 Java 在语言层面提供的互斥原语,其实 Java 里面还有很多其他类型的锁,但作为互斥锁,原理都是相通的:锁,一定有一个要锁定的对象,至于这个锁定的对象要保护的资源以及在哪里加锁 / 解锁,就属于设计层面的事情了。
保护没有关联关系的多个资源
在现实世界里,球场的座位和电影院的座位就是没有关联关系的,这种场景非常容易解决,那就是球赛有球赛的门票,电影院有电影院的门票,各自管理各自的。
同样这对应到编程领域,也很容易解决。例如,银行业务中有针对账户余额(余额是一种资源)的取款操作,也有针对账户密码(密码也是一种资源)的更改操作,我们可以为账户余额和账户密码分配不同的锁来解决并发问题,这个还是很简单的。
当然,我们也可以用一把互斥锁来保护多个资源,例如我们可以用 this 这一把锁来管理账户类里所有的资源:账户余额和用户密码。
但是用一把锁有个问题,就是性能太差,会导致取款、查看余额、修改密码、查看密码这四个操作都是串行的。而我们用两把锁,取款和修改密码是可以并行的。用不同的锁对受保护资源进行精细化管理,能够提升性能。这种锁还有个名字,叫细粒度锁。
保护有关联关系的多个资源
如果多个资源是有关联关系的,那这个问题就有点复杂了。例如银行业务里面的转账操作,账户 A 减少 100 元,账户 B 增加 100 元。这两个账户就是有关联关系的。那对于像转账这种有关联关系的操作,我们应该怎么去解决呢?
我们声明了个账户类:Account,该类有一个成员变量余额:balance,还有一个用于转账的方法:transfer(),然后怎么保证转账操作 transfer() 没有并发问题呢?
直觉会告诉你这样的解决方案:用户 synchronized 关键字修饰一下 transfer() 方法就可以了。
临界区内有两个资源,分别是转出账户的余额 this.balance 和转入账户的余额 target.balance,并且用的是一把锁 this,符合我们前面提到的,多个资源可以用一把锁来保护,这看上去完全正确呀。真的是这样吗?可惜,这个方案仅仅是看似正确,为什么呢?
问题就出在 this 这把锁上,this 这把锁可以保护自己的余额 this.balance,却保护不了别人的余额 target.balance,就像你不能用自家的锁来保护别人家的资产,也不能用自己的票来保护别人的座位一样。
我们具体分析一下,假设有 A、B、C 三个账户,余额都是 200 元,我们用两个线程分别执行两个转账操作:账户 A 转给账户 B 100 元,账户 B 转给账户 C 100 元,最后我们期望的结果应该是账户 A 的余额是 100 元,账户 B 的余额是 200 元, 账户 C 的余额是 300 元。
我们假设线程 1 执行账户 A 转账户 B 的操作,线程 2 执行账户 B 转账户 C 的操作。这两个线程分别在两颗 CPU 上同时执行,那它们是互斥的吗?我们期望是,但实际上并不是。因为线程 1 锁定的是账户 A 的实例(A.this),而线程 2 锁定的是账户 B 的实例(B.this),所以这两个线程可以同时进入临界区 transfer()。同时进入临界区的结果是什么呢?线程 1 和线程 2 都会读到账户 B 的余额为 200,导致最终账户 B 的余额可能是 300(线程 1 后于线程 2 写 B.balance,线程 2 写的 B.balance 值被线程 1 覆盖),可能是 100(线程 1 先于线程 2 写 B.balance,线程 1 写的 B.balance 值被线程 2 覆盖),就是不可能是 200。
使用锁的正确姿势
只要我们的锁能覆盖所有受保护资源就可以了。在上面的例子中,this 是对象级别的锁,所以 A 对象和 B 对象都有自己的锁,如何让 A 对象和 B 对象共享一把锁呢?
其实方案还挺多的,比如可以让所有对象都持有一个唯一性的对象,这个对象在创建 Account 时传入。方案有了,完成代码就简单了。示例代码如下,我们把 Account 默认构造函数变为 private,同时增加一个带 Object lock 参数的构造函数,创建 Account 对象时,传入相同的 lock,这样所有的 Account 对象都会共享这个 lock 了。
这个办法确实能解决问题,但是有点小瑕疵,它要求在创建 Account 对象的时候必须传入同一个对象,如果创建 Account 对象时,传入的 lock 不是同一个对象,那可就惨了,会出现锁自家门来保护他家资产的荒唐事。在真实的项目场景中,创建 Account 对象的代码很可能分散在多个工程中,传入共享的 lock 真的很难。
所以,上面的方案缺乏实践的可行性,我们需要更好的方案。还真有,就是用 Account.class 作为共享的锁。Account.class 是所有 Account 对象共享的,而且这个对象是 Java 虚拟机在加载 Account 类的时候创建的,所以我们不用担心它的唯一性。使用 Account.class 作为共享的锁,我们就无需在创建 Account 对象时传入了。
相信你对如何保护多个资源已经很有心得了,关键是要分析多个资源之间的关系。如果资源之间没有关系,很好处理,每个资源一把锁就可以了。如果资源之间有关联关系,就要选择一个粒度更大的锁,这个锁应该能够覆盖所有相关的资源。除此之外,还要梳理出有哪些访问路径,所有的访问路径都要设置合适的锁,这个过程可以类比一下门票管理。
我们再引申一下上面提到的关联关系,关联关系如果用更具体、更专业的语言来描述的话,其实是一种“原子性”特征,在前面的文章中,我们提到的原子性,主要是面向 CPU 指令的,转账操作的原子性则是属于是面向高级语言的,不过它们本质上是一样的。
“原子性”的本质是什么?其实不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见。例如,在 32 位的机器上写 long 型变量有中间状态(只写了 64 位中的 32 位),在银行转账的操作中也有中间状态(账户 A 减少了 100,账户 B 还没来得及发生变化)。所以解决原子性问题,是要保证中间状态对外不可见。
我们用 Account.class 作为互斥锁,来解决银行业务里面的转账问题,虽然这个方案不存在并发问题,但是所有账户的转账操作都是串行的,例如账户 A 转账户 B、账户 C 转账户 D 这两个转账操作现实世界里是可以并行的,但是在这个方案里却被串行化了,这样的话,性能太差。
试想互联网支付盛行的当下,8 亿网民每人每天一笔交易,每天就是 8 亿笔交易;每笔交易都对应着一次转账操作,8 亿笔交易就是 8 亿次转账操作,也就是说平均到每秒就是近 1 万次转账操作,若所有的转账操作都串行,性能完全不能接受。
那下面我们就尝试着把性能提升一下。
向现实世界要答案
现实世界里,账户转账操作是支持并发的,而且绝对是真正的并行,银行所有的窗口都可以做转账操作。只要我们能仿照现实世界做转账操作,串行的问题就解决了。
我们试想在古代,没有信息化,账户的存在形式真的就是一个账本,而且每个账户都有一个账本,这些账本都统一存放在文件架上。银行柜员在给我们做转账时,要去文件架上把转出账本和转入账本都拿到手,然后做转账。这个柜员在拿账本的时候可能遇到以下三种情况:
- 文件架上恰好有转出账本和转入账本,那就同时拿走;
- 如果文件架上只有转出账本和转入账本之一,那这个柜员就先把文件架上有的账本拿到手,同时等着其他柜员把另外一个账本送回来;
- 转出账本和转入账本都没有,那这个柜员就等着两个账本都被送回来。
上面这个过程在编程的世界里怎么实现呢?其实用两把锁就实现了,转出账本一把,转入账本另一把。在 transfer() 方法内部,我们首先尝试锁定转出账户 this(先把转出账本拿到手),然后尝试锁定转入账户 target(再把转入账本拿到手),只有当两者都成功时,才执行转账操作。
经过这样的优化后,账户 A 转账户 B 和账户 C 转账户 D 这两个转账操作就可以并行了。
上面的实现看上去很完美,并且也算是将锁用得出神入化了。相对于用 Account.class 作为互斥锁,锁定的范围太大,而我们锁定两个账户范围就小多了,这样的锁,叫细粒度锁。使用细粒度锁可以提高并行度,是性能优化的一个重要手段。
这个时候可能你已经开始警觉了,使用细粒度锁这么简单,有这样的好事,是不是也要付出点什么代价啊?编写并发程序就需要这样时时刻刻保持谨慎。
的确,使用细粒度锁是有代价的,这个代价就是可能会导致死锁。
在详细介绍死锁之前,我们先看看现实世界里的一种特殊场景。如果有客户找柜员张三做个转账业务:账户 A 转账户 B 100 元,此时另一个客户找柜员李四也做个转账业务:账户 B 转账户 A 100 元,于是张三和李四同时都去文件架上拿账本,这时候有可能凑巧张三拿到了账本 A,李四拿到了账本 B。张三拿到账本 A 后就等着账本 B(账本 B 已经被李四拿走),而李四拿到账本 B 后就等着账本 A(账本 A 已经被张三拿走),他们要等多久呢?他们会永远等待下去…因为张三不会把账本 A 送回去,李四也不会把账本 B 送回去。我们姑且称为死等吧。
现实世界里的死等,就是编程领域的死锁了。死锁的一个比较专业的定义是:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。
上面转账是怎么发生死锁的呢?我们假设线程 T1 执行账户 A 转账户 B 的操作,账户 A.transfer(账户 B);同时线程 T2 执行账户 B 转账户 A 的操作,账户 B.transfer(账户 A)。当 T1 和 T2 同时执行完①处的代码时,T1 获得了账户 A 的锁(对于 T1,this 是账户 A),而 T2 获得了账户 B 的锁(对于 T2,this 是账户 B)。之后 T1 和 T2 在执行②处的代码时,T1 试图获取账户 B 的锁时,发现账户 B 已经被锁定(被 T2 锁定),所以 T1 开始等待;T2 则试图获取账户 A 的锁时,发现账户 A 已经被锁定(被 T1 锁定),所以 T2 也开始等待。于是 T1 和 T2 会无期限地等待下去,也就是我们所说的死锁了。
如何预防死锁
并发程序一旦死锁,一般没有特别好的方法,很多时候我们只能重启应用。因此,解决死锁问题最好的办法还是规避死锁。
那如何避免死锁呢?要避免死锁就需要分析死锁发生的条件,有个叫 Coffman 的牛人早就总结过了,只有以下这四个条件都发生时才会出现死锁:
- 互斥,共享资源 X 和 Y 只能被一个线程占用;
- 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
- 不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
- 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。
反过来分析,也就是说只要我们破坏其中一个,就可以成功避免死锁的发生。
其中,互斥这个条件我们没有办法破坏,因为我们用锁为的就是互斥。不过其他三个条件都是有办法破坏掉的。
- 对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。
- 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
- 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。
当我们在编程世界里遇到问题时,应不局限于当下,可以换个思路,向现实世界要答案,利用现实世界的模型来构思解决方案,这样往往能够让我们的方案更容易理解,也更能够看清楚问题的本质。