一.为什么不要使用volatile关键字
为什么我们不应该使用volatile类型 - 知乎 (zhihu.com)https://zhuanlan.zhihu.com/p/102406978C 程序员经常用volatile来表示该变量可以在当前执行线程之外被修改。 结果,当使用共享数据结构时,他们有时会倾向于在内核代码中使用votatile。 换句话说,他们将volatile类型视为一种简单的原子变量,而事实并非如此。 在Linux内核代码中使用volatile类型的地方几乎都是不正确的使用。 本文将档描述其原因。
理解volatile的关键点在于其目的是抑制优化,这几乎从来不是人们真正想要做的事。 在内核中,人们必须保护共享数据以免受不必要的并发访问,这是个不同寻常的考验。 防止不必要的并发过程也顺便以更有效的方式避免了几乎所有与优化相关的问题。
像是volatile,内核原语(spinlocks,mutexes,memory barriers等)确保了并发访问共享数据的安全,内核原语同时阻止了不需要的优化。如果能正确的使用这些同步原语,当然同时也就没有必要使用volatile类型。如果你觉得volatile依然是必须的,也许在代码的某个地方存在一个bug。在正确编写的内核代码中,volatile只会降低代码的性能。
考虑下面一段典型的内核代码:
spin_lock(&the_lock);
do_something_on(&shared_data);
do_something_else_with(&shared_data);
spin_unlock(&the_lock);
如果所有的代码都遵从锁的使用规则,当持有the_lock
时,共享数据share_data
的值不会被意外的修改。在其他任何想修改共享数据的代码中都会等待这把锁释放。spinlock同步原语同时扮演着memory barrier的角色,spinlock的实现明确的加了memory barrier功能,这意味着不会在它们之间优化数据访问。所以编译器可能认为它知道share_data
存储的值,但是由于spinlock()的调用(同时充当了memory barrier), 我们会强制编译器忘记他知道的一切事情(例如share_data
的值)。这里将不会存在访问共享数据的优化问题。
即使shared_data
被定义成volatile类型,锁依然是需要的。但是编译器也会阻止临界区内对share_data
访问的优化,但是我们明知没有人会并发的访问共享数据。当持有锁后,share_data
并不是易变(volatile)的。当处理共享数据时,正确的使用锁会使volatile类型没有必要,同时使用volatile类型反而可能对性能有害。
volatile类型最初是为内存映射的I/O寄存器设计的。 在内核中,寄存器访问也应该受到锁的保护,但是也不希望编译器在临界区内“优化”寄存器访问。 但是,在内核中,I/O内存访问始终是通过调用专门的函数完成。直接通过指针访问I/O内存的方法是不被推荐的,并且不适用于所有架构平台。 这些特定的函数是为了防止不必要的优化,因此,再次的不需要volatile。
可能会尝试使用volatile的另一种情况是,处理器正在忙于等待变量的值。 但是,执行繁忙等待的正确方法是:
while (my_variable != what_i_want)
cpu_relax();
调用cpu_relax()会降低cpu使用功耗或者在超线程处理器上会主动让出使用资源。当然它也充当了一条compiler barrier( 意味着它们可防止屏障一侧的优化延续到另一侧)。所以,再一次的说明volatile是没有必要的。当然,让cpu忙等是一种反社会行为。
在内核中,仍然有一些罕见的情况使volatile是有意义的:
- 上面提到的访问I/O寄存器的函数可能会使用volatile类型。从本质上讲,每个访问I/O寄存器函数调用本身都会变成一小的临界区,并确保按程序员的预期进行内存访问。
- 被inline并改变内存值得汇编代码可能会由于没有其他可见的副作用而被GCC编译器优化掉。在asm语句前加上volatile可以避免被优化。
- Linux中
jiffies
变量是一个特殊的存在,因为每次引用时它都可能是不同的值,同时读取该变量值的地方又是无锁的。因此jiffies
可以被定义成volatile类型, 但是强烈反对对其他变量使用volatile类型。在这方面,jiffies
被认为是“遗留的愚蠢”问题(linus说的),修复它带来的麻烦远比其价值更多。 - 指向一致性内存中可能由I/O设备修改的数据结构的指针有时可以合理声明volatile。网络适配器使用的环形缓冲区就是一个典型的例子,网络适配器会改变指针的值用来表明哪个描述符已经被处理了。
对于大多数代码,以上所有关于volatile使用的理由都不适用。最终,使用volatile的地方就看起来像是个bug,需要对代码进行额外的检查。试图使用volatile的开发者应该往后退一步,思考什么是他们真正想要实现的。
通常我们欢迎移除volatile变量的补丁程序,只要带有可以证明正确考虑并发问题的理由即可。
二. 不使用volatile的解决方案一:ACCESS_ONCE()实现
#define __ACCESS_ONCE(x) ({ \
__maybe_unused typeof(x) __var = (__force typeof(x)) 0; \
(volatile typeof(x) *)&(x); })
#define ACCESS_ONCE(x) (*__ACCESS_ONCE(x))
这个宏的名字确实很好的描述了其功能。它的目的就是确保所生成的代码对向其传递的参数的值确实只被访问过一次。人们可能会奇怪为什么这很重要。 归结为以下事实:C编译器将假定正在编译的程序的地址空间中只有一个执行线程。 并发不是C语言本身内置的,因此处理并发访问的机制必须建立在C语言之上。 ACCESS_ONCE()是一种这样的机制。
例如,考虑下面来自kernel/mutex.c的代码片段:
for (;;) {
struct task_struct *owner;
owner = ACCESS_ONCE(lock->owner);
if (owner && !mutex_spin_on_owner(lock, owner))
break;
/* ... */
这是一小段自旋等待代码,并期望快速获得mutex一旦mutex的持有者释放锁,这样就不用睡眠了。此for循环比此处显示的要多得多,但是此代码足以说明为什么ACCESS_ONCE()是必须的。
想象一下,正在使用的编译器是由狂热的开发人员开发的,他们将以各种方式对代码进行优化。这不是纯粹的假设场景。正如Paul McKenney最近证明的那样:“当他们讨论您不希望孩子知道的优化技术时,我已经看到他们眼中的闪光!” 这些开发人员创的编译器,可能会得出以下结论:由于所讨论的代码实际上并未修改lock-> owner,因此没有必要在每次循环中实际获取其值。 然后,编译器可能会将代码重新排列为:
owner = ACCESS_ONCE(lock->owner);
for (;;) {
if (owner && !mutex_spin_on_owner(lock, owner))
break;
编译器遗漏的是lock-> owner被另一个执行线程完全更改的事实。造成的结果就是我们没办法通知这里的循环退出,导致我们不想看到的结果。ACCESS_ONCE()的调用阻止了编译器对这种情况的优化,这样就可以保证代码按照我们的期望运行。
碰巧的是,优化访问问题并不是此代码可能遇到的唯一危害。 某些处理器体系结构(例如x86)没有足够的寄存器。 在这样的系统上,如果编译器要生成性能最高的代码,则必须谨慎选择要保留在寄存器中的值。 可以将特定值push到寄存器组,然后再pull。 如果上述mutex代码发生这种情况,则结果可能是对lock->owner的多处引用。 这将导致问题发生。如果在循环的中间更改lock->owner的值,则期望其本地owner变量的值仍然保持不变的代码可能会造成致命的混乱。 再次,ACCESS_ONCE()调用告诉编译器不要这样做,以避免潜在的问题。
ACCESS_ONCE()的实现在linux/compiler.h文件,相当直接:
#define ACCESS_ONCE(x) (*(volatile typeof(x) *)&(x))
换句话说,它通过暂时将相关变量转换为volatile类型来工作。
考虑到编译器优化带来的各种危害,人们可能会想知道为什么这种情况不经常发生。 答案是大多数并发的数据访问都(或肯定应该)受锁保护。 spinlock和mutex均充当优化屏障,这意味着它们可防止屏障一侧的优化延续到另一侧。 如果代码仅在持有相关锁的情况下访问共享变量,并且如果该变量仅在释放锁(并由其他线程持有)时才可以更改,则编译器将不会产生微妙的问题。 仅在没有持有锁(或显式障碍)的情况下访问共享数据的地方,才需要类似ACCESS_ONCE()的构造。 可伸缩性压力会导致创建更多此类代码,但是大多数内核开发人员仍然不必在大多数时间担心ACCESS_ONCE()。
三. 不使用volatile的解决方案二:READ_ONCE()实现
有了READ_ONCE(),在你Linux编程中或许真的就可以完全不用volatile了。因为READ_ONCE()就是volatile的封装。
但是,READ_ONCE()除了阻止对给定单个变量的访问重新排序之外,它们根本不限制 CPU,也就是说,如果想要限制多线程针对多个变量访问重新排序,需要采用其他方式。 请注意。
READ_ONCE()的前世今生参考下面的文章:
ACCESS_ONCE() and compiler bugs [LWN.net]https://lwn.net/Articles/624126/
四.READ_ONCE()和ACCESS_ONCE()之间的区别
人们发现有一个编译器bug导致ACCESS_ONCE()在某些情况下不能正确使用。因此提出一个新的方案,那就是READ_ONCE()宏。READ_ONCE()宏会想办法避开这个bug。因此现在内核中基本是使用READ_ONCE()宏。也就是说,如果不关注编译器bug,可以认为READ_ONCE()和ACCESS_ONCE()功能完全一样 ———— 避免使用volatile类型变量(或指针),取而代之的是在需要的时候把普通变量(或指针)临时添加volatile修饰,在添加修饰的这一小段代码内防止编译器优化。
五.READ_ONCE()使用建议
问:Instead of using READ_ONCE() everywhere, why not just declare goflag as volatile on line 10 ?
答:A volatile declaration is in fact a reasonable alternative in this particular case. However, use of READ_ONCE() has the benefit of clearly flagging to the reader that goflag is subject to concurrent reads and updates. Note that READ_ONCE() is especially useful in cases where most of the accesses are protected by a lock (and thus not subject to change), but where a few of the
accesses are made outside of the lock. Using a volatile declaration in this case would make it harder for the reader to note the special accesses outside of the lock, and would also make it harder for the compiler to generate good code under the lock.
问:READ_ONCE() only affects the compiler, not the CPU. Don’t we also need memory barriers to make sure that the change in goflag’s value propagates to the CPU in a timely fashion.
答:No, memory barriers are not needed and won’t help here. Memory barriers only enforce ordering among multiple memory references: They absolutely do not guarantee to expedite the propagation of data from one part of the system to another. This leads to a quick rule of thumb: You do not need memory barriers unless you are using more than one variable to
communicate between multiple threads.
But what about nreadersrunning? Isn’t that a second variable used for communication? Indeed it is, and there really are the needed memory-barrier instructions buried in __sync_fetch_and_add(), which make sure that the thread proclaims its presence before checking to see if it should start.