【数据结构之旅】「线程锁算法专项」引领你走进CLH队列锁机制原理世界

简介: 【数据结构之旅】「线程锁算法专项」引领你走进CLH队列锁机制原理世界

CLH算法指南


技术扩展


SMP(对称多处理器架构)


  • SMP(Symmetric Multi-Processor),即对称多处理器结构,指服务器中多个CPU对称工作,每个CPU访问内存地址所需时间相同。其主要特征是共享,包含对CPU,内存,I/O等进行共享

image.png


  • SMP优点是能够保证内存一致性,缺点是这些共享的资源很可能成为性能瓶颈,随着CPU数量的增加,每个CPU都要访问相同的内存资源,可能导致内存访问冲突,可能会导致CPU资源的浪费。常用的PC机就属于这种



NUMA(非一致性内存访问)


  • NUMA(Non-Uniform Memory Access)非一致存储访问,  将CPU分为CPU模块,每个CPU模块由多个CPU组成, 并且具有独立的本地内存、 I/O 槽口等,模块之间可以通过互联模块相互访问  ,访问本地内存的速度将远远高于访问远地内存 ( 系统内其它节点的内存 ) 的速度,这也是非一致存储访问 NUMA 的由来


image.png


  • NUMA优点是可以较好地解决原来SMP系统的扩展问题,缺点是由于访问远程内存的延时远远超过本地内存,因此当CPU数量增加时,系统性能无法线性增加


自旋锁和互斥锁

CLH锁是一种自旋锁,那么我们先来看看自旋锁是什么?


自旋锁


  • 自旋锁说白了也是一种互斥锁,只不过没有抢到锁的线程会一直自旋等待锁的释放,处于busy-waiting的状态,此时等待锁的线程不会进入休眠状态,而是一直忙等待浪费CPU周期。
  • 因此自旋锁适用于锁占用时间短的场合


互斥锁


  • 互斥锁说的是传统意义的互斥锁,就是多个线程并发竞争锁的时候,没有抢到锁的线程会进入休眠状态即【sleep-waiting】当锁被释放时候,处于休眠状态的一个线程会再次获取到锁
  • 缺点:就是这一些列过程需要线程切换,需要执行很多CPU指令,同样需要时间。如果CPU执行线程切换的时间比锁占用的时间还长,那么可能还不如使用自旋锁。因此互斥锁适用于锁占用时间长的场合

CLH锁机制


CLH锁其实就是一种是基于逻辑队列非线程饥饿的一种自旋公平锁,由于是 Craig、Landin 和 Hagersten三位大佬的发明,因此命名为CLH锁,CLH是一种基于单向链表的高性能、能确保无饥饿性,提供先来先服务公平性的自旋锁

  • 申请加锁的线程通过前驱节点的变量进行自旋
  • 前置节点解锁后,当前节点会结束自旋,并进行加锁



CLH节点模型


  • CLH队列中的结点QNode中含有一个locked字段,该字段若为true表示该线程需要获取锁,且不释放锁,为false表示线程释放了锁。
  • 结点之间是通过隐形的链表相连,之所以叫隐形的链表是因为这些结点之间没有明显的next指针,而是通过myPred所指向的结点的变化情况来影响myNode的行为。


CLH锁原理


  • 首先有一个尾节点指针,通过这个尾结点指针来构建等待线程的逻辑队列因此能确保线程线程先到先服务的公平性,因此尾指针可以说是构建逻辑队列的桥梁
  • 此外这个尾节点指针是原子引用类型,避免了多线程并发操作的线程安全性问题
  • 通过等待锁的每个线程在自己的某个变量上自旋等待,这个变量将由前一个线程写入。
  • 由于某个线程获取锁操作时总是通过尾节点指针获取到前一线程写入的变量,而尾节点指针又是原子引用类型,因此确保了这个变量获取出来总是线程安全的



CLH锁分析


  • 在SMP架构下,CLH更具有优势。
  • 在NUMA架构下,如果当前节点与前驱节点不在同一CPU模块下,跨CPU模块会带来额外的系统开销,而MCS锁更适用于NUMA架构


image.png

加锁逻辑


  1. 获取当前线程的锁节点,如果为空,则进行初始化;
  2. sync方法获取链表的尾节点,并将当前节点置为尾节点,此时原来的尾节点为当前节点的前置节点
  3. 如果尾节点为空,表示当前节点是第一个节点,直接加锁成功
  4. 如果尾节点不为空,则基于前置节点的锁值(locked==true)进行自旋,直到前置节点的锁值变为false



解锁逻辑


  1. 获取当前线程对应的锁节点,如果节点为空或者锁值为false,则无需解锁,直接返回
  2. 【sync方法为尾节点赋空值,赋值不成功表示当前节点不是尾节点,则需要将当前节点的locked=false解锁节点】。如果当前节点是尾节点,则无需为该节点设置

CLHLock上还有一个尾指针,始终指向队列的最后一个结点。


CLHLock的类图如下所示

image.png




简易代码


// CLHLock.java
public class CLHLock {
   /**
    * CLH锁节点
    */
   private static class CLHNode {
       // 锁状态:默认为false,表示线程没有获取到锁;true表示线程获取到锁或正在等待
       // 为了保证locked状态是线程间可见的,因此用volatile关键字修饰
       volatile boolean locked = false;
   }
   // 尾结点,总是指向最后一个CLHNode节点
   // 【注意】这里用了java的原子系列之AtomicReference,能保证原子更新
   private final AtomicReference<CLHNode> tailNode;
   // 当前节点的前继节点
   private final ThreadLocal<CLHNode> predNode;
   // 当前节点
   private final ThreadLocal<CLHNode> curNode;
   // CLHLock构造函数,用于新建CLH锁节点时做一些初始化逻辑
   public CLHLock() {
       // 初始化时尾结点指向一个空的CLH节点
       tailNode = new AtomicReference<>(new CLHNode());
       // 初始化当前的CLH节点
       curNode = new ThreadLocal() {
           @Override
           protected CLHNode initialValue() {
               return new CLHNode();
           }
       };
       // 初始化前继节点,注意此时前继节点没有存储CLHNode对象,存储的是null
       predNode = new ThreadLocal();
   }
   /**
    * 获取锁
    */
   public void lock() {
       // 取出当前线程ThreadLocal存储的当前节点,初始化值总是一个新建的CLHNode,locked状态为false。
       CLHNode currNode = curNode.get();
       // 此时把lock状态置为true,表示一个有效状态,
       // 即获取到了锁或正在等待锁的状态
       currNode.locked = true;
       // 当一个线程到来时,总是将尾结点取出来赋值给当前线程的前继节点;
       // 然后再把当前线程的当前节点赋值给尾节点
       // 【注意】在多线程并发情况下,这里通过AtomicReference类能防止并发问题
       // 【注意】哪个线程先执行到这里就会先执行predNode.set(preNode);语句,因此构建了一条逻辑线程等待链
       // 这条链避免了线程饥饿现象发生
       CLHNode preNode = tailNode.getAndSet(currNode);
       // 将刚获取的尾结点(前一线程的当前节点)付给当前线程的前继节点ThreadLocal
       // 【思考】这句代码也可以去掉吗,如果去掉有影响吗?
       predNode.set(preNode);
       // 【1】若前继节点的locked状态为false,则表示获取到了锁,不用自旋等待;
       // 【2】若前继节点的locked状态为true,则表示前一线程获取到了锁或者正在等待,自旋等待
       while (preNode.locked) {
           System.out.println("线程" + Thread.currentThread().getName() + "没能获取到锁,进行自旋等待。。。");
       }
       // 能执行到这里,说明当前线程获取到了锁
       System.out.println("线程" + Thread.currentThread().getName() + "获取到了锁!!!");
   }
   /**
    * 释放锁
    */
   public void unLock() {
       // 获取当前线程的当前节点
       CLHNode node = curNode.get();
       // 进行解锁操作
       // 这里将locked至为false,此时执行了lock方法正在自旋等待的后继节点将会获取到锁
       // 【注意】而不是所有正在自旋等待的线程去并发竞争锁
       node.locked = false;
       System.out.println("线程" + Thread.currentThread().getName() + "释放了锁!!!");
       // 小伙伴们可以思考下,下面两句代码的作用是什么??
       CLHNode newCurNode = new CLHNode();
       curNode.set(newCurNode);
       // 【优化】能提高GC效率和节省内存空间,请思考:这是为什么?
       // curNode.set(predNode.get());
   }
}
复制代码



代码流程


  1. 当一个线程需要获取锁时,会创建一个新的QNode,将其中的locked设置为true表示需要获取锁
  2. 然后线程对tail域调用getAndSet方法,使自己成为队列的尾部,同时获取一个指向其前趋的引用myPred。
  3. 然后该线程就在前趋结点的locked字段上旋转,直到前趋结点释放锁。
  4. 当一个线程需要释放锁时,将当前结点的locked域设置为false,同时回收前趋结点

该算法也是空间有效的,如果有n个线程,L个锁,每个线程每次只获取一个锁,那么需要的存储空间是O(L+n),n个线程有n个myNode,L个锁有L个tail。


这种算法有一个缺点是在NUMA系统架构下性能表现很差,因为它自旋的locked是前驱线程的,如果内存位置较远,那么性能会受到损失。【但是在SMP这种cache一致性的系统架构上表现良好。】




流程说明


  • 线程A需要获取锁,其myNode域为true,些时tail指向线程A的结点,然后线程B也加入到线程A后面,tail指向线程B的结点。
  • 然后线程A和B都在它的myPred对象上旋转,一旦它的myPred结点的locked字段变为false,它就可以获取锁进行继续执行业务方法。
  • 明显线程A的myPred locked域为false,此时线程A获取到了锁,如下图所示。


从代码中可以看出lock方法中有一个while循环,这 是在等待前趋结点的locked域变为false,这是一个自旋等待的过程。unlock方法很简单,只需要将自己的locked域设置为false即可。

image.png


CLH优缺点


唯一的缺点是在NUMA系统结构下性能很差,在这种系统结构下,每个线程有自己的内存,如果前趋结点的内存位置比较远,自旋判断前趋结点的locked域,性能将大打折扣,但是在SMP系统结构下该法还是非常有效的。一种解决NUMA系统结构的思路是MCS队列锁。

相关文章
|
5月前
|
存储 机器学习/深度学习 人工智能
软考中级软件设计师专项-数据结构与算法上篇
软件设计师考试数据结构模块涵盖数组、链表、栈、队列、树、图等基础结构及其操作,重点考查二分查找、快排与归并排序、树/图的DFS/BFS遍历算法,要求掌握时间与空间复杂度分析,理解哈希、堆的应用场景,强调通过合理选择数据结构优化程序性能,解决存储管理与计算效率问题,为系统设计奠定核心逻辑基础。
630 1
软考中级软件设计师专项-数据结构与算法上篇
机器学习/深度学习 算法 自动驾驶
1053 0
|
5月前
|
机器学习/深度学习 算法 搜索推荐
从零开始构建图注意力网络:GAT算法原理与数值实现详解
本文详细解析了图注意力网络(GAT)的算法原理和实现过程。GAT通过引入注意力机制解决了图卷积网络(GCN)中所有邻居节点贡献相等的局限性,让模型能够自动学习不同邻居的重要性权重。
986 0
从零开始构建图注意力网络:GAT算法原理与数值实现详解
|
6月前
|
传感器 算法 定位技术
KF,EKF,IEKF 算法的基本原理并构建推导出四轮前驱自主移动机器人的运动学模型和观测模型(Matlab代码实现)
KF,EKF,IEKF 算法的基本原理并构建推导出四轮前驱自主移动机器人的运动学模型和观测模型(Matlab代码实现)
201 2
|
6月前
|
算法
离散粒子群算法(DPSO)的原理与MATLAB实现
离散粒子群算法(DPSO)的原理与MATLAB实现
302 0
|
7月前
|
机器学习/深度学习 人工智能 编解码
AI视觉新突破:多角度理解3D世界的算法原理全解析
多视角条件扩散算法通过多张图片输入生成高质量3D模型,克服了单图建模背面细节缺失的问题。该技术模拟人类多角度观察方式,结合跨视图注意力机制与一致性损失优化,大幅提升几何精度与纹理保真度,成为AI 3D生成的重要突破。
849 0
|
4月前
|
Java 调度 数据库
Python threading模块:多线程编程的实战指南
本文深入讲解Python多线程编程,涵盖threading模块的核心用法:线程创建、生命周期、同步机制(锁、信号量、条件变量)、线程通信(队列)、守护线程与线程池应用。结合实战案例,如多线程下载器,帮助开发者提升程序并发性能,适用于I/O密集型任务处理。
454 0
|
4月前
|
Java
如何在Java中进行多线程编程
Java多线程编程常用方式包括:继承Thread类、实现Runnable接口、Callable接口(可返回结果)及使用线程池。推荐线程池以提升性能,避免频繁创建线程。结合同步与通信机制,可有效管理并发任务。
227 6
|
5月前
|
算法 Java
Java多线程编程:实现线程间数据共享机制
以上就是Java中几种主要处理多线程序列化资源以及协调各自独立运行但需相互配合以完成任务threads 的技术手段与策略。正确应用上述技术将大大增强你程序稳定性与效率同时也降低bug出现率因此深刻理解每项技术背后理论至关重要.
425 16

热门文章

最新文章