volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些未知的因素更改。volatile 提醒编译器它后面所定义的变量随时都有可能改变,因此编译后的程序每次需要存储或读取这个变量的时候,都会直接从变量地址中读取数据。如果没有 volatile 关键字,则编译器可能优化读取和存储,可能暂时使用寄存器中的值,如果这个变量由别的程序更新了的话,将出现不一致的现象。所以遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。
C/C++中的volatile不提供任何防止乱序的功能,也并不保证访存的原子性。
多线程情况下,在本次线程内, 当读取一个变量时,编译器优化时有时会先把变量读取到一个寄存器中;以后再取变量值时,就直接从寄存器中取值;当内存变量或寄存器变量在因别的线程等而改变了值,该寄存器的值不会相应改变,从而造成应用程序读取的值和实际的变量值不一致。
编译时乱序
现代编译器的代码优化和编译器指令重排可能会影响到代码的执行顺序。编译期指令重排是通过调整代码中的指令顺序,在不改变代码语义的前提下,对变量访问进行优化。从而尽可能的减少对寄存器的读取和存储,并充分复用寄存器。但是编译器对数据的依赖关系判断只能在单执行流内,无法判断其他执行流对竞争数据的依赖关系。就拿无锁环形队列来说,如果Writer做的是先放置数据,再更新索引的行为。如果索引先于数据更新,Reader就有可能会因为判断索引已更新而读到脏数据。
运行时内存乱序访问
CPU还有乱序执行(Out-of-Order Execution)的特性。流水线(Pipeline)和乱序执行是现代CPU基本都具有的特性。机器指令在流水线中经历取指、译码、执行、访存、写回等操作。为了CPU的执行效率,流水线都是并行处理的,在不影响语义的情况下。处理器次序(Process Ordering,机器指令在CPU实际执行时的顺序)和程序次序(Program Ordering,程序代码的逻辑执行顺序)是允许不一致的。
我们了解到每个CPU都会有自己私有L1 Cache。L1 Cache命中的情况下,访问数据一般需要2个指令周期。而且当CPU遭遇写数据cache未命中时,内存访问延迟增加很多。硬件工程师为了追求极致的性能,在CPU和L1 Cache之间又加入一级缓存,我们称之为store buffer。store buffer和L1 Cache还有点区别,store buffer只缓存CPU的写操作。store buffer访问一般只需要1个指令周期,这在一定程度上降低了内存写延迟。不管cache是否命中,CPU都是将数据写入store buffer。
arm/power架构
x86架构
x86上cpu核和cache以及内存之间,存在着store buffer,当cpu0写操作执行成功后,修改只存在于store buffer中,并未写到cache以及内存上,因此cpu1读取不到最新的x值。对于arm/power来说,同样也有store buffer,而且还可能会有invalid queue,导致cpu1读不到最新的x值。
对于没有invalid queue的x86系列cpu来说,当修改从store buffer刷入cache时,就能够保证在其他核上能够读到最新的修改。但是,对于存在invalid queue的cpu来说,则不一定。
store buffer存在于cpu核与cache之间,对于x86架构来说,store buffer是FIFO,因此不会存在乱序,写入顺序就是刷入cache的顺序。CPU0的store-load操作,在别的CPU看来乱序执行了,变成了load-store次序(等待其他core给它response的时候,就可以先写store buffer,然后继续后面的读操作,对外表现就是写读乱序。)。这种内存模型,我们称之为完全存储定序(Total Store Order),简称TSO。store和load的组合有4种。分别是store-store,store-load,load-load和load-store。TSO模型中,只存在store-load存在乱序,另外3种内存操作不存在乱序(store-store由fifo队列保证,没有)。
但是对于ARM/Power架构来说,store buffer并未保证FIFO,因此先写入store buffer的数据,是有可能比后写入store buffer的数据晚刷入cache的。从这点上来说,store buffer的存在会让ARM/Power架构出现乱序的可能。store barrier存在的意义就是将store buffer中的数据,刷入cache。
在某些cpu中,存在invalid queue。invalid queue用于缓存cache line的失效消息,也就是说,当cpu0写入W0(x, 1),并从store buffer将修改刷入cache,此时cpu1读取R1(x, 0)仍是允许的。因为使cache line失效的消息被缓冲在了invalid queue中,还未被应用到cache line上。这也是一种会使得指令乱序的可能。load barrier存在的意义就是将invalid queue缓冲刷新。
总结
volatile仅解决查询数据时从内存地址直接获取,不读缓存。而内存屏障解决程序中多语句可能存在乱序的情况,造成脏读或者脏写。一个解决的是这个值当前是否最新的,一个解决的是当前运行的语句顺序是否符合代码编写的预期。
如果不做多余的防护措施,单核时代的无锁环形队列在多核CPU中,一个CPU核心上的Writer写入数据,更新index后。另一个CPU核心上的Reader依靠这个index来判断数据是否写入的方式不一定可靠。index有可能先于数据被写入,从而导致Reader读到脏数据。
写屏障:防止store store 乱序,读屏障:防止load load乱序,全屏障:防止4种乱序。
在具有强内存排序(如:x86)的机器上,这些较弱(读、写)的屏障将防止编译器重新排列,而不会发出任何实际的机器代码。
在内存排序较弱(如:arm)的机器上,它们将阻止编译器重新排序并且还发射可能需要的任何硬件屏障。即使在内存排序较弱的机器上,读或写屏障可能使用比全屏障更便宜的指令。
With these guidelines in mind, the writer can do this:
// 如果不加屏障,q->items[q->num_items] 只写入到store buffer,其他cpu感知不到,q->num_items可能已经写入到cache,其他cpu可以感知,则有可能读取到脏数据
q->items[q->num_items] = new_item;
pg_write_barrier();
++q->num_items;
And the reader can do this:
// 如果不加屏障,num_items可能读取的时失效的值,而q->items[i]可能是新的value,此时如果用num_items 处理数组,则消费不全
num_items = q->num_items;
pg_read_barrier();
for (i = 0; i < num_items; ++i)
/* do something with q->items[i] */
c++ - Why do we use the volatile keyword? - Stack Overflow
C语言丨深入理解volatile关键字 - 知乎
当我们在谈论cpu指令乱序的时候,究竟在谈论什么? - 知乎
内存一致性模型-TSO - 知乎
x86 架构下 StoreLoad 屏障-CSDN博客
聊聊原子变量、锁、内存屏障那点事 | 浅墨的部落格
multithreading - How do I Understand Read Memory Barriers and Volatile - Stack Overflow
理解 Memory barrier(内存屏障)无锁环形队列_无锁循环队列 内存屏障-CSDN博客
PgSQL · 源码分析 · PG中的无锁算法和原子操作应用一则-阿里云开发者社区