【译】Memory Reordering Caught in the Act

原文:http://preshing.com/20120515/memory-reordering-caught-in-the-act/

编写lock-free C/C++程序时,在保证memory ordering正确性上要非常小心,否则,奇怪的事就来了。Intel在《x86/64 Architecture Specification》Volume 3, §8.2.3一节中列出了一些可能发生的“怪事”。来看一个小例子:X,Y两个变量均被初始化为0, 编写如下汇编代码,由两个处理器(processor)执行:

为了清楚的阐述CPU ordering,此处使用了汇编指令。每个处理器将一个变量赋值为1(store),并读取另外一个变量(load),此处的r1、r2代表寄存器。正常情况下,不管处理器的执行顺序如何,r1,r2所有可能的结果为:

r1==0, r2 ==1 
r1==1, r2==0
r1==1, r2==1

不可能的结果为:

r1==0, r2==0。

按Intel文档中的描述, 上述结果是有可能的,这就是本文所要讲述的“怪事”(至少是违反直觉的)。
Intel x86/x64处理器和大多数处理器家族一样,在不影响单个线程执行结果的前提下,允许对内存交互指令进行重排(reorder)。特别指出:处理器允许将store动作延迟到任何load动作之后,只要load、store的操作的不是同一块内存。因此,您编写的汇编代码在执行时可能变成了这样:


来,试试!

“好好好,你说这事可能发生,但我从来没见过,叫我如何相信?”
那...叫我们来试试如何:源码在
这份代码包括win32、POSIX两个版本,由两个派生线程重复执行上述transaction代码,并由主线程核对执行结果。
第一个工作线程源码如下:x, y, r1, r2为全局变量,两个POSIX semaphores用于和主线程的并发处理:

第二个线程处理类似,只需将r1换成r2,beginSema1换成beginSema2

sem_t beginSema1;
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

       // ----- THE TRANSACTION! -----
       X = 1;
       asm volatile("" ::: "memory");        // Prevent compiler reordering
       r1 = Y;

       sem_post(&endSema);                   // Notify transaction complete
   }
   return NULL;  // Never returns
};

在每个事务之前添加一个短暂的随机延迟,以便错开线程的时序。记住,有两个工作线程,我们试图让他们的指令重叠(译注:这两句话似乎是矛盾的)。随机延迟是使用与之前的帖子中使用的相同的MersenneTwister来实现的,例如在测量锁争用 时以及验证递归的Benaphore是否工作时。别被asm volatile这行代码唬到,这只是告诉GCC编译器在生成机器码时,不要重排store和load,以防GCC在编译优化时又想出了什么“歪点子”。

来看编译后的汇编代码:

$ gcc -O2 -c -S -masm=intel ordering.cpp
$ cat ordering.s
    ...
    mov    DWORD PTR _X, 1
    mov    eax, DWORD PTR _Y
    mov    DWORD PTR _r1, eax
    ...

Store和load的顺序和预期的一致,先执行X=1,随后执行r1=Y。

下面是主线程代码,职责如下:初始化后,进入无限循环,重置x,y为0,通过信号量触发两个线程运行。
需要注意的是:所有写入共享内存的方式都是在sem_post之前发生的,所有从共享内存读取的数据都是在sem_wait之后发生的。在与主线程通信时,工作线程遵循相同的规则。信号量给我们在每个平台上获取和释放语义。这意味着我们保证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
}

检验真理的时刻到了,这是我在Intel Xeon W3520、Cygwin环境下运行的结果:


这下你总算信了吧!运行过程中,内存重排序大约每6600次检测到一次。当我在Core 2 Duo E6300、Ubuntu 环境下测试时,出现的概率甚至更低。你已经开始意识到, bugs在未被发现的情况下蔓延到lock-free代码中。现在,你可能在想:“我不需要这该死的reording”。OK,至少有两种方法。
一种是将两个线程绑定到同一个CPU core上,pthread并未提供相应的结构,但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);

这样一来,重排序消失了。因为单个处理器上是保序的,哪怕线程在任意时候被抢占并重新调度。当然,将两个线程绑定到一个核上,致使其它CPU Core未被有效利用(由此看来,这并不是个好办法)。
我在Playstation 3上编译、运行,并未检测到内存重排。这表明(但不确认)PPU内的两个硬件线程可能会有效地作为单个处理器,具有非常细粒度的硬件调度。

采用StoreLoad Barrier避免memory reordering

另一种避免memory reordering的方法是:在两条指令间引入CPU屏障(CPU Barrier)。本例中,我们要阻止Store和随后的Load指令发生重排,引入的CPU Barrier通常称为StoreLoad Barrier。

在X86/X64处理器上,没有专门的StoreLoad barrier指令,但有一些指令可完成另丰富的功能。Mfence指令为full memory barrier指令,它可以避免任何情况的内存重排。GCC中的实现方式如下:

   for (;;)                                  // Loop indefinitely
   {
       sem_wait(&beginSema1);                // Wait for signal from main thread
       while (random.integer() % 8 != 0) {}  // Add a short, random delay

       // ----- THE TRANSACTION! -----
       X = 1;
       asm volatile("mfence" ::: "memory");  // Prevent memory reordering
       r1 = Y;

       sem_post(&endSema);                   // Notify transaction complete
   }

查看编译生成的汇编代码来验证效果:

    ...
    mov    DWORD PTR _X, 1
    mfence
    mov    eax, DWORD PTR _Y
    mov    DWORD PTR _r1, eax
    ...

修改后,内存重排消失了, 两个线程可运行在两个不同的CPU cores上。

类似的指令和不同的平台

其实,mfence不是x86/x64下唯一的full memory barrier。在这些处理器上,任何locked指令,如xchg均属于full memory barrier - 只要您不使用SSE指令或write-combined memory,像本示例一样。实际上,如果你使用MemoryBarrier指令时,Microsoft C++编译器会生成xchg指令(至少VS2008如此)。

Mfence指令适用于x86/x64,如果想编写可移植的代码,可以采用预处理宏技术。Linux内核提供了一组宏:smp_mb、smp_rmb、smp_wmb,并提供了一组实现alternate implementations on different architectures. 如在PowerPC上,smp_mb被实现为sync.

不同的CPU家族有其自己的memory ordering指令集,编译器根据自身喜好提供此类功能,而跨平台项目则为此封装自己的抽象层...而这些对简化lock-free编程毫无益处。这也是为何C++11引入C++11 atomic library标准的原因,标准化、更为方便的编写lock-free的可移植代码。

译注:

  1. Memory Reordering准确的理解应该是和Memory相关的机器指令的重排序。
  2. Memory Reordering将其认定同类的或访问相同内存的CPU指令尽可能的放到一起执行。
  3. Memory Reording的原则在于:重排前后,单个线程上的行为保持一致,前述例子中,每个程序单独运行时其结果是一致的,也可以理解为“保序”。
  4. 在编写多线程程序时,我们通常通过添加mutex、semaphores 等方式执行并发保护,而非lock-free程序。这类锁按本文的描述均属于full memory barrier,程序当然不会出现memory reordering问题。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容