美文网首页
内存指令重排以及顺序一致性

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

作者: Teech | 来源:发表于2019-07-26 17:49 被阅读0次

    当使用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();
    }
    

    相关文章

      网友评论

          本文标题:内存指令重排以及顺序一致性

          本文链接:https://www.haomeiwen.com/subject/ixqdrctx.html