C++中的原子性:神话还是现实

34

我在MSDN上读了一篇关于无锁编程的文章Lockless Programming。它说:

在所有现代处理器上,您可以假定自然对齐的本地类型的读写是原子的。只要内存总线至少与被读取或写入的类型一样宽,CPU 就会在单个总线事务中读取和写入这些类型,使其他线程无法看到它们处于半完成状态。

它还给出了一些例子:

// This write is not atomic because it is not natively aligned.
DWORD* pData = (DWORD*)(pChar + 1);
*pData = 0;

// This is not atomic because it is three separate operations.
++g_globalCounter;

// This write is atomic.
g_alignedGlobal = 0;

// This read is atomic.
DWORD local = g_alignedGlobal;

我阅读了很多回答和评论,都说在C++中没有任何东西是保证原子性的,甚至标准中也没有提到,在SO上现在我有点困惑。我是否误解了这篇文章?还是文章作者谈论的是非标准且特定于MSVC++编译器的内容?

所以根据这篇文章,下面的赋值必须是原子的,对吗?

struct Data
{
    char ID;
    char pad1[3];
    short Number;
    char pad2[2];
    char Name[5];
    char pad3[3];
    int Number2;
    double Value;
} DataVal;

DataVal.ID = 0;
DataVal.Number = 1000;
DataVal.Number2 = 0xFFFFFF;
DataVal.Value = 1.2;

如果条件成立,那么将Name [5]pad3 [3]替换为std::string Name;在内存对齐方面是否有任何区别?对Number2Value变量的赋值仍然是原子操作吗?

请问有人可以解释一下吗?


2
它并非普遍适用,仅在x86中有时候适用。读取是原子性的,写入也是。但是更新值(例如递增)则不是。 - osgx
1
@sad_man,一个单独的读取和一个单独的写入应该是原子操作的,但是你不能使用已经读取的值(进行测试、增加等操作)并将其写回。你需要一种CAS(比较并交换/设置)或条件存储的方式,以便同时执行读取和写入的原子操作。 - bestsss
2
请注意,操作的原子性并不意味着其他线程将能够立即或根本看到更改。因此,您仍然需要同步。 - fredoverflow
1
哦,我明白了,这篇文章特别讲述无锁算法,这是计算机科学中最难的领域之一。除非你是天才,否则我建议你远离它。无锁算法基本上不可能进行测试和调试,它们的正确性必须经过正式证明。 - fredoverflow
3
@FredOverflow:梦想本身并没有什么不好。你必须瞄准星辰,才能达到足够的高度。 - ali_bahoo
显示剩余13条评论
8个回答

30

这个建议是针对特定架构的,适用于x86和x86_64的底层编程。您还应该检查编译器是否重排了您的代码。您可以使用“编译器内存屏障”进行检查。

x86的低级原子读写在Intel参考手册“Intel® 64和IA-32体系结构软件开发人员手册”第3A卷中有描述(http://www.intel.com/Assets/PDF/manual/253668.pdf)第8.1.1节

8.1.1保证的原子操作

Intel486处理器(及其后续处理器)保证始终会执行以下基本内存操作:

  • 读取或写入一个字节
  • 读取或写入以16位边界对齐的字
  • 读取或写入以32位边界对齐的双字

Pentium处理器(及其后续处理器)保证始终会执行以下附加内存操作:

  • 读取或写入以64位边界对齐的四字
  • 16位访问未被缓存的内存位置,适合在32位数据总线中

P6系列处理器(及其后续处理器)保证始终会执行以下附加内存操作:

  • 访问缓存在高速缓存行内,不对齐的16、32和64位访问

此文档还有关于像Core2这样的新处理器的原子描述。并非所有非对齐操作都是原子操作。

其他英特尔手册推荐阅读此白皮书:

http://software.intel.com/en-us/articles/developing-multithreaded-applications-a-platform-consistent-approach/


12

我认为你对这句话的理解有误。

原子性可以在特定指令架构上得到保证(适用于此体系结构的特定指令)。MSDN文章解释说,在x86体系结构上,可以期望对于C++内置类型的读写操作是原子的

然而,C++标准并不假设使用的是哪种架构,因此标准无法提供这样的保证。实际上,C++被用于嵌入式软件,这些软件所支持的硬件功能要更加有限。

C++0x定义了std::atomic模板类,使得无论类型如何,读写操作都可以变成原子操作。编译器会以符合标准的方式根据类型特征和目标架构选择最佳的方法来实现原子性。

新标准还定义了一整套操作,类似于MSVC InterlockExchange,它也被编译成由硬件提供的最快(同时安全)的基元操作。


3

c++标准不保证原子行为。然而,实际上简单的读取和存储操作将是原子性的,正如文章所述。

如果您需要原子性,请明确表达并使用某种锁。

*counter = 0; // this is atomic on most platforms
*counter++;   // this is NOT atomic on most platforms

"整数的简单操作将是原子性的"。您必须提到只有读取和常量写入是简单的,但更新不是。 - osgx
@osgx:您所说的更新是指什么?这是一次更新吗?DataVal.Number2 = someother_int;。这不是原子操作吗? - ali_bahoo
@osgx:我认为首先读取了“someother_int”的值,然后将其写入“Number2”。 - ali_bahoo
1
number2 = some_int 这个操作包含多个步骤。读取 some_int 是原子的,写入 number2 也是原子的;但整个操作不是原子的。 - osgx
@osgx:我明白了。那么可以使用InterlockedExchange使整个操作原子化。 - ali_bahoo

2
非常注意,当依赖于简单字大小操作的原子性时,要小心,因为事情可能会与您的期望不同。在多核架构上,您可能会看到乱序读写。这将需要内存屏障来防止(更多细节请参见此处)。
对于应用程序开发人员来说,最重要的是使用操作系统保证原子性的基元或使用适当的锁。

1

在我看来,这篇文章对底层架构做了一些假设。由于C++对架构只有一些最基本的要求,例如原子性方面的保证在标准中无法给出。例如,一个字节至少要有8位,但你可以有一个字节为9位的架构,但整数为16位...从理论上讲。

因此,当编译器专门针对x86架构时,可以使用特定的功能。

NB:结构体通常默认按本机字边界对齐。您可以通过#pragma语句禁用它,因此不需要填充填充。


如果您不介意,我有两个问题。1. 类(classes) 是否默认由 MSVC++ 对齐?2. 您提到要对齐本机字边界。在 x64 环境中是否也是这种情况? - ali_bahoo
广告1:类也是对齐的(基本上任何复合数据类型)。 广告2:请查看 http://msdn.microsoft.com/en-us/library/2e70t5y1(v=vs.80).aspx。在 x64 上,对齐将在 8 字节边界上进行(如果没有通过 #pragam pack 进行更改),或者以数据类型大小的倍数来对齐。 - king_nak

1

我认为他们试图传达的是,硬件本身实现的数据类型会在硬件内部更新,因此从另一个线程读取将永远不会给您一个“部分”更新的值。

考虑在32位+机器上的32位整数。它在1个指令周期内被完全写入或读取,而较大尺寸的数据类型,例如32位机器上的64位整数,将需要更多周期,因此理论上写入它们的线程可能在这些周期之间被中断,因此该值不处于有效状态。

不使用字符串也不能使其原子化,因为字符串是一种更高级别的构造,而不是在硬件中实现的。 编辑:根据您对更改为字符串的评论(未),它不应对之后声明的字段产生任何影响,正如另一个答案中提到的,编译器将默认对齐字段。

之所以没有标准,是因为正如文章中所述,这是关于现代处理器如何实现指令的问题。您的标准C/C++代码应在16位或64位机器上完全相同(只是性能有差异),但是如果您假设您只会在64位机器上执行,则任何小于64位的内容都是原子的。(除SSE等类型外)


1

我认为文章中提到的原子性在实际应用中有很少的用途。这意味着您将读取/写入有效值,但可能已过时。因此,如果读取一个int,您将完全读取它,而不是从另一个线程当前正在写入的旧值中读取2个字节和其他2个字节从新值。

对于共享内存来说,重要的是内存屏障。它们由同步原语(如C++0x atomic类型、mutexes等)保证。


这对于 OP 的意图可能没有多少用处,但这并不意味着没有任何良好的有效用例依赖于该行为。以这种方式更新值对于从逻辑上不可变类型计算的值来说是完全可以接受的,因此既不需要原子操作也不需要互斥锁,仅需要使用 volatile 关键字即可。例如,延迟计算哈希码或 c 字符串长度就是这样的情况。 - Aiueiia

0
我认为将 char Name[5] 更改为 std::string Name 不会有任何区别,如果您仅用于单个字符赋值,因为索引运算符将返回对底层字符的直接引用。完整的字符串赋值不是原子性的(而且你不能用char数组来实现它,所以我猜你也没有打算这样使用)。

我编辑了这个问题。我认为现在更清楚了。我不想进行原子字符串赋值。我想知道它是否会改变内存对齐方式。 - ali_bahoo

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接