解密诡异并发问题的幕后黑手:可见性问题
摘要:可见性问题还是由 CPU 的缓存导致的,而缓存导致的可见性问题是导致诸多诡异的并发编程问题的“幕后黑手”之一。
本文分享自华为云社区《【高并发】一文解密诡异并发问题的第一个幕后黑手——可见性问题》,作者:冰 河。
并发编程一直是很让人头疼的问题,因为多线程环境下不太好定位问题,它不像一般的业务代码那样打个断点,debug 一下基本就能够定位问题所在。并发编程中,出现的问题往往都是很诡异的,而且大多数情况下,问题也不是每次都会重现的。那么,我们如何才能够更好的解决并发问题呢?这就需要我们了解造成这些问题的“幕后黑手”究竟是什么!
1、可见性
对于什么是可见性,比较官方的解释就是:一个线程对共享变量的修改,另一个线程能够立刻看到。
说的直白些,就是两个线程共享一个变量,无论哪一个线程修改了这个变量,则另外的一个线程都能够看到上一个线程对这个变量的修改。这里的共享变量,指的是多个线程都能够访问和修改这个变量的值,那么,这个变量就是共享变量。
例如,线程 A 和线程 B,它们都是直接修改主内存中的共享变量,无论是线程 A 修改了共享变量,还是线程 B 修改了共享变量,则另一个线程从主内存中读取出来的变量值,一定是修改过的值,这就是线程的可见性。

2、可见性问题
可见性问题,可以这样理解:一个线程修改了共享变量,另一个线程不能立刻看到,这是由 CPU 添加了缓存导致的问题。
理解了什么是可见性,再来看可见性问题就比较好理解了。既然可见性是一个线程修改了共享变量后,另一个线程能够立刻看到对共享变量的修改,如果不能立刻看到,这就会产生可见性的问题。
2.1 单核 CPU 不存在可见性问题
理解可见性问题我们还需要注意一点,那就是 在单核 CPU 上不存在可见性问题。 这是为什么呢?
因为在单核 CPU 上,无论创建了多少个线程,同一时刻只会有一个线程能够获取到 CPU 的资源来执行任务,即使这个单核的 CPU 已经添加了缓存。这些线程都是运行在同一个 CPU 上,操作的是同一个 CPU 的缓存,只要其中一个线程修改了共享变量的值,那另外的线程就一定能够访问到修改后的变量值。

2.2 多核 CPU 存在可见性问题
单核 CPU 由于同一时刻只会有一个线程执行,而每个线程执行的时候操作的都是同一个 CPU 的缓存,所以,单核 CPU 不存在可见性问题。但是到了多核 CPU 上,就会出现可见性问题了。
这是因为在多核 CPU 上,每个 CPU 的内核都有自己的缓存。当多个不同的线程运行在不同的 CPU 内核上时,这些线程操作的是不同的 CPU 缓存。一个线程对其绑定的 CPU 的缓存的写操作,对于另外一个线程来说,不一定是可见的,这就造成了线程的可见性问题。

例如,上面的图中,由于 CPU 是多核的,线程 A 操作的是 CPU-01 上的缓存,线程 B 操作的是 CPU-02 上的缓存,此时,线程 A 对变量 V 的修改对线程 B 是不可见的,反之亦然。
2.3 Java 中的可见性问题
使用 Java 语言编写并发程序时,如果线程使用变量时,会把主内存中的数据复制到线程的私有内存,也就是工作内存中,每个线程读写数据时,都是操作自己的工作内存中的数据。

此时,Java 中线程读写共享变量的模型与多核 CPU 类似,原因是 Java 并发程序运行在多核 CPU 上时,线程的私有内存,也就是工作内存就相当于多核 CPU 中每个 CPU 内核的缓存了。
由上图,同样可以看出,线程 A 对共享变量的修改,线程 B 不一定能够立刻看到,这也就会造成可见性的问题。
3、代码示例
我们使用一个 Java 程序来验证多线程的可见性问题,在这个程序中,定义了一个 long 类型的成员变量 count,有一个名称为 addCount 的方法,这个方法中对 count 的值进行加 1 操作。同时,在 execute 方法中,分别启动两个线程,每个线程调用 addCount 方法 1000 次,等待两个线程执行完毕后,返回 count 的值,代码如下所示。
我们运行下这个程序,结果如下图所示。

可以看到这个程序的结果是 1509,而不是我们期望的 2000。这是为什么呢?让我们一起来分析下这个程序。
首先,变量 count 属于 ThreadTest 类的成员变量,这个成员变量对于线程 A 和线程 B 来说,是一个共享变量。假设线程 A 和线程 B 同时执行,它们同时将 count=0 读取到各自的工作内存中,每个线程第一次执行完 count++操作后,同时将 count 的值写入内存,此时,内存中 count 的值为 1,而不是我们想象的 2。而在整个计算的过程中,线程 A 和线程 B 都是基于各自工作内存中的 count 值进行计算。这就导致了最终的 count 值小于 2000。
归根结底:可见性的问题是 CPU 的缓存导致的。
4、总结
可见性是一个线程对共享变量的修改,另一个线程能够立刻看到,如果不能立刻看到,就可能会产生可见性问题。在单核 CPU 上是不存在可见性问题的,可见性问题主要存在于运行在多核 CPU 上的并发程序。归根结底,可见性问题还是由 CPU 的缓存导致的,而缓存导致的可见性问题是导致诸多诡异的并发编程问题的“幕后黑手”之一。我们只有深入理解了缓存导致的可见性问题,并在实际工作中时刻注意避免可见性问题,才能更好的编写出高并发程序。
版权声明: 本文为 InfoQ 作者【华为云开发者社区】的原创文章。
原文链接:【http://xie.infoq.cn/article/58c0323fde2c9d0f93560f601】。文章转载请联系作者。
评论