我之前以为 volatile 变量可以让编译器内建 Memory Barrier,从而可以实现自旋锁的功能(通常用 InterlockedExchange() 实现),于是写了下面的测试代码:
01 | typedef struct _ThreadParam |
10 | UINT nRnd1, nRnd2, nTestRnd; |
11 | const UINT RANGE_MIN = 10; |
12 | const UINT RANGE_MAX = 100; |
14 | srand (( UINT ) time (NULL)); |
20 | nRnd1 = ( UINT )((( double ) rand () / ( double )RAND_MAX) * RANGE_MAX) + RANGE_MIN; |
21 | nRnd2 = ( UINT )((( double ) rand () / ( double )RAND_MAX) * RANGE_MAX) + RANGE_MIN; |
24 | TestConcurrentAccess(nRnd1, nRnd2); |
26 | _tprintf(_T( "%d. round 1: %d, round 2: %d/n" ), nTestRnd, nRnd1, nRnd2); |
27 | _tprintf(_T( "global counter: %d/n" ), g_nCnt); |
28 | if (nRnd1 + nRnd2 != g_nCnt) |
35 | DWORD TestConcurrentAccess( UINT nRnd1, UINT nRnd2) |
45 | hThd = CreateThread(NULL, 0, ThreadTest, &p1, 0, &dwTid); |
46 | hThd2 = CreateThread(NULL, 0, ThreadTest, &p2, 0, &dwTid); |
48 | WaitForSingleObject(hThd, INFINITE); |
49 | WaitForSingleObject(hThd2, INFINITE); |
54 | DWORD WINAPI ThreadTest( LPVOID pParam) |
56 | _ASSERT(pParam != NULL); |
58 | ThreadParam* pThdParam = (ThreadParam*)pParam; |
60 | for ( UINT i = 0; i < pThdParam->nRound; i++) |
编译与运行环境:
g_nCnt 被声明为 volatile,用 volatile 修饰的变量对其的操作都是对实际的内存进行操作,我以为上面的并发线程 ThreadTest() 中 g_nCnt++ 操作是原子的,即对并发线程 ThreadTest() 提供 [写-写 并发] 功能(事实证明是错误的),所以上面的代码我的预期结果是 nRnd1 + nRnd2 != g_nCnt 永远不成立,程序一直在执行不退出。
但实际运行结果事与愿违,每次运行后,TestConcurrentAccess() 运行不超过 100 轮 (nTestRnd < 100),就达到 nRnd1 + nRnd2 != g_nCnt 条件,程序就结束了。
后来请教 MSDN 上一位牛人 江写生,以及自己反汇编调试,才明白 volatile 的实际作用。
volatile 变量的作用
volatile 变量的作用有两点:
-
1) volatile 变量的每次 读/写 都要用 读/写 内存指令,不能因为假定两次 读/写 之间没变化而编译优化,保存在寄存器中。
这个和 non-cached 数据无关,启动或关闭的 编译优化 不是 CPU 的 cache 特性(即不是这个 #pragma section("section", nocache)),而是:是否使用寄存器中的已有的数据。cache 的一致性由 CPU 硬件完成,不用程序员操心。
-
2) volatile 变量的每次 读/写 要严格按语句的先后顺序,不能因为假定 volatile 变量没额外的 side effects 而编译优化,产生打乱顺序的 "流水线" 执行顺序。(VC9 这点没严格做到,/O2 优化有时仍会打乱 volatile 与 non-volatile 之间的执行顺序)
volatile 本身不提供并发控制。
MSDN: volatile (C++) 参考里的示例代码没有并发控制的功能,它之所以能行,是因为一个线程写,另一个线程读,即 [读-写 并发]。
示例中 Sentinel = false 只需一条指令,线程 ThreadFunc1() 读时,线程 ThreadFunc2() 要么在这条指令前,要么在这条指令后,所以没问题。
而我上面的 volatile UINT g_nCnt; 和 g_nCnt++; 代码,用 OllyDbg 调试后发现是这样编译的:
Debug Compile Option: /Od /EHsc /MDd
Release Compile Option: /O2 /EHsc /MD
Debug 编译为:
2 | A1 88714100 MOV EAX,DWORD PTR DS:[417188] |
4 | A3 88714100 MOV DWORD PTR DS:[417188],EAX |
Release 编译为:
2 | 8305 78334000 01 ADD DWORD PTR DS:[403378],1 |
这是一条 Opcode=8305 的复杂 ADD 指令,在 UP 上似乎没问题,但在 MP 上就不保证并发的正确了,此时应该用 LOCK 指令前缀锁住总线,换句话说这条 ADD DWORD PTR DS:[403378],1 在我的 Core 2 Duo 上不是原子的指令,这也就是一开始的测试代码出错的原因。
InterlockedIncrement() 提供的写并发
有两组 InterlockedXXX() 函数,一组是 Windows API (kernel32.dll 导出),另一组是 VC 编译器内建的 intrinsic 函数 _InterlockedXXX(),功能是相同的。
将测试代码中并发线程 ThreadTest() 中 g_nCnt++ 改为 _InterlockedIncrement((long*)&g_nCnt)。
对于 UINT g_nCnt 变量,无论用不用 volatile 修饰都可以正确完成并发,因为 _InterlockedIncrement() 依靠的是 LOCK 指令前缀而非 non-cached 特性,且 VC8 的 volatile 修饰也不产生 non-cached 段区。
仍然使用上面的编译选项,对 _InterlockedIncrement() 的编译如下:
2 | _InterlockedIncrement(( long *)&g_nCnt); |
Debug, Release 下均编译为:
2 | B8 78334000 MOV EAX,Volatile.00403378 |
4 | F0:0FC108 LOCK XADD DWORD PTR DS:[EAX],ECX |
实测中,用上面 _InterlockedIncrement() 方法,运行了 500 轮 TestConcurrentAccess() 都没有出现过 nRnd1 + nRnd2 != g_nCnt 的问题。
volatile 使用示例
(1). 非 volatile 变量的无作用操作被优化掉
02 | __declspec ( noinline ) void TestVolatile_1() |
(2). 非 volatile 变量的操作被优化成寄存器操作
02 | __declspec ( noinline ) void TestVolatile_2() |
08 | for (v = 0; v < 10; v++) |
12 | for (nv = 0; nv < 10; nv++) |
LOCK# 信号
参考:Intel 64 and IA-32 Architectures Software Developer's Manuals
以下是我对 Intel Manuals 的翻译和摘录:
From: Volume 2A.: Ch3. Instruction Set Reference: 3.2 Instruction (A-M)
LOCK -- Assert LOCK# Signal Prefix
LOCK 前缀将导致处理器在执行伴随指令时,产生 LOCK# 信号,这可以让此执行指令成为原子指令。在 MP 系统上,LOCK# 信号可以保证处理器互斥地访问共享内存。
对于 Intel 后来的一些处理器(包括 Pentium 4, Xeon, P6 family),可以在不产生 LOCK# 信号的情况下做总线锁定。See the "IA-32 Architecture Compatibility" section below.
The LOCK prefix can be prepended only to the following instructions and only to those forms of the instructions where the destination operand is a memory operand (目的操作数为内存): ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG. If the LOCK prefix is used with one of these instructions and the source operand is a memory operand, an undefined opcode exception (#UD) may be generated. An undefined opcode exception will also be generated if the LOCK prefix is used with any instruction not in the above list. The XCHG instruction always asserts the LOCK# signal regardless of the presence or absence of the LOCK prefix. (XCHG 总是产生 LOCK# 信号,无论有没有 LOCK 前缀)
...
IA-32 Architecture Compatibility
从 P6 family 开始的处理器,当有 LOCK 前缀,并且正在访问的内存区域已在处理器内部 cached 时,通常不会产生 LOCK# 信号,而是仅锁定处理器的 cache。此时由处理器的 cache 一致性检查机制来保证原子性的内存操作。See "Effects of a Locked Operation on Internal Processor Caches" in Chapter 8 of Intel® 64 and IA-32 Architectures Software Developer's Manual, Volume 3A, the for more information on locking of caches.
From: Volume 3A.: Ch8. Multiple-Processor Management
8.1.4 Effects of a LOCK Operation on Internal Processor Caches
For the P6 and more recent processor families, if the area of memory being locked during a LOCK operation is cached in the processor that is performing the LOCK operation as write-back memory and is completely contained in a cache line, the processor may not assert the LOCK# signal on the bus. Instead, it will modify the memory location internally and allow it's cache coherency mechanism to ensure that the operation is carried out atomically. This operation is called "cache locking." The cache coherency mechanism automatically prevents two or more processors that have cached the same area of memory from simultaneously modifying data in that area.
总之,有 LOCK 前缀不一定产生 LOCK# 信号,但 XCHG 一定会产生 LOCK# 信号,关于这点在 云风 的《游戏之旅:我的编程感悟》中第六章也有提到。所以应该在效率要求高的地方避免使用 XCHG。
MemoryBarrier()
Windows Server 2003 以上的 Windows API 中有一个 MemoryBarrier() 宏用于建立其前后紧挨指令的内存访问壁障(指令不能被处理器 reorder),在 IA32 VC8 上的实现是:
1 | FORCEINLINE VOID MemoryBarrier ( VOID ) |
上面就是利用了 XCHG 会锁总线的特性 (Volume 3A. 8.1.2.1 Automatic Locking) 产生的副作用 (Volume 3A. 8.2.3.9 Loads and Stores Are Not Reordered with Locked Instructions)。