关于memory barrier/memory ordering 的笔记和总结(2)



             Memory ordering

Memory ordering用来描述系统中的processor对内存的操作如何对其它processor可见(可见的定义见前面的描述)。同时需要说明的是,大多数文献都采用reorder这个表达方式,是从执行等价的角度来描述的:比如P1上执行两个写操作WRITE(A)WRITE(B),如果对于观察者P2来说P1|WRITE(B)先于P2|WRITE(A)可见,那么就可以认为P1的写操作发生了reorder。对读操作也是类似的。

影响memoryordering的因素很多,包括:

  1. 体系结构,X86ARMmemory ordering就截然不同;

  2. 内存的类型,体系结构一般都会定义若干种内存类型,不同的内存类型有不同的memoryordering,比如X86分为Strong Uncacheable (UC)Uncacheable (UC-)Write Combining (WC)Write Through (WT)Write Back (WB)Write Protected (WP),而ARM的内存类型用memory typememory attribute来描述,不同的内存类型通常对应了不同的用途(具体情况见对应的厂商文档)。本文描述的是一般情况,也就是不做特殊处理,直接通过内存分配接口分配到的内存,也就是X86WB类型,ARMShareable Normal memory

  3. 具体的指令,比如INTELREP MOVSBREP STOSBmemory ordering就和一般的mov指令不一样。除非特别说明,而本文描述的是普通的内存访问指令,通常是C语言的赋值语句对应的汇编指令。

和前面描述SC的时候一样,XYZ表示变量,初始值都为0P1 P2表示processorR(X)=1表示从X中读到了值1W(X)=1表示向X中写入了值1P1|R(X)=1表示P1执行R(X)=1。符号A->B表示动作A先于动作B发生。

         X86memory ordering

X86memory ordering属于strong order,其与SC的要求接近,在大多数典型场景,没有必要使用memory barrier指令。即使是和外设的DMA操作共享的内存,X86也能通过bus snoop完成强顺序保证(查看linux内核分配一致性内存的接口dma_alloc_coherent,你会发现尼玛就是分配内存咯,并没有设置页表的PWT/PCD标识,也没有设置Memory type range registers(MTRRs))

                       普通内存操作

X86实现的memory ordering比较接近SC的要求,其违反SC的场景是:读操作可能和按照program order中在前面的对不同地址的写操作发生reorder,也就是读操作先于program order在其前面的对其他地址的写操作对外生效。注意:这里仅限于对不同地址的读和写。

也就是以下执行在X86上是可以发生的,但是并不满足SC的要求:

P1: W(X)=1R(Y)=0

P2: W(Y)=1R(X)=0

本例子明显不符合SC的要求,因为如果P1|R(Y)=0,那么必然有P1|R(Y)=0 –> P2|W(Y)=1

结合SC按照program order生效的要求,很容易得到P1|W(X)=1 -> P2|R(X)=0的悖论。

X86允许读操作先于按照program order在其前面的写操作对外生效,P1|W(X)=1 -> P1|R(Y)=0不一定成立,使得上面的序列在X86上变得合法。

 

以上执行序列可以看成是X86write buffer对程序员的体现,write bufferCPU上的一个部件,当一个写操作由于种种原因不能立即放到cache/内存中的时候,CPU可以先把它放到本CPUwrite buffer中,后续再刷新到内存中,这个write buffer只对本CPU可见。这就造成对一个地址的写操作在本 CPU看来已经完成,但是对其它CPU还不可见,而CPU继续执行后续的读操作,在其它CPU看起来,就造成了读和先前的写发生了reorder

                       Memory barrier指令

X86memory barrier指令包括lfence sfence mfence,这些指令通常在使用内存模型(比如Write Combining的操作),特殊的指令(REP MOVSB REP STOSB)才需要关注。

  1. lfence

    lfence确保program order在其前面的读不会和program order在其后面的读和写发生reorder,也就是lfence前面的读操作总是比lfence后面的读操作和写操作先生效。

  2. sfence

    sfence确保program order在其前面的写不会和program order在其后面的写发生reorder,也就是sfence前面的写操作总是比sfence后面的写操作先生效

  3. mfence

    mfence确保program order在其前面的写和写不会和program order在其后面的读和写发生reorder,也就是sfence前面的读操作和写操作总是比sfence后面的读操作和写操作先生效

                       隐含的memor barrier指令

serializing instructionsI/O instructionslocked instructions这些指令会可以产生和mfence类似的附加效果其中

  1. serializinginstructions一般关心的主要就是IRET指令(还有一些比如LGDTLIDT,一般的用户基本不会接触到,详细的列表可以从INTEL的文档中找到),也就是硬中断完成的时候CPU自动执行的指令。

  2. I/Oinstruction是用于访问外设的INOUT指令

  3. lockedinstructions是带LOCK前缀的指令,这些指令通常用于完成原子操作。

         ARMmemory ordering

ARMmemory order属于weak order,与SC差距极大。如果涉及到免锁设计,对ARM体系结构, memorybarrier的使用是不可避免的。

ARMmemory order和对应内存的memory typeShareability domain紧密相关,memory type用页表(page descriptor)中的memory attributes index field来描述,Shareability domain通过Shareability field来描述。其中memory type可以分为Normaldevice,这里描述的是Normal memory,也是我们通常使用的内存,device memory通常是外设映射的register,不是我们讨论的对象。Shareability domain包括Non-shareable(NSH)Inner Shareable(ISH)Outer Shareable(OSH)Full system(SY),通常情况下ISH对应一个OS管理的processorOSH除了ISH外通常还包含外设映射的内存,full system则指的是整个系统(这些概念我不是很清晰,看起来是为虚拟化准备的?)LINUX系统没有区分这些,统统使用SY

 

                       普通内存操作

         对相同内存地址的操作

不同processor对相同地址的操作相关的一致性,被称为coherence,由cache的相关机制来保证,ARM体系中其在memory ordering上有以下特征:

  1. 对相同地址的写操作符合SC的要求,具有全局一致性;也就是所有的写操作体现出全局一致的顺序。

  2. 相同processor对相同地址的读操作对外体现的顺序和program order一致。

    例:

    X的初始值为0

    T1: W(X)=1

    T2:R(X)=1,R(X)=0

    以上的序列是不可能出现的,因为在T2中,由于规则2,有T2|R(X)=1 -> T2|R(X)=0,按照规则1X对所有processors体现的值的顺序应该是01,不可能先读到1,然后读到0

注意:ARM不保证对同一地址的读操作和写操作之间也对其它processor可见的顺序也和programorder一致,也就是在其它processor看来,可能发生乱序。

XY的初始值都是0

T1: W(X)=1R(X)=1, [address dependency],W(Y)=1

T2: R(Y)=1[address dependency],R(X)=0

address dependency是地址依赖(后面有详细描述)以上的序列是允许的,因为T1|W(X)=1T1|R(X)=1自己对T2的可见顺序上是没有保证的。可能T1|W(X)=1T1|R(X)=1完成后很久才传播到T2

         对不同内存地址的操作

  1. 除了存在特殊情况,对ARM系统,我们可以认为内存操作可以按照任何顺序对其他processor可见,也就是以任何顺序reorder。这里说的特殊情况,包括后续描述的存在特殊指令和存在依赖关系的内存操作。

    例子:

    P1: W(X)=1 W(Y)=1

    P2: R(Y)=1 R(X)=0

    对于X86,以上的执行序列是不允许的,因为X86的写操作之间不能reorder,读操作之间也不能reorderP2|R(Y)=1意味着P1|W(Y)=1 -> P2|R(Y)=1,根据X86的要求,有P1|W(X)=1->P1|W(Y)=1 ,P2|R(Y)=1->P2|R(X)=0,也就是:P1|W(X)=1 -> P2|R(X)=0,这显然是不合法的,因此是不允许的。

    而对ARM,如上描述,其运行读操作之前reorder,也允许写操作之间reorder,那么无论是写的reorder使得P1P2体现的执行序列为: P1|W(Y)=1 -> P2|R(X)=1还是读的reorder使得P2P1体现的执行序列为:P2|R(X)=0->P2|R(Y)=1,都会允许例子中的序列发生。

     

    需要注意的是:通过汇编方式,对寄存器的重用不会使得内存操作对其它processor变得有序,例子:

    R3=X , R1=R3, R3=Y

    其中R1R3为寄存器,XY为共享变量。R3=X表示使用汇编把共享变量X读入寄存器R3。以上序列读取XY的时候重用了寄存器R3,这种重用不会确保在其它processor看来,对X的读入先于对Y的读入,也就是在其它processor看来,对Y的读入可能先于对X的读入。

     

  2. 写操作不保证全局顺序的一致性(就更不是multi-copy atomic的了),也就是对于processor A W(X)=1先于W(Y)= 2生效,其它的processor可能看到的是相反的顺序。而对比X86X86是保证写操作的全局一致性的。

    T1: W(X)=1

    T2: R(X)=1, W(Y) =1

    T3: R(Y)=1, R(X) =0

    已经明确(T2读到了X的值为1)对T2有如下的内存操作顺序T1|W(X)=1 -> T2|R(X)=1  T2|R(X)=1->T2|W(Y) =1(比如T2T3的读操作和后续的写操作之间存在address dependency),并不能认为对其它processor有内存操作顺序T1|W(X)=1-> T2|W(Y) =1的存在。X86是保证写操作顺序可传递和全局一致的。

  3. 如果一个写操作(包括写的地址、写下去的值)在程序的单线程顺序执行中不会出现,那么ARM保证在并发环境其也就不会对其他processor可见。这其实就规定了如果一个写操作与之前的读操作存在依赖关系(数据依赖、地址依赖、控制依赖),这个读操作和写操作对其他processorprogram order可见。

                       有依赖关系的内存操作

Address Dependency

如果一个读操作获取到的值被用来计算后续内存操作的地址,无论获取到的值是否改变了后续的内存操作的地址,这个读操作和这些的内存操作都存在Address Dependency。存在Address Dependency的读操作和其后对应内存操作,对所有processor可见的顺序和program order一致,也就是不会reorder

比如:

r1=y
r3=(r1 xor r1)//
运算不会改变r2=*(&x+ r3)中的实际地址,但是仍然构成addressdependency
r2=*(&x + r3)

虽然r3=(r1 xorr1),得到的r3始终为0,不会影响r2=*(&x + r3),但是仍然构成了address dependency,这被称为artificial dependency。不过需要说明的是:这里是为了方便说明使用了C语句,如果你想用C语言直接这样写,绝大多数编译器会识别出r3=(r1 xor r1) 这个运算不会改变r2=*(&x + r3)中的地址,而优化掉这个操作,就不能达到构造一个addressdependency的目的。如果实在需要,只能使用嵌入式汇编了:

LDR R0,[R4]
STR R0,[R2] EOR R1,R0,R0
DMB LDR R2,[R1,R3]

address dependency还有一个作用:对于有依赖关系的读操作和写操作,该写操作之后的其它内存操作不能在该写操作的地址读取完成前执行,因为由于不清楚该写操作要操作的地址,是否和后续的内存操作的地址一致,故必须等待该写操作的地址确定后才能继续执行

Control Dependency

如果一个读操作读取的值被用于后续的条件判断,那么该读操作和条件判断操作之后的内存操作存在control dependency,无论后续的内存操作是不是受条件判断的影响,无论其是否处于条件判断产生的一个分支。比如:

a = READ(X);//局部变量a为读取全局量X的值

if(a == 1){

b = READ(Y);

}

WRITE(Y) = 1;

其中的读操作READ(X)与读操作READ(Y)形成了control dependency,也和写操作WRITE(Y)=1形成了control denpendency(尽管WRITE(Y) = 1是否执行不受读取出的之影响)。同时和address dependency一样,是否形成control dependency不受读取的值是否改变了条件判断的结果的影响。

对于产生了controldependency的读操作和后续的写操作,对所有processor可见的顺序和program order一致,也就是不会reorder,这一点可以和ARM官方文档中的“Writesthat would not occur in a simple sequential execution of the program cannot beobserved by other observers.”对应。。注意:只有对于获取用于条件计算的值的读和后续的写操作才能确保操作顺序,对于获取用于条件计算的值的读和后续的读操作是不行的。

Data Dependency

如果一个读操作获取的值被用于后续的写操作写入的值的计算,那么这个读操作和后续的写操作就存在data dependency,且读操作先于写操作对所有的processor生效。和Address dependencyControl dependency一样,读操作读取的值只要用于了待写入值的计算,无论是否改变了结果,data dependency都成立。

a = READ(X);//局部变量a为读取全局量X的值

WRITE(Y) = a XOR a + 1;

其中写到Y的值的计算用到了READ(X)读到的值,经过该值并不会影响写入到Y中的值,data dependency依然成立,READ(X)WRITE(Y)不会乱序。

总结

以上的依赖关系,只存在于读操作以及特定模式的后续操作中,写操作和后续的操作不存在依赖关系。

                       特殊指令(包括barrier)

以下描述DMBDSB指令都有两个属性(参数),用来表达该barrier生效的Shareability Domain ( NSH表示Non-shareableISH表示Inner ShareableOSH表示Outer ShareableSY表示Full system,缺省是SY)和内存操作类型(LD表示读操作,ST表示写操作,缺省表示读写操作),比如DMB ISHST 表示对Inner Shareability Domain的读写操作生效,如果单单使用DMB,表示对整个系统的读写操作都生效,在linux系统中,只单独使用DMB,没有带参数。

      以下的描述中PEe指的执行barrier指令的processorPEx/PEy指的是任意的processor

         DMB指令

DMB(Data memory barrier)指令用来实现内存栅,它把内存操作分成两部分:group Agroup B

group A:所有在DMB指令之前已经对PEe可见的所有内存操作(当然就包含了PEeprogram order上在该DMB指令之前的内存操作);以及processorPEx在执行group A中内存操作执行之前对PEx可见的所有内存操作。

group B:所有PEe中按照programorderDMB之后的内存操作;以及其它processor中需要读取到group B中的写操作的写入值后才会执行的内存操作(没太明白,这里说的其实是controldependency?)。

DMB指令保证,对所有的processorgroup A中的内存操作先于group B中的内存操作可见(生效)。按照reorder的方式理解就是:group A中的内存操作不会与group B中的内存操作发生乱序。

注意:按照以上的描述,即使是使用DMB指令,也无法实现multi-copyatomic,因为其保证的是内存操作对同一个processor生效的相对顺序,对内存操作在不同processor之间的生效顺序是无保障的。

使用DMB——简单场景:

P1: W(X)=1DMBW(Y)=1

P2: R(Y)=1DMBR(X)=0

我们知道:对于以上的序列,如果没有DMB指令,在RAM环境下是允许的,在增加了DMB指令后,P1中的DMB指令确保了P1|W(X)=1P1|W(Y)=1不会乱序,也就是P1|W(X)=1 -> P1|W(Y)=1,;而P2中的DMB确保了P2|R(Y)P2|R(X)不会乱序,也就是P2|R(Y)->P2|R(X);很容易得到结论:以上的序列是不会出现的。

使用DMB——cumulative场景:

这也是ARM不能保证写操作顺序全局一致性的典型例子。

X Y的初始值都是0

T1: W(X)=1

T2: R(X)=1, [artificial address dependency]R(Y) =0

T3:W(Y)=1

T4:R(Y)=1,[artificial address dependency]R(X) =0

对于RAM,以上的系列是可能的,也就是T1T3X和对Y的写操作,对T2T4呈现出不同的可见顺序(注意:artificialaddress dependency已经确保了读操作不会乱序),T2W(X)=1先发生,而对T4,W(Y)=1先发生。为了确保W(X)W(Y)T2T4体现一样的顺序,可以采用:

T1: W(X)=1

T2: R(X)=1, DMB,R(Y)=0

T3:W(Y)=1

T4:R(Y)=1, DMB, R(X)=0

这样的话,以上的序列就是不可能出现的。由于T2读到了X1,因此,对T2,T1|W(X)=1 -> T2|R(X)=1,按照DMB的语义,对所有processor(包括T4/T2):T1|W(X)=1 -> T2|R(Y)=0;同样可以得到对所有的processor(包括T2/T4: T3|W(Y)=1 -> T4|R(X)=0,这是不可能的,因为:T4|R(X)=0,那么对T4,必然有T4|R(X)=0 –> T1|W(X)=1,就是上面的三个关系需要对T4都成立,于是对T4T3|W(Y)=1->T4|R(Y)=0(DMB) ->T4|R(X)=0 –> T1|W(X)=1->T2|R(Y)=0,按照DMB的要求对所有processorT3|W(Y)=1->T2|R(Y)=0,对T2来讲,这是不可能的。

 

DMB能够保证以上序列不出现的关键在于:DMB不仅保持了本processor内部的操作对外的可见顺序,也保证了DMB之前对该processor可见的其他操作对外的可见顺序。这个区别于各种依赖关系的特性,被称为cumulative

 

         DSB指令

DSB指令保证:在执行DSB指令前对PEe生效的内存操作,在DSB指令执行完成前对其它processor生效。program order中在DSB指令之后指令只能在DSB指令完成后才能开始执行

         Load acquire-Store release

这两个指令通常用来实现原子操作,其功能和X86XCHG(CAS,compare and swap)指令类似(不同的是,其没有ABA问题),对于ARM环境,内核采用该指令实现原子操作、互斥原语。

  1. Load acquire-Store release作用的Shareability DomainLoad acquireStore release指令指定的地址所在的Shareability Domain

  2. Load acquire-Store release对所有的memory type生效。

  3. Load acquire和紧跟的Store release指令按照program order对其它PE可见(也就是对所有PE,总有Load acquire->Store release)

  4. Load acquire是一个读操作,该读操作先于按照program order在其之后的读写操作对所有processor生效。

  5. Store release是一个写操作,该写操作后于按照program order在其之前读写操作对所有processor生效。在PEe(执行Store releasePE)执行Store release前对PEe可见的写操作,对其它PE都在Store release的写操作之前生效。只有在Store release要写入的地址在对应的Load acquire之后没有被写入过,Store release才会成功(允许对其它非Load acquire preserve的地址的写操作),只有成功的Storerelease才有以上的memoryordering特性。

  6. Store release的写操作是multi-copy atomic的,也就是如果其对一个PE生效,那么其对所有的PE生效。

一些资料上认为Loadacquire-Store release可以完全取代DMB指令,这是不对的,因为Loadacquire-Store release保证这两个指令之间的读写不会“外泄”,却无法保证两个指令之外的指令不会入侵,这一点,看看内核atomic64_sub_return就明白了:

static inline u64 atomic64_add_return(u64i, atomic64_t *v)

{

      u64result;

      unsignedlong tmp;

 

      smp_mb();

 

      __asm____volatile__("@ atomic64_add_return\n"

"1:  ldrexd    %0, %H0, [%3]\n"

"     adds%0, %0, %4\n"

"     adc %H0, %H0, %H4\n"

"     strexd     %1, %0, %H0, [%3]\n"

"     teq  %1, #0\n"

"     bne 1b"

      :"=&r" (result), "=&r" (tmp), "+Qo"(v->counter)

      :"r" (&v->counter), "r" (i)

      :"cc");

 

      smp_mb();

 

      returnresult;

}

                       DMB vs依赖关系

DMB和依赖关系(address dependency/control dependency/data dependency)的差别在于其影响的内存操作的范围:

  1. address dependency/control dependency/data dependency能确保顺序的内存操作的是本processor上和依赖相关的读操作和其后对应的内存操作(addressdependency是需要使用读取到的值计算地址的读写操作,control dependency是使用读取的值作条件判断的判断语句之后的写操作,data dependency是使用读取到的值作为写入值的写操作),影响的范围不会波及本processor上的其它内存操作,更加不会影响其它processor上的内存操作的顺序。

  2. 依赖关系仅仅存在与读操作和后续的其它内存操作之间,写操作和后续的内存操作之前不存在类似的关系。

  3. DMBbarrier指令影响的内存操作的范围比依赖关系要大,在本processor上,其会影响program order上在DMB指令前后的所有指令;同时其还具有积累/传递效应,可以影响到其它processor上的内存操作(比如其他CPU上所有对DMB的执行processor生效的操作)

 

1

X Y Z的初始值都是0

T1: W(X)=1

T2: R(X)=1, [artificial address dependency]W(Y) =1

T3: R(Y)=1, [artificial address dependency]R(X) =0

其中的artificialaddress dependency就是故意制造的address dependency,确保了读操作和后面的写操作之间不会乱序。

以上的执行序列是可能出现的,因为T2|R(X)=1只是表明了T1|W(X)=1已经对T2生效,也就是对T2,有T1|W(X)=1 -> T2|R(X)=1 ,由于address dependency的作用,对其它processorT2|R(X)=1->T2|W(Y) =1。但是由于ARM下,写操作的顺序不具备传递性,不能认为对T3T1|W(X)=1 -> T2|W(Y)=1,因此T3|R(X)=0是可能存在的。为了确保T3|R(X)=0不存在,可以有以下的做法:

T1: W(X)=1

T2: R(X)=1, DMB, W(Y) =1

T3: R(Y)=1, [artificial address dependency]R(X) =1

这样,T3|R(X)=0就不会出现了,因为DMB指令确保了:对(共享域内)所有的processorDMB之前对T2可见的内存操作先于DMB之后的内存操作可见。T2|R(X)=1表明T1|W(X)=1DMB指令之前已经对T2可见,因此对所有processor,包括T3,有T1|W(X)=1 -> T2|W(Y)=1;且T3|R(Y)=1表明对T3, T2|W(Y)=1 -> T3|R(Y) =1, address dependency确保了T3|R(Y)=1 -> T3|R(X)=1,因此对T3, T1|W(X)=1->T3|R(X) 很明显不可能出现T3|R(X)=0

可见依赖关系能够确保的只是当前processor内的内存操作对外的可见顺序,涉及到其它processor的就无能为力,而DMB等内存栅指令能够做到这一点。

 

 

CPU的结构上理解我们可以认为ARM的写操作propagate到各个CPU核心上的顺序是不确定的,address dependency只关注本CPU核心的执行顺序,不会影响到这个扩散顺序,而DMB指令则能够保证内存操作扩散顺序的一致性。

你可能感兴趣的:(linux内核)