6

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

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

我已经看到它暗示了很多地方,简单的原子/易失性就是线程安全的。我不明白这是怎么回事。我是否还需要一个内存屏障来确保在实际保证在另一个线程中读取/写入之前读取/写入任何原子值或其他值?

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

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

我想这将取决于 ResumeThread 中的任何内存障碍?我应该对 val64 进行联锁交换吗?如果线程正在运行,那会如何改变事情呢?

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

4

2 回答 2

6

它用于确保一个值将被整体读取/写入

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

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

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

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

波动性根本不能保证线程安全

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

应用volatile可确保代码优化器不会认为寄存器中的值是准确的,并强制它再次读取内存。它真的只对那些本身不稳定的内存值很重要,设备通过内存映射 I/O 暴露其寄存器。自从试图将语义放在具有弱内存模型的处理器之上的核心含义以来,它一直被严重滥用,安腾是最令人震惊的例子。您今天使用volatile获得的结果在很大程度上取决于您使用的特定编译器和运行时。永远不要将它用于线程安全,始终使用同步原语。

简单地成为原子/易失性是线程安全的

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

真正的程序经常受到这种同步需求的影响,并表现出阿姆达尔定律的行为。换句话说,添加一个额外的线程实际上并没有使程序更快。有时实际上使它变慢。谁找到了更好的捕鼠器就可以保证获得诺贝尔奖,我们还在等待。

于 2015-01-31T13:26:48.747 回答
2

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

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

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

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

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

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

在您的示例中,我希望您确实需要使用线程安全访问来更新 val64,无论线程是否已启动。

如果线程已经在运行,那么您肯定需要对 val64 进行某种线程安全的写入,或者使用 InterchangeExchange64 或类似方法,或者通过获取和释放某种同步对象来执行适当的内存屏障指令。同样,线程也需要使用线程安全的访问器来读取它。

在线程还没有恢复的情况下,就有点不太清楚了。ResumeThread 可能会使用或充当同步函数并执行内存屏障操作,但文档没有指定它这样做,所以最好假设它没有。

参考:

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

关于“易失性”,包括内存围栏... https://msdn.microsoft.com/en-us/library/windows/desktop/ms686355%28v=vs.85%29.aspx

于 2015-01-31T11:36:01.057 回答