【问题标题】:Is dereferencing a READ ONLY non-atomic pointer to an atomic object in different threads safe?取消引用指向不同线程中原子对象的只读非原子指针是否安全?
【发布时间】:2014-02-25 01:13:05
【问题描述】:

如果我这样写:

std::atomic<bool> *p = new std::atomic<bool>(false); // At the beginning of the program  

//...  

void thread1()  
{  
    while (!(*p))  
        // Do something  
}  

//...  

void thread2()  
{  
    //...  
    *p = true;  
    //...
}  

thread1thread2 将同时运行。 p 的值自初始化以来从未更改。在这种情况下,取消引用操作是否安全?出于性能原因,我想避免使用原子指针。

【问题讨论】:

  • 如果 p 的值永远不会改变,为什么不使用普通的 bool 变量呢?该操作就这样保存了,所以是的,至少当 p 是 int-aligned 时。
  • @PMF p 只是一个指针,p 的值不会改变,但 *p 会改变。
  • @ZizengTai:是的,但你为什么不使用布尔值?使您的代码更加简单,因为不需要取消引用。
  • @PMF 对不起,我发布的代码只是一个简化。实际上我需要将很多原子布尔值放入 std::list,但由于 std::list 需要对象的复制构造(从任何 std::atomic 中删除),它似乎只能通过指针来实现。跨度>
  • @ZizhengTai:是的,将原子放入容器中很痛苦。我建议从 std::atomic 继承并使复制构造函数执行您期望它执行的操作,并改用该类。比这好多了。

标签: c++ multithreading pointers atomic


【解决方案1】:

是的,它是安全的。如果没有至少一个线程修改共享变量,您将无法进行数据竞争。由于两个线程都没有修改p,所以没有种族。

【讨论】:

  • @kuroineko 你错了。 *p ("the bool") 是原子的,因此内存模型保证在thread2 中写入*p 之前排序的任何写入都发生在另一个线程中的任何读取之前,该线程在读取*p 之后排序与该写入同步。如果“从”观察到true*p 的写入,则保证在相应的写入之前看到“主”对“另一个变量”所做的任何其他更新。
  • 对不起,我将 OP 的问题误解为“您可以在没有原子访问的情况下轮询此变量吗”,正如您在我的回答中看到的那样。当然,只要 bool 是原子的,您就可以对其进行轮询,尽管我仍然认为向不打算专门从事多任务处理的人展示多任务同步是一种糟糕的方式。编辑你的问题,我会立即取消我的反对票。
  • @kuroineko 我完全同意您的观点,即原子是一种极低级的工具,并且使用某种基于任务的并行性可能会更好地为 OP 提供服务。
【解决方案2】:

您发布的代码和问题是两个不同的东西。

代码将起作用,因为您不要取消对非原子指针的引用。您取消引用std::atomic&lt;bool&gt;*,这将导致(操作员重载)顺序一致的获取/存储。这可能比必要的效率低(大多数时候这样的标志用于释放操作),但它是安全的。

否则,只要没有其他线程修改数据,解除对 whatever(包括原子变量)的有效非原子指针的引用是安全的

在另一个线程写入非原子指针的情况下解除对它的引用仍然是“安全的”,因为它不会崩溃。然而,没有正式的保证内存不会出现乱码(但是,对于对齐的 POD,由于处理器访问内存的方式,有一个非常实际的保证),但更重要的是,它是不安全,因为没有内存订购保证。使用这样的标志时,通常会执行以下操作:

do_work(&buf); // writes data to buf
done = true;   // synchronize

这在一个线程中按预期工作,但不能保证在并发的情况下正常工作。为此,您需要先发制人的保证。否则,有可能其他线程在写入数据尚未实现之前就获取了对标志的更新。

【讨论】:

  • 很抱歉,我不太明白...您说“代码可以工作,因为您取消引用非原子指针”,但我认为std::atomic&lt;bool&gt; * 只是一个指针(就像任何指针一样)。你的意思是它重载了* 运算符吗?在一个线程中读取共享内存块并同时在另一个线程中写入它不会导致程序崩溃,但可能会产生脏数据,对吧?
  • std::atomic 是一个类模板,具有多种类型的特化(其中bool)。它具有operator=operator T 的重载,这意味着如果您分配给它(直接或通过您取消引用的指针),您正在调用这些重载函数之一。反过来,这些调用函数,例如atomic_loadatomic_load_explicit(memory_order_seq_cst) 的简写。所以是的,这是安全的。
  • 解除对同时修改的指针的引用不会崩溃(当然,假设指针是有效的)。但它可能(至少在理论上)返回一个乱码的结果。实际上,所有现代 CPU 都在高速缓存行上运行,不可能写入小于完整高速缓存行的内容。此外,实际上,在所有主流 CPU 上,bool 要么为 1,要么为 0,因此根本不可能有其他一些“垃圾值”。但是,请注意,如果没有适当的原子访问,您就没有发生之前的保证。这意味着事情可能不会按照发生的顺序看到
【解决方案3】:

取消引用(即:读取地址)在英特尔架构上是原子的。此外,由于恒定,我想它不仅在 Intel/AMD 上是正确的。但是请查看this post 了解更多信息。

澄清:在其他架构上,有可能在写入地址时切换线程,当仅修改部分地址时,其他线程读取的地址将无效.

对于 Intel,这不会发生如果地址在内存中对齐

此外,由于*pstd::atomic&lt;bool&gt;,它已经实现了所有需要的东西(本机、asm、内存栅栏)。

【讨论】:

  • 在此示例中,对p 的单次写入发生在线程创建之前,因此与它们的构造函数同步。线程运行时没有并发写入 p,因此无论 CPU 架构如何,对 p 的访问都不需要是原子的。
【解决方案4】:

这取决于您的两次访问周围的情况。如果 master 在设置 boolean 之前写入了一些数据,slave 需要一个内存屏障来确保它不会在 boolean 之前读取所述数据。

也许现在你的线程只是在等待这个布尔值退出,但是如果有一天你决定 master 应该,例如,将终止状态传递给 slave,你的代码可能会中断。
如果你 6 个月后回来修改这段代码,你确定你会记得你的从循环之外的区域是一个非共享读取区域,而你的主布尔更新之前的区域是一个非共享写入区域?

无论如何,您的布尔值必须是可变的,否则编译器可能会将其优化掉。或者更糟的是,您同事的编译器可能会在您编写另一段不可靠的代码时。

众所周知,易失性变量通常不足以用于线程同步,因为它们没有实现内存屏障,就像这个简单的例子一样:

主人:

// previous value of x = 123
x = 42;
*p = true;

从处理器上的总线逻辑:

write *p = true

奴隶:

while (!*p) { /* whatever */ }
the_answer = x; // <-- boom ! the_answer = 123

从属处理器上的总线逻辑:

write x = 42 // too late...

(如果 master 的总线写入被乱序调度,则会出现对称问题)

当然,您可能永远不会在您的特定台式计算机上看到如此罕见的情况,就像您可以偶然运行一个程序破坏自己的内存而不会崩溃一样。

尽管如此,使用这种泄漏同步编写的软件是定时炸弹。在一系列总线架构上编译并运行它们足够长的时间,有一天......Ka-boom!


事实上,C++11 正在伤害多处理器编程很多,它允许创建任务,就像它没有任何东西一样,并且在相同的情况下时间只提供蹩脚的原子、互斥锁和条件变量来处理同步(当然还有该死的尴尬未来)。

同步任务(尤其是工作线程)最简单、最有效的方法是让它们处理队列中的消息。这就是驱动程序和实时软件的工作方式,任何多处理器应用程序都应如此,除非出现一些非凡的性能要求。

强迫程序员用美化的标志来控制多任务处理是愚蠢的。您需要非常清楚地了解硬件如何使用原子计数器。
C++ 的学究派再次迫使每个人和他的狗成为另一个领域的专家,只是为了避免编写蹩脚、不可靠的代码。

和往常一样,你会看到大师们带着放纵的微笑大肆宣扬他们的“良好实践”,而人们则在破碎的自制队列中在愚蠢的旋转循环中消耗兆焦耳的 CPU 功率,相信“无需等待”同步是最重要的。效率的阿尔法和欧米茄。

这种对性能的痴迷不是问题。 “阻塞”调用只消耗可用计算能力的碎片,还有许多其他因素会损害性能,比操作系统同步原语高几个数量级(缺乏在给定的处理器,开始)。

考虑你的 thread1 从站。访问一个原子布尔值会将一把沙子扔到总线缓存齿轮中,使这种特定的访问速度减慢大约 20 倍。这浪费了几十个周期。除非你的奴隶只是在循环中玩弄它的虚拟拇指,否则这少数几个循环将与一个循环将持续的数千或数百万个相比相形见绌。 另外,如果你的奴隶完成了工作而它的兄弟奴隶没有工作,会发生什么?它会在这个标志上无用地旋转并浪费 CPU,还是阻塞在任何互斥体上?
消息队列正是为了解决这些问题而发明的。

像消息队列读取这样的正确操作系统调用可能会消耗几百个周期。那么呢?
如果您的从属线程只是在那里增加 3 个计数器,那么就是您的设计有问题。您不会启动线程来移动几根火柴,就像您不会为每个字节分配内存字节一样,即使是 C++ 这样的高级语言。

如果您不使用线程来咀嚼面包屑,您应该依赖简单且经过验证的机制,例如 等待 队列或信号量或事件(选择 posix 或 Microsot 的)由于缺乏便携式解决方案),您不会注意到对性能有任何影响。

编辑:更多关于系统调用开销

基本上,调用等待队列将花费几微秒。

假设您的平均工作人员处理数字 10 到 100 毫秒,系统调用开销将无法从背景噪音中分辨出来,并且线程终止响应将保持在可接受的范围内(

我最近实现了一个Mandelbrot set explorer 作为并行处理的测试用例。它绝不代表所有并行处理案例,但我仍然注意到一些有趣的事情。

在我的 I3 Intel 2 cores / 4 CPUs @3.1 GHz 上,每个 CPU 使用一个 worker,我测量了纯计算并行化(即没有工作人员之间的数据依赖关系)。

  • 将线程分别本地化到一个内核上(而不是让操作系统调度程序将线程从一个内核移动到另一个内核)将比率从 3.2 提高到 3.5(超出理论最大值 4)

  • 除了将线程锁定到不同的内核之外,最显着的改进是算法本身的优化(更高效的计算和更好的负载平衡)。

  • 大约 1000 个 C++11 互斥锁用于让 4 个工作人员从一个公共队列中提取数据的成本为 7 毫秒,即每次调用 7 微秒。

我几乎无法想象每秒执行超过 1000 次同步的高性能设计(否则您的时间可能会更好地用于改进设计),因此基本上您的“阻塞”调用将花费远低于 1% 的功率在相当便宜的 PC 上可用。
选择权在你,但我不确定从一开始就实现原始原子对象是否会成为性能的决定性因素。

我建议从简单的队列开始并进行一些基准测试。您可以使用 pthread posix 接口,或者将 this pretty good sample 作为转换为 C++11 的基础。

然后,您可以在无同步错误的环境中调试程序并评估算法的性能。

如果队列被证明是真正的 CPU 占用者并且您的算法无法重构以避免过多的同步调用,那么切换到任何自旋锁应该相对容易假设效率更高,特别是如果您的计算已经被简化并且数据依赖关系已经预先整理好。

P.S:如果这不是商业机密,我会很高兴听到更多关于您的算法的信息。

【讨论】:

  • 谢谢!实际上我陷入了困境......整个故事是:标志(是的,其中不止一个)用于告诉一些工作线程退出。说实话,我的部分代码对内存有点讨厌,因为这实际上是一个用于进行科学计算的对性能非常敏感的程序。它的本质要求工作线程尽可能少地使用互斥锁和原子事物,但是用户体验需要一种方法来从主线程内响应地终止工作线程。我想知道是否有任何方法可以有效地实现这一目标。
  • 所以我认为等待队列和你提到的其他机制对于这个特定目的来说可能有点太贵了。
  • 如果不查看您的算法或至少对它们进行的计算类型有所了解,我几乎无法判断这一点。无论如何,您可能希望查看我对工作测试用例的粗略性能估计的编辑。
  • XD 是的,这不是商业机密。简单地说,该算法使用深度优先搜索来生成有序数字组合,然后应用一些定理从数字中神奇地生成(数学)图。在叶级生成图后,算法会测试图的一些属性。我正在做的是告诉算法检查 DFS 树的几个节点处的原子标志,一旦发现标志被设置,它就会结束搜索。
  • 虽然这里很难把全貌描述清楚,但我想你可以意识到,检查标志的位置越深,当用户按下“暂停”时程序的响应就越慢按钮。老实说,整个算法是一个紧密结合的错综复杂的混乱,而且 DFS 的性质决定了尝试在整个结构中插入队列、事件循环等是非常棘手的,因为树不去在所有计算完成之前退出事件循环和他的伙伴所在的“外太空”。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2019-08-28
  • 1970-01-01
  • 1970-01-01
  • 2020-01-18
相关资源
最近更新 更多