解密并发幕后黑手:线程切换引发的原子性问题
摘要:原子性是指一个或者多个操作在 CPU 中执行的过程不被中断的特性。原子性操作一旦开始运行,就会一直到运行结束为止,中间不会有中断的情况发生。
本文分享自华为云社区《【高并发】解密导致并发问题的第二个幕后黑手——原子性问题》,作者: 冰 河。
原子性
原子性是指一个或者多个操作在 CPU 中执行的过程不被中断的特性。原子性操作一旦开始运行,就会一直到运行结束为止,中间不会有中断的情况发生。
我们也可以这样理解原子性,就是线程在执行一系列操作时,这些操作会被当做一个不可拆分的整体执行,这些操作要么全部执行,要么全部不执行,不会存在只执行一部分的情况,这就是原子性操作。
关于原子性操作一个典型的场景就是转账,例如,小明和小刚的账户余额都是 200 元,此时小明给小刚转账 100 元,如果转账成功,则小明的账户余额为 100 元,小刚的账户余额为 300 元;如果转账失败,则小明和小刚的账户余额仍然为 200 元。不会存在小明账户为 100 元,小刚账户为 200 元,或者小明账户为 200 元,小刚账户为 300 元的情况。
这里,小明给小刚转账 100 元的操作,就是一个原子性操作,它涉及小明账户余额减少 100 元,小刚账户余额增加 100 元的操作,这两个操作是一个不可分割的整体,要么全部执行,要么全部不执行。
小明给小刚转账成功,则如下所示。

小明给小刚转账失败,则如下所示。

不会出现小明账户为 100 元,小刚账户为 200 元的情况。

也不会出现小明账户为 200 元,小刚账户为 300 元的情况。

线程切换
在并发编程中,往往设置的线程数目会大于 CPU 数目,而每个 CPU 在同一时刻只能被一个线程使用。而 CPU 资源的分配采用了时间片轮转策略,也就是给每个线程分配一个时间片,线程在这个时间片内占用 CPU 的资源来执行任务。当占用 CPU 资源的线程执行完任务后,会让出 CPU 的资源供其他线程运行,这就是任务切换,也叫做线程切换或者线程的上下文切换。
如果大家还是不太理解的话,我们可以用下面的图来模拟线程在 CPU 中的切换过程。

在图中存在线程 A 和线程 B 两个线程,其中线程 A 和线程 B 中的每个小方块代表此时线程占有 CPU 资源并执行任务,这个小方块占有的时间,被称为时间片,在这个时间片中,占有 CPU 资源的线程会在 CPU 上执行,未占有 CPU 资源的线程则不会在 CPU 上执行。而每个虚线部分就代表了此时的线程不占用 CPU 资源。CPU 会在线程 A 和线程 B 之间频繁切换。
原子性问题
理解了什么是原子性,再看什么是原子性问题就比较简单了。
原子性问题是指一个或者多个操作在 CPU 中执行的过程中出现了被中断的情况。
线程在执行某项操作时,此时如果 CPU 发生了线程切换,CPU 转而去执行其他的任务,中断了当前线程执行的操作,这就会造成原子性问题。
如果你还不能理解的话,我们来举一个例子:假设你在银行排队办理业务,小明在你前面,柜台的业务员为小明办理完业务,正好排到你时,此时银行下班了,柜台的业务员微笑着告诉你:实在不好意思,先生(女士),我们下班了,您明天再来吧!此时的你就好比是正好占有了 CPU 资源的线程,而柜台的业务员就是那颗发生了线程切换的 CPU,她将线程切换到了下班这个线程,执行下班的操作去了。

Java 中的原子性问题
在 Java 中,并发程序是基于多线程技术来编写的,这也会涉及到 CPU 的对于线程的切换问题,正是 CPU 中对任务的切换机制,导致了并发编程会出现原子性的诡异问题,而原子性问题,也成为了导致并发问题的第二个“幕后黑手”。
在并发编程中,往往 Java 语言中一条简单的语句,会对应着 CPU 中的多条指令,假设我们编写的 ThreadTest 类的代码如下所示。
接下来,我们打开 ThreadTest 类的 class 文件所在的目录,在 cmd 命令行输入如下命令。
得出如下的结果信息,如下所示。
这里,我们主要关注下 incrementCount()方法对应的 CPU 指令,如下所示。
可以看到,Java 语言中短短的几行 incrementCount()方法竟然对应着那么多的 CPU 指令。这些 CPU 指令我们大致可以分成三步。
指令 1:把变量 count 从内存加载的 CPU 寄存器。
指令 2:在寄存器中执行 count++操作。
指令 3:将结果写入缓存(可能是 CPU 缓存,也可能是内存)。
在操作系统执行线程切换时,可能发生在任何一条 CPU 指令完成后,而不是程序中的某条语句完成后。如果线程 A 执行完指令 1 后,操作系统发生了线程切换,当两个线程都执行 count++操作后,得到的结果是 1 而不是 2。这里,我们可以使用下图来表示这个过程。

由上图,我们可以看出:线程 A 将 count=0 加载到 CPU 的寄存器后,发生了线程切换。此时内存中的 count 值仍然为 0,线程 B 将 count=0 加载到寄存器,执行 count++操作,并将 count=1 写到内存。此时,CPU 切换到线程 A,执行线程 A 中的 count++操作后,线程 A 中的 count 值为 1,线程 A 将 count=1 写入内存,此时内存中的 count 值最终为 1。
所以,如果在 CPU 中存在正在执行的线程,恰好此时 CPU 发生了线程切换,则可能会导致原子性问题,这也是导致并发编程频繁出问题的根源之一。我们只有充分理解并掌握线程的原子性以及引起原子性问题的根源,并在日常工作中时刻注意编写的并发程序是否存在原子性问题,才能更好的编写出并发程序。
总结
缓存带来的可见性问题、线程切换带来的原子性问题和编译优化带来的有序性问题,是导致并发编程频繁出现诡异问题的三个源头,我们已经介绍了缓存带来的可见性问题和线程切换带来的原子性问题。下一篇中,我们继续深耕高并发中的有序性问题。

版权声明: 本文为 InfoQ 作者【华为云开发者社区】的原创文章。
原文链接:【http://xie.infoq.cn/article/fb324e44ff019066e8fc53592】。文章转载请联系作者。
评论