对Preshing大神的文章进行简单翻译
当我们正在使用C或者C++编写lock-free代码的时候,一个需要重点关注的是强制内存正确排序。
Intel在其x86/64架构说明书的第3卷第8.2.3节中列出了一些需要特别关注的点。这里举个例子说明一下。假设在内存的某处存放着两个整数X和Y,并且都被初始化为0。两个处理器在同时运行,分别执行下列机器码:
| Processor 1 | Processor 2 |
|---|---|
| mov [X], 1 | mov [Y], 1 |
| mov r1, [Y] | mov r2, [X] |
mov [X],1代表store to X,mov r1,[X]代表load from X。
注意,不要被例子中的汇编语言所吓倒。这的确是解释CPU排序的最好方式。每个处理器分别将1存储至整型变量,然后分别将另一个整数的值加载至寄存器中。(r1和r2代表x86的寄存器,诸如eax)。
现在,无论哪个处理器先将1写入至内存,我们很自然地期待另一个处理可以阅读到1,这意味着最终结果是r1 = 1,r2 = 1。但是根据Intel的说明,结果并一定如我们所想。说明书指出这个例子的最终结果是r1 = 0,r2 = 0也同样是正确的——这是一个违反直觉的结果!
这说明Intel x86/64(与大多处理器相同)允许根据特定的规则,对机器执行在内存中的顺序进行重新排序,只要其不影响单线程的执行结果即可。特别地是,每个处理器从不同位置加载值的时候,允许延迟存储的影响。最后,指令的执行顺序可能是下列方式:
| Processor 1 | Processor 2 |
|---|---|
| mov r1, [Y] | mov r2, [X] |
| mov [X], 1 | mov [Y], 1 |
Let's Make it Happen
接下来,让我们来动手试试,亲眼见证一下奇迹的发生。这个例子在Win32和POSIX标准中都可以正常运行。它会产生两个无限重复执行的工作线程,主线程负责对它们进行同步并检测每个结果。
这是第一个工作线程的源代码。X, Y, r1和r2是全局变量,并且POSIX信号量用于协调每个循环的开始和结束。
sem_t beginSemal;
sem_t endSema;
int X, Y;
int r1, r2;
void* thread1Func(void* param)
{
MersenneTwister random(1); // Initialize random number generator
for(;;) // Loop indefinitely
{
sem_wait(&beginSema1); // Wait for signal from main thread
while(random.integer() % 8 != 0) {} // Add a short, random delay
X = 1;
asm volatile("" ::: "memory"); // Prevent compiler reordering
r1 = Y;
sem_post(&endSema); // Notify transaction complete
}
return NULL;
};
在每个事务之前添加一个短暂的随机延迟,以错开线程的时间。记住,这里有两个工作线程,并且我们试图让它们的指令重叠。
不要被上述代码中的asm volatile所吓倒。这只是一个指令,告诉GCC编译器不要重新排列存储和加载,以防止它在优化期间开始产生任何有趣的做法(我们这里重点观察CPU的重排,对于编译器的重排在后续进行讨论)。我们可以通过检测汇编代码来进行验证。正如我们所期待的那样,存储和加载以所需的顺序执行。
$ gcc -02 -c -S -masm=intel ordering.cpp
$ cat ordering.s
...
mov DWORD PTR _X, 1
mov eax, DWORD PTR _Y
mov DWORD PTR _r1, eax
...
主线程代码如下所示。它执行所有的管理工作。在初始化之后,它无限循环,将X和Y重置为0,然后在每次迭代中启动工作线程。
特别注意所有对共享内存的写操作发生在sem_post之前,所有对共享内存的读操作发生在sem_wait之后。与主线程通信时,工作线程中遵循相同的规则。信号量在每个平台都为我们提供了acquire and release semantics。这意味着我们保证X = 0和Y = 0的初始值将完全传递给工作线程,并且r1和r2的结果将完全传播回主线程。换句话说,信号量帮助我们避免了其他因素的干扰,使我们重点关注于实验本身!
int main()
{
// Initialize the semaphores
sem_init(&beginSema1, 0, 0);
sem_init(&beginSema2, 0, 0);
sem_init(&endSema, 0, 0);
// Spawn the threads
pthread_t thread1, thread2;
pthread_create(&thread1, NULL, thread1Func, NULL);
pthread_create(&thread2, NULL, thread2Func, NULL);
// Repeat the experiment ad infinitum
int detected = 0;
for(int iterations = 1; ; iterations++)
{
// Reset X and Y
X = 0;
Y = 0;
// Signal both threads
sem_post(&beginSema1);
sem_post(&beginSema2);
// Wait for both threads
sem_wait(&endSema);
sem_wait(&endSema);
// Check if there was a simultaneous reorder
if(r1 == 0 && r2 == 0)
{
detected++;
printf("%d reorders detected after %d iterations\n", detected, iterations);
}
}
return 0; // Never returns
}
最后,我们来看一下在visual studio 2019上的运行结果。
现在,假设你想消除这些重排问题,这里至少有两种方式可以做到。一种方式是设置thread affinities,以便两个工作线程仅在同一个CPU核心上运行。这里没有可移植的方法去设置affinities,但是在Linux上可以通过下列方式来进行实现:
cpu_set_t cpus;
CPU_ZERO(&cpus);
CPU_SET(0, &cpus);
pthread_setaffinity_np(thread1, sizeof(cpu_set_t), &cpus);
pthread_setaffinity_np(thread2, sizeof(cpu_set_t), &cpus);
设置之后,重排的问题就被解决了。这是因为单个处理器不会看到它自己的操作乱序,即使线程在任意时间被抢占和重新调度。当然,通过将两个线程锁定到一个内核,会导致其他内核资源被浪费。
注意,我编译并运行这个例子在Playstation 3,但是却没有内存乱序被检测到。这表明(但未证实)PPU内的两个硬件线程可以有效地充当单个处理器,具有非常细粒度的硬件调度。
Preventing It With a StoreLoad Barrier
在当前例子中,另一个解决方法是在两条指令之间设置CPU屏障。在常见的屏障中,我们需要一个StoreLoad屏障(对于这样的语句 Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见)。
在x86/64处理器上,没有仅充当StoreLoad屏障的特定指令,但有几条指令可以做到这一点,甚至更多。mfence指令是一个完整的内存屏障,它可以防止任何类型的内存重新排序。在GCC,具体实现如下:
for(;;)
{
sem_wait(&beginSemal);
while(random.integer() % 8 != 0) {}
X = 1;
asm volatile("mfence" ::: "memory"); // Prevent memory reordering
r1 = Y;
sem_post(&endSema);
}
同样,你可以通过查看汇编代码来验证其正确性。
...
mov DWORD PTR _X, 1
mfence
mov eax, DWORD PTR _Y
mov DWORD PTR _r1, eax
...
通过这个修改,内存重排序问题消失了,同时,我们仍然允许两个线程在不同的CPU内核上运行。
Similar Instuctions and Different Platforms
有趣的是,mfence并不是x86/64上唯一充当完整内存屏障的指令。在这些处理器上,任何锁定指令,例如xchg,也可以作为一个完整内存屏障——前提是您不使用SSE指令或写聚合内存,这在本次实例中没有出现过。实际上,当你使用MemoryBarrier内部函数时,Microsoft C++编译器会生成xchg,至少在Visual Studio 2008中是这样的。
mfence指令是x86/64的特定指令。如果你想让代码具有可移植性,你可以把这个内在函数包装在一个宏内。Linux内核将其封装在一个名为smp_mb的宏中,以及smp_rmb和smp_wmb等相关宏,并在不同的体系结构上提供了替代实现。例如,在PowerPC,smp_mb以sync的形式实现。
不同的CPU都有独特的指令执行内存排序,使得不同的操作系统有不同的实现。这些都没有帮助简化无锁编程!这也是最近引入C++11原子库标准的部分原因。这是一种标准化的尝试,并是编写可移植的无锁代码更容易。