在高频交易(HFT)和其他需要极高性能的领域中,原子操作是一种重要的工具。原子操作是一种不可分割的操作,它要么完全执行,要么完全不执行。这意味着在多线程环境中,原子操作可以确保数据的一致性和正确性,而无需使用锁或其他同步机制。
为什么需要原子操作
原子操作主要解决两个问题:不可分割性和可见性。
不可分割性
原子操作是不可分割的,这意味着在执行过程中,其他线程无法观察到它的中间状态。
int counter = 0;
void increment() { counter++; // 这不是一个原子操作}在上面的代码中,counter++ 不是一个原子操作,因为它实际上是由多个步骤组成的:读取 counter 的值、增加它的值、然后写回 counter。如果两个线程同时执行 increment(),可能会导致竞态条件,最终结果可能不正确。
而使用原子操作可以避免这个问题:
#include <atomic>std::atomic<int> counter(0);void increment() { counter++; // 这是一个原子操作}在这个例子中,counter++ 是一个原子操作,确保了即使多个线程同时执行 increment(),counter 的值也会正确地增加。
可见性
原子操作可以通过使用正确的内存序列来确保线程之间的可见性,建立同步关系,保证内存操作的先后顺序。它可以确保当一个线程看到标志位被改变时,也能安全地看到标志位改变前写入的数据。
int payload = 0; // 普通的非原子变量(代表我们要传递的数据)std::atomic<bool> flag(false);
// 线程 A 执行void producer() { payload = 42; // 1. 准备数据 flag.store(true, std::memory_order_release); // 2. 发布标志}
// 线程 B 执行void consumer() { while (!flag.load(std::memory_order_acquire)) { // 等待标志被设置 } // 3. 标志已设置,安全读取数据 // 此时 payload 保证等于 42,不会读到旧值 int data = payload;}在这个例子中,std::memory_order_release像是一个屏障,它保证了在设置 flag 之前的所有写入操作(比如写入 payload),绝不会被编译器或 CPU 重排到 flag 设置之后;std::memory_order_acquire 则保证了在读取到 flag 为 true 之后,后续的读取操作(比如读取 payload)能看到之前线程发布的所有数据。
原子操作是如何实现的
同样从原子性和可见性的角度来看,原子操作的实现依赖于底层的硬件支持和编译器的优化。
原子性
原子操作通常由处理器提供的特殊指令来实现,这些指令确保了操作的不可分割性。例如,在 x86 架构上,LOCK 前缀可以用于确保某些指令的原子性。
在最开始的单核CPU中,原子操作主要通过禁用中断来实现,以确保操作的不可分割性。
后来的多核CPU引入了复杂性,最初原子性是通过总线锁定来实现的,更详细的说,当一个处理器执行原子操作时,它会锁定总线,确保其他处理器无法访问内存。这种方法虽然有效,但性能较差。
现代CPU引入了缓存一致性协议(MESI协议),允许处理器在不锁定总线的情况下实现原子操作。更详细的说,当一个处理器执行原子操作时,它会获取相关缓存行的独占权,确保其他处理器无法访问这些缓存行,从而实现原子性。这种方法大大提高了性能。此时,“锁”的粒度从整个总线缩小到单个缓存行,极大地减少了锁竞争,提高了系统的整体性能。
可见性
原子操作的可见性是通过内存屏障(memory barriers)来实现的。
常用的内存屏障是 std::memory_order_release 和 std::memory_order_acquire,它们分别用于确保写操作和读操作的可见性。对于release语义,根据cppreference:
no reads or writes in the current thread can be reordered after this store.
对于acquire语义:
no reads or writes in the current thread can be reordered before this load.
需要注意的是,这里是所有的内存操作不允许重排,不仅仅是读或者写。
而为了实现可见性,通过软件和硬件两部分实现。
软件方面
这部分主要由编译器来处理。比如std::memory_order_release,这意味着编译器在优化代码时,不能将当前线程中的读写操作重新排序到这个存储操作之后。同样的,std::memory_order_acquire,这意味着编译器不能将当前线程中的读写操作重新排序到这个加载操作之前。
硬件方面
这部分主要由CPU来处理,这部分依然有两个问题。第一个问题比较简单,CPU在执行指令时,可能会对指令进行乱序执行,这意味着CPU可能会将某些指令提前或延后执行,从而提高性能。
第二个问题是,CPU在对缓存进行操作时,CPU为了加速MESI协议,存在Invalidate Queue和Store Buffer。
CPU的乱序执行
CPU的乱序执行是指CPU在执行指令时,并不严格按照程序中指令的顺序来执行,而是根据指令之间的依赖关系和资源的可用性来决定执行顺序。这种优化可以提高CPU的利用率和整体性能。
编译器在翻译带有原子序的代码时,会向 CPU 发送特定的硬件内存屏障指令(Hardware Memory Barriers),具体而言,对于存储操作的release,保证该操作之前的内存操作不会乱序执行到该操作之后。对于加载操作的acquire,保证该操作之后的内存操作不会乱序执行到该操作之前。
Invalidate Queue和Store Buffer
Invalidate Queue和Store Buffer是CPU在实现MESI协议时,为了提高性能而引入的两个机制。
Invalidate Queue是指CPU在接收到其他CPU的Invalidate请求时,并不会立即处理这个请求,而是将其放入一个队列中,等到合适的时机再去处理。这样可以减少CPU在处理Invalidate请求时的等待时间,从而提高性能。
Store Buffer是指CPU在执行Store操作时,并不会立即将数据写入缓存,而是先将数据放入一个缓冲区中,等到合适的时机再去写入缓存。这样可以减少CPU在执行Store操作时的等待时间,从而提高性能。
但是,这两个机制可能会导致可见性问题。例如,假设有两个线程,线程A和线程B,线程A在执行一个原子操作之前,先执行了一些非原子操作,而线程B在执行原子操作之后,读取了线程A的非原子操作的结果。如果CPU在处理Invalidate请求时,将其放入Invalidate Queue中,而不是立即处理,那么线程B可能会看到一个不一致的状态,从而导致程序错误。
x86结构
对于x86架构,x86是完全存储定序架构(Total Store Order, TSO),因此存在Reorder Buffer,指令存在execute(执行)和retire(提交)两个阶段。
Load指令
1、执行阶段
CPU执行时,发生所谓的推测性读取,首先检查Store Buffer,如果Store Buffer中有一个Store指令正在等待提交,并且这个Store指令的地址与当前Load指令的地址相同,那么这个Load指令就会直接从Store Buffer中读取数据,而不是从缓存中读取数据。如果Store Buffer中没有这样的Store指令,那么这个Load指令就会从缓存中读取。
2、等待提交阶段
当一个指令执行完成后,它的结果会被存入Reorder Buffer中,并且按照程序原本的顺序进行提交(Retire)。如果一个Load指令在执行阶段从Store Buffer中读取了数据,那么它的结果就会被标记为“未决状态”,直到它所在的指令被提交后,才会正式生效。
此时,如果在这个Load指令被提交之前,其他CPU核心修改了这个地址的值,那么这个Load指令就会被标记为无效(Invalid),并且重新执行。
3、提交阶段
如果没有发生无效化,那么这个Load指令就会按照程序原本的顺序提交(Retire),并且它的结果正式生效。
Store指令
1、执行阶段
在乱序执行引擎中,当 CPU 执行单元计算出 Store 指令的目标地址和要写入的数据时,它就会立刻把这笔数据塞进 Store Buffer,该指令也是具有推测性。因为CPU 前面可能还有分支预测指令,如if语句,如果分支预测错误,那么这个Store指令就会被丢弃(Squash),并且Store Buffer中的数据也会被丢弃。
在此阶段,虽然数据已经被放入Store Buffer中,但它没有被Reorder Buffer退休,因此没有资格被刷入 L1 Cache。
2、提交阶段
Reorder Buffer按指令顺序提交指令,确认它前面没有任何指令抛出异常,分支预测也没有出错时,ROB 就会宣布这条 Store 指令 “正式提交(Retire)”。此时,该存在于Store Buffer的指令才有资格被刷入 L1 Cache。
3、刷入阶段
由于Store Buffer的存在,数据不会被立刻刷入L1 Cache,而是会在稍后的某个时刻被刷入L1 Cache。CPU会根据当前的负载和其他因素来决定何时将Store Buffer中的数据刷入L1 Cache。
CPU乱序与内存序
从理论上讲,内存操作的乱序总共有四种组合:Store-Store、Load-Store、Load-Load 和 Store-Load。对于release语义,要求禁止Store-Store和Load-Store重排,对于acquire语义,要求禁止Load-Store和Load-Load重排。
可以看出,对于release-acquire语义,无法阻止Store-Load重排。
对于x86架构来说,x86是完全存储定序架构(Total Store Order, TSO),其天然保证禁止Store-Store、Load-Store和Load-Load重排,但不保证禁止Store-Load重排。因此,在x86架构上,对于release和acquire语义,编译器不需要插入额外的内存屏障指令,因为x86架构已经保证了这些重排的禁止。
对于Store-Store重排,指令在ROB中退休后,由于x86架构的Store Buffer是FIFO,因此先发生的Store操作会先被处理,从而保证了Store-Store重排的禁止。
对于Load-Load重排,x86架构比较复杂,首先会有Load Buffer和推测执行,当CPU遇到连续的两个Load时,会把它们放入Load Buffer中,如果Load A发生cache miss,此时CPU会推测执行Load B,如果Load B发生cache hit,此时B的结果会被返回,而A的结果还没有返回,事实上发生了Load-Load重排。
虽然 B 的数据提前读到了,但 x86 架构规定,所有指令的最终结果必须存入 Reorder Buffer 中,并且严格按照程序原本的顺序提交或者退休(Retire)。这意味着,哪怕 B 提前执行完,它的结果也只能以“未决状态”暂存在 CPU 内部,等待 A 读取完成并提交后,B 才能跟着提交。
此时,还会发生其他情况,比如其他CPU核心修改了B的值,因此在A的值还未读到,B的值就已经被修改了,此时B的结果就不正确了。CPU引入了缓存嗅探(Cache Snooping),如果收到了B的invalidate请求,CPU会检查Load Buffer中的所有未决Load,如果发现有一个Load正在等待B的结果,那么这个Load就会被标记为无效(Invalid),并且重新执行。
对于Load-Store重排,x86通过Store Buffer和Reorder Buffer配合来保证禁止Load-Store重排。当CPU执行一个Store时,会把其放入Store Buffer中,x86架构要求,所有指令乱序执行,但必须严格按照程序原本的代码顺序进行提交(Retire),这由Reorder Buffer保证,只有当排在前面的Load指令提交后,后面的Store指令才能提交,从而保证了Load-Store重排的禁止。
而对于Store-Load重排,x86架构并不禁止这种重排,因为它的内存模型允许Store-Load重排。这是由于Store Buffer的存在,尽管Reorder Buffer保证了指令的提交顺序,但Store Buffer中的数据可能会在后续的Load指令之后才刷入缓存,导致可见性问题,从而导致Store-Load重排。
fence的特殊性
对于原子变量的store和load,比如对于release的store:
no reads or writes in the current thread can be reordered after this store.
但是对于fence的release,std::atomic_thread_fence(std::memory_order_release)该指令没有涉及任何内存操作,那么他的语义是哪个变量?
事实上,根据cppref:
an atomic_thread_fence with std::memory_order_release ordering prevents all preceding reads and writes from moving past ALL subsequent stores.
即,std::atomic_thread_fence(std::memory_order_release)的语义是:在这个fence之前的内存操作(无论是读还是写),都不能被重排到这个fence之后的任何Store操作之后。