谁需要在x86上使用内存屏障呢?


Who ordered memory fences on an x86?

具有宽松存储模型的多处理器的行为是会非常令人困惑的,其写操作可以是乱序的,读操作可以是推测的,并返回未来的值,这是多么的混乱啊!为了保证一些一致性,你需要使用内存屏障,并且有几种不同的内存屏障。

在危险的宽松存储多核处理器领域,x86看上去就是一块绿洲。Intel 64 Architecture Memory Ordering White Paper 以及AMD 规格说明 AMD64 ArchitectureProgrammer’s Manual 中详细说明了Intel x86存储模型,并列出了如下大量的存储顺序保证:

  • 不同的加载指令(Loads)不会被重排序
  • 不同的存储指令(Stores)不会被重排序
  • 存储指令不会与老的加载指令重排序
  • 在多处理器系统中,存储顺序遵循因果一致性(存储顺序遵循传递可见性)
  • 在多处理器系统中,对相同地址的存储操作具有全序关系
  • 在多处理器系统中,加锁的指令具有全序关系
  •  加载与存储指令不会与加锁的指令重排序

x86也有内存屏障指令mfence, lfence, 以及sfence(但是代价很高,需要100个周期);但是考虑上面提供的存储顺序保证,为什么还有人需要内存屏障指令呢?著名的双检测加锁(double-checkedlocking pattern)在x86上就不需要任何屏障并工做的很好。

这是一个非常重要的问题,因为你不希望编译器违反你的代码屏障,也就是说你不希望它产生错误的代码,因此我决定找到一些答案。

下面是在x86规格说明中列出的一个重要的非保证事项:

加载指令可能会与先前的对不同地址进行操作的存储指令重排序

下面是x86规格说明中的一个例子:x和y位于共享内存中,并且都初始化为0,r1和r2时处理器寄存器。

Thread 0

Thread 1

 

mov [_x], 1

mov r1, [_y]

mov [_y], 1

mov r2, [_x]

存储

加载

当这两个线程在不同核上运行时,允许出现非直观的结果r1==0并且r2==0。注意当处理器在存储指令之前先执行加载指令(访问不同的地址)的情况下,结果将一直这样。

这个例子有多重要呢?

我需要找到一个会被这种宽松情况破坏的算法。我已经看到Dekker算法中提到了这一点,但是还有一个更现代的Peterson锁用到了这一点。这是一个用于两个线程的互斥算法,它假设每个线程都可以访问一个线程自身的id,一个是0,另一个是1。下面是改编自TheArt of Multiprocessor Programming 的版本:


class Peterson

{

private:

    // indexed bythread ID, 0 or 1

    bool_interested[2];

    // who'syielding priority?

    int _victim;

public:

    Peterson()

    {

        _victim =0;

       _interested[0] = false;

       _interested[1] = false;

    }

    void lock()

    {

        // threadIDis thread local,

        // initialized either to zero or one

        int me =threadID;

        int he = 1- me;

       _interested[me] = true;

        _victim =me;

        while(_interested[he] && _victim == me)

           continue;

    }

    void unlock()

    {

        int me =threadID;

       _interested[me] = false;

    }

}


 

为了解释它是如何工作的,让我来模仿其中一个线程。当我(那个线程)想要获取一个锁时,我设置我的_interested槽为true,我是唯一可以写这个槽的,虽然另一个线程可以读它。同时我将_victim指向我自己,然后检测另一个线程是否也对这个锁感兴趣,如果它感兴趣并且我是牺牲者,那么我就旋转等待。但是一旦它不再感兴趣或者将他自己设置成了牺牲者,那么这个锁就是我的。当我完成临界代码后,我重置我的_interested槽,从而可以释放另一个线程。

让我们对此进行一点简化,并且区分两个线程的代码。我们使用两个变量zeroWants 和oneWants分别对应两个槽,而不是使用一个数组_intereste。

 

zeroWants = false;

oneWants = false;

victim = 0;

Thread 0

Thread 1

zeroWants = true;
victim = 0;
while (oneWants && victim == 0)
 continue;
// critical code
zeroWants = false;

oneWants = true;
victim = 1;
while (zeroWants && victim == 1)
 continue;
// critical code
oneWants = false;

 

最后,让我们将执行代码的前部分改写成伪汇编。

Thread 0

Thread 1

store(zeroWants, 1)
store(victim, 0)
r0 = load(oneWants)
r1 = load(victim)

store(oneWants, 1)
store(victim, 1)
r0 = load(zeroWants)
r1 = load(victim)

 

现在分别看对zeroWants 和oneWants的加载与存储,它们和x86重排序例子中的模式一样。处理器可以随意的将对oneWants的读操作移动到对zeroWants(以及victim)的写操作之前。类似的,它可以将对zeroWants的读操作移动到对oneWants的写操作之前。我们最终可能得到下面的执行顺序:

 

Thread 0

Thread 1

r0 = load(oneWants)
store(zeroWants, 1)
store(victim, 0)
r1 = load(victim)

r0 = load(zeroWants)
store(oneWants, 1)
store(victim, 1)
r1 = load(victim)

 

最初zeroWants 和 onneWants都被初始化为0,所以r1和r2最终可能都是0。在这个例子中,自旋锁永远不会执行,两个线程都直接进入了临界区。x86上的Peterson算法被破坏了!

当然,有方法来修复它。mfence将强制正确的顺序。它可以被放在一个线程中存储zeroWants和加载oneWants之间以及另一个线程存储oneWants和加载zeroWants之间的任何位置。

所以我们有了一个在x86上需要真实的屏障的实例。这个问题是真实的!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章