自旋锁是专为防止多处理器并发而引入的一种锁,它在内核中大量应用于中断处理等部分(对于单处理器来说,防止中断处理中的并发可简单采用关闭中断的方式,即在标志寄存器中关闭/打开中断标志位,不需要自旋锁)。---百度百科
背景
由于在多处理器环境中资源的有限性和并发安全问题,很多时候需要进行互斥访问(mutual exclusion),所以需要引入锁的机制,只有获取了锁的线程才能够对资源进行访问,由于多线程运行是依赖cpu的时间分片,所以同一时刻只能有一个线程获取到锁。那么没有获取到锁的线程通常有两种方式,1. 互斥锁:线程阻塞,释放cpu资源等待重新调度请求。2. 自旋锁:没有获取到锁的线程一直循环等待判断锁是否已经被释放,一旦检测到锁已经被释放,就有机会直接获取到锁。
实现方案
自旋锁是实现同步的一种方案,是一种非阻塞锁。自旋锁的核心机制就在自旋两个字,即用自旋操作来替代阻塞操作。某一线程尝试获取某个锁时,如果该锁已经被另一个线程占用的话,则此线程将不断循环检查该锁是否被释放,而不是让此线程挂起或睡眠。一旦另外一个线程释放该锁后,此线程便能获得该锁。自旋是一种忙等待状态,过程中会一直消耗CPU的时间片。自旋锁的实现方案主要SpinLock、Ticket Lock、MCSLock和 CLHLock这几种。接下来我们将由简入繁一一介绍这几种实现方式。
自旋锁(Spin lock)
自旋锁是指当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。如下图所示
自旋锁的原理比较简单,如果持有锁的线程能在短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞状态,它们只需要等一等(自旋),等到持有锁的线程释放锁之后即可获取,这样就避免了用户进程和内核切换的消耗。
因为自旋锁避免了操作系统进程调度和线程切换,所以自旋锁通常适用在时间比较短的情况下。由于这个原因,操作系统的内核经常使用自旋锁。但是,如果长时间上锁的话,自旋锁会非常耗费性能,它阻止了其他线程的运行和调度。线程持有锁的时间越长,则持有该锁的线程将被OS调度程序中断的风险越大。如果发生中断情况,那么其他线程将保持旋转状态(反复尝试获取锁),而持有该锁的线程并不打算释放锁,这样导致的是结果是无限期推迟,直到持有锁的线程可以完成并释放它为止。
可以给自旋锁设定一个自旋时间,等时间一到立即释放自旋锁。自旋锁的目的是占着CPU资源不进行释放,等到获取锁立即进行处理。但是如果自旋执行时间太长,会有大量的线程处于自旋状态占用 CPU 资源,进而会影响整体系统的性能。因此自旋的周期选的额外重要,DK在1.6 引入了适应性自旋锁,适应性自旋锁意味着自旋时间不是固定的了,而是由前一次在同一个锁上的自旋时间以及锁拥有的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间。
代码实现
利用java中原子类AtomicReference实现,CAS保证操作的原子性
public class SpinLockTest {
private AtomicReference<Thread> monitor = new AtomicReference<Thread>();
public void lock() {
Thread currentThread = Thread.currentThread();
// 利用compareAndSet方法进行自旋操作
while (!monitor.compareAndSet(null, currentThread)) {
}
}
public void unlock() {
Thread currentThread = Thread.currentThread();
// 利用compareAndSet方法进行自旋操作
monitor.compareAndSet(currentThread, null);
}
}
这种简单自旋锁会有一些问题,无法保证多线程竞争的公平性,可能会引起线程饥饿问题。通常可以采用排队方式解决这种问题,可以称之为排队自旋锁(QueuedSpinlock),计算机科学家们使用了各种方式来实现排队自旋锁,如TicketLock,MCSLock,CLHLock,接下来我们开始介绍TicketLock。
TicketLock
在计算机科学领域中,TicketLock 是一种同步机制或锁定算法,一种自旋锁,它使用ticket 来控制线程执行顺序。
TicketLock 是基于先进先出(FIFO) 队列的机制。它增加了锁的公平性,其设计原则如下:TicketLock 中有两个 int 类型的数值,开始都是0,第一个值是队列ticket(队列票据), 第二个值是 出队(票据)。队列票据是线程在队列中的位置,而出队票据是现在持有锁的票证的队列位置。简单来说,就是队列票据是你取票号的位置,出队票据是你距离叫号的位置,具
public class TicketLock {
// 队列票据(当前排队号码)
private AtomicInteger queueNum = new AtomicInteger();
// 出队票据(当前需等待号码)
private AtomicInteger dueueNum = new AtomicInteger();
private ThreadLocal<Integer> ticketLocal = new ThreadLocal<>();
public void lock(){
int currentTicketNum = dueueNum.incrementAndGet();
// 获取锁的时候,将当前线程的排队号保存起来
ticketLocal.set(currentTicketNum);
while (currentTicketNum != queueNum.get()){
// doSometh...
}
}
// 释放锁:从排队缓冲池中取
public void unLock(){
Integer currentTicket = ticketLocal.get();
queueNum.compareAndSet(currentTicket,currentTicket + 1);
}
}
TicketLock 虽然解决了公平性的问题,但是多处理器系统上,每个进程/线程占用的处理器都在读写同一个变量queueNum ,每次读写操作都必须在多个处理器缓存之间进行缓存同步,这会导致繁重的系统总线和内存的流量,大大降低系统整体的性能。为了解决这个问题,MCSLock 和 CLHLock 应运而生。
CLHLock
CLH锁是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程只在本地变量上自旋,它不断轮询前驱的状态,如果发现前驱释放了锁就结束自旋。
具体设计:
-
线程持有自己的curNode变量,curNode中有一个locked属性,true:需要锁,false:不需要锁。
-
线程持有前驱的node引用,轮询前驱node的locked属性,true的时候自旋,false的时候代表前驱释放了锁,结束自旋。
-
tail始终指向最后加入的线程。
运行流程如下图示例:
-
初始化:tail指向一个curNode的locked属性为false且preNode为空的节点。
-
当线程1进来的时候,线程A持有的node节点,curNode的locked属性为true,preNode指向之前的head节点。
-
当线程2进来的时候,线程2持有的curNode节点,curNode的locked属性为true,preNode指向线程1的curNode节点,线程2的curNode节点locked属性为true,线程1轮询线程B的curNode节点的locked状态,为true自旋。
-
线程1执行完后释放锁(修改locked属性为false),线程2轮询到线程1的curNode节点locked属性为false,结束自旋。
代码实现:
public class CLHLock {
AtomicReference<Node> tail = new AtomicReference<Node>();
// 使用ThreadLocal实现了变量的线程隔离
ThreadLocal<Node> curNode;
ThreadLocal<Node> preNode = new ThreadLocal<Node>();
public CLHLock() {
//初始化node
curNode = new ThreadLocal<Node>() {
@Override
protected Node initialValue() {
return new Node();
}
};
//初始化tail,指向一个node(节点locked属性为false)
tail.set(new Node());
}
public void lock() {
Node myNode = node.get();
//修改为true,表示需要获取锁
myNode.locked = true;
// 利用AtomicReference的getAndSet
Node preNode = tail.getAndSet(myNode);
//设置当前节点的前驱节点
this.preNode.set(preNode);
//轮询前驱节点的locked属性,尝试获取锁
while (preNode.locked) {
}
}
public void unlock() {
//将节点locked属性设置为false,
node.get().locked = false;
//当前节点设置为前驱节点
node.set(preNode.get());
}
private class Node{
//默认:不需要锁
private boolean locked = false;
}
如果有n个线程,M个锁,每个线程每次只获取一个锁,那么需要的存储空间是O(M+n),n个线程有n个myNode,M个锁有M个tail,所以CLH队列锁的优点是空间复杂度比较低,CLH的一种变体被应用在了JAVA并发框架中(AbstractQueuedSynchronizer.Node)。CLH在SMP系统结构下是非常有效的。但在NUMA系统结构下,每个线程有自己的内存,如果前趋结点的内存位置比较远,自旋判断前趋结点的locked域,性能会下降很多。在NUMA系统结构体系下MCS队列锁性能会好很多。
MCSLock
MCS Lock 也是一种基于链表的可扩展、高性能、公平的自旋锁。申请线程只在本地变量上自旋,直接前驱负责通知其结束自旋,从而极大地减少了不必要的处理器缓存同步的次数,降低了总线和内存的开销。所以相比CLH Lock,线程自旋的实现规则进行了调整,CLH是在前趋结点的locked域上自旋等待,而MCS是在自己的结点的locked域上自旋等待。正因为如此,它解决了CLH在NUMA系统架构中获取locked域状态内存过远的问题。
实现原理:
-
每个线程持有一个curNode,curNode有一个locked属性,true:等待获取锁,false:可以获取到锁,并且curNode持有下一个nextNode(后继者)的引用(tail指定的节点后续节点引用为空)
-
线程在轮询自己node的locked状态,true:锁被其他线程暂用,等待获取锁,自旋。false:排队的上一个线程已释放锁。
-
线程释放锁的时候,修改curNode上后继者(nextNode)的locked属性,通知后继者结束自旋。
public class MCSLock {
AtomicReference<Node> tail;
ThreadLocal<Node> curNode;
public void lock() {
tail = new AtomicReference<Node>(new Node());
Node node = curNode.get();
Node pred = tail.getAndSet(node);
if (pred != null) {
node.locked = true;
pred.nextNode = node;
// wait until predecessor gives up the lock
while (node.locked) {
}
}
}
public void unlock() {
Node node = curNode.get();
if (node.nextNode == null) {
if (tail.compareAndSet(node, null))
return;
// wait until predecessor fills in its next field
while (node.nextNode == null) {
}
}
node.nextNode.locked = false;
node.nextNode = null;
}
class Node {
// 默认为false
boolean locked = false;
Node nextNode = null;
}
}
CLH锁 vs MCS锁
-
两者都是基于链表,将获取锁的线程状态借助节点(node)保存,每个线程都有一份独立的节点,减少了多处理器之间缓存同步,极大的提高了性能
-
两者区别在于线程自旋的实现规则:CLHLock是通过轮询其前驱节点的状态,而MCS则是查看当前节点的锁状态(具体实现:CLHLock是基于隐式链表,没有真正的后续节点属性,MCSLock是显示链表,有一个指向后续节点的属性。)
重点回顾
1. 自旋锁是为了在多处理器下提高效率而引入的,自旋锁核心思路是线程获取锁的时候,如果锁被其他线程持有,则当前线程将循环等待,直到获取到锁。
2. 自旋锁在等待期间不会释放自己的线程,占用cpu时间分片。自旋锁不适用于线程占用锁时间过长的场景,会导致cpu成为瓶颈,需要设定自旋周期。
3. 自旋锁本身无法保证公平性,可能导致线程饥饿问题,所以引入了可以保证公平性的TicketLock ,TicketLock 是采用排队叫号的机制来实现的一种公平锁,但是它每次读写操作都必须在多个处理器缓存之间进行缓存同步,这会导致繁重的系统总线和内存的流量,大大降低系统整体的性能。
4. 为了解决性能问题引入基于链表实现的自旋锁CLHLock和MCSLock,通过链表的方式避免了减少了处理器缓存同步,极大的提高了性能,区别在CLHLock是通过轮询其前驱节点的状态,而MCS则是查看当前节点的锁状态。