(2)CLH锁

原文地址:http://xw-z1985.iteye.com/blog/2064372

1、为什么要引入CLH锁

       前一篇文章中,介绍了TAS、TTAS两种自旋锁。这两种锁的缺点是:任何一个处理器每一次对锁成功的访问(getAndSet(true)和set(false)任意一个方法的调用),都会将其他处理器的cache中的缓存失效掉。这样会导致以下后果:

  1. 其他处理器无法再采用局部自旋的方式对相同数据进行访问,后续的其他处理器对锁的访问都会独占bus发送消息。而锁的释放也是需要占用bus,因此有可能其他处理器对bus的占用而导致锁的释放被延迟;
  2. 当锁释放后的一刹那,其他正在局部自旋的处理器同时竞争去获取锁(调用getAndSet(true)),原本只有一个处理器能在此次竞争中获胜,但其他获取失败的处理器还是在bus上制造了大量无用的交通阻塞,因此也会导致锁的释放被延迟;
  3. 由于数据访问存在局部性,cache从memory中加载数据时,一次会加载一个数据块,即cache存储的是cache line。Cache中锁状态数据的失效,会导致其他和锁状态处于同一个cache line中的数据也被失效掉,从而影响其他原本与锁不存在竞争的处理器的执行速度。
  4. 另外,最后一个缺点就是无法实现锁的公平性,最先自旋等待获取锁的处理器,在竞争中,不一定最先获取到锁。
    虽然基于时间延迟的BackoffTTAS锁能一定程度的降低cache被无故失效的次数,但如何设置最优的延迟时间却是一个难题,因为不同的平台会有不同的最优参数,同时,引入延迟后,有可能导致处理器不能及时的得到锁,造成无谓的等待。

    那么我们希望最理想的情况是:

  1. 由于锁(此处指排它锁)同一时刻只能由一个处理器持有,所以在锁释放后的一刹那,最多只需要通知一个正在等待持有锁的处理器去获取锁,其他处理器就不需要同时去凑热闹了,轮到它的时候,会自动只通知他。
  2. 当某个处理器获取到锁或者释放锁时,不要失效掉其他还未轮到顺序的处理器的cache,这样这些处理器就能一直在自己的cache中局部自旋,不会占用bus
    CLH锁就是基于这种理念设计出来的
 

2、CLH锁介绍

       CLH算法中,每个申请锁的线程通过一个QNode对象表示,QNode中有一个volatile boolean类型的成员变量locked,locked为true,则表示对应的线程已经获取到了锁或者正在等待获取锁;locked为false,则表示对应的线程已经释放了锁。

  1. public class QNode {  
  2.     public volatile boolean locked = false;  
  3. }  

       而锁则由多个QNode对象组成的虚拟链表表示,之所以称为虚拟链表,是因为QNode之间并没有类似于next指针之类的引用,QNode之间通过锁的一个本地线程(ThreadLocal)变量myPred相连,myPred指向当前节点的前驱节点,即当前线程的前一个线程。而链表的尾节点则通过锁AtomicReference<QNode>类型的tail成员变量指向,即tail指向加入到申请锁的队列中的最近一个线程。

  1. public class CLHLock implements Lock {  
  2.     private AtomicReference<QNode> tail;  
  3.     private ThreadLocal<QNode> myNode;  
  4.     private ThreadLocal<QNode> myPred;  
  5.   
  6.     public CLHLock() {  
  7.         tail = new AtomicReference<QNode>(new QNode());  
  8.         myNode = new ThreadLocal<QNode>() {  
  9.             protected QNode initialValue() {  
  10.                 return new QNode();  
  11.             }  
  12.         };  
  13.         myPred = new ThreadLocal<QNode>() {  
  14.             protected QNode initialValue() {  
  15.                 return null;  
  16.             }  
  17.         };  
  18.     }  
  19.   
  20.     public void lock() {  
  21.         QNode qnode = myNode.get();  
  22.         qnode.locked = true;  
  23.         QNode pred = tail.getAndSet(qnode);  
  24.         myPred.set(pred);  
  25.         while (pred.locked) {}  
  26.     }  
  27.   
  28.     public void unlock() {  
  29.         QNode qnode = myNode.get();  
  30.         qnode.locked = false;  
  31.         myNode.set(myPred.get());  
  32.     }  
  33. }  

        当一个线程申请锁时:

  1. 首先会实例化一个QNode节点,并将其设置为自己的本地线程变量myNode;
  2. 然后将myNode的locked域设置为true,表明该线程正在等待获取锁;
  3. 接着通过AtomicReference的getAndSet()方法,将myNode设置为队列的最后一个节点,并返回其前驱节点;
  4. 最后该线程一直在其前驱节点的locked域自旋,直到locked域变为false,即前驱节点释放了锁。注意,对于SMP架构,由于是在cache中自旋,所以是高效的;
       当一个线程释放锁时:
  1. 将myNode的locked域设置为false,使得后继线程停止自旋以便获取到锁;
  2. 然后重用前驱节点,将前驱节点设置为myNode,以便下一次该线程获取锁时使用。之所以能重用,是因为此时其前驱线程不会再使用该前驱节点了。而该线程原来的myNode,可能被其后继线程或tail引用。对于java语言来说,重用实际上是没有必要的,因为如果不重用,则前驱节点是可以被jvm回收的,从而降低内存的使用。
        下图阐述了所得获取和释放过程(引自The art of Multiprocessor Programming一书)


 

3、结论

       CLH算法将多个线程的自旋分散在不同的节点进行,当一个线程释放锁时,只会将其后继结点的cache失效掉,对于其他线程没有任何影响,其他线程还是在各自的cache中进行局部自旋。这样,就大大减少了bus上的交通阻塞,不会导致锁的释放被延迟。

       另外,CLH算法由于总是将最近一个申请锁的线程放在链表的最后,从而带来了一个附加的功能,那就是对锁竞争的公平性,保证了先申请锁的线程先得到锁。

       而且,CHL算法也是空间有效的,对于N个线程,L个锁,如果每个线程每次最多只能获取一个锁,则需要的存储空间是O(N+L),其中N个线程对应N个myNode,L个锁对应L个tail。

       当然,CLH算法也有一个缺点,在cache-less的NUMA架构体系下,因为每个线程自旋的是其前驱线程的QNode中的locked域,如果内存位置比较远,则性能是要打折扣的。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值