12

所以我知道在 C++ 中没有什么是原子的。但我试图弄清楚我是否可以做出任何“伪原子”假设。原因是我想避免在一些只需要非常弱的保证的简单情况下使用互斥锁。

1)假设我已经全局定义了 volatile bool b,最初我设置为 true。然后我启动一个执行循环的线程

while(b) doSomething();

同时,在另一个线程中,我执行 b=true。

我可以假设第一个线程将继续执行吗?换句话说,如果 b 开始为真,并且第一个线程在第二个线程分配 b=true 的同时检查 b 的值,我可以假设第一个线程将 b 的值读取为真吗?或者是否有可能在赋值 b=true 的某个中间点,b 的值可能被读取为 false?

2) 现在假设 b 最初为假。然后第一个线程执行

bool b1=b;
bool b2=b;
if(b1 && !b2) bad();

而第二个线程执行 b=true。我可以假设 bad() 永远不会被调用吗?

3) int 或其他内置类型怎么样:假设我有 volatile int i,它最初是(比如说)7,然后我分配 i=7。我可以假设在此操作期间的任何时间,从任何线程,i 的值都将等于 7?

4)我有volatile int i=7,然后我从某个线程执行i++,所有其他线程只读取i的值。我可以假设我在任何线程中都没有任何价值,除了 7 或 8?

5) 我有 volatile int i,我从一个线程执行 i=7,从另一个线程执行 i=8。之后,我是否保证是 7 或 8(或者我选择分配的任何两个值)?

4

7 回答 7

14

标准 C++ 中没有线程,线程不能作为库实现

因此,该标准对使用线程的程序的行为没有任何规定。您必须查看线程实现提供的任何额外保证。

也就是说,在我使用的线程实现中:

(1) 是的,您可以假设不相关的值不会写入变量。否则,整个内存模型就会消失。但是请注意,当您说“另一个线程”永远不会设置b为 false 时,这意味着任何地方,永远。如果是这样,那么该写入可能会被重新排序以在您的循环期间发生。

(2) 不,编译器可以对 b1 和 b2 的赋值重新排序,因此 b1 有可能最终为真而 b2 为假。在这样一个简单的情况下,我不知道它为什么会重新排序,但在更复杂的情况下,可能有很好的理由。

[编辑:哎呀,当我回答(2)时,我忘记了 b 是不稳定的。对 volatile 变量的读取不会重新排序,抱歉,所以在典型的线程实现中是可以的(如果有任何这样的事情),您可以假设您最终不会得到 b1 true 和 b2 false。]

(3) 同 1.volatile一般与线程无关。然而,它在某些实现(Windows)中是相当令人兴奋的,并且实际上可能意味着内存屏障。

(4) 在int写入是原子的架构上是的,尽管volatile与它无关。也可以看看...

(5) 仔细检查文档。可能是的,而且 volatile 再次无关紧要,因为在几乎所有架构上,int写入都是原子的。但是,如果intwrite 不是原子的,那么就不会(前一个问题也不会),即使它是易失的,原则上您也可以获得不同的值。但是,鉴于这些值 7 和 8,我们正在谈论一个非常奇怪的架构,用于包含要分两个阶段写入的相关位的字节,但使用不同的值,您可能更合理地获得部分写入。

举一个更合理的例子,假设出于某种奇怪的原因,您在一个只有 8 位写入是原子的平台上有一个 16 位 int。奇怪,但合法,因为int必须至少 16 位,你可以看到它是如何产生的。进一步假设您的初始值为 255。那么增量可以合法地实现为:

  • 读取旧值
  • 在寄存器中增加
  • 写入结果的最高有效字节
  • 写入结果的最低有效字节。

在第三步和第四步之间中断递增线程的只读线程可以看到值 511。如果写入顺序相反,它可以看到 0。

如果一个线程正在写入 255,另一个线程同时写入 256,并且写入交错,则可能会永久留下不一致的值。在许多架构上是不可能的,但要知道这不会发生,您至少需要了解一些有关架构的信息。C++ 标准中没有禁止它,因为 C++ 标准谈到执行被信号中断,但没有执行被程序的另一部分中断的概念,也没有并发执行的概念。这就是为什么线程不仅仅是另一个库 - 添加线程从根本上改变了 C++ 执行模型。它要求实现以不同的方式做事,因为您最终会发现,例如,您是否在 gcc 下使用线程而忘记指定-pthreads.

在对齐 int写入是原子的但int允许未对齐写入而不是原子的平台上也会发生同样的情况。例如 x86 上的 IIRC,如果未对齐int的写入跨越缓存线边界,则不能保证它们是原子的。int由于这个原因和其他原因,x86 编译器不会错误对齐已声明的变量。但是,如果您玩结构包装的游戏,您可能会举出一个例子。

所以:几乎任何实现都会为您提供所需的保证,但可能会以相当复杂的方式实现。

一般来说,我发现不值得尝试依赖平台特定的内存访问保证,我不完全理解,以避免互斥体。使用互斥体,如果速度太慢,请使用由真正了解架构和编译器的人编写的高质量无锁结构(或实现一个设计)。它可能是正确的,并且在正确的情况下可能会胜过我自己发明的任何东西。

于 2010-05-01T23:52:59.760 回答
6

大多数答案都正确地解决了您将遇到的 CPU 内存排序问题,但没有一个详细说明编译器如何通过以打破您假设的方式重新排序您的代码来挫败您的意图。

考虑取自这篇文章的一个例子:

volatile int ready;       
int message[100];      

void foo(int i) 
{      
    message[i/10] = 42;      
    ready = 1;      
}

-O2及以上,最新版本的 GCC 和 Intel C/C++(不了解 VC++)将ready首先进行存储,因此它可以与计算重叠i/10volatile不救你!):

    leaq    _message(%rip), %rax
    movl    $1, _ready(%rip)      ; <-- whoa Nelly!
    movq    %rsp, %rbp
    sarl    $2, %edx
    subl    %edi, %edx
    movslq  %edx,%rdx
    movl    $42, (%rax,%rdx,4)

这不是错误,它是利用 CPU 流水线的优化器。如果另一个线程ready在访问其内容之前正在等待,message那么您将面临一场令人讨厌和晦涩的比赛。

使用编译器屏障以确保您的意图得到尊重。一个利用 x86 相对强排序的示例是发布/使用包装器,在 Dmitriy Vyukov 的 Single-Producer Single-Consumer 队列中发布

// load with 'consume' (data-dependent) memory ordering 
// NOTE: x86 specific, other platforms may need additional memory barriers
template<typename T> 
T load_consume(T const* addr) 
{  
  T v = *const_cast<T const volatile*>(addr); 
  __asm__ __volatile__ ("" ::: "memory"); // compiler barrier 
  return v; 
} 

// store with 'release' memory ordering 
// NOTE: x86 specific, other platforms may need additional memory barriers
template<typename T> 
void store_release(T* addr, T v) 
{ 
  __asm__ __volatile__ ("" ::: "memory"); // compiler barrier 
  *const_cast<T volatile*>(addr) = v; 
} 

我建议,如果您打算冒险进入并发内存访问领域,请使用一个可以为您处理这些细节的库。当我们都在等待n2145std::atomic查看 Thread Building Blockstbb::atomic或即将推出的boost::atomic.

除了正确性之外,这些库还可以简化您的代码并阐明您的意图:

// thread 1
std::atomic<int> foo;  // or tbb::atomic, boost::atomic, etc
foo.store(1, std::memory_order_release);

// thread 2
int tmp = foo.load(std::memory_order_acquire);

使用显式内存排序,foo的线程间关系清晰。

于 2010-05-13T01:45:12.447 回答
2

可能这个线程很古老,但 C++ 11 标准确实有一个线程库和一个用于原子操作的庞大原子库。目的是专门用于并发支持和避免数据竞争。相关标头是原子的

于 2013-07-10T05:56:38.233 回答
1

依赖它通常是一个非常非常糟糕的主意,因为您最终可能会发生坏事并且只有一些架构。最好的解决方案是使用有保证的原子 API,例如 Windows Interlocked api。

于 2010-05-02T12:50:38.900 回答
0

如果您的 C++ 实现提供了由n2145或其某些变体指定的原子操作库,您大概可以依赖它。否则,您通常不能在语言级别依赖关于原子性的“任何东西”,因为现有 C++ 标准未指定任何类型的多任务处理(因此处理多任务处理的原子性)。

于 2010-05-01T23:55:56.973 回答
0

C++ 中的 Volatile 与 Java 中的作用不同。正如史蒂夫所说,所有情况都是未定义的行为。有些情况对于编译器、给定的处理器架构和多线程系统来说是可以的,但是切换优化标志会使你的程序表现不同,因为 C++03 编译器不知道线程。

C++0x 定义了避免竞争条件的规则和帮助您掌握它的操作,但可能知道目前还没有一个编译器可以实现与该主题相关的标准的所有部分。

于 2010-05-02T00:09:43.077 回答
-1

我的回答会令人沮丧:不,不,不,不,不。

1-4)允许编译器对它写入的变量做任何它喜欢的事情。它可以在其中存储临时值,只要最终执行的操作与在真空中执行的线程执行相同的操作即可。任何东西都是有效的

5) 不,不保证。如果一个变量不是原子的,并且您在一个线程上写入它,并在另一个线程上读取或写入它,这是一个竞争案例。该规范将此类竞争情况声明为未定义的行为,并且绝对会发生任何事情。话虽如此,您将很难找到不给您 7 或 8 的编译器,但编译器给您其他东西是合法的。

我总是提到这种对种族案件的高度可笑的解释。

http://software.intel.com/en-us/blogs/2013/01/06/benign-data-races-what-c​​ould-possibly-go-wrong

于 2013-09-03T06:31:18.410 回答