积极答复者
VC8中volatile无法起到预期的memory barrier作用

问题
-
想用volatile变量做个应用层的自旋锁设施。
关于volatile参考:http://msdn.microsoft.com/en-us/library/12a04hfd(VS.80).aspx
我先做了一个volatile全局变量的测试,代码如下:
typedef struct _ThreadParam { UINT nRound; }ThreadParam; volatile UINT g_nCnt; int _tmain() { UINT nRnd1, nRnd2, nTestRnd; const UINT RANGE_MIN = 10; const UINT RANGE_MAX = 100; srand((UINT)time(NULL)); nTestRnd = 0; while (TRUE) { g_nCnt = 0; nRnd1 = (UINT)(((double)rand() / (double)RAND_MAX) * RANGE_MAX) + RANGE_MIN; nRnd2 = (UINT)(((double)rand() / (double)RAND_MAX) * RANGE_MAX) + RANGE_MIN; nTestRnd++; TestConcurrentAccess(nRnd1, nRnd2); _tprintf(_T("%d. round 1: %d, round 2: %d\n"), nTestRnd, nRnd1, nRnd2); _tprintf(_T("global counter: %d\n"), g_nCnt); if (nRnd1 + nRnd2 != g_nCnt) break; } return 0; } DWORD TestConcurrentAccess(UINT nRnd1, UINT nRnd2) { DWORD dwTid; HANDLE hThd, hThd2; ThreadParam p1, p2; p1.nRound = nRnd1; p2.nRound = nRnd2; hThd = CreateThread(NULL, 0, ThreadTest, &p1, 0, &dwTid); hThd2 = CreateThread(NULL, 0, ThreadTest, &p2, 0, &dwTid); WaitForSingleObject(hThd, INFINITE); WaitForSingleObject(hThd2, INFINITE); return 0; } DWORD WINAPI ThreadTest(LPVOID pParam) { _ASSERT(pParam != NULL); ThreadParam* pThdParam = (ThreadParam*)pParam; for (UINT i = 0; i < pThdParam->nRound; i++) { Sleep(5); g_nCnt++; } return 0; }
如果上面全局变量 g_nCnt 被声明为 UINT g_nCnt; 则理论上因为线程调度的原因,g_nCnt++ 操作有被打断的可能,导致 _tmain() 测试中的 nRnd1 + nRnd2 != g_nCnt 条件满足,从而退出程序。实际测试中确实是这样,测试了几次,每次 TestConcurrentAccess() 用例没跑过 50 轮,就会达到这个错误条件。而且每次都是 g_nCnt 比 nRnd1 + nRnd2 小 1。
我觉得用 volatile 修饰 g_nCnt 后,编译器在 g_nCnt 上建立了存储壁障代码,应该能解决 g_nCnt++ 被打断的问题,可是实测后发现,volatile UINT g_nCnt; 依然会导致到达 nRnd1 + nRnd2 != g_nCnt 条件( 我的实测中,TestConcurrentAccess() 用例不超过 100 轮,就会达到这个条件)。
大家可以试试上面的代码,期望的情况是永远都执行下去,只能用 Ctrl+C 中断。
请教一下各位有谁能解释这个问题。
PS:
编译环境 VC8,关键的编译选项 /O2 /EHsc /MD,平台 ntkrpamp.exe(带PAE的MP版内核) Core 2 Duo
答案
全部回复
-
多谢 @江写生,你讲的很透彻。
后来看了一些资料,又实验了下,总结了下,要实现 memory barrier 实际涉及的问题有以下这些。
volatile 修饰的变量,只保障两个事情:
1. 防止运行时,CPU 做 cache。
2. 防止编译时,编译器对 volatile 变量的操作指令及其附近的指令,做 reorder。
但是 volatile 不保障对其修饰的变量的操作指令是原子的,这意味着不提供 MP 之间的指令并发同步。
MP 和 UP 上的原子操作实现考虑的问题不同,UP 上只考虑调度切换,而要实现 MP 上的原子写操作(同步),还需要锁 bus:
3. x86 上用 LOCK 前缀指令指示,在后跟指令的执行时间内发 LOCK# 锁住 bus。
在 P4 之后的 CPU 上,有 LOCK 前缀不一定发 LOCK#,但对上面的 non-cached 的 volatile 数据一定是有 LOCK# 的。
1、2、3 点共同作用,使得 MP 上写操作是原子的。所以看看 intrin.h 中的 _InterlockedIncrement() 声明的参数也是 volatile* 的:
long __cdecl _InterlockedIncrement(long volatile*);
----------
以下是我用 OllyDbg 调试的代码,供参考:
release: /O2 /EHsc /MD
debug: /Od /EHsc /MDd
----------
1. 先看出错代码的(只用到 volatile 变量):
volatile UINT g_nCnt; __asm int 3; g_nCnt++; __asm nop;
(1). debug 编译为:
INT3 MOV EAX,DWORD PTR DS:[417188] ADD EAX,1 MOV DWORD PTR DS:[417188],EAX NOP
(2). release 编译为:
INT3 ADD DWORD PTR DS:[403378],1 NOP
和 @江写生 说的一条 INC 指令类似,只不过变成了一条 Opcode=8305 的 ADD,大同小异。
在实测中也会达到 nRnd1 + nRnd2 != g_nCnt 的条件,就说明这一条指令 ADD DWORD PTR DS:[403378],1 在 Core 2 Duo 的 MP 环境下不是原子的,当两个核同时执行此指令时就会出问题,所以要锁 bus。
PS.
实测中加不加 volatile 的 UINT g_nCnt,都生成上面执行指令,我猜 volatile 的 non-cached 特性可能是使用数据段区的标识位来实现,想到有个预处理指示 #pragma section("section", nocache)。
----------
2. 再看用 _InterlockedIncrement() 的:
volatile UINT g_nCnt; __asm int 3; _InterlockedIncrement((long*)&g_nCnt); __asm nop;
release、debug 均编译为类似:
INT3 MOV EAX,Volatile.00403378 MOV ECX,1 LOCK XADD DWORD PTR DS:[EAX],ECX NOP
PS.
实测中,用 volatile UINT g_nCnt + _InterlockedIncrement() 方法,500 轮测试中没出现过 nRnd1 + nRnd2 != g_nCnt 的问题。
而用 UINT g_nCnt + _InterlockedIncrement() 方法,500 轮测试也没有出现问题,难道仅靠到 _InterlockedIncrement() 的参数 long volatile * 的类型转换就起到了 non-cached 的作用?还是出错的时机未到。