Skip to main content

Memory Ordering

处理器和编译器都会尝试各种手段让程序尽可能快速地运行. 一个处理器可能会判断两条指令间是否会相互影响, 如果相互没有影响且按修改后的顺序可以提升速度的话, 则处理器会将指令执行顺序修改. 比如一个指令是从主存取数据, 而后续其他指令可能都会在第一条指令结束前都执行完成(只要这样的执行顺序不会改变程序的预期行为). 另外编译器也可能修改或重写部分程序来让程序更快地执行, 前提仍然是修改后不会改变程序的行为.

指令乱序执行优化

如下是一段简单的代码:

fn f(a: &mut i32, b: &mut i32) { 
*a+=1;
*b+=1;
*a+=1;
}

由于三条语句相互不影响, 可能编译器在编译后, 会将这段代码修改为:

fn f(a: &mut i32, b: &mut i32) { 
*a+=2;
*b+=1;
}

而当处理器在执行这个函数时, 可能会由于 *b 已经在 cache 中而 *a 需要从主存中获取, 从而先执行 *b+=1. 尽管有这样的执行优化, 但执行结果是一致的. 两个值的增量操作的执行顺序改变相对程序其余部分是透明的.

但这样的乱序执行且不改变程序行为的优化没有包括多线程环境运行的情况, 上面的程序优化后在单线程环境下可以保证是正确的, 因为 &mut i32 可以保证值的互斥访问(没有其他操作会同时对值进行修改且不会在多线程环境下使用).

唯一可能出现问题的情况就是当值在多线程共享时, 或换句话说, 是当使用原子类型在多线程间共享值时. 这也是为什么我们需要通过原子类型上的操作显式告知编译器和处理器能够做什么和不能做什么的原因. 因为它们的默认乱序优化逻辑会忽略多线程间的相互影响, 从而导致程序行为受优化影响而改变.

处理器/编译器无关的高级抽象表达: Memory Ordering

现在的问题是: 我们如何告知编译器和处理器它们能做什么和不能做什么优化. 但如果由程序员负责告知二者能和不能做什么, 则编写多线程程序使用原子类型时会是十分痛苦的, 可能还会和处理器架构直接相关.

因此, C++ 和 Rust 等语言在原子类型的操作方法上都会要求提供类似 std::sync::atomic::Ordering 这样的枚举. Ordering 枚举值只有有限的几个, 但都是经过仔细挑选的, 且适用于绝大部分场景.

Ordering 是一个高级抽象的概念, 它避免了程序员去考虑实际的编译器和处理器细节(比如指令乱序), 让程序可以架构独立, 且适用于任何处理器和编译器版本.

Rust 中提供如下 Ordering:

  • Ordering::Relaxed: 宽松
  • Ordering::{Release, Acquire, AcqRel}: 释放和获取
  • Ordering::SeqCst: 顺序一致

Ordering 在程序开发者和编译器作者间建立起联系.

另外实际在 C++ 中还有一个 consume ordering 的概念, 在 Rust 中是故意移除掉这个概念了的.