当你们在谈论锁的时候,我听不懂你们在谈论什么钥匙由于自己计算机知识体系的残缺,经常会遇到一些想不明白的玩意。也是机缘巧合,我发现一种比较笨的方法让帮助我消化这类问题 ———— 去翻 Intel 的指令、编程与优化三大指南。毕竟大多数情况下,不管你要做什么事情你都要给 CPU 相应的指令,如果可以的话还应该去优化它的性能。 我是个经常忘记自己锁没锁门的人,但不同于强迫症患者的是,我有时真的出门没锁门,万幸的是,我往往钥匙也不带。这样每当我因为内心的怀疑而折返回家时,我总会很高兴没有白走这一趟 ———— 毕竟我既锁了门,又拿到了钥匙。美滋滋。 于是乎,在我第一次写带锁的代码时我就产生了对锁产生了特殊的感觉。 你不知道有多少人在等着进门很显然,单单完成一个原子操作是非常简单的,这当然应该是 CPU 在做的事情, CPU 做事情都非常简单而且好理解。CPU 以 Cache Line 为单位,使用一套 Cache 一致性协议和仲裁机制来判断哪个核心胜出。一致性协议在手册中有详细的介绍,仲裁的办法可能涉及到机密,我就不瞎猜了。 但如果有看过 Golang 的锁实现,你会发现比你想象的要复杂很多,毕竟 runtime 要照顾好所有排队等锁的 goroutine 的“情绪”,还要这个过程中保持高效。 这就很麻烦了,runtime 中有许多内部函数在获取/维护 goroutine 的状态,如果你想抽出来在自己的代码中实现,你会发现能拿出来的功能少的可怜。这注定了你没法很好的“安慰”门外的 goroutine 们,要么忽悠它们轮着去敲门,要么轰一部分滚蛋。我看的几个 Golang 的无锁队列实现都选择了前者,说的“计算机”一点,就是无限自旋。听上去就让人恨忧伤呐。 看电影 吃饭 喝酒 大床房老司机都会发现,这个小标题就是对某种不可描述的套路的总结。尽管最终步骤就 3秒,也是很辛苦。能不能简单一点?纯粹一点? 在阅读 Golang CAS 实现的汇编代码的时候,我发现了一条“LOCK”指令,感觉很有意思 ———— 既然我已经有了 CMPXCHG 和缓存一致性协议, 为什么还要 LOCK 一下? 这是多余的套路吗? 于是我草草翻了把 Intel 的手册,其对 Lock 指令在 P6 以后的 CPU上的作用方式进行了简单说明,其中有这么一句”the processor may not assert the LOCK# signal on the bus”。我又惊又喜,果断删掉了 LOCK。结果,我就碰到了竞争状态下奇奇怪怪的数据结果。看来该有的套路还是得走。 按理说,在这个以瘦为美的时代两个人怎么着也用不了很大一张床,又不是三个人,四个人,五个人,满身大汉的人。但对于 CPU 来讲,它就是那么“作”,缓存一致性协议的单位是一个 Cache Line, 也就是 64B。你其中一个 bit 变了,其他 511bit 也跟着作废了。这也是为什么大家会在一些代码中发现 Cache Line 对齐的操作。在 Golang 中我们可以通过加 _padding 的方式来强行把“榻榻米”升级到“大床房”。 自旋无锁队列比起有锁结构之所以能快,无非是在特殊应用场景(多核抢夺内存中的环形队列)下,可以更加简单粗暴。毕竟底下用的都是 CAS 操作,能有啥大区别? 上文提到有看过几个 Golang 的无锁队列,它们在 CAS 失败后选择了无限循环,并通过 runtime.Gosched() 来出让时间片。也就是一个非常粗糙的自旋锁。 我们只能这么做?在 Golang 的源码中我发现了更高级的做法。先忽略涉及到 Goroutine 的一系列操作,单就自旋本身,我们可以利用 PAUSE 指令来实现更高效的版本。 PAUSE 又称 Spin Loop Hint,不言自明,我就不做介绍了。 很多人会反驳,不做无限循环难不成“旋一会”然后返回操作失败?我个人觉得还真得这样。在 Golang 中之所以可以“无限循环”是因为其有一套 Goroutine “排队”机制,我们自己是没法维护这套东西的。 玩具总得来说,用 Golang 实现无锁队列更多时候是一个玩具。有兴趣的话,可以搜几个实现,看看网友是怎么“喷”的。而我写这个无锁队列,纯粹是为了弥补我的知识缺陷,毕竟我对锁产生过好奇。 代码:fqueue |