多核 CPU 上 32 位读取的原子性

Atomicity of 32bit read on multicore CPU

本文关键字:读取 原子性 CPU 多核      更新时间:2023-10-16

(注意:我根据我认为人们可能能够提供帮助的地方为这个问题添加了标签,所以请不要大喊大叫:))

在我的VS 2017 64位项目中,我有一个32位长值m_lClosed。当我想更新它时,我使用Interlocked系列函数之一。

考虑在线程 #1 上执行的这段代码

LONG lRet = InterlockedCompareExchange(&m_lClosed, 1, 0);   // Set m_lClosed to 1 provided it's currently 0

现在考虑在线程 #2 上执行的以下代码:

if (m_lClosed) // Do something

我知道在单个 CPU 上,这不会成为问题,因为更新是原子的,读取也是原子的(请参阅 MSDN),因此线程抢占不能使变量处于部分更新状态。 但是在多核 CPU 上,如果每个线程位于不同的 CPU 上,我们实际上可以让这两段代码并行执行。 在这个例子中,我认为这不是一个问题,但是测试可能正在更新的东西仍然感觉不对。

这个网页告诉我,多个CPU上的原子性是通过LOCK汇编指令实现的,防止其他CPU访问该内存。 这听起来像是我需要的,但为上面的 if 测试生成的汇编语言只是

cmp   dword ptr [l],0  

。看不到LOCK指令。

在这种情况下,我们应该如何确保读取的原子性?

编辑 24/4/18

首先感谢这个问题引起的所有兴趣。 我在实际代码下面显示;我故意保持简单,专注于这一切的原子性,但显然,如果我从第一分钟开始就展示这一切会更好。

其次,实际代码所在的项目是VS2005项目;因此无法访问C++11原子学。 这就是为什么我没有在问题中添加 C++11 标签的原因。 我正在使用VS2017与"临时"项目,以节省每次在学习时进行更改时构建巨大的VS2005。 另外,它是一个更好的IDE。

是的,所以实际代码存在于 IOCP 驱动的服务器中,整个原子性是关于处理封闭的套接字:

class CConnection
{
//...
DWORD PostWSARecv()
{
if (!m_lClosed)
return ::WSARecv(...);
else
return WSAESHUTDOWN;
}
bool SetClosed()
{
LONG lRet = InterlockedCompareExchange(&m_lClosed, 1, 0);   // Set m_lClosed to 1 provided it's currently 0
// If the swap was carried out, the return value is the old value of m_lClosed, which should be 0.
return lRet == 0;
}
SOCKET m_sock;
LONG m_lClosed;
};

调用者将调用SetClosed();如果它返回true,它将调用::closesocket()等。 请不要质疑为什么会这样,它只是:)

考虑如果一个线程关闭套接字而另一个线程尝试发布WSARecv()会发生什么。 您可能会认为WSARecv()会失败(毕竟套接字已关闭!但是,如果使用与我们刚刚关闭的套接字句柄相同的套接字句柄建立新连接怎么办 - 然后我们将发布将成功的WSARecv(),但这对我的程序逻辑来说是致命的,因为我们现在将完全不同的连接与此 CConnection 对象相关联。 因此,我有if (!m_lClosed)测试。 您可能会争辩说我不应该在多个线程中处理相同的连接,但这不是这个问题的重点:)

这就是为什么我需要在拨打WSARecv()电话之前测试m_lClosed

现在,很明显,我只将m_lClosed设置为 1,所以撕裂的读/写并不是一个真正的问题,但这是我关心的原则。 如果我将m_lClosed设置为2147483647,然后测试2147483647,该怎么办? 在这种情况下,撕裂的读/写会更成问题。

这实际上取决于您的编译器和您正在运行的 CPU。

如果内存地址正确对齐,x86 CPU 将以原子方式读取不带LOCK前缀的 32 位值。但是,如果变量用作其他一些相关数据的锁定/计数,则很可能需要某种内存屏障来控制 CPU 的无序执行。未对齐的数据可能无法以原子方式读取,尤其是在值跨越页面边界时。

如果您不是手动编码汇编,则还需要担心编译器对优化进行重新排序。

使用 Visual C++ 进行编译时,任何标记为volatile的变量在编译器(可能还有生成的机器代码)中都有排序约束:

_ReadBarrier、_WriteBarrier 和_ReadWriteBarrier编译器内部函数仅阻止编译器重新排序。在 Visual Studio 2003 中,对易失性引用到易失性引用进行排序;编译器不会对易失性变量访问重新排序。在 Visual Studio 2005 中,编译器还使用获取语义对易失性变量执行读取操作,并发布语义对易失性变量执行写入操作(如果 CPU 支持)。

Microsoft特定的易失性关键字增强功能:

使用/volatile:ms 编译器选项时(默认情况下,当面向 ARM 以外的体系结构时),编译器会生成额外的代码,以维护对易失性对象的引用之间的排序,以及维护对其他全局对象的引用的排序。特别:

对易失性
  • 对象的写入(也称为易失性写入)具有 Release 语义;也就是说,在对指令序列中的易失性对象进行写入之前发生的对全局或静态对象的引用将在编译的二进制文件中的易失性写入之前发生。

  • 对易失性
  • 对象的读取(也称为易失性读取)具有 Acquire语义;也就是说,在读取指令序列中的易失性存储器之后发生的对全局或静态对象的引用将在编译的二进制文件中的易失性读取之后发生。

这使易失性对象可用于多线程应用程序中的内存锁定和释放。


对于 ARM 以外的体系结构,如果未指定/volatile 编译器选项,则编译器的执行方式与指定/volatile:ms 一样;因此,对于 ARM 以外的体系结构,我们强烈建议您指定/volatile:iso,并在处理跨线程共享的内存时使用显式同步基元和编译器内部函数。

Microsoft为大多数互锁*函数提供了编译器内部函数,它们将编译为类似LOCK XADD ...而不是函数调用。

直到"最近",C/C++ 通常不支持原子操作或线程,但在添加了原子支持的 C11/C++11 中发生了变化。使用<atomic>标头及其类型/函数/类会将对齐和重新排序责任移动到编译器,因此您不必担心这一点。您仍然必须对内存屏障做出选择,这决定了编译器生成的机器代码。在放宽内存顺序的情况下,load原子操作很可能最终成为 x86 上的简单MOV指令。如果编译器确定目标平台需要,则更严格的内存顺序可以添加围栏和可能的LOCK前缀。

在 C++11 中,对非原子对象(如m_lClosed)的不同步访问是未定义的行为。

该标准提供了正确编写此标准所需的所有功能;您不需要非可移植功能,例如InterlockedCompareExchange。 相反,只需将变量定义为atomic

std::atomic<bool> m_lClosed{false};
// Writer thread...
bool expected = false;
m_lClosed.compare_exhange_strong(expected, true);
// Reader...
if (m_lClosed.load()) { /* ... */ }

这绰绰有余(它强制顺序一致性,这可能很昂贵)。在某些情况下,可以通过放宽原子操作的内存顺序来生成稍微快一点的代码,但我不会担心这一点。

正如我在这里发布的那样,这个问题从来都不是关于保护代码的关键部分,而是纯粹关于避免撕裂的读/写。 user3386109 在这里发表了一条评论,我最终使用了该评论,但拒绝在此处发布作为答案。 因此,我提供了我最终用于完成这个问题的解决方案;也许将来会帮助某人。

下面显示了m_lClosed的原子设置和测试:

long m_lClosed = 0;

线程 1

// Set flag to closed
if (InterlockedCompareExchange(&m_lClosed, 1, 0) == 0)
cout << "Closed OK!n";

线程 2

此代码将替换if (!m_lClosed)

if (InterlockedCompareExchange(&m_lClosed, 0, 0) == 0)
cout << "Not closed!";

<</div> div class="answers">好的,所以事实证明这真的没有必要; 这个答案详细解释了为什么我们不需要使用任何联锁操作进行简单的读/写(但我们确实需要读取-修改-写入)。