并发编程基础:线程安全问题的源头

对程序员来说,解决线程安全问题一直是我们项目中非常重要的一个环节,比如微服务(分布式)中的分布式锁问题,单体应用中各种加锁问题、单例等。但是在实现这些涉及多线程并发方案的时候,总是会出现一些常常违背我们正常思维的bug,而这些并发编程中出现的bug的源头就是——可见性、原子性、有序性。

一、物理计算机的内存模型

                                                        

  • 问题根源

随着计算机技术的不断进步,计算机的CPU、内存、I/O 设备都在不断更新迭代,速度也越来越快。但是,三者之间的速度差异这个核心矛盾一直存在。并且他们之间的速度差距简直是天壤之别。按速度排序:CPU>内存>I/O设备。在处理器执行一个程序时,都要访问内存,有些还要访问 I/O设备,根据木桶理论,整个应用的处理性能取决于最慢的操作——读取I/O设备。所以单纯提高CPU的性能是无效的。

所以为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:

  1. CPU 增加了缓存,以均衡与内存的速度差异;
  2. 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
  3. 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

现在我们几乎所有的程序都默默地享受着这些成果,但是天下没有免费的午餐,并发程序很多诡异问题的根源也在这里。

二、缓存导致的多线程下的可见性问题

  • 如何解决CPU与内存之间的速度差异——缓存

                                               在这里插入图片描述

为了提高CPU的利用率在CPU和内存之间加入高速缓存,缓存大大缩小了高速CPU与低速内存之间的差距。以三层缓存架构为例:

  • L1 Cache最接近CPU, 容量最小(如32K、64K等)、速度最高,每个核上都有两个L1 Cache,一个存数据 L1d Cache, 一个存指令 L1i Cache;
  • L2 Cache容量更大(如256K)、速度更低, 一般情况下,每个核上都有一个独立的L2 Cache;
  • L3 Cache最接近内存,容量最大(如12MB),速度最低,在同一个CPU插槽之间的核共享一个L3 Cache。

基于高速缓存的存储交互很好的解决了处理器与内存之间的矛盾。程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。

  • 什么是缓存一致性(可见性)

当一个线程修改了线程共享变量的值,其它线程能够立即得知这个修改。从性能角度考虑,没有必要在修改后就立即同步修改的值——如果多次修改后才使用,那么只需要最后一次同步即可,在这之前的同步都是性能浪费。因此,实际的可见性定义要弱一些,只需要保证:当一个线程修改了线程共享变量的值,其它线程在使用前,能够得到最新的修改值

可见性可以认为是最弱的“一致性”(弱一致),只保证用户见到的数据是一致的,但不保证任意时刻,存储的数据都是一致的(强一致)。

  • 使用缓存带来的问题——缓存不一致问题

高速缓存的出现对于单核CPU是没有问题的,但是在多核CPU中,每条线程都有可能运行在不同的CPU中,这就会导致每条线程都有自己的高速缓存,而他们又共享同一块内存(下文成主存 ,main memory 主要内存),当多个处理器运算都涉及到同一块内存区域的时候,就有可能发生缓存不一致的现象,也就是可见性问题。

单核时代只有Core0,Core0修改Core0读,没什么问题;但是,现在Core0修改后,Core1并不知道数据已经失效,继续傻傻的使用,轻则数据计算错误,重则导致死循环、程序崩溃等。

说到这,可能有同学就会问了,为什么要使用多CPU或者说是多核CPU那?如果不使用不就没问题了?假如一台计算机只有一个CPU,想要同时执行多个进程,就需要经常进行上下文切换,这个代价是比较大的,所以为了提高性能,一台计算机往往有多个CPU。

实际的可见性问题还要扩展到两个方向:

  • 除三级缓存外,各厂商实现的硬件架构中还存在多种多样的缓存,都存在类似的可见性问题。例如,寄存器就相当于CPU与L1 Cache之间的缓存。
  • 各种高级语言(包括Java)的多线程内存模型中,在线程栈内自己维护一份缓存是常见的优化措施,但显然在CPU级别的缓存可见性问题面前,一切都失效了

注意:以上只是最简单的可见性问题,不涉及重排序等。其实重排序也会导致可见性问题,同时,缓存上的可见性也会引起一些看似重排序导致的问题(CPU优化时,通过硬件优化(继续添加缓存)——Store Buffer,Store Forwarding,Invalid Queue导致的伪重排序问题)。

  • 缓存可见性问题的延伸:

  1. 由于传统的MESI协议是同步请求数据,这会导致CPU卡顿,利用率不高。所以CPU通过Store Buffer和Invalidate Queue组件来对其进行优化,但是由于这两个组件的引入,又导致缓存和主存之间的通信并不是实时的,在多线程下会导致可见性与有序性问题。也就是说,缓存一致性模型只能保证缓存变更可以保证其他缓存也跟着改变,但是不能保证立刻、马上执行;
  2. 为了解决1中出现的新问题,又出现了内存屏障,在软件层面对CPU进行优化。通过使用内存屏障来解决缓存的有序性和可见性问题(再次强调:缓存可见性和并发编程中的可见性可以互相类比,但是他们并不是一回事儿)。写内存屏障(Store Memory Barrier)可以促使处理器将当前store buffer(存储缓存)的值写回主存。读内存屏障(Load Memory Barrier)可以促使处理器处理invalidate queue(失效队列)。进而避免由于Store Buffer和Invalidate Queue的非实时性带来的问题。

三、原子性问题

由于 IO 太慢,早期的操作系统就发明了多进程,即便在单核的 CPU 上我们也可以一边听着歌,一边写 Bug,这个就是多进程的功劳。

操作系统允许某个进程执行一小段时间,例如 50 毫秒,过了 50 毫秒操作系统就会重新选择一个进程来执行(我们称为“任务切换”),这个 50 毫秒称为“时间片”。

                                                     

在一个时间片内,如果一个进程进行一个 IO 操作,例如读个文件,这个时候该进程可以把自己标记为“休眠状态”并出让 CPU 的使用权,待文件读进内存,操作系统会把这个休眠的进程唤醒,唤醒后的进程就有机会重新获得 CPU 的使用权了。这里的进程在等待 IO 时之所以会释放 CPU 使用权,是为了让 CPU 在这段等待时间里可以做别的事情,这样一来 CPU 的使用率就上来了;此外,如果这时有另外一个进程也读文件,读文件的操作就会排队,磁盘驱动在完成一个进程的读操作后,发现有排队的任务,就会立即启动下一个读操作,这样 IO 的使用率也上来了。

是不是很简单的逻辑?但是,虽然看似简单,支持多进程分时复用在操作系统的发展史上却具有里程碑意义,Unix 就是因为解决了这个问题而名噪天下的。早期的操作系统基于进程来调度 CPU,不同进程间是不共享内存空间的,所以进程要做任务切换就要切换内存映射地址,而一个进程创建的所有线程,都是共享一个内存空间的,所以线程做任务切换成本就很低了。现代的操作系统都基于更轻量的线程来调度,现在我们提到的“任务切换”都是指“线程切换”。

Java 并发程序都是基于多线程的,自然也会涉及到任务切换,但是任务切换是导致原子性问题的源头。

任务切换的时机大多数是在时间片结束的时候,我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条 CPU 指令完成,例如count += 1,至少需要三条 CPU 指令:

  • 指令 1:首先,需要把变量 count 从内存或缓存加载到 CPU 的寄存器;
  • 指令 2:之后,在寄存器中执行 +1 操作;
  • 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

操作系统做任务切换,可以发生在任何一条 CPU 指令执行完,是的,是 CPU 指令,而不是高级语言里的一条语句。对于上面的三条指令来说,我们假设 count=0,如果线程 A 在指令 1 执行完后做线程切换,线程 A 和线程 B 按照下图的序列执行,那么我们会发现两个线程都执行了 count+=1 的操作,但是得到的结果不是我们期望的 2,而是 1。

                                               

我们潜意识里面觉得 count+=1 这个操作是一个不可分割的整体,就像一个原子一样,线程的切换可以发生在 count+=1 之前,也可以发生在 count+=1 之后,但就是不会发生在中间。我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。因此,很多时候我们需要在高级语言层面保证操作的原子性。

四、指令重排序导致多线程下的有序性问题

编译器、底层硬件(CPU等)出于“优化”的目的(与其等待阻塞指令(如等待缓存刷入)完成,不如先去执行其他指令。与处理器乱序执行相比,编译器重排序能够完成更大范围、效果更好的乱序优化。,按照某种规则将指令重新排序(尽管有时候看起来像乱序)。重排序也是单核时代非常优秀的优化手段,有足够多的措施保证其在单核下的正确性。在多核时代,如果工作线程之间不共享数据或仅共享不可变数据,重排序也是性能优化的利器。然而,如果工作线程之间共享了可变数据,由于两种重排序的结果都不是固定的,会导致工作线程似乎表现出了随机行为。

处理器层面的乱序优化节省了大量等待时间,提高了处理器的性能。所谓“乱序”只是被叫做“乱序”,实际上也遵循着一定规则:只要两个指令之间不存在数据依赖,就可以对这两个指令乱序。不必关心数据依赖的精确定义,可以理解为:只要不影响程序单线程、顺序执行的结果,就可以对两个指令重排序

不进行乱序优化时,处理器的指令执行过程如下:

  1. 指令获取。
  2. 如果输入的运算对象是可以获取的(比如已经存在于寄存器中),这条指令会被发送到合适的功能单元。如果一个或者更多的运算对象在当前的时钟周期中是不可获取的(通常需要从主内存获取),处理器会开始等待直到它们是可以获取的。
  3. 指令在合适的功能单元中被执行。
  4. 功能单元将运算结果写回寄存器。

乱序优化下的执行过程如下:

  1. 指令获取。
  2. 指令被发送到一个指令序列(也称执行缓冲区或者保留站)中。
  3. 指令将在序列中等待,直到它的数据运算对象是可以获取的。然后,指令被允许在先进入的、旧的指令之前离开序列缓冲区。(此处表现为乱序)
  4. 指令被分配给一个合适的功能单元并由之执行。
  5. 结果被放到一个序列中。
  6. 仅当所有在该指令之前的指令都将他们的结果写入寄存器后,这条指令的结果才会被写入寄存器中。(重整乱序结果)

当然,为了实现乱序优化,还需要很多技术的支持,如寄存器重命名分枝预测等,但大致了解到这里就足够。后文的注释中会据此给出内存屏障的实现方案。

乱序优化在单核时代不影响正确性;但多核时代的多线程能够在不同的核上实现真正的并行,一旦线程间共享数据,就出现问题了。在 Java 领域一个经典的案例就是利用双重检查创建单例对象,例如下面的代码:在获取实例 getInstance() 的方法中,我们首先判断 instance 是否为空,如果为空,则锁定 Singleton.class 并再次检查 instance 是否为空,如果还为空则创建 Singleton 的一个实例。


public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

假设有两个线程 A、B 同时调用 getInstance() 方法,他们会同时发现 instance == null ,于是同时对 Singleton.class 加锁,此时 JVM 保证只有一个线程能够加锁成功(假设是线程 A),另外一个线程则会处于等待状态(假设是线程 B);线程 A 会创建一个 Singleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程 B 检查 instance == null 时会发现,已经创建过 Singleton 实例了,所以线程 B 不会再创建一个 Singleton 实例。这看上去一切都很完美,无懈可击,但实际上这个 getInstance() 方法并不完美。问题出在哪里呢?出在 new 操作上,我们以为的 new 操作应该是:

  1. 分配一块内存 M;
  2. 在内存 M 上初始化 Singleton 对象;
  3. 然后 M 的地址赋值给 instance 变量。

但是实际上优化后的执行路径却是这样的:

  1. 分配一块内存 M;
  2. 将 M 的地址赋值给 instance 变量;
  3. 最后在内存 M 上初始化 Singleton 对象。

优化后会导致什么问题呢?我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。

                           

 

(所以为了解决这个问题,我们可以在变量instance上加volatile关键字,因为该关键字可以禁止重排序,如果创建instance过程不会被重排,其他线程也就不会获取到不等于null但却未初始化的instance。时间片切换之后,当前线程虽然不会继续执行,但是它持有的锁不会释放,其他线程也就进不到同步代码块中,直到当前线程重新抢占时间片,继续执行完代码块中的内容,才会释放锁,其他线程重新争抢锁。)

总结

在介绍可见性、原子性、有序性的时候,特意提到缓存导致的可见性问题,线程切换带来的原子性问题,编译优化带来的有序性问题,其实缓存、线程、编译优化的目的和我们写并发程序的目的是相同的,都是提高程序性能。但是技术在解决一个问题的同时,必然会带来另外一个问题,所以在采用一项技术的同时,一定要清楚它带来的问题是什么,以及如何规避。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值