VC++,/volatile:x86 上的 ms
VC++, /volatile:ms on x86
When the /volatile:ms compiler option is used—by default when architectures other than ARM are targeted—the compiler generates extra code to maintain ordering among references to volatile objects in addition to maintaining ordering to references to other global objects.
使用 /volatile:ms
和 /volatile:iso
编译的具体代码有哪些不同?
要完全理解这一点,需要上一点历史课。 (谁不喜欢历史呢?...历史专业的人说。) /volatile:ms
语义首先添加到Visual Studio 2005 的编译器。Starting with that version,标记为 volatile
的变量通过该变量自动对读取施加获取语义,并在写入时释放语义。
这是什么意思?它与内存模型有关,特别是与允许编译器对内存访问操作重新排序的积极程度有关。具有 acquire 语义的操作会阻止后续的内存操作被提升到它之上;具有 release 语义的操作可防止前面的内存操作延迟到它之后。顾名思义,获取语义通常在获取资源时使用,而释放语义通常在释放资源时使用。 MSDN has a more complete description of acquire and release semantics;它说:
An operation has acquire semantics if other processors will always see
its effect before any subsequent operation's effect. An operation has
release semantics if other processors will see every preceding
operation's effect before the effect of the operation itself. Consider
the following code example:
a++;
b++;
c++;
From another processor's point of view, the preceding operations can
appear to occur in any order. For example, the other processor might
see the increment of b
before the increment of a
.
For example, the InterlockedIncrementAcquire
routine uses acquire semantics to increment a variable. If you rewrote the preceding code example as follows:
InterlockedIncrementAcquire(&a);
b++;
c++;
other processors would always see the increment of a
before the increments of b
and c
.
Likewise, the InterlockedIncrementRelease
routine uses release semantics to increment a variable. If you rewrote the code example once again, as follows:
a++;
b++;
InterlockedIncrementRelease(&c);
other processors would always see the increments of a
and b
before the increment of c
.
现在,正如 MSDN 所说,atomic 操作具有获取和释放语义。而且,事实上,在 x86 上,没有办法给一条指令只获取或释放语义,所以即使实现其中之一也需要指令是原子的(编译器通常会通过发出 LOCK CMPXCHG
说明)。
2005 年 Visual Studio 增强了 volatile
语义之前,想要编写正确代码的开发人员需要使用 Interlocked*
系列函数,如 MSDN 文章中所述。不幸的是,许多开发人员未能做到这一点,并且得到的代码大多是偶然工作的(或根本不工作)。但很有可能它 确实 工作是偶然的,考虑到 x86 相对严格的内存模型。自从 on x86, most loads and stores already have acquire/release semantics 以来,您通常可以免费获得所需的语义,因此您甚至不需要将任何内容设为原子。 (非时态存储是明显的例外,但在这种情况下,这些都无关紧要。)我怀疑 x86 上的这种易用性是什么,再加上程序员通常无法理解和做正确的事情,说服微软在VS 2005中加强volatile
的语义
改变的另一个潜在原因是多线程代码的重要性日益增加。 2005 年左右是奔腾 4 芯片 HyperThreading were beginning to become popular, effectively bringing simultaneous multi-threading to every users' desktop. Probably not coincidentally, VS 2005 also removed the option to link to single-threaded version of the C run-time libraries 的时候。当您拥有多线程代码(可能在多个处理器上执行)时,您才真正不得不开始担心是否正确获取内存访问语义。
使用 VS 2005 及更高版本,您可以将指针参数标记为 volatile
并获得所需的获取语义。波动性 implied/imposed 获取语义,使多线程代码 运行 在多处理环境中安全。在 2011 年之前,这 极其 重要,因为 C 和 C++ 语言标准完全没有关于线程的规定,并且没有提供编写正确代码的可移植方式。
这使我们正确地回答了您的问题。如果您的代码采用 volatile
的这些扩展语义,则您需要传递 /volatile:ms
开关以确保编译器继续应用它们。如果您编写了使用现代原语进行原子、线程安全操作的 C++11 样式代码,则不需要 volatile
来拥有这些扩展语义并且可以安全传递 /volatile:iso
。换句话说,,如果你的代码"misuses volatile
as std::atomic
",那么你会看到行为上的差异,需要/volatile:ms
来保证volatile
确实 与 std::atomic
.
效果相同
事实证明,与 /volatile:ms
相比,我很难找到 /volatile:iso
实际更改生成代码的示例。 Microsoft 的优化器实际上在重新排序指令方面非常保守,这是 acquire/release 语义应该防止的事情类型。
这是一个简单的示例(我们使用 volatile
全局变量来保护临界区,正如您在简单的 "lock-free" 实现中可能会发现的那样) 应该 证明差异:
volatile bool CriticalSection;
int Data[100];
void FillData(int i)
{
Data[i] = 42; // fill data item at index 'i'
CriticalSection = false; // release critical section
}
如果在 -O2
处使用 GCC 编译它,它将生成以下机器代码:
FillData(int):
mov eax, DWORD PTR [esp+4] // retrieve parameter 'i' from stack
mov BYTE PTR [CriticalSection], 0 // store '0' in 'CriticalSection'
mov DWORD PTR [Data+eax*4], 42 // store '42' at index 'i' in 'Data'
ret
即使您的汇编语言不流利,您也应该能够看到优化器已重新排序 存储,以便释放关键部分( CriticalSection = false
) before 数据被填充 (Data[i] = 42
)——恰好与原始 C 代码中语句出现的顺序相反。 volatile
对这种重新排序没有影响,因为 GCC 遵循 ISO 语义,就像 /volatile:iso
会(理论上)一样。
顺便说一下,请注意……嗯……易变 :-) 这个排序是怎样的。如果我们在 GCC 中的 -O1
处编译,我们会得到按照与原始 C 代码相同的顺序执行所有操作的指令:
FillData(int):
mov eax, DWORD PTR [esp+4] // retrieve parameter 'i' from stack
mov DWORD PTR [Data+eax*4], 42 // store '42' at index 'i' in 'Data'
mov BYTE PTR [CriticalSection], 0 // store '0' in 'CriticalSection'
ret
当您开始在其中放入更多指令以供编译器重新排列时,尤其是如果要内联此代码,您可以想象保留原始顺序的可能性有多大。
但是,正如我所说,MSVC 实际上在重新排序指令方面非常保守。不管我指定的是/volatile:ms
还是/volatile:iso
,我得到的机器码都是一样的:
FillData, COMDAT PROC
mov eax, DWORD PTR [esp+4]
mov DWORD PTR [Data+eax*4], 42
mov BYTE PTR [CriticalSection], 0
ret
FillData ENDP
商店按照原始顺序完成。我玩过各种不同的排列,引入了额外的变量和操作,但都无法找到导致 MSVC 重新排序存储的神奇序列。因此,目前在实践中,很可能在针对 x86 体系结构时,您不会看到 /volatile:iso
开关设置有很大差异。但至少可以说这是一个非常宽松的保证。
请注意,这一经验观察结果与 一致,即仅在 ARM 上观察到语义差异,引入这些开关的全部原因是为了避免对这种新支持的性能造成损失平台。同时,在 x86 方面,生成代码的语义没有实际变化,因为基本上没有成本。 (除了一些极其微不足道的优化可能性,但这需要他们的优化器有两个完全独立的调度程序,这可能不是对开发人员时间的良好利用。)
重点是,对于 /volatile:iso
,MSVC 允许 像 GCC 一样对存储进行重新排序。使用 /volatile:ms
,您 保证 它不会因为 volatile
暗示该变量的 acquire/release 语义。
额外阅读: 那么,在严格符合 ISO 的代码中,volatile
应该 用于什么( 即,当使用/volatile:iso
开关时)?嗯,volatile
基本上是指内存映射 I/O。这就是它最初推出时的初衷,现在仍然是它的主要目的。我曾听人开玩笑地说 volatile
用于 reading/writing 磁带机。基本上,您将指针标记为 volatile
以防止编译器优化读写。例如:
volatile char* pDeviceIOAddr = ...;
void Wait()
{
while (*pDeviceIOAddr)
{ }
}
使用 volatile
限定参数的类型可防止编译器假定后续读取 return 相同的值,从而迫使它在每次循环中都进行新的读取。换句话说:
mov eax, DWORD PTR [pDeviceIoAddr] // get pointer
Wait:
cmp BYTE PTR [eax], 0 // dereference pointer, read 1 byte,
jnz Wait // and compare to 0
如果 pDeviceIoAddr
不是 volatile
,则可以省略整个循环。优化器 肯定 在实践中这样做,包括 MSVC。或者,您可能会得到以下病态代码:
mov eax, DWORD PTR [pDeviceIoAddr] // get pointer
mov al, BYTE PTR [eax] // dereference pointer, read 1 byte
Wait:
cmp al, 0 // compare it to 0
jnz Wait
其中指针被取消引用一次,在循环之外,将字节缓存在寄存器中。循环顶部的指令只是测试注册值,创建无循环或无限循环。哎呀
但是请注意,在 ISO 标准 C++ 中使用 volatile
并不能消除对临界区、互斥锁或其他类型锁的需要。如果另一个线程可能修改 pDeviceIOAddr
,即使上述代码的正确版本也无法正常工作,因为 address/pointer 的读取确实 not 具有获取语义.获取语义将如下所示:
Wait:
mov eax, DWORD PTR [pDeviceIoAddr] // get pointer (acquire semantics)
cmp BYTE PTR [eax], 0 // dereference pointer, read 1 byte,
jnz Wait // and compare to 0
要做到这一点,您需要 C++11 的 std::atomic
.
我怀疑它可能从某个时间点开始,如果还没有的话。
存在未记录的选项 /volatileMetadata-
与 /volatileMetadata
。当它打开时(默认),会生成一些元数据用于在 ARM 上模拟 x86。
信息来自 my issue report,其中的一些链接:
Visual Studio 2019 version 16.10 Preview 2 在以 x64 为目标时默认启用易失性元数据以提高仿真性能
-
因此,至少在假设上,/volatile:iso
可能对易失性元数据很重要,并影响 ARM 上的 x86 代码执行。
我无法确认它发生了。通过使用和不使用 /volatileMetadata-
编译相同的二进制文件,我至少通过二进制文件大小确认提到的元数据确实存在。
When the /volatile:ms compiler option is used—by default when architectures other than ARM are targeted—the compiler generates extra code to maintain ordering among references to volatile objects in addition to maintaining ordering to references to other global objects.
使用 /volatile:ms
和 /volatile:iso
编译的具体代码有哪些不同?
要完全理解这一点,需要上一点历史课。 (谁不喜欢历史呢?...历史专业的人说。) /volatile:ms
语义首先添加到Visual Studio 2005 的编译器。Starting with that version,标记为 volatile
的变量通过该变量自动对读取施加获取语义,并在写入时释放语义。
这是什么意思?它与内存模型有关,特别是与允许编译器对内存访问操作重新排序的积极程度有关。具有 acquire 语义的操作会阻止后续的内存操作被提升到它之上;具有 release 语义的操作可防止前面的内存操作延迟到它之后。顾名思义,获取语义通常在获取资源时使用,而释放语义通常在释放资源时使用。 MSDN has a more complete description of acquire and release semantics;它说:
An operation has acquire semantics if other processors will always see its effect before any subsequent operation's effect. An operation has release semantics if other processors will see every preceding operation's effect before the effect of the operation itself. Consider the following code example:
a++; b++; c++;
From another processor's point of view, the preceding operations can appear to occur in any order. For example, the other processor might see the increment of
b
before the increment ofa
.For example, the
InterlockedIncrementAcquire
routine uses acquire semantics to increment a variable. If you rewrote the preceding code example as follows:InterlockedIncrementAcquire(&a); b++; c++;
other processors would always see the increment of
a
before the increments ofb
andc
.Likewise, the
InterlockedIncrementRelease
routine uses release semantics to increment a variable. If you rewrote the code example once again, as follows:a++; b++; InterlockedIncrementRelease(&c);
other processors would always see the increments of
a
andb
before the increment ofc
.
现在,正如 MSDN 所说,atomic 操作具有获取和释放语义。而且,事实上,在 x86 上,没有办法给一条指令只获取或释放语义,所以即使实现其中之一也需要指令是原子的(编译器通常会通过发出 LOCK CMPXCHG
说明)。
2005 年 Visual Studio 增强了 volatile
语义之前,想要编写正确代码的开发人员需要使用 Interlocked*
系列函数,如 MSDN 文章中所述。不幸的是,许多开发人员未能做到这一点,并且得到的代码大多是偶然工作的(或根本不工作)。但很有可能它 确实 工作是偶然的,考虑到 x86 相对严格的内存模型。自从 on x86, most loads and stores already have acquire/release semantics 以来,您通常可以免费获得所需的语义,因此您甚至不需要将任何内容设为原子。 (非时态存储是明显的例外,但在这种情况下,这些都无关紧要。)我怀疑 x86 上的这种易用性是什么,再加上程序员通常无法理解和做正确的事情,说服微软在VS 2005中加强volatile
的语义
改变的另一个潜在原因是多线程代码的重要性日益增加。 2005 年左右是奔腾 4 芯片 HyperThreading were beginning to become popular, effectively bringing simultaneous multi-threading to every users' desktop. Probably not coincidentally, VS 2005 also removed the option to link to single-threaded version of the C run-time libraries 的时候。当您拥有多线程代码(可能在多个处理器上执行)时,您才真正不得不开始担心是否正确获取内存访问语义。
使用 VS 2005 及更高版本,您可以将指针参数标记为 volatile
并获得所需的获取语义。波动性 implied/imposed 获取语义,使多线程代码 运行 在多处理环境中安全。在 2011 年之前,这 极其 重要,因为 C 和 C++ 语言标准完全没有关于线程的规定,并且没有提供编写正确代码的可移植方式。
这使我们正确地回答了您的问题。如果您的代码采用 volatile
的这些扩展语义,则您需要传递 /volatile:ms
开关以确保编译器继续应用它们。如果您编写了使用现代原语进行原子、线程安全操作的 C++11 样式代码,则不需要 volatile
来拥有这些扩展语义并且可以安全传递 /volatile:iso
。换句话说,volatile
as std::atomic
",那么你会看到行为上的差异,需要/volatile:ms
来保证volatile
确实 与 std::atomic
.
事实证明,与 /volatile:ms
相比,我很难找到 /volatile:iso
实际更改生成代码的示例。 Microsoft 的优化器实际上在重新排序指令方面非常保守,这是 acquire/release 语义应该防止的事情类型。
这是一个简单的示例(我们使用 volatile
全局变量来保护临界区,正如您在简单的 "lock-free" 实现中可能会发现的那样) 应该 证明差异:
volatile bool CriticalSection;
int Data[100];
void FillData(int i)
{
Data[i] = 42; // fill data item at index 'i'
CriticalSection = false; // release critical section
}
如果在 -O2
处使用 GCC 编译它,它将生成以下机器代码:
FillData(int):
mov eax, DWORD PTR [esp+4] // retrieve parameter 'i' from stack
mov BYTE PTR [CriticalSection], 0 // store '0' in 'CriticalSection'
mov DWORD PTR [Data+eax*4], 42 // store '42' at index 'i' in 'Data'
ret
即使您的汇编语言不流利,您也应该能够看到优化器已重新排序 存储,以便释放关键部分( CriticalSection = false
) before 数据被填充 (Data[i] = 42
)——恰好与原始 C 代码中语句出现的顺序相反。 volatile
对这种重新排序没有影响,因为 GCC 遵循 ISO 语义,就像 /volatile:iso
会(理论上)一样。
顺便说一下,请注意……嗯……易变 :-) 这个排序是怎样的。如果我们在 GCC 中的 -O1
处编译,我们会得到按照与原始 C 代码相同的顺序执行所有操作的指令:
FillData(int):
mov eax, DWORD PTR [esp+4] // retrieve parameter 'i' from stack
mov DWORD PTR [Data+eax*4], 42 // store '42' at index 'i' in 'Data'
mov BYTE PTR [CriticalSection], 0 // store '0' in 'CriticalSection'
ret
当您开始在其中放入更多指令以供编译器重新排列时,尤其是如果要内联此代码,您可以想象保留原始顺序的可能性有多大。
但是,正如我所说,MSVC 实际上在重新排序指令方面非常保守。不管我指定的是/volatile:ms
还是/volatile:iso
,我得到的机器码都是一样的:
FillData, COMDAT PROC
mov eax, DWORD PTR [esp+4]
mov DWORD PTR [Data+eax*4], 42
mov BYTE PTR [CriticalSection], 0
ret
FillData ENDP
商店按照原始顺序完成。我玩过各种不同的排列,引入了额外的变量和操作,但都无法找到导致 MSVC 重新排序存储的神奇序列。因此,目前在实践中,很可能在针对 x86 体系结构时,您不会看到 /volatile:iso
开关设置有很大差异。但至少可以说这是一个非常宽松的保证。
请注意,这一经验观察结果与
重点是,对于 /volatile:iso
,MSVC 允许 像 GCC 一样对存储进行重新排序。使用 /volatile:ms
,您 保证 它不会因为 volatile
暗示该变量的 acquire/release 语义。
额外阅读: 那么,在严格符合 ISO 的代码中,volatile
应该 用于什么( 即,当使用/volatile:iso
开关时)?嗯,volatile
基本上是指内存映射 I/O。这就是它最初推出时的初衷,现在仍然是它的主要目的。我曾听人开玩笑地说 volatile
用于 reading/writing 磁带机。基本上,您将指针标记为 volatile
以防止编译器优化读写。例如:
volatile char* pDeviceIOAddr = ...;
void Wait()
{
while (*pDeviceIOAddr)
{ }
}
使用 volatile
限定参数的类型可防止编译器假定后续读取 return 相同的值,从而迫使它在每次循环中都进行新的读取。换句话说:
mov eax, DWORD PTR [pDeviceIoAddr] // get pointer
Wait:
cmp BYTE PTR [eax], 0 // dereference pointer, read 1 byte,
jnz Wait // and compare to 0
如果 pDeviceIoAddr
不是 volatile
,则可以省略整个循环。优化器 肯定 在实践中这样做,包括 MSVC。或者,您可能会得到以下病态代码:
mov eax, DWORD PTR [pDeviceIoAddr] // get pointer
mov al, BYTE PTR [eax] // dereference pointer, read 1 byte
Wait:
cmp al, 0 // compare it to 0
jnz Wait
其中指针被取消引用一次,在循环之外,将字节缓存在寄存器中。循环顶部的指令只是测试注册值,创建无循环或无限循环。哎呀
但是请注意,在 ISO 标准 C++ 中使用 volatile
并不能消除对临界区、互斥锁或其他类型锁的需要。如果另一个线程可能修改 pDeviceIOAddr
,即使上述代码的正确版本也无法正常工作,因为 address/pointer 的读取确实 not 具有获取语义.获取语义将如下所示:
Wait:
mov eax, DWORD PTR [pDeviceIoAddr] // get pointer (acquire semantics)
cmp BYTE PTR [eax], 0 // dereference pointer, read 1 byte,
jnz Wait // and compare to 0
要做到这一点,您需要 C++11 的 std::atomic
.
我怀疑它可能从某个时间点开始,如果还没有的话。
存在未记录的选项 /volatileMetadata-
与 /volatileMetadata
。当它打开时(默认),会生成一些元数据用于在 ARM 上模拟 x86。
信息来自 my issue report,其中的一些链接:
Visual Studio 2019 version 16.10 Preview 2 在以 x64 为目标时默认启用易失性元数据以提高仿真性能
因此,至少在假设上,/volatile:iso
可能对易失性元数据很重要,并影响 ARM 上的 x86 代码执行。
我无法确认它发生了。通过使用和不使用 /volatileMetadata-
编译相同的二进制文件,我至少通过二进制文件大小确认提到的元数据确实存在。