苹果M1 ARM处理器的发布启发了我,他在推特上谈到了无锁编程的危险,这引发了激烈的讨论。考虑到在推特的约束下试图讨论像CPU内存模型这样复杂的事情的讨论进行得非常顺利,但是这仍然让我想以博客形式稍微扩展一下这个话题。
这旨在作为对无锁编程的危险的偶然介绍(我上次写它大约是在15年前),而且还解释了为什么ARM的弱内存模型会破坏某些代码,以及为什么该代码可能已经被破坏了。我也想解释为什么C ++ 11严格地改善了无锁情况(尽管有相反的反对)。
强制性免责声明:如果您的代码在线程之间共享数据时始终使用锁,那么您就不必担心这些-正确实现的锁可以避免所有这些数据争用(不同于此视频中的可怕锁)。
无锁编程的基本问题最好通过无锁生产者/消费者模式示例来解释,生产者线程如下所示(省略了数据/函数边界的C ++伪代码):
g_data1 = calc1(); g_data2 = calc2(); g_data3 = calc3(); g_flag = true; //表示数据已准备好使用
这省略了大量细节(什么时候清除g_flag?线程如何避免旋转?),但足以满足我的目的。问题是,此代码(特别是生产者线程)有什么问题?
基本的问题是代码依赖于在标志之前写入的三个数据变量,但是并没有强制执行该操作。如果编译器重新安排了写操作,则在写入所有数据之前,可以将g_flag设置为true,并且使用者线程可能会看到不正确的值。
优化编译器在重新安排操作方面会非常激进,这是使生成的代码快速运行的方式之一。他们这样做可能是为了减少寄存器的使用,提高CPU管道的使用率,或者仅仅是因为添加了一些随机启发法以使Office XP加载速度更快。编译器为什么要重新安排事情是没有必要考虑太多的,重要的是要意识到它们可以并且可以做。
编译器被“允许”重新排列写入内容,因为“按原样”规则表明,只要生成的程序在没有优化的情况下“按原样”运行,它们就可以完成工作。由于C / C ++抽象机长期以来一直假定执行线程为单线程-没有外部观察者-所有这些写入重新安排都是正确和合理的,并且已经进行了数十年。
那么问题是,必须采取什么措施才能阻止编译器破坏我们漂亮的代码?让我们假设一下,我们是一名大约2005年的程序员,正在努力实现这一目标。这里有一些坏主意:
将g_flag声明为volatile。这可以防止编译器省略对g_flag的读/写操作,但是,令很多人惊讶的是,它不能防止问题重排。不允许编译器相对于易失性读/写重新排序,但允许编译器相对于“正常”读/写重新排列它们。添加volatile并不能解决我们的重新排序问题(VC ++上的/ volatile:ms可以解决,但它是该语言的非标准扩展,可能会生成较慢的代码)。
如果将g_flag声明为volatile还不够,那么请尝试将所有四个变量声明为volatile!然后,编译器无法重新排列写入内容,我们的代码将可以在某些计算机上工作。
事实证明,编译器并不是唯一需要重新安排读写的事情。 CPU也喜欢这样做。这与乱序执行(代码始终不可见)是分开的,实际上,有序CPU会对读/写进行重新排序(Xbox 360 CPU),而乱序CPU大多不会重新排序读取/写入(x86 / x64 CPU)。
因此,如果将所有四个变量声明为volatile,那么您的代码将只能在x86 / x64上正确运行。而且,此代码可能效率很低,因为无法优化对这些变量的读/写操作,从而可能导致多余的工作(例如,当g_data1两次传递给DoSomething时)。
如果您对效率低下的不可移植代码感到满意,请随时在此处停止,但我认为我们可以做得更好。但是,让我们继续将自己限制在2005年可用的选项中。我们现在必须利用…内存障碍。
在x86 / x64上,我们需要一个编译器内存屏障来防止重新排序。这可以解决问题:
g_data1 = calc1(); g_data2 = calc2(); g_data3 = calc3(); _ReadWriteBarrier(); //仅适用于VC ++并且已弃用,但在2005年还可以g_flag = true; //表示数据已准备就绪,可以使用。
这告诉编译器不要重新布置跨越该障碍的写入,这正是我们所需要的。在写入g_flag之后可能需要另一个障碍,以确保值被写入,但是细节太不确定了,以至于我不想讨论。在使用者线程中应使用类似的屏障,但为了使事情保持简单,我现在暂时忽略该线程。
问题是该代码在内存模型较弱的CPU上仍然被破坏。 “弱”内存模型表示可以对读和写进行重新排序(以提高效率或简化实现)的CPU,其中包括ARM,PowerPC,MIPS,除x86 / x64以外,基本上每个使用中的CPU。解决该问题的方法也是内存障碍,但这一次它需要是一条CPU指令,该指令告诉CPU不要重新排序。像这样:
g_data1 = calc1(); g_data2 = calc2(); g_data3 = calc3(); MemoryBarrier(); //仅Windows,以及昂贵的完整内存屏障。 g_flag = true; //表示数据已准备就绪,可以使用。
MemoryBarrier的实际实现取决于CPU。实际上,正如评论所暗示的那样,MemoryBarrier并不是真正的理想选择,因为我们只需要写/写屏障,而不是昂贵得多的完整内存屏障(这会使读取等待写入完全完成),但这已经足够了为了我们今天的目的。
我假设MemoryBarrier内在函数也是编译器的内存障碍,因此我们只需要一个,另一个,因此我们出色/高效的生产者线程现在变为:
#ifdef X86_OR_X64 #define GenericBarrier _ReadWriteBarrier #else #define GenericBarrier MemoryBarrier #endif g_data1 = calc1(); g_data2 = calc2(); g_data3 = calc3(); GenericBarrier(); //为什么我必须自己定义这个? g_flag = true; //表示数据已准备就绪,可以使用。
如果您的2005年前后代码没有这些内存障碍,那么您的代码在所有CPU上都将被破坏,并且一直被破坏,因为始终允许编译器重新排列写入。有了这些内存屏障(根据不同的编译器和平台的需要实现),您的代码就变得美观且可移植。
事实证明,ARM的内存不足模型并没有使事情变得更加复杂。如果您正在编写无锁代码且未使用任何类型的内存屏障,则由于编译器重新排序,您的代码可能会在任何地方损坏。如果您使用内存屏障,则应该很容易地将它们扩展为包括硬件内存屏障。
上面的代码容易出错(障碍会去哪里?),冗长且效率低下。幸运的是,当C ++ 11出现时,我们有了更好的选择。在C ++ 11之前,该语言并没有真正的内存模型,只是隐含的假设是所有代码都是单线程的,如果您触摸了锁之外的共享数据,那么上帝会怜悯您的灵魂。 C ++ 11添加了一个内存模型,该内存模型承认线程的存在。这使上面的无障碍代码更明确地被破坏了,但是还提供了修复它的新选项,如下所示:
g_data1 = calc1(); g_data2 = calc2(); g_data3 = calc3(); g_flag = true; //表示数据已准备就绪,可以使用。
更改是微妙的,容易错过。我所做的只是将g_flag的类型从bool更改为std :: atomic 。这告诉编译器不要忽略该变量的读写,不要在对该变量的读写之间重新安排读写,并根据需要添加适当的CPU内存屏障。
通过使用memory_order_release,我们可以告诉编译器确切的操作,以便它可以使用适当(便宜)类型的内存屏障指令,或者对于x86 / x64,可以不使用内存屏障指令。现在,我们的代码相对干净并且完全有效。
此时,编写使用者线程很容易。实际上,使用新的g_flag声明,使用者线程的原始版本现在是正确的!但是,我们可以对其进行一些优化:
std :: memory_order_acquire标志告诉编译器我们不需要完整的内存屏障–读取-获取屏障仅可确保g_flag之前的数据值不来自共享存储,而不会阻止其他重新排序。
整理代码以使线程可以避免繁忙等待和其他问题留给读者练习。
如果您想学习这些技术,请先仔细阅读Jeff Preshing关于无锁编程的介绍,或者这就是为什么他们称其为弱指令CPU,然后考虑加入修道院或女修道院。无锁编程是C ++工具箱中最危险的锤子,也就是说很多,而且很少适用。
Jeff Preshing对无锁编程进行了更现代的介绍(并感谢本文中的预发布建议)