Rmb*_*bRT 5 c++ multithreading propagation memory-visibility stdatomic
语境
我正在用 C++编写一个线程安全的原型线程/协程库,并且我正在使用原子来使任务切换无锁。我希望它尽可能高效。我对原子和无锁编程有一个大致的了解,但我没有足够的专业知识来优化我的代码。我做了很多研究,但很难找到我的具体问题的答案:不同内存顺序下不同原子操作的传播延迟/可见性是多少?
当前假设
我读到对内存的更改是从其他线程传播的,它们可能会变得可见:
我不确定这种延迟的可见性和不一致的传播是仅适用于非原子读取,还是也适用于原子读取,这可能取决于使用的内存顺序。当我在 x86 机器上开发时,我无法在弱有序系统上测试行为。
无论操作类型和使用的内存顺序如何,所有原子读取都总是读取最新值吗?
我很确定所有读-修改-写(RMW) 操作总是读取任何线程写入的最新值,而不管使用的内存顺序如何。对于顺序一致的操作似乎也是如此,但前提是对变量的所有其他修改也是顺序一致的。据说两者都很慢,这对我的任务不利。如果不是所有原子读取都获得最新值,那么我将不得不使用 RMW 操作来读取原子变量的最新值,或者在 while 循环中使用原子读取,以我目前的理解。
写入的传播(忽略副作用)是否取决于内存顺序和使用的原子操作?
(仅当上一个问题的答案是并非所有原子读取总是读取最新值时,此问题才有意义。请仔细阅读,我在这里不询问副作用的可见性和传播。我只关心原子变量本身的值。)这意味着根据用于修改原子变量的操作,可以保证任何后续原子读取接收变量的最新值。因此,我必须在保证始终读取最新值的操作之间进行选择,或者使用宽松的原子读取,以及这种特殊的写入操作,以保证对其他原子操作的修改的即时可见性。
首先,让我们摆脱房间里的大象:atomic在代码中使用并不能保证实现无锁。atomic只是无锁实现的推动者。 is_lock_free()会告诉你它对于 C++ 实现和你正在使用的底层类型是否真的是无锁的。
术语“最新”在多线程世界中非常含糊。因为操作系统可能使一个线程处于休眠状态的“最新”线程可能不再是另一个处于活动状态的线程的最新线程。
std::atomic唯一的保证是防止竞争条件,通过确保在一个线程中的一个原子上执行的R、M 和 RMW以原子方式执行,没有任何中断,并且所有其他线程看到之前的值或之后的值,但永远不会看到什么之间。因此,atomic通过在同一原子对象上的并发操作之间创建顺序来同步线程。
您需要将每个线程视为具有自己时间的平行宇宙,而您不知道平行宇宙中的时间。就像在量子物理学中一样,你在一个线程中唯一能知道关于另一个线程的是你可以观察到的东西(即宇宙之间“之前发生过”的关系)。
这意味着您不应该设想多线程时间,就好像所有线程中都有绝对的“最新”时间一样。您需要考虑相对于其他线程的时间。这就是为什么原子不会创建绝对最新的,而只会确保原子将具有的连续状态的顺序排序。
传播不依赖于内存顺序,也不依赖于执行的原子操作。 memory_order是关于围绕原子操作的非原子变量的顺序约束,就像栅栏一样。对其工作原理的最佳解释当然是Herb Sutters 演示,如果您正在研究多线程优化,那绝对值得花一个半小时。
尽管特定的 C++ 实现可能会以影响传播的方式实现某些原子操作,但您不能依赖于您所做的任何此类观察,因为无法保证传播在下一个版本中以相同的方式工作编译器或另一个 CPU 架构上的另一个编译器。
在设计无锁算法时,很容易通过读取原子变量来获取最新状态。但是,尽管这种只读访问是原子的,但紧随其后的操作却不是。所以下面的指令可能假设一个已经过时的状态(例如,因为线程在原子读取后立即发送睡眠)。
取if(my_atomic_variable<10)并假设你读9.假设你在尽可能最好的世界是和9将是所有的并发线程的绝对最新值集。比较它的值<10不是原子的,所以当比较成功和if分支时,my_atomic_variable可能已经有一个新的值10。无论传播速度有多快,即使保证读取,也可能出现这种问题始终获取最新值。我什至还没有提到ABA问题。
读取的唯一好处是避免数据竞争和 UB。但是,如果您想跨线程同步决策/操作,则需要使用 RMW,例如比较和交换(例如atomic_compare_exchange_strong),以便原子操作的排序产生可预测的结果。
经过一番讨论,以下是我的发现:首先,让我们定义原子变量的最新值的含义:在挂钟时间内,对原子变量的最新写入,因此,来自外部观察者”的观点。如果有多个同时的最后写入(即,在同一周期内在多个核心上),那么选择其中之一并不重要。
\n\n任何内存顺序的原子加载都不能保证读取最新值。这意味着写入必须先传播,然后才能访问它们。这种传播可能相对于它们的执行顺序来说是无序的,并且对于不同的观察者来说顺序也可能不同。
\n\nstd::atomic_int counter = 0;\n\nvoid thread()\n{\n // Imagine no race between read and write.\n int value = counter.load(std::memory_order_relaxed);\n counter.store(value+1, std::memory_order_relaxed);\n}\n\nfor(int i = 0; i < 1000; i++)\n std::async(thread);\nRun Code Online (Sandbox Code Playgroud)\n\n在这个例子中,根据我对规范的理解,即使没有读写执行干扰,仍然可能有多次执行读取thread相同的值,所以最终counter不会是1000。这是因为当使用正常读取时,虽然保证线程以正确的顺序读取对同一变量的修改(它们不会读取新值,并且在下一个值时读取旧值),但不能保证它们读取全局最新写入的值到一个变量。
这产生了相对论效应(如爱因斯坦物理学中的那样),即每个线程都有自己的“真理”,这正是我们需要使用顺序一致性(或获取/释放)来恢复因果关系的原因:如果我们简单地使用宽松负载,那么我们甚至可能会破坏因果关系和明显的时间循环,这是由于指令重新排序与无序传播相结合而发生的。内存排序将确保单独线程感知的那些单独现实至少在因果上是一致的。
原子读-修改-写 (RMW) 操作(例如交换、compare_exchange、fetch_add、\xe2\x80\xa6)保证对上面定义的最新值进行操作。这意味着写入的传播是强制的,并导致内存上出现一种通用视图(如果您进行的所有读取都来自使用 RMW 操作的原子变量),与线程无关。因此,如果您使用atomic.compare_exchange_strong(value,value, std::memory_order_relaxed)or atomic.fetch_or(0, std::memory_order_relaxed),那么您一定会感知到包含所有原子变量的全局修改顺序。请注意,这并不能保证非 RMW 读取的任何顺序或因果关系。
std::atomic_int counter = 0;\n\nvoid thread()\n{\n // Imagine no race between read and write.\n int value = counter.fetch_or(0, std::memory_order_relaxed);\n counter.store(value+1, std::memory_order_relaxed);\n}\n\nfor(int i = 0; i < 1000; i++)\n std::async(thread);\nRun Code Online (Sandbox Code Playgroud)\n\n在这个例子中(同样,假设所有thread()执行都不会相互干扰),在我看来,规范禁止value包含除全局最新写入值之外的任何内容。所以,counter最终总是 1000。
那么,什么时候使用哪种读法呢?
\n\n如果您只需要每个线程内的因果关系(对于按顺序发生的事情可能仍然有不同的看法,但至少每个读者对世界都有因果一致的看法),那么原子加载和获取/释放或顺序一致性就足够了。
\n\n但如果您还需要新鲜读取(以便您绝不能读取除全局(跨所有线程)最新值之外的值),那么您应该使用 RMW 操作进行读取。这些本身并不能为非原子和非 RMW 读取创建因果关系,但所有线程上的所有 RMW 读取都共享完全相同的世界视图,并且始终是最新的。
\n\n所以,总结一下:如果允许不同的世界观,请使用原子加载,但如果您需要客观的现实,请使用 RMW 来加载。
\n| 归档时间: |
|
| 查看次数: |
780 次 |
| 最近记录: |