原子操作、MESI和内存屏障引起我对锁理解的智障

前言:

先向已经贡献大量公开文档的前辈们致敬,不管是中文的、英文的;再鄙视一下ARM文档中关于DMB之类的文字,赤裸裸的鄙视,我截一段大家感受下:“The DMB instruction ensures that all affected memory accesses by the PE executing the DMB instruction that appear in program order before the DMB instruction and those which originate from a different PE, to the extent required by the DMB options, which have been Observed-by the PE before the DMB instruction is executed, are Observed-by each PE, to the extent required by the DMB options, before any affected memory accesses that appear in program order after the DMB instruction are Observed-by that PE.”。写这种臭长臭长的大长句+一堆特定含义词,这是手册中应该出现的吗?!莫不是不死族为转化我人族大骑士设计的咒语吧!

气哼哼的开始正文


一直以来,我自认为对锁的理解是比较深入且没有错误的,知道最近遇到一串儿问题,我才发现,我对锁的使用级理解没有问题,但是原理级理解是存在智障(智力屏障)的。而再增加数据存储屏障、指令屏障,引起我智障的铺垫有很长,但我自己思考了一下,然后再次阅读查找了之间查过的资料,最终找到了我的理解中存在的灰色地带,我表述成几个问题:

1. 保障了什么就可以保障原子操作?

2. MESI能保障什么,保障不了什么?

3. 内存屏障要保障什么?

4. 自旋锁如何锁得住?

所谓原子操作,只需要保障在多CPU之间对某一内存地址中的值的增减是有序的,即可实现原子计数,所谓的原子操作。这个影响范围其实是非常小的,而且现代CPU对内存的访问都会经过多级的cache,cacheline的大小一般都会超过或等于CPU的位宽(32位、64位CPU),所以,在所有CPU的cache之间,如果存在某种同步机制,那么原子操作的也就会在极少范围内、以极少的代价实现;而LDXRB/LDAXRB/LDXRH/LDXR/LDAXR/LDXP/LDAXP以及与这组指令成对使用的STXRB/...即用于此目的;关于这组寄存器只需要简单说一下就能明白:

LD*X* addr

操作此addr中的数据

ST*X* addr

在多个CPU上如果同时执行这三行,只有最先ST*X*到此addr的那句生效,返回0,而其他ST*X* addr都会返回1,然后程序就可以知道自己的原子操作有没有生效。 也就是说,LD*X*与ST*X*的使用能保障写入顺序,也只能保障写入顺序。 而保障了写入顺序,也就实现了原子操作。

而这种在所有CPU的cache之间做同步的一般都会是MESI,MESI的详细内容不展开论述,仅只出一点:MESI实现多CPU间被cache内存的一致性的通知机制,但也仅实现通知机制,不会将不一致的cache的数据统一(也就是不传输数据)。这是什么意思呢:当多个CPU共同操作内存中的某个地址,MESI不会保障单一CPU对此地址的写入在其他CPU的cache生效,而是通过使单一CPU对此地址的写入引起其他CPU的cacheline变脏,来实现这些CPU的cacheline的内容的一致性的保障,其他共享此内存的CPU的对应cacheline变脏,置为脏,则对此cacheline的读就需要重新从内存(也可能是下一级cache)加载此cacheline。

至此,原子操作和MESI就明确了,也就是第1、2问题算是得到回答。那么MESI能保障了原子操作,那么它能保障多核间对某临界资源的访问一致性吗?答案是不能。可能我们假设实现一个原子计数,多核间对此数据的操作是对齐的,LD*X*得到0则尝试ST*X*置1,返回0(成功)后即认为成功抢到锁,然后就可以开心的去加载临界资源。到目前为止,这个理解并没有问题,但是只怕遇到一种情况:当上一个释放原子计数(置0)的兄弟A的指令是乱序执行的,释放原子计数的操作被乱序执行在了操作某块临界资源之前,这时候,成功争得原子计数的老兄B,也对这块临界资源发生了操作,若是B对临界资源的操作在A之后生效了也就罢了,如果B兄弟的操作先生效了,这块临界资源自然就产生了混乱,所以,MESI保不住这块临界资源。

于是第3个问题就顺理成章的被推到了前台,要说内存屏障在保障什么,首先肯定要提现在CPU的指令乱序且并行执行的原理,用一句话表述就是:有顺序的汇编代码在真实的单个CPU中,是会根据指令之间是否存在依赖性、指令执行所需时间发生重排以及并发执行的,并且执行结果很多时候都会不写入(直接投入后续计算)。更粗鲁一点说,其实一个CPU(PE)是可以在一个时钟周期内同时执行多次1+1=2的计算的;对比不支持指令乱序并行执行的古代CPU(古代把能算出1+1=2的这段电路就算做一个CPU),现代每一个CPU核都是有一堆古代核(很不准确的说)。这样的指令执行方式,极大加速了CPU效率,但是也带来了一些问题,有些执行很慢的指令如:遇到冷cache,需要从主存加载数据;操作外设寄存器,外设反应远慢与CPU;这样的一段代码大概率都会出现后快指令先于前慢指令执行的情况,这使“后指令非内存地址可感的对前指令操作结果存在依赖”的情况出现执行错误。还有很多时候一些指令沉积在流水线中,没有达到提交点,而它的提交又是必然会发生的,此时cache中的数据没变,但是却必然会发生改变。当然,上面所说的这些并不是重点,重点是,内存屏障包括“DMB 作用域、DSB 作用域、ISB”等这些数据屏障,指令屏障能保障什么:他们只能保障前文指令在DMB/DSB/ISB指令后的指令前完全给出结果(执行完毕),单核性质的。这么说,屏障,能保障多核间对某临界资源的访问一致性吗?很遗憾答案是不能,可能说,它能保障:“对于执行屏障的CPU 1,到屏障点后未开始下一条指令时,CPU 1流水线内所有计算结果都会显化给屏障作用域内其他CPU看到”,但是看到也就是看到,并不知道是什么时候看到的,跟多核并没有几毛钱的关系。

原子操作保得住多核对原子空间的有序操作,但保不住临界资源,因为乱序并发;然后又说了内存屏障包的住乱序并发,但跟多核并没有几毛钱关系;而第4个问题,自旋锁如何锁得住,就是综合使用原子操作和内存屏障,才能保住临界资源。天色已晚,该丢点代码出来,下面是自旋锁的获取锁:

/* Spinlock implementation. The memory barriers are implicit with the load-acquire and store-release instructions.*/

static inline void arch_spin_lock(arch_spinlock_t *lock){

unsigned int tmp;

arch_spinlock_t lockval, newval;

asm volatile( /* Atomically increment the next ticket. */

ARM64_LSE_ATOMIC_INSN( /* LL/SC */

" prfm pstl1strm, %3\n" /* 内存预加载 %3是 "+Q" (*lock) */

"1: ldaxr %w0, %3\n" /* 加载*lock,与ldxr的区别在于,load-aquire,隐含屏障 */

" add %w1, %w0, %w5\n"

" stxr %w2, %w1, %3\n" /* 抢锁位(排位) */

" cbnz %w2, 1b\n", /* 抢到否? */

/* LSE atomics */

" mov %w2, %w5\n" /* */

" ldadda %w2, %w0, %3\n"

__nops(3))

/* Did we get the lock? */

" eor %w1, %w0, %w0, ror #16\n" /* 计算是否抢到锁(owner是否为自己)  */

" cbz %w1, 3f\n"

/* No: spin on the owner. Send a local event to avoid missing an unlock before the exclusive load.*/

" sevl\n"

"2: wfe\n"

" ldaxrh %w2, %4\n" /* 加载owner */

" eor %w1, %w2, %w0, lsr #16\n" /* 继续算是否抢到锁(owner是否为自己) */

" cbnz %w1, 2b\n"

/* We got the lock. Critical section starts here. */

"3:"

: "=&r" (lockval), "=&r" (newval), "=&r" (tmp), "+Q" (*lock)

: "Q" (lock->owner), "I" (1 << TICKET_SHIFT)

: "memory");}

然后自旋锁的释放:

static inline void arch_spin_unlock(arch_spinlock_t *lock){

unsigned long tmp;

asm volatile(ARM64_LSE_ATOMIC_INSN(

/* LL/SC */

" ldrh %w1, %0\n" /* 加载owner */

" add %w1, %w1, #1\n" /* 要释放锁,owner增 */

" stlrh %w1, %0", /* 存出去,让大家看到 */

/* LSE atomics */

" mov %w1, #1\n"

" staddlh %w1, %0\n"

__nops(1))

: "=Q" (lock->owner), "=&r" (tmp)

:  "memory");

}

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