Windows中的原子性、易变性和线程安全性

6

据我理解,原子性用于确保值被整体读取/写入,而不是部分读取/写入。例如,在线程之间共享的64位值实际上是两个32位DWORD(假设在x86平台上),必须是原子的,这样两个DWORD将同时被读取/写入。这样就可以防止一个线程只读取到变量的一半,而未更新另一半。如何保证原子性?

此外,据我所知,易变性根本不能保证线程安全。这是真的吗?

我看到很多地方都暗示仅仅具有原子性/易变性就是线程安全的,但我不明白其中的道理。难道我不需要内存屏障来确保任何值,无论是否具有原子性,都在其被读取/写入到另一个线程之前进行读取/写入吗?

例如,让我们假设我创建了一个挂起的线程,对可用于该线程的结构进行了一些计算以更改某些值,然后恢复它,例如:

HANDLE hThread = CreateThread(NULL, 0, thread_entry, (void *)&data, CREATE_SUSPENDED, NULL);
data->val64 = SomeCalculation();
ResumeThread(hThread);

我想这取决于ResumeThread中是否有任何内存屏障? 我应该对val64进行交错交换吗? 如果线程正在运行,那会改变什么?

我知道我在这里问了很多问题,但基本上我想弄清楚的是标题中所问的:Windows中原子性,易失性和线程安全的良好解释。谢谢


“volatile” 的意思是只要不要优化掉对变量的重复访问。它并不对原子性、操作重排序或缓存一致性施加任何限制。 - Mark Ransom
2个回答

6
它用于确保一个值将被完整读取/写入。这只是原子性的一小部分。在其核心,它意味着“不可中断”,处理器上的指令其副作用无法与另一个指令交错执行。通过设计,当内存更新可以在单个内存总线周期内执行时,内存更新是原子的。这需要内存位置的地址对齐,以便单个周期可以更新它。非对齐访问需要额外的工作,一个周期写入的部分字节和另一个周期写入的部分字节。现在它不再是不可中断的。获得对齐的更新非常容易,编译器提供了保证。或者更广泛地说,由编译器实现的内存模型提供的保证。它简单地选择对齐的内存地址,有时故意留下几个字节的未使用空间来获取下一个变量对齐。大于处理器本机字长的变量的更新永远不可能是原子的。
但更重要的是,使线程工作需要的处理器指令类型。每个处理器都实现了CAS指令的变体,比较和交换。这是你需要实现同步的核心原子指令。高级同步原语,如监视器(也称条件变量)、互斥锁、信号、关键部分和信号量都是在该核心指令之上构建的。
这是最小的要求,处理器通常提供额外的指令来使简单操作成为原子操作。例如增加一个变量,在其核心中是可中断操作,因为它需要读取-修改-写入操作。需要它是原子的需求非常普遍,例如大多数C++程序都依赖它来实现引用计数。

易变性根本不保证线程安全

它确实没有。这是一种属性,起源于更容易的时代,当时机器只有一个处理器核心。它只影响代码生成,特别是代码优化器尝试消除内存访问并使用处理器寄存器中的值副本的方式。从代码执行速度上来看,从寄存器中读取值比从内存中读取值快3倍。
使用volatile可以确保代码优化器不会认为寄存器中的值是准确的,并强制它再次读取内存。这只对那些本身不稳定的存储器值有意义,通过内存映射I/O公开其寄存器的设备。自那时以来,它一直被滥用,试图在具有弱内存模型的处理器上放置语义,Itanium是最严重的例子。今天,使用volatile得到的结果严重依赖于您使用的特定编译器和运行时。永远不要在线程安全方面使用它,而应始终使用同步原语。

仅仅是原子/易失性就是线程安全的。

如果这是真的,编程将更加简单。原子操作仅涵盖非常简单的操作,真正的程序经常需要使整个对象线程安全。所有成员都原子更新,并且从未公开部分更新的对象视图。即使是像迭代列表这样简单的操作也是一个核心示例,您不能在查看其元素时另一个线程修改该列表。这时,您需要使用更高级别的同步原语,这种原语可以阻止代码,直到安全继续为止。

真实的程序经常需要同步,并表现出Amdahl定律的行为。换句话说,添加额外的线程实际上并不能使程序更快,有时甚至会使其变慢。谁能找到更好的解决方案,就能获得诺贝尔奖,我们还在等待。


2
一般来说,C和C++对于多线程程序中读写“volatile”对象的行为没有任何保证。(新的C++11可能会有保证,因为它现在将线程作为标准的一部分,但传统上线程不是标准的一部分。)在旨在实现可移植性的代码中使用volatile并对原子性和缓存一致性做出假设是一个问题。特定编译器和平台是否以线程安全的方式处理对“volatile”对象的访问是一个未知数。
通常规则是:“volatile”不足以确保线程安全访问。您应该使用某些平台提供的机制(通常是一些函数或同步对象)来安全地访问线程共享值。
现在,具体到Windows,具体到VC++ 2005+编译器,具体到x86和x64系统,如果要访问基本对象(如int),可以使其线程安全,前提条件是:
1. 在64位和32位Windows上,对象必须是32位类型,并且必须是32位对齐的。 2. 在64位Windows上,对象也可以是64位类型,并且必须是64位对齐的。 3. 必须声明为volatile。
如果满足这些条件,则对对象的访问将是volatile、原子的,并被包围在确保缓存一致性的指令中。必须满足大小和对齐条件,以便编译器生成访问对象时执行原子操作的代码。声明对象为volatile确保编译器不会进行与缓存先前读取到寄存器的值有关的代码优化,并确保生成的代码在访问时包括适当的内存屏障指令。
即使如此,对于访问小型内容,最好仍然使用类似Interlocked*函数的东西,对于较大的对象和数据结构,请使用标准的同步对象(如互斥锁或临界区)。理想情况下,获取已包含适当锁定的数据结构的库并使用它们。尽可能让您的库和操作系统来完成大部分工作!
在您的示例中,我认为您需要使用线程安全访问来更新val64,无论线程是否已启动。
如果线程已经运行,则绝对需要某种线程安全写入val64,无论是使用InterchangeExchange64或类似方法,还是通过获取和释放某种同步对象来执行适当的内存屏障指令。同样,线程也需要使用线程安全访问器来读取它。
在线程尚未恢复的情况下,情况就不太清楚了。ResumeThread可能使用或表现得像同步函数,并执行内存屏障操作,但文档没有指定它是否这样做,因此最好假设它不这样做。
参考资料:

关于32位和64位对齐类型的原子性... https://msdn.microsoft.com/en-us/library/windows/desktop/ms684122%28v=vs.85%29.aspx

关于'volatile'包括内存屏障... https://msdn.microsoft.com/en-us/library/windows/desktop/ms686355%28v=vs.85%29.aspx


这是一个很好的答案,但我标记了Hans的答案为正确答案,因为它本质上是关于原子性、可见性和线程安全的规范化答案。我非常感谢你提供的Windows细节。如果有一种方法可以将赏金分给你们两个,我会这样做的。 - loop

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