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

Atomicity, Volatility and Thread Safety in Windows

我对原子性的理解是,它用于确保一个值整体 read/written 而不是部分。例如,实际上是两个 32 位 DWORD 的 64 位值(此处假设为 x86)在线程之间共享时必须是原子的,以便两个 DWORD 同时为 read/written。这样一来,一个线程就无法读取未更新的一半变量。如何保证原子性?

此外,我的理解是波动性根本不能保证线程安全。是真的吗?

我看到它在很多地方都暗示着 atomic/volatile 就是 thread-safe。我不明白那是怎么回事。我是否也不需要内存屏障来确保任何值,无论是原子值还是其他值,都是 read/written,然后才能真正保证它们在另一个线程中是 read/written?

例如,假设我创建了一个暂停的线程,进行一些计算以将一些值更改为线程可用的结构,然后恢复,例如:

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

我想这取决于 ResumeThread 中的任何内存屏障?我应该为 val64 进行联锁交换吗?如果线程是 运行 会怎样?

我敢肯定我在这里问了很多,但基本上我想弄清楚的是我在标题中提出的问题:对 Windows 中的原子性、波动性和线程安全性的一个很好的解释.谢谢

一般来说,C 和 C++ 不保证在多线程程序中读取或写入 'volatile' 对象的行为。 ('new' C++11 可能会这样做,因为它现在将线程作为标准的一部分,但传统上线程还不是标准 C 或 C++ 的一部分。)使用 volatile 并对原子性和缓存一致性做出假设可移植的代码是一个问题。关于特定编译器和平台是否会以线程安全的方式处理对 'volatile' 对象的访问,这是一个废话。

一般规则是:'volatile' 不足以确保线程安全访问。您应该使用一些平台提供的机制(通常是一些函数或同步对象)来安全地访问线程共享值。

现在,特别是在 Windows,特别是 VC++ 2005+ 编译器,特别是在 x86 和 x64 系统上,访问原始对象(如 int)可以成为线程-安全如果:

  1. 在 64 位和 32 位 Windows 上,对象必须是 32 位类型,并且必须是 32 位对齐的。
  2. 在 64 位 Windows 上,对象也可能是 64 位类型,并且必须是 64 位对齐的。
  3. 必须声明为可变的。

如果这些都是真的,那么对对象的访问将是易变的、原子的,并且被确保缓存一致性的指令所包围。必须满足大小和对齐条件,以便编译器生成在访问对象时执行原子操作的代码。将对象声明为 volatile 可确保编译器不会进行与缓存它可能已读入寄存器的先前值相关的代码优化,并确保生成的代码在访问时包含适当的内存屏障指令。

即便如此,您最好还是使用诸如 Interlocked* 函数之类的东西来访问小东西,并使用标准同步对象(例如 Mutexes 或 CriticalSections)来访问更大的对象和数据结构。理想情况下,获取库并使用已经包含适当锁的数据结构。让您的图书馆和 OS 尽可能多地完成艰苦的工作!

在您的示例中,我希望您确实需要使用线程安全访问来更新 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

it's used to make sure a value will be read/written in whole

这只是原子性的一小部分。它的核心意思是 "uninterruptible",处理器上的一条指令,其副作用不能与另一条指令交错。按照设计,当内存更新可以用单个内存总线周期执行时,它是原子的。这需要内存位置的地址对齐,以便单个周期可以更新它。未对齐的访问需要额外的工作,一部分字节由一个周期写入,一部分由另一个周期写入。现在它不再是不间断的了。

获取对齐更新非常容易,这是编译器提供的保证。或者,更广泛地说,通过编译器实现的内存模型。它只是选择对齐的内存地址,有时故意留下几个字节的未使用间隙以使下一个变量对齐。对大于处理器本机字长的变量的更新永远不可能是原子的。

但更重要的是使线程工作所需的处理器指令类型。每个处理器都实现了 CAS instruction、比较和交换的变体。它是实现同步所需的核心原子指令。更高级别的同步原语,如监视器(又名条件变量)、互斥体、信号、关键部分和信号量都构建在该核心指令之上。

这是最低要求,处理器通常会提供额外的处理器来使简单的操作原子化。就像增加一个变量一样,它的核心是一个可中断的操作,因为它需要一个读-修改-写操作。需要它是原子的是很常见的,大多数 C++ 程序都依赖它来实现引用计数。

volatility does not guarantee thread safety at all

没有。这是一个可以追溯到更轻松的时代的属性,那时候机器只有一个处理器核心。它只影响代码生成,特别是代码优化器试图消除内存访问并使用处理器寄存器中值的副本的方式。对代码执行速度产生很大很大的影响,从寄存器中读取值比从内存中读取值快 3 倍。

应用volatile确保代码优化器不会认为寄存器中的值是准确的并强制它再次读取内存。它真正只对自身不稳定的内存值类型有影响,设备通过内存映射 I/O 公开其寄存器。它一直被严重滥用,因为它的核心含义是试图将语义置于内存模型较弱的处理器之上,Itanium 是最令人震惊的例子。您今天使用 volatile 得到的结果在很大程度上取决于您使用的特定编译器和运行时。切勿将其用于线程安全,而应始终使用同步原语。

simply being atomic/volatile is thread-safe

如果这是真的,编程会简单得多。原子操作只涵盖非常简单的操作,真正的程序往往需要保持整个对象线程安全。以原子方式更新其所有成员,并且从不公开部分更新的对象的视图。像迭代列表这样简单的事情就是一个核心示例,您不能在查看列表元素时让另一个线程修改列表。那就是您需要使用更高级别的同步原语的时候,这种原语可以在安全继续之前阻止代码。

真实的程序通常会遇到这种同步需求并表现出 Amdahls' law 行为。换句话说,添加一个额外的线程实际上并不能使程序更快。有时实际上使它变慢。谁能为此找到更好的捕鼠器,谁就能获得诺贝尔奖,我们还在等待。