内存指令重排以及顺序一致性

当使用c/c++编写lock-free代码时,必须要非常小心注意到内存指令可能会被重排了。否则会得到意想不到的结果。
intel列出了这些“惊喜”在Volume 3, §8.2.3在x86/64架构说明,下面列举一个简单的例子来重现这个情况。假设有2个整数X和Y在内存的某处,都初始化为0。2个处理器,并行的执行的下列机器码。


每个处理器分别存储1到对应的整形变量中,然后加载另外一个整形变量到一个寄存器中。(r1,r2分别是寄存器的名字,可以理解成%eax)
按照常理,无论哪个处理器先把写入1,很自然的想到另一个处理器读取这个写入的新值回去,意味着结束时,至少r1=1或者r2=1,或者r1=1且r2=1。但是intel的说明书阐述了并一定非要这样,r1或者r2等于0也是合法的。额~看起来真的很违法直觉。
去理解Intel X86/64的处理器,和其他大多数处理器类似,都允许重排内存操作的机器指令通过一个特定的规则,只要不改变单线程程序的执行顺序。每个处理器都允许延迟存储效果的发生尤其在不同位置上加载时。执行的顺序可能最后顺序就像如下所示。

注意:这里的执行顺序并不是编译器优化的结果,而是处理器会乱序执行的问题。

下面我们来重现这个问题

全部代码https://github.com/iamjokerjun/memReordering
在这里。X,Y,r1和r2都是全局变量,通过信号量来同步开始和结束每个循环。

sem_t beginSema1;
sem_t endSema;

int X, Y;
int r1, r2;

void *thread1Func(void *param)
{
    MersenneTwister random(1);                //初始化线程安全的随机数种子发射器
    for (;;)                                                  
    {
        sem_wait(&beginSema1);                // 等待主线程的信号
        while (random.integer() % 8 != 0) {}  // 添加一个随机的延迟

        // ----- 下面是事务! -----
        X = 1;
        asm volatile("" ::: "memory");        // 阻止编译器优化
        r1 = Y;

        sem_post(&endSema);                   // 通知事务完成
    }
    return NULL;  
};

上述代码中的asm volatile("" ::: "memory")只是告诉编译器生成机器指令时不要去重排加载和存储指令。
可以通过objdump工具看到汇编指令没有被编译器重排。

  40097e:   8b 05 04 07 20 00       mov    0x200704(%rip),%eax        # 601088 <Y>
  400984:   bf a0 10 60 00          mov    $0x6010a0,%edi
  400989:   89 05 f5 06 20 00       mov    %eax,0x2006f5(%rip)        # 601084 <r1>

在我的i5 4核心的ubuntu系统下测试结果如下:

差不多每3000次出现一次内存操作指令重排的情况发生。
主线程代码如下:

int main()
{
    // 初始化信号量
    sem_init(&beginSema1, 0, 0);
    sem_init(&beginSema2, 0, 0);
    sem_init(&endSema, 0, 0);

    // 产生2个子线程
    pthread_t thread1, thread2;
    pthread_create(&thread1, NULL, thread1Func, NULL);
    pthread_create(&thread2, NULL, thread2Func, NULL);

    // 重复试验,获取试验次数
    int detected = 0;
    for (int iterations = 1; ; iterations++)
    {
        // 重置 X and Y
        X = 0;
        Y = 0;
        // 唤醒2个子线程
        sem_post(&beginSema1);
        sem_post(&beginSema2);
        // 等待两个子线程测试结束
        sem_wait(&endSema);
        sem_wait(&endSema);
        // 如果重排的话
        if (r1 == 0 && r2 == 0)
        {
            detected++;
            printf("%d reorders detected after %d iterations\n", detected, iterations);
        }
    }
    return 0;  // Never returns
}

注意所有的共享内存的写入需要在sem_post之前以及所有的从共享内存去读都要在sem_wait之后。worker线程和主线程都这样。这个意味着我们保证初始化X=0以及Y=0在worker线程开始工作前以及结果r1和r2会在worker线程的事务完成后返回。

如果我们想消除内存指令重排,我们至少有2个方式去达到目的。
一个方法是使得2个线程同时执行在一个cpu核心上,在我们的demo里打开USE_SINGLE_HW_THREAD宏定义为1,就可以绑定2个线程到同一个cpu核心中。

    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);

这样操作后,重排的问题没有了。因为单处理器不能发现乱序的发生,甚至线程被抢占和重新调度时也是如此。当然这么做,另外一个核就浪费了。

使用StoreLoad Barrier来阻止重排

另一种方式就是使用cpu Barrier在两条指令之间。这里我们需要阻止load后store的重排。在通用的屏障用法中,我们需要一个 StoreLoad barrier。
在X86/64处理器中,没有特殊的指令专门的StoreLoad屏障。mfence指令是全内存屏障,阻止各种内存指令重排,在gcc中asm volatile("mfence" ::: "memory");
下面我们显示反汇编内容:

  400a14:   c7 05 6a 06 20 00 01    movl   $0x1,0x20066a(%rip)        # 601088 <Y>
  400a1b:   00 00 00 
  400a1e:   0f ae f0                mfence 
  400a21:   8b 05 65 06 20 00       mov    0x200665(%rip),%eax        # 60108c <X>

我们可以看到中间插入了一条mfence 指令。这样可以运行分别运行在不同的cpu 核心上了。

mfence指令只作用在X86/64中。linux针对不同平台封装了一些宏定义smp_rmb,smp_rmb以及smp_wmb来实现同步。c++11中引入了atomic库,使写lock-free 代码更容易了。
在C++11中 可以这样的形式写:

std::atomic<int> X(0), Y(0);
int r1, r2;

void thread1()
{
    X.store(1);
    r1 = Y.load();
}

void thread2()
{
    Y.store(1);
    r2 = X.load();
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容