【数据结构之旅】「线程锁算法专项」引领你走进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队列锁。

相关文章
|
4月前
|
设计模式 消息中间件 安全
【JUC】(3)常见的设计模式概念分析与多把锁使用场景!!理解线程状态转换条件!带你深入JUC!!文章全程笔记干货!!
JUC专栏第三篇,带你继续深入JUC! 本篇文章涵盖内容:保护性暂停、生产者与消费者、Park&unPark、线程转换条件、多把锁情况分析、可重入锁、顺序控制 笔记共享!!文章全程干货!
390 2
|
10月前
|
前端开发 Java
java实现队列数据结构代码详解
本文详细解析了Java中队列数据结构的实现,包括队列的基本概念、应用场景及代码实现。队列是一种遵循“先进先出”原则的线性结构,支持在队尾插入和队头删除操作。文章介绍了顺序队列与链式队列,并重点分析了循环队列的实现方式以解决溢出问题。通过具体代码示例(如`enqueue`入队和`dequeue`出队),展示了队列的操作逻辑,帮助读者深入理解其工作机制。
362 1
|
存储 C语言 C++
【C++数据结构——栈与队列】顺序栈的基本运算(头歌实践教学平台习题)【合集】
本关任务:编写一个程序实现顺序栈的基本运算。开始你的任务吧,祝你成功!​ 相关知识 初始化栈 销毁栈 判断栈是否为空 进栈 出栈 取栈顶元素 1.初始化栈 概念:初始化栈是为栈的使用做准备,包括分配内存空间(如果是动态分配)和设置栈的初始状态。栈有顺序栈和链式栈两种常见形式。对于顺序栈,通常需要定义一个数组来存储栈元素,并设置一个变量来记录栈顶位置;对于链式栈,需要定义节点结构,包含数据域和指针域,同时初始化栈顶指针。 示例(顺序栈): 以下是一个简单的顺序栈初始化示例,假设用C语言实现,栈中存储
655 77
232.用栈实现队列,225. 用队列实现栈
在232题中,通过两个栈(`stIn`和`stOut`)模拟队列的先入先出(FIFO)行为。`push`操作将元素压入`stIn`,`pop`和`peek`操作则通过将`stIn`的元素转移到`stOut`来实现队列的顺序访问。 225题则是利用单个队列(`que`)模拟栈的后入先出(LIFO)特性。通过多次调整队列头部元素的位置,确保弹出顺序符合栈的要求。`top`操作直接返回队列尾部元素,`empty`判断队列是否为空。 两题均仅使用基础数据结构操作,展示了栈与队列之间的转换逻辑。
|
并行计算 安全 Java
Python GIL(全局解释器锁)机制对多线程性能影响的深度分析
在Python开发中,GIL(全局解释器锁)一直备受关注。本文基于CPython解释器,探讨GIL的技术本质及其对程序性能的影响。GIL确保同一时刻只有一个线程执行代码,以保护内存管理的安全性,但也限制了多线程并行计算的效率。文章分析了GIL的必要性、局限性,并介绍了多进程、异步编程等替代方案。尽管Python 3.13计划移除GIL,但该特性至少要到2028年才会默认禁用,因此理解GIL仍至关重要。
1101 16
Python GIL(全局解释器锁)机制对多线程性能影响的深度分析
|
10月前
|
存储 Java 编译器
Java 中 .length 的使用方法:深入理解 Java 数据结构中的长度获取机制
本文深入解析了 Java 中 `.length` 的使用方法及其在不同数据结构中的应用。对于数组,通过 `.length` 属性获取元素数量;字符串则使用 `.length()` 方法计算字符数;集合类如 `ArrayList` 采用 `.size()` 方法统计元素个数。此外,基本数据类型和包装类不支持长度属性。掌握这些区别,有助于开发者避免常见错误,提升代码质量。
955 1
|
12月前
|
NoSQL 算法 安全
Redis原理—1.Redis数据结构
本文介绍了Redis 的主要数据结构及应用。
Redis原理—1.Redis数据结构
|
12月前
|
算法 调度 C++
STL——栈和队列和优先队列
通过以上对栈、队列和优先队列的详细解释和示例,希望能帮助读者更好地理解和应用这些重要的数据结构。
296 11
☀☀☀☀☀☀☀有关栈和队列应用的oj题讲解☼☼☼☼☼☼☼
### 简介 本文介绍了三种数据结构的实现方法:用两个队列实现栈、用两个栈实现队列以及设计循环队列。具体思路如下: 1. **用两个队列实现栈**: - 插入元素时,选择非空队列进行插入。 - 移除栈顶元素时,将非空队列中的元素依次转移到另一个队列,直到只剩下一个元素,然后弹出该元素。 - 判空条件为两个队列均为空。 2. **用两个栈实现队列**: - 插入元素时,选择非空栈进行插入。 - 移除队首元素时,将非空栈中的元素依次转移到另一个栈,再将这些元素重新放回原栈以保持顺序。 - 判空条件为两个栈均为空。
|
12月前
|
缓存 监控 算法
内网监控管理软件:PHP 语言队列算法揭秘
在数字化办公环境中,内网监控管理软件对企业的稳定运行和信息安全至关重要。本文深入介绍PHP中的队列算法及其在内网监控软件中的应用,包括监控数据收集、任务调度和日志记录等场景,通过代码示例展示其实现方法。队列算法可提高性能、保证数据顺序并实现异步处理,为企业提供高效的安全保障。
215 1