为什么要指令重排序和屏障的作用

本文深入探讨了内存屏障在解决单处理器下的指令乱序及多处理器下的内存同步问题中的作用。介绍了不同类型的内存屏障及其应用场景,解释了如何利用内存屏障保证程序逻辑的正确性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

内存屏障是一个很神奇的东西,之前翻译了linux内核文档memory-barriers.txt,对内存屏障有了一定有理解。现在用自己的方式来整理一下。
在我看来,内存屏障主要解决了两个问题:单处理器下的乱序问题和多处理器下的内存同步问题。

为什么会乱序
现在的CPU一般采用流水线来执行指令。一个指令的执行被分成:取指、译码、访存、执行、写回、等若干个阶段。然后,多条指令可以同时存在于流水线中,同时被执行。
指令流水线并不是串行的,并不会因为一个耗时很长的指令在“执行”阶段呆很长时间,而导致后续的指令都卡在“执行”之前的阶段上。
相反,流水线是并行的,多个指令可以同时处于同一个阶段,只要CPU内部相应的处理部件未被占满即可。比如说CPU有一个加法器和一个除法器,那么一条加法指令和一条除法指令就可能同时处于“执行”阶段, 而两条加法指令在“执行”阶段就只能串行工作。
相比于串行+阻塞的方式,流水线像这样并行的工作,效率是非常高的。

然而,这样一来,乱序可能就产生了。比如一条加法指令原本出现在一条除法指令的后面,但是由于除法的执行时间很长,在它执行完之前,加法可能先执行完了。再比如两条访存指令,可能由于第二条指令命中了cache而导致它先于第一条指令完成。
一般情况下,指令乱序并不是CPU在执行指令之前刻意去调整顺序。CPU总是顺序的去内存里面取指令,然后将其顺序的放入指令流水线。但是指令执行时的各种条件,指令与指令之间的相互影响,可能导致顺序放入流水线的指令,最终乱序执行完成。这就是所谓的“顺序流入,乱序流出”。

指令流水线除了在资源不足的情况下会卡住之外(如前所述的一个加法器应付两条加法指令的情况),指令之间的相关性也是导致流水线阻塞的重要原因。
CPU的乱序执行并不是任意的乱序,而是以保证程序上下文因果关系为前提的。有了这个前提,CPU执行的正确性才有保证。比如:

a++; b=f(a); c--;

由于b=f(a)这条指令依赖于前一条指令a++的执行结果,所以b=f(a)将在“执行”阶段之前被阻塞,直到a++的执行结果被生成出来;而c--跟前面没有依赖,它可能在b=f(a)之前就能执行完。(注意,这里的f(a)并不代表一个以a为参数的函数调用,而是代表以a为操作数的指令。C语言的函数调用是需要若干条指令才能实现的,情况要更复杂些。)

像这样有依赖关系的指令如果挨得很近,后一条指令必定会因为等待前一条执行的结果,而在流水线中阻塞很久,占用流水线的资源。而编译器的乱序,作为编译优化的一种手段,则试图通过指令重排将这样的两条指令拉开距离, 以至于后一条指令进入CPU的时候,前一条指令结果已经得到了,那么也就不再需要阻塞等待了。比如将指令重排为:

a++; c--; b=f(a);

相比于CPU的乱序,编译器的乱序才是真正对指令顺序做了调整。但是编译器的乱序也必须保证程序上下文的因果关系不发生改变。

乱序的后果
乱序执行,有了“保证上下文因果关系”这一前提,一般情况下是不会有问题的。因此,在绝大多数情况下,我们写程序都不会去考虑乱序所带来的影响。
但是,有些程序逻辑,单纯从上下文是看不出它们的因果关系的。比如:

*addr=5; val=*data;

从表面上看,addr和data是没有什么联系的,完全可以放心的去乱序执行。但是如果这是在某某设备驱动程序中,这两个变量却可能对应到设备的地址端口和数据端口。并且,这个设备规定了,当你需要读写设备上的某个寄存器时,先将寄存器编号设置到地址端口,然后就可以通过对数据端口的读写而操作到对应的寄存器。那么这么一来,对前面那两条指令的乱序执行就可能造成错误。
对于这样的逻辑,我们姑且将其称作隐式的因果关系;而指令与指令之间直接的输入输出依赖,也姑且称作显式的因果关系。CPU或者编译器的乱序是以保持显式的因果关系不变为前提的,但是它们都无法识别隐式的因果关系。再举个例子:

obj->data = xxx; obj->ready = 1;

当设置了data之后,记下标志,然后在另一个线程中可能执行:

if (obj->ready) do_something(obj->data);

虽然这个代码看上去有些别扭,但是似乎没错。不过,考虑到乱序,如果标志被置位先于data被设置,那么结果很可能就杯具了。因为从字面上看,前面的那两条指令其实并不存在显式的因果关系,乱序是有可能发生的。

总的来说,如果程序具有显式的因果关系的话,乱序一定会尊重这些关系;否则,乱序就可能打破程序原有的逻辑。这时候,就需要使用屏障来抑制乱序,以维持程序所期望的逻辑。

屏障的作用
内存屏障主要有:读屏障、写屏障、通用屏障、优化屏障、几种。
以读屏障为例,它用于保证读操作有序。屏障之前的读操作一定会先于屏障之后的读操作完成,写操作不受影响,同属于屏障的某一侧的读操作也不受影响。类似的,写屏障用于限制写操作。而通用屏障则对读写操作都有作用。而优化屏障则用于限制编译器的指令重排,不区分读写。前三种屏障都隐含了优化屏障的功能。比如:

tmp = ttt; *addr = 5; mb(); val = *data;

有了内存屏障就了确保先设置地址端口,再读数据端口。而至于设置地址端口与tmp的赋值孰先孰后,屏障则不做干预。

有了内存屏障,就可以在隐式因果关系的场景中,保证因果关系逻辑正确。

多处理器情况
前面只是考虑了单处理器指令乱序的问题,而在多处理器下,除了每个处理器要独自面对上面讨论的问题之外,当处理器之间存在交互的时候,同样要面对乱序的问题。
一个处理器(记为a)对内存的写操作并不是直接就在内存上生效的,而是要先经过自身的cache。另一个处理器(记为b)如果要读取相应内存上的新值,先得等a的cache同步到内存,然后b的cache再从内存同步这个新值。而如果需要同步的值不止一个的话,就会存在顺序问题。再举前面的一个例子:
  <CPU-a>              <CPU-b>
  obj->data = xxx;
  wmb();               if (obj->ready)
  obj->ready = 1;          do_something(obj->data);

前面也说过,必须要使用屏障来保证CPU-a不发生乱序,从而使得ready标记置位的时候,data一定是有效的。但是在多处理器情况下,这还不够。data和ready标记的新值可能以相反的顺序更新到CPU-b上!
其实这种情况在大多数体系结构下并不会发生,不过内核文档memory-barriers.txt举了alpha机器的例子。alpha机器可能使用分列的cache结构,每个cache列可以并行工作,以提升效率。而每个cache列上面缓存的数据是互斥的(如果不互斥就还得解决cache列之间的一致性),于是就可能引发cache更新不同步的问题。
假设cache被分成两列,而CPU-a和CPU-b上的data和ready都分别被缓存在不同的cache列上。
首先是CPU-a更新了cache之后,会发送消息让其他CPU的cache来同步新的值,对于data和ready的更新消息是需要按顺序发出的。如果cache只有一列,那么指令执行的顺序就决定了操作cache的顺序,也就决定了cache更新消息发出的顺序。但是现在假设了有两个cache列,可能由于缓存data的cache列比较繁忙而使得data的更新消息晚于ready发出,那么程序逻辑就没法保证了。不过好在SMP下的内存屏障在解决指令乱序问题之外,也将cache更新消息乱序的问题解决了。只要使用了屏障,就能保证屏障之前的cache更新消息先于屏障之后的消息被发出。
然后就是CPU-b的问题。在使用了屏障之后,CPU-a已经保证data的更新消息先发出了,那么CPU-b也会先收到data的更新消息。不过同样,CPU-b上缓存data的cache列可能比较繁忙,导致对data的更新晚于对ready的更新。这里同样会出问题。
所以,在这种情况下,CPU-b也得使用屏障。CPU-a上要使用写屏障,保证两个写操作不乱序,并且相应的两个cache更新消息不乱序。CPU-b上则需要使用读屏障,保证对两个cache单元的同步不乱序。可见,SMP下的内存屏障一定是需要配对使用的。
所以,上面的例子应该改写成:
  <CPU-a>              <CPU-b>
  obj->data = xxx;     if (obj->ready)
  wmb();                   rmb();
  obj->ready = 1;          do_something(obj->data);

CPU-b上使用的读屏障还有一种弱化版本,它不保证读操作的有序性,叫做数据依赖屏障。顾名思义,它是在具有数据依赖情况下使用的屏障,因为有数据依赖(也就是之前所说的显式的因果关系),所以CPU和编译器已经能够保证指令的顺序。
再举个例子:

  <CPU-a>              <CPU-b>
  init(newval);        p = data;
  <write barrier>      <data dependency barrier>
  data = &newval;      val = *p;

这里的屏障就可以保证:如果data指向了newval,那么newval一定是初始化过的。

 

误区
在SMP环境下,内存屏障保证的是“一个CPU的多个操作的顺序”(被另一个CPU所观察到的顺序),而不保证“两个CPU的操作顺序”。
举例来说,有如下事件序列:
CPU-0:a = 5; CPU-0:wmb(); CPU-1:rmb(); CPU-1: i = a;

假设从时间顺序上看,CPU-0对内存a的写操作“a = 5”发生于CPU-1的读操作“ i = a”之前,并且中间使用了内存屏障,那么在CPU-1上,i一定等于5么?
未必!因为内存屏障并不保证“两个CPU的操作顺序”。为什么会是这样呢?
一方面,这样的保证没有必要。两个CPU上执行的操作本身是没有关联的,程序没有要求应该谁先谁后。有可能“a = 5”先执行,也有可能“i = a”先执行,这都符合程序逻辑。只是现在这个case恰好“a = 5”先执行而已。
另一方面,两个CPU的操作孰先孰后,是无法通过外部时间来度量的。也就是说,“a = 5”先于“i = a”这件事情不能以它们发生的先后顺序来度量。假设,CPU-0执行了“a = 5”,一个CPU主频周期之后,CPU-1要执行“i = a”。这时候CPU-1如何知道“a = 5”这件事情已经发生了呢?它若想知道,唯一的办法只能跟其他CPU同步一下缓存,但是缓存同步的时间显然远远大于一个CPU主频周期。同步完成之后呢?且不说缓存同步导致CPU性能变差。的确,现在CPU-1可以知道现在“a = 5”已经发生了,但是“a = 5”到底是发生在同步发起之前还是同步过程中呢?依然没法知道。除非CPU在修改自己的cache的时候给每个内存单元打一个时间戳,并且时间戳层层传递到内存,并且记录下来。(记录时间戳花费的空间可能比元数据还大!)
更进一步,即便有时间戳,假设CPU-0执行“a = 5”、CPU-1执行“a = 3”,这两个操作发生在同一个主频周期,如何度量谁先谁后呢?从时间顺序上显然是没法度量的,因为两个操作是同时发生的,没有先后顺序。但是又非得度量其先后顺序不可,最后a到底等于几总该有个结论吧。度量的标准只能是谁先抢到总线、把a的新值从cache更新到内存,谁就是先者。
所以度量内存操作的先后顺序看的是谁先同步到内存(这一步是串行的,不可能同时发生),而不是看操作发生的时间顺序。可能会这样,CPU-0后执行操作,但是由于种种原因先抢到了总线而先把a更新到内存,那么它就是先者。
那么,CPU在看到内存屏障指令之后,是不是应该立马flush cache,使得内存同步的顺序跟时间顺序更为趋近呢?CPU也许可以这么做。但是其实意义并不大,无论如何内存同步顺序永远不可能与时间顺序完全一致,毕竟CPU是并行工作的,而内存同步是串行的。并且flush cache的开销是巨大的,因为内存屏障的作用范围不是某次内存操作,而是屏障前的所有内存操作,所以要flush只能flush所有的cache。

<think>我们正在讨论Java中的指令重排序问题。根据引用内容,指令重排序是编译器处理器为了优化性能而对指令执行顺序进行重新排序的过程。在单线程环境中,这种重排序不会影响程序的正确性,因为编译器处理器会保证程序的执行结果与代码逻辑顺序一致。但在多线程环境中,指令重排序可能导致意想不到的结果,因为不同线程之间的操作顺序可能被打乱。 接下来,我将详细解释指令重排序的定义、原因以及它对并发程序的影响,并参考提供的引用内容进行说明。 ### 1. 指令重排序的定义 指令重排序是指编译器处理器在不改变程序语义的前提下,重新安排指令的执行顺序以优化性能。重排序主要发生在两个层面: - **编译器重排序**:编译器在生成字节码或机器码时调整指令顺序。 - **处理器重排序**:处理器在执行指令时,可能改变指令的执行顺序(如乱序执行)。 在单线程程序中,重排序不会影响程序的最终结果,因为编译器处理器会遵守**数据依赖性**原则:如果两条指令存在数据依赖关系(如一条指令的输出是另一条指令的输入),则不会对它们重排序。例如: ```java int a = 1; // 语句1 int b = a + 1; // 语句2 ``` 语句2依赖于语句1的结果,因此不会被重排序到语句1之前[^1]。 ### 2. 指令重排序的原因 重排序的主要目的是提高性能: - **充分利用CPU流水线**:避免指令等待(如等待内存加载数据),让后续独立指令提前执行。 - **减少内存访问延迟**:通过重排序将内存访问操作集中执行,减少缓存未命中的影响。 ### 3. 指令重排序对并发程序的影响 在多线程环境中,指令重排序可能导致**内存可见性问题****程序逻辑错误**。因为不同线程可能观察到其他线程操作的重排序版本。例如: ```java // 引用[3]中的示例 public class VolatileExample { private volatile boolean flag = false; private int number = 0; public void writer() { number = 42; // 语句1 flag = true; // 语句2 } public void reader() { if (flag) { // 语句3 System.out.println(number); // 语句4 } } } ``` - 如果没有重排序,当线程B执行`reader()`看到`flag==true`时,`number`一定已被赋值为42。 - 但如果发生重排序,`writer()`中的语句1语句2可能被重排序(因为两者无数据依赖)。此时线程B可能在`number`赋值前看到`flag=true`,导致打印出0。 ### 4. 如何避免指令重排序的问题 Java提供以下机制防止重排序导致的问题: - **`volatile`关键字**:禁止对`volatile`变量的读写操作与其前后的普通变量读写重排序(内存屏障效果)[^2][^3]。 - **`synchronized`关键字**:通过互斥锁保证临界区内指令不会被重排序到临界区外。 - **`final`关键字**:保证构造函数中对`final`字段的赋值不会被重排序到构造函数之外。 ### 5. 内存屏障(Memory Barrier) 内存屏障是一种CPU指令,用于禁止特定类型的重排序: - **写屏障(Store Barrier)**:确保屏障前的所有写操作对其他线程可见。 - **读屏障(Load Barrier)**:确保屏障后的读操作能获取最新数据。 在Java中,`volatile`变量的读写会自动插入内存屏障[^2]。 ### 总结 - **指令重排序**是性能优化手段,但在多线程中可能破坏程序正确性。 - **数据依赖性**是单线程中重排序的约束条件。 - **并发控制**需使用`volatile`、`synchronized`等机制防止重排序问题。 §§相关问题§§ 1. Java内存模型(JMM)如何规范指令重排序? 2. 除了`volatile`,还有哪些方法可以保证多线程环境下的内存可见性? 3. 如何通过代码演示指令重排序导致的问题? 4. Happens-Before原则与指令重排序的关系是什么?[^3]</think>### Java中的指令重排序:定义与并发影响 #### 1. **指令重排序的定义** 指令重排序是编译器处理器为了提高程序执行效率,在**不改变单线程程序语义**的前提下,对指令执行顺序进行重新排列的优化技术。主要包括两种类型: - **编译器重排序**:在编译字节码阶段调整指令顺序 - **处理器重排序**:CPU执行时并行执行无依赖的指令(乱序执行) 例如以下代码: ```java int a = 1; // 语句1 int b = 2; // 语句2 int c = a + b; // 语句3 ``` 编译器处理器可能将语句12的顺序互换,因为两者无数据依赖关系,但不影响语句3的结果[^1]。 #### 2. **重排序的触发条件** 重排序仅在满足**数据依赖性规则**时被禁止: - **写后读**:`a=1; b=a;`(不可重排序) - **写后写**:`a=1; a=2;`(不可重排序) - **读后写**:`b=a; a=1;`(不可重排序) 无这些依赖关系的指令可能被重排序[^1]。 #### 3. **对并发程序的影响** 在多线程环境中,重排序会导致**内存可见性问题****程序逻辑错误**: **典型问题示例**: ```java // 引用[3]的示例 public class ReorderingDemo { private int x = 0; private boolean ready = false; // 非volatile // 线程A执行 public void writer() { x = 42; // 语句1 ready = true; // 语句2 可能被重排到语句1前 } // 线程B执行 public void reader() { if (ready) { // 语句3 System.out.println(x); // 可能输出0(未初始化值) } } } ``` 可能发生的时间序列: 1. 线程B执行语句3(`ready=true`) 2. 线程A重排序后先执行语句2 3. 线程B输出`x=0` 4. 线程A执行语句1(`x=42`) #### 4. **解决方案:内存屏障** 通过内存屏障禁止特定类型的重排序: ```java private volatile boolean ready = false; // 添加volatile ``` `volatile`的作用: 1. 禁止重排序:确保`x=42`始终在`ready=true`**之前**执行 2. 保证可见性:修改立即对其他线程可见 3. 插入内存屏障:生成`LoadStore`+`StoreStore`屏障[^2] #### 5. **Happens-Before规则** Java内存模型通过Happens-Before规则解决重排序问题: - **volatile规则**:对volatile变量的写操作先于后续读操作 - **监视器锁规则**:解锁操作先于后续加锁操作 - **传递性规则**:若A先于B,B先于C,则A先于C 在引用[3]的示例中: ```java public void writer() { number = 42; // 普通写 flag = true; // volatile写 → 建立内存屏障 } public void reader() { if (flag) { // volatile读 → 建立内存屏障 System.out.println(number); // 保证看到42 } } ``` `volatile`写操作前的所有操作(`number=42`)对后续`volatile`读操作可见[^3]。 ### 总结 | 特性 | 单线程环境 | 多线程环境 | |------|------------|------------| | **重排序允许性** | ✅ 安全 | ❌ 危险 | | **数据依赖性** | 自动保持 | 可能被破坏 | | **解决方案** | 无需处理 | volatile/synchronized | | **关键机制** | - | 内存屏障 + Happens-Before | > 指令重排序是提升性能的重要手段,但在并发编程中必须通过`volatile`、`synchronized`或`final`等机制约束其行为,避免出现不可预测的结果[^1][^2][^3]。
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值