1 前言
自旋锁(spinlock)是用来在多处理器环境中工作的一种锁。如果内核控制路径发现spinlock是unlock,就获取锁并继续执行;相反,如果内核控制路径发现锁由运行在另一个CPU上的内核控制路径lock,就在周围“旋转”,反复执行一条紧凑的循环指令,直到锁被释放。spinlock的循环指令表示“忙等”:即使等待的内核控制路径无事可做(除了浪费时间),它也在CPU上保持运行。
spinlock的实现依赖这样一个假设:锁的持有线程和等待线程都不能被抢占。但是在虚拟化场景下,vCPU可能在任意时刻被hypervisor调度,导致其他vCPU上的锁等待线程忙等浪费CPU。这会导致已知的Lock Holder Preemption(LHP)和 Lock Waiter Preemption(LWP)问题。
- LHP:虚拟机中的锁持有线程被抢占,导致锁等待线程忙等,直到锁持有者线程再次被调度并释放锁后,锁等待线程才能获取到锁。从锁持有线程被抢占到其再次被调度运行这段时间,其余锁等待线程的忙等其实是在浪费CPU算力。
- LWP:虚拟机中的下一个锁等待线程被抢占,直到其下一次再次被调度并获取锁后,其余锁等待线程的忙等其实锁在浪费CPU算力。
本文首先介绍了最初的spinlock实现,然后介绍了ticket spinlock和mcs lock,最后介绍了qspinlock和pv(paravirt) spinlock。
2 最初的spinlock
linux kernel 2.6.24及之前版本的spinlock就是一个整数。其实现基于原子操作。spinlock初始化为1,加锁时,将spinlock减1(原子操作),然后判断其值是否为0,如果为0,则成功获取到锁;如果为负数,则需要忙等,直到spinlock的值变为正数,再次尝试加锁操作。解锁时,将spinlock的值置1即可。
3 ticket spinlock
最初的spinlock存在一个重要的缺陷:公平性;其实现机制无法确保等待时间最长的竞争者优先获取到锁。
为了解决这种无序竞争带来的不公平的问题,ticket spinlock被提出了。ticket spinlock的实现类似叫号系统,每个锁的竞争者领取一个号码,锁持有者释放锁的时候递增号码,这样下一个竞争者就能获取到锁。
4 MCS spinlock
ticket spinlock存在一个性能问题:cache颠簸;在MP系统中,每次获取锁的值的时候都会刷新所有竞争CPU上的cache,这在竞争激烈的情况下会损耗大量的系统性能。
为了解决ticket spinlock带来的cache颠簸的问题,MCS spinlock被提出了。MCS其实就是这两位作者的简称,John M. Mellor-Crummey and Michael L. Scott,所以MCS并不是一个算法名称,也和自旋锁本身没啥关系。
内核开发者Tim Chen在内核中引入了MCS spinlock,通过让每个CPU在自己的自旋锁结构体变量上自旋,能够避免绝大部分的cache颠簸。
mcs_spinlock包含一个next指针和一个整形变量用于表示当前锁的状态。3.15版本的内核中mcs_spinlock定义如下:
|
假定多个CPU需要竞争的锁为MCS Lock,MCS Lock初始时next为NULL,locked为0。MCS Lock初始状态如下图所示:
当CPU 0需要获取锁时,会使用CPU 0的锁结构,使用原子指令将自己的锁结构的地址与MCS Lock的next指针交换,并获取MCS Lock的next指针的旧值,如果该旧值为NULL,则CPU 0成功获取到锁。注意:CPU 0是第一个获取锁的,其自己的锁结构的locked的值是不需要设置为1的。CPU 0获取到锁后的状态如下图所示:
当CPU 0持锁时,CPU 1来竞争锁,CPU 1使用原子指令将自己的锁结构的地址与MCS Lock的next指针交换,并获取MCS Lock的next指针的旧值,此时该旧值为CPU 0的锁结构的地址,此时CPU 1不能获取到锁,CPU 1需要将CPU 0的锁结构的next指针指向CPU 1的锁结构地址,这样CPU 0在释放锁的时候就知道下一个排队的是CPU 1;设置好CPU 0的锁结构的next指针之后,CPU 1就需要在自己的锁的locked值上自旋直到其值变为1。CPU 0获取到锁后CPU 1来获取锁时的状态如下图所示:
CPU 0释放锁时,使用原子条件交换指令,如果MCS Lock的next指针指向CPU 0的锁结构,则将MCS Lock的next指针设为NULL,此时没有其他等待者,释放锁的流程结束;如果MCS Lock的next指针不指向CPU 0的锁结构,说明此时还有其他等待者,通过CPU 0的锁结构的next指针可以获取到下一个等待的CPU的锁结构,将下一个等待的CPU的锁结构的locked值置为1,CPU 0的解锁流程结束。注意:CPU 0解锁时如果其锁结构的next指针为NULL,但是MCS Lock的next指针不为NULL,CPU 0需要自旋一段时间以等待CPU 1设置CPU 0的锁结构的next指针。解锁过程如下图所示:
CPU 1一直在自己的锁结构的locked值上自旋,直到其值变为1之后,CPU 1获取到锁,之后就可以开始执行CPU 1临界区的代码。CPU 1获取到锁后的状态如下图所示:
MCS Lock加锁解锁的过程中有两个地方需要注意:
- 因为每个CPU来获取锁的时候都是使用原子指令将自己的锁结构的地址与MCS Lock的next指针交换,因此MCS Lock的next指针始终指向等待锁队列的最后一个,下一个来获取锁的CPU能将其自己的锁结构的地址添加到等待队列的队尾;该交换指令是原子操作,因此只有一个CPU能获取到当前队尾的CPU的锁结构地址,因此一个MCS Lock只可能存在一个排队队列。
- 每个CPU都在自己的锁结构的locked值上自旋,可以避免绝大部分的cache颠簸。
5 qspinlock
MCS Lock并没有完全替代Ticket spinlock,其中一个原因是MCS Lock的数据结构大于32bit,内核中很多重要的结构体中都内嵌了spinlock,其中一些(典型的如struct page)的体积是不允许变大的。后来Waiman Long提出了qspinlock,并由Peter Zijlstra进行了改进,形成了现在的qspinlock。qspinlock是在kernel 4.16成为默认spinlock的。
qspinlock基于MCS Lock进行优化,既解决了MCS Lock的数据结构大于32bit的问题,又避免了cache颠簸的问题。后续对qspinlock的分析都是基于linux kernel 4.19。
qspinlock结合使用32bit的qspinlock结构体和的per-CPU mcs_spinlock,设计有几个要点:
- 数据结构压缩:qspinlock结构体的大小是32bit,使用位来表示锁状态(8bit)和队尾的CPU。
- 避免cache颠簸:qspinlock的设计使用了MCS spinlock队列对等待的CPU进行排队,每个排队中的CPU都在自己的per-CPU MCS spinlock上自旋,因此可以避免cache颠簸。
5.1 qspinlock对数据结构的压缩
qspinlock的数据结构大小为32bit,其定义如下(include/asm-generic/qspinlock_types.h):
|
qspinlock结构体由一个union组成,可以看到一个32bit的qspinlock被分成了4部分,如下图所示:
其中各部分说明如下:
- locked:用于指示qspinlock是否加锁,0表示未加锁,其余值表示已加锁(通常情况下_Q_LOCKED_VAL == 1表示加锁,在PV场景下可能会使用_Q_SLOW_VAL == 3)。
- pending:第一个等待锁的CPU需要先设置pending位,后续等待锁的CPU则全部进入MCS spinlock队列自旋等待。最初Waiman Long的patch并未包含该位,引入该pending位后,第一个等待者可以避免与访问自己的MCS spinlock数组相关的缓存未命中惩罚。
- tail index:2个bit位,每个CPU在同一时刻可能存在4种不同的上下文:Normal, Software interrupt, Hardware interrupt, Non-maskable interrupt,因此每个CPU的per-CPU MCS spinlock需要包含一组共4个MCS spinlock,每个MCS spinlock对应一个上下文场景。
- tail cpu:队尾的CPU编号(+1),将编号+1是为了和没有CPU排队的情况区分开来。
5.2 qspinlock对MCS spinlock的使用
前面提到,qspinlock的设计使用了MCS spinlock队列对等待的CPU进行排队,每个排队中的CPU都在自己的per-CPU MCS spinlock上自旋,per-cpu MCS spinlock定义如下:
|
MAX_NODES的值为4,可以看到每个CPU有4个struct qnode结构类型的per-cpu变量,per-cpu类型定义如下:
|
stuct qnode包含了struct mcs_spinlock,在PV场景下(内核编译时指定了CONFIG_PARAVIRT_SPINLOCKS),(暂时忽略CONFIG_NUMA_AWARE_SPINLOCKS)。多CPU竞争qspinlock时的示意图如下:
- 第一个锁等待者在设置好pending位之后,就在qspinlock结构的locked上自旋,直到锁持有者释放锁(将qspinlock结构的locked值设置为0)。
- 第二个锁等待者需要将自己放入mcs_spinlock队列尾部,因为其是mcs_spinlock队列的头,其在qspinlock结构的pending | locked上自旋,直到qspinlock结构的pending和locked均变为0。
- 第三个及以后的锁等待者将自己放入mcs_spinlock队列尾部,并在自己的per-cpu MCS spinlock上自旋,直到到达队列头部,然后在qspinlock结构的pending | locked上自旋,直到qspinlock结构的pending和locked均变为0。
5.3 qspinlock加锁流程
如前所述,qspinlock加锁分三个阶段:
- 在qspinlock结构上竞争。
- 在mcs_spinlock队列上排队。
- 获取锁。
“在qspinlock结构上竞争”时,又分以下几种情况:
- 情况一:锁当前是unlock状态,直接加锁。
- 情况二:临时状态,此时(tail, pending, lock)的值为(0, 1, 0)。
- 情况三:tail != 0 || pending != 0,进入mcs_spinlock队列排队。
- 情况四:设置pending位成功,在qspinlock的locked值上自旋。
- 情况五:竞争pending位失败,进入mcs_spinlock队列排队。
“在mcs_spinlock队列上排队”时,也分两种情况:
- 排队情况一:已经排到队列头,在qspinlock的pending | locked上自旋。等待(tail, pending, lock)从(*, x, y)变为(*, 0, 0)。
- 排队情况二:在mcs_spinlock队列中,在CPU的per-cpu MCS spinlock的locked上自旋,直到到达队列头部。
当锁的持有者释放锁之后,mcs_spinlock队列头的CPU可以进入“获取锁”阶段,也分两种情况:
- 获取锁情况一:没有其他排队的竞争者,加锁并将qspinlock的tail清空。
- 获取锁情况二:有其他排队的竞争者,加锁并让mcs_spinlock队列中的下一个CPU成为队列头。
qspinlock加锁流程如下图所示:
下面的描述中将省略(tail, pending, lock),出现形如(x, y, z)均表示qspinlock结构中的tail == x && pending == y && lock == z。
5.3.1 在qspinlock上竞争情况一
锁当前是unlock状态,即(0, 0, 0),直接加锁,然后进入临界区。这是最快的情况,对应的代码在queued_spin_lock函数中,具体可以参看代码注释: