再说 lock-free 编程
现在问一个有意思的问题:当缓存争用时,哪一个方法更慢?结果可能会让你大吃一惊哦。
在 Intel 4 核处理器下测试结果如下:
图中,当 CPU 使用 2 个核时,BaselineCounter 方法是单核单路情况的 2.11 倍。其他情况类似。通过结果比对,我们可以得知:更多的并发性导致结果更加槽糕。这很大部分原因由内存争用所致。
当 CAS 操作失败,通过旋转等待可以改善 CASCounter 方法的在多核处理器上的性能(具体技巧可以参考夏天是个好季节兄的自己动手实现一个轻量级的信号量(一)、(二))。这可以大大减少活锁和关联内联阻碍锁耗费的时间。
当然,这个示例非常极端。它频繁反复修改同一个内存地址。通过期间插入特定的函数调用,延迟访问共享内存可以极大缓解压力。
比如插入 2 个函数调用,我们得到了如下数据:
这个时候,我们看到多核所花费的时间少于单核了。这就是我们使用并行所带来的加速。看到这里,我们可能会想,既然从 2 到 64 个函数调用使得结果越来越好,那么超过 64 个函数调用岂不是会变得更好?实际上,在插入 128 个函数调用之后,加速已经达到极限。结果如下所示:
如何计算加速比,请参考并行思维 [II]。
天下没有免费的午餐,CAS 也不例外。我们应当慎之又慎的将 lock-free CAS 代码放到我们的代码中,且必须清楚的知道线程执行它们的频繁程度。我们可以用下面这句话来作为总结:共享是魔鬼。它从根本上限制应用程序可伸缩性,最好尽量避免。共享内存需要并发控制,而并发控制需要 CAS。CAS 又非常昂贵,因此共享内存也非常昂贵。有很多人提出 lock-free 技术,事务内存,读写锁等可以改善程序可伸缩性。但很遗憾,这种情况很少出现。CAS 往往比正确实现锁机制的解决方案更加糟糕。很大原因要归结于共享内存、乐观失败尝试、缓存失效等。
overred 兄在 review 这篇文章的时候,提了一个很好的问题:在使用 Interlocked API 的时候,共享变量不用 volatile 修饰。
为了更方便说明这个问题,俺写个简单点的代码示例,如下所示:
using System; namespace Lucifer.CSharp.Sample { class Program { static volatile int x; static void Main(string[] args) { Foo(ref x); } static void Foo(ref int y) { while (y == 0) ; } } }
当我们在 Visual Studio 中编译这段代码时,IDE 会给出编译警告,如下所示:
通常来说,我们对于这样的编译警告应该给予足够重视。比如在上面的例子中,JIT 编译器会认为 y 一直未变,从而引起死循环。在 IA64 平台上,这会被认为普通内存访问代替了特殊的 load-acquire 访问,这就可能导致 CPU 指令重组方面的一些 Bug。但是有一种情况例外,就是使用 Interlocked API 和 Thread.VolatileXXX 方法以及锁。因为这些 API 内部都会显式要求内存栅栏和硬件原子指令,而不管外部共享变量是否采用 volatile 修饰。因此,文中采用的测试方法还是很安全嘀。
如果你觉得这个编译警告很烦人,可以使用 #pragma 指令禁掉这种警告,如下所示:
static volatile int x; static void Foo() { #pragma warning disable 0420 Interlocked.Exchange(ref x, 1); #pragma warning restore 0420 }
当然,也可以完全不用 volatile 修饰符。CLR 内存模型保证了这一点。
如何正确使用 volatile ,请参考并发数据结构:谈谈volatile变量。