再说 lock-free 编程
lock-free 编程实在让人又爱又恨。博主以前曾经写过几篇关于 lock-free 编程的文章。比如关于无锁编程、并发数据结构:迷人的原子。如果想更加深入的了解和实践 lock-free 编程,可以参考CLR 2.0 Memory Model、并发数据结构:Stack。这篇文章并不打算继续阐述如何使用 lock-free 技术,而是谈一下它的负面影响。从而让大家对 lock-free 有个更加全面的认识。
说到 lock-free 编程,现实中经常使用 CAS 原语。CAS 是英文 Compare and Swap 的简写。在 Windows 和 .NET 平台,由于历史原因,它被写做 Interlocked API。原子操作在 x86 架构 CPU 对应的汇编指令有 XCHG、CMPXCHG、INC 等,当然还得加上 LOCK 作为前缀(更多信息请看 并发数据结构:迷人的原子)。
CAS 原语在轻度和中度争用情况下确实可以大幅度提高程序性能。但凡事有利必有弊,CAS 原语极度扼杀了程序的可伸缩性(其他缺点请看关于无锁编程)。各位看官可能觉得这种观点有点偏激,但事实如此。请容博主细细道来:
- CAS 的原子性完全取决于硬件实现。大多数 Intel 和 AMD 的 CPU 采用了一种叫做 MOSEI 缓存一致性协议来管理缓存。这种架构下,处理器缓存内 CAS 操作相对成本低廉。但一旦资源争用,就会引起缓存失效和总线占用。缓存越失效,总线越被占用,完成 CAS 操作也越被延迟。缓存争用是程序可伸缩性杀手。当然对于非 CAS 内存操作来说也是如此,但 CAS 情况更加槽糕。
- CAS 操作要比普通内存操作花费更多 CPU 周期。这归功于缓存分级的额外负担、刷新写缓冲区与穿越内存栅栏限制和需求以及编译器对 CAS 操作优化的能力。
- CAS 经常被用在优化并行操作上。这意味着 CAS 操作失败将导致重新尝试某些指令(典型的回滚操作)。即便没有任何争用,它也会做一些无用功。不论成功或失败都会增加争用的风险。
大多数 CAS 操作发生在锁进入和退出时。尽管锁可由单一 CAS 操作构建,但 .NET CLR Monitor 类却使用了两个(一个在 Enter 方法,另一个在 Exit 方法)。lock-free 算法也经常使用 CAS 原语来代替使用锁机制。但是由于内存重组,这样的算法也常常需要显式的栅栏,即便使用了 CAS 指令。锁机制非常邪恶,但大多数合格的开发人员都知道让锁持有尽量少的时间。因此,虽然锁机制让人非常讨厌,且影响性能。但相对于大量,频繁的 CAS 操作而言,它却并不影响程序的可伸缩性。
举个很简单的例子,增加计数 100,000,000 次。要做到这样,有几种方式。如果仅运行在单核单处理器上,我们可以使用普通的内存操作:
static volatile int counter = 0; static void BaselineCounter() { for (int i = 0; i < Count; i++) { counter++; } }
很明显,上述代码示例不是线程安全的,但给计数器提供了一个很好的时间基准。下面我们使用 LOCK INC 来作为线程安全的第一种方式:
static volatile int counter = 0; static void LockIncCounter() { for (int i = 0; i < Count; i++) { Interlocked.Increment(ref counter); } }
现在代码示例线程安全了。我们还可以采取另外一种方式来保证线程安全。如果需要执行一些验证(比如内存溢出保护),我们通常会使用这种方式。就是使用 CMPXCHG(即 CAS):
static volatile int counter = 0; static void CASCounter() { for (int i = 0; i < Count; i++) { int oldValue; do { oldValue = counter; } while (Interlocked.CompareExchange(ref counter, oldValue + 1, oldValue) != oldValue); } }