深入了解锁细节


背景

编写并行程序必须要面对的一个难题:如何正确有效的保护共享数据。一些场景必然无法逃避锁的使用,最常见的应用就是:本地缓存。当使用锁的时候我们该注意什么?当锁成为性能瓶颈的时候我们该怎么优化做到有的放矢?

操作代价

如果关注性能,自然少不了要理解下,锁使用过程中性能到底消耗在哪里。如果一个锁自始至终只被一个线程使用。或者,一个锁被多个线程使用过,但是在任意时刻,都只有一个线程尝试获取锁,我们将以上两种锁称为非竞争锁,这种情况对性能的影响很小。对性能影响最严重的情况出现在多个线程同时尝试获取锁,即锁竞争。性能影响主要在于上下文切换以及数据的cacheline miss。因此,面对需要使用锁的情况,不要过分担心非竞争锁带来的开销,要关注那些真正发生了锁竞争的临界区中性能的优化。

加锁耗时:10ns
原子+1:50ns
CAS:100ns
自旋锁竞争(cacheline miss):100ns
信号量竞争(上下文切换):1000ns
内存访问:100ns
http://yarchive.net/comp/linux/lock_costs.html

因此,在保证程序正确性的前提下,解决锁同步带来的性能损失的主要方向应该是降低锁竞争。通常,有以下三类方法:

  1. 减少持有锁的时间
  2. 降低请求锁的频率
  3. 用其他协调机制取代独占锁

减少持有锁的时间

尽可能缩短持有锁的时间。这可以通过把不需要用锁保护的代码移出同步块来实现, 尤其是那些花费“昂贵”的操作,以及那些潜在的阻塞操作

降低请求锁的频率

  • 分拆锁
    如果一个锁守护多个相互独立的状态变量,你可能能够通过分拆锁,使每一个锁守护不同的变量,使每一个锁被请求的频率都变小了。分拆锁对于中等竞争强度的锁,能够有效地把它们大部分转化为非竞争的锁

  • 分离锁
    将大对象分成若干加锁块的集合,并且它们归属于相互独立的对象。例如,ConcurrentHashMap 的实现使用了一个包含 16 个锁的数组,每一个锁都守护 HashMap 的 1/16 。假设 Hash 值均匀分布,将会把对于锁的请求减少到约为原来的 1/16 。当多处理器系统的大负荷访问需要更好的并发性时,锁的数量还可以增加。

  • 避免热点域
    在某些应用中,我们会使用一个共享变量缓存常用的计算结果。每次更新操作都需要修改该共享变量以保证其有效性。例如,队列的 size,counter,链表的头节点引用等。在多线程应用 中,该共享变量需要用锁保护起来。这种在单线程应用中常用的优化方法会成为多线程应用中的“热点域 (hot field) ”,从而限制可伸缩性。如果一个队列被设计成为在多线程访问时保持高 吞吐量,那么可以考虑在每个入队和出队操作时不更新队列 size 。 ConcurrentHashMap 中为了避免这个问题,在每个分片的数组中维护一个独立的计数器,使用分离的锁保护,而不是维护一个全局计数。

  • 替代独占锁
    使用更高效的并发方式管理共享状态。例如并发容器,读-写锁,不可变对象,以及原子变量。

    • 多个读者可以并发访问共享资源,但是写者必须独占获得锁。对于多数操作都为读操作的数据结构,读写锁比独占锁提供更好的并发性。
    • 原子变量提供了避免“热点域”更新导致锁竞争的方法,如计数器、序列发生器、或者对链表数据结构头节点引用的更新。

总结

以上只是一些通用的减少锁竞争的手段,针对不同的业务场景还可以使用其他措施来优化锁竞争,例如:使用自旋锁代替独占锁,批量读取数据,减少锁冲突导致的上下文切换。