当使用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();
}
网友评论