Linux内核同步原语之自旋锁(Spin Lock)
在Linux内核代码中,大量使用了自旋锁。自旋锁不会让
自旋锁(Spinlock)是一种在 Linux 内核中广泛运用的底层同步机制。
自旋锁是用来在多CPU环境中工作的一种特殊的锁,也就是说只有真正有两个或以上执行序列同时执行时此锁才起作用。如果内核控制路径发现自旋锁空闲,就获取该自旋锁并继续执行程序;相反,如果内核控制路径发现锁已经由运行在另一个CPU上的内核控制路径持有了,就自己自旋等待,反复执行一条紧凑的循环指令,直到锁被释放为止。自旋锁的循环等待是所谓的“忙”等,即使等待的内核控制路径无事可做,它也在CPU上一直保持运行,并不会让当前的内核控制路径主动交出CPU的控制权。
由自旋锁保护的每个临界区都是禁止内核抢占的。在单处理器系统上,这种锁本身并不起锁的作用,仅仅退化成禁止内核抢占。
MCS自旋锁
传统自旋锁有一个很大的性能问题,所有等待同一个自旋锁的CPU在同一个变量上自旋等待,获得或者释放锁的时候会对这个变量进行修改。对于单CPU的系统,这个不是问题,但是对于SMP多CPU系统来说,由于缓存一致性的问题,一个CPU写入了一个变量后,必须要让所有其它处理器上对应该变量的缓存行失效,还需要使用内存屏障,在拥有几百甚至几千个处理器的大型系统中,将导致系统性能大幅下降。
为了解决上面的问题,聪明的内核设计者们想出了MCS(MCS是“Mellor-Crummey”和“Scott”这两个发明人的名字的首字母缩写)自旋锁,它有两个优点:
- 保证自旋锁申请者以先进先出的顺序获取锁(FIFO)。
- 只在本地可访问的标志变量上自旋。
MCS自旋锁的主要策略是为每个处理器创建一个变量副本,每个处理器在申请自旋锁的时候在自己的本地变量上自旋等待,避免缓存同步的开销。其自旋锁结构体的定义如下(代码位于kernel/locking/mcs_spinlock.h中):
struct mcs_spinlock {
struct mcs_spinlock *next;
int locked;
int count;
};
MCS自旋锁采用链表结构将全体锁申请者的信息串成一个单向链表。每个锁申请者必须提前分配一个本地的mcs_spinlock结构体,其中至少包括 2 个域:本地自旋变量locked和指向下一个申请者mcs_spinlock结构体的指针变量next。locked初始值为0,申请者自旋等待locked值从0变成1。而MCS自旋锁变量就是一个永远指向最后一个申请者的mcs_spinlock结构体的指针。
当前一个持有自旋锁的CPU释放该自旋锁的时候,会将下一个节点的locked域变成1,从而让对应的CPU跳出自旋等待。
但是,MCS自旋锁也有一个致命的弱点,就是这个锁的结构体占用空间太大了。锁里面有一个指针变量next,如果在64位系统上光这个指针就要占用8个字节,再加上locked占用4个字节,count也要占用4个字节,一共有可能要占用16个字节。由于自旋锁经常会嵌入在另一个结构体里面,用来保护该结构体,而且这个受保护的结构体可能在内核中使用频繁,对大小十分敏感,因此MCS自旋锁并不能完全取代普通的自旋锁实现。
队列自旋锁
为了解决MCS自旋锁自身的问题,又引入了所谓的队列自旋锁(Queued Spin Lock)。我们先来看看队列自旋锁的结构定义:
typedef struct qspinlock {
union {
atomic_t val;
#ifdef __LITTLE_ENDIAN
struct {
u8 locked;
u8 pending;
};
struct {
u16 locked_pending;
u16 tail;
};
#else
struct {
u16 tail;
u16 locked_pending;
};
struct {
u8 reserved[2];
u8 pending;
u8 locked;
};
#endif
};
} arch_spinlock_t;
可以看到,这个自旋锁结构是一个联合体,一共占用32位,4个字节。而且,按照大端字节序和小端字节序刚好布局是反的。拿小端字节序来说,首先它是一个32位的原子结构val,同时它又能被拆分成三个部分:
- 前8位是locked域,表示自旋锁是否已经被某个CPU所持有了;
- 接着8位是pending域,这个是对MCS自旋锁的一个优化,后面会解释;
- 最后的16位是tail域,通过这个域可以找到自旋锁队列的最后一个节点。
按照各个域的作用,定义了如下一些宏:
#define _Q_SET_MASK(type) (((1U << _Q_ ## type ## _BITS) - 1)\
<< _Q_ ## type ## _OFFSET)
#define _Q_LOCKED_OFFSET 0
#define _Q_LOCKED_BITS 8
#define _Q_LOCKED_MASK _Q_SET_MASK(LOCKED)
#define _Q_PENDING_OFFSET (_Q_LOCKED_OFFSET + _Q_LOCKED_BITS)
#if CONFIG_NR_CPUS < (1U << 14)
#define _Q_PENDING_BITS 8
#else
#define _Q_PENDING_BITS 1
#endif
#define _Q_PENDING_MASK _Q_SET_MASK(PENDING)
#define _Q_TAIL_IDX_OFFSET (_Q_PENDING_OFFSET + _Q_PENDING_BITS)
#define _Q_TAIL_IDX_BITS 2
#define _Q_TAIL_IDX_MASK _Q_SET_MASK(TAIL_IDX)
#define _Q_TAIL_CPU_OFFSET (_Q_TAIL_IDX_OFFSET + _Q_TAIL_IDX_BITS)
#define _Q_TAIL_CPU_BITS (32 - _Q_TAIL_CPU_OFFSET)
#define _Q_TAIL_CPU_MASK _Q_SET_MASK(TAIL_CPU)
#define _Q_TAIL_OFFSET _Q_TAIL_IDX_OFFSET
#define _Q_TAIL_MASK (_Q_TAIL_IDX_MASK | _Q_TAIL_CPU_MASK)
#define _Q_LOCKED_VAL (1U << _Q_LOCKED_OFFSET)
#define _Q_PENDING_VAL (1U << _Q_PENDING_OFFSET)
可以看到,如果当前系统中的CPU数量大于等于2^14,也就是16K,会使用不同的编码格式。这里我们暂时不分析这种情况,除非是超级计算机,否则不可能碰到这种情况。
tail域又被拆分成了两个部分,第一部分有2个比特,表示在某个CPU上节点的序号,所以一个CPU上最多有4个节点;第二部分有14个比特,表示是哪个CPU(CPU对应的编号加1)在等待这个自旋锁,所以一共能表示16384个CPU。
每个处理器需要4个队列节点,原因如下:
- 获得自旋锁时会禁止内核抢占,所以进程在等待自旋锁的过程中不会被其他进程抢占;
- 自旋锁是不可重入的,一个自旋锁只能被同一个CPU获得一次,否则会造成死锁;
- 但是,一个CPU在等待一个自旋锁的过程中可能被软中断抢占,然后在软中断处理程序中等待另一个自旋锁(软中断可以抢占进程,但是不能抢占另一个正在执行的软中断);
- 软中断处理程序在等待自旋锁的过程中可能被“硬”中断抢占,然后在“硬”中断处理程序中等待又另一个自旋锁(在“硬”中断处理程序中使用的自旋锁必须要先关闭该CPU上的“硬”中断,这是通过调用函数spin_lock_irqsave或spin_lock_irq实现的,否则会造成死锁);
- “硬”中断处理程序在等待自旋锁的过程中可能被不可屏蔽中断抢占,然后在不可屏蔽中断处理程序中等待又另一个自旋锁。
以上这种场景是极度极端的情况,如果进程要和“硬”中断处理程序共享数据,那么它就会先关本地中断再获得自旋锁,这样的话当前CPU上自旋锁只能嵌套一层,最多只会用到一个节点。因此,基于以上这些原因,一个CPU上最多应该只可以等待4个自旋锁。如果在某些架构下不可屏蔽中断还能够再被另外一个不可屏蔽中断抢占的话,那么就可能超过4个自旋锁,这时对这个超出的自旋锁,会回退到普通自旋锁的模式。
队列自旋锁对MCS自旋锁做了很多优化,主要有如下几点:
- 自旋锁结构的大小只占用了4个字节,这主要归功于将指向MCS锁内的指针变量用一个16位的tail域来取代。能这么做的原因就是前面说的,自旋锁要保证能正常工作,在一个CPU上最多只能同时持有4个。因此,每个CPU上最多只需要4个MCS结构体就够了。而且,一般系统中的CPU的个数是不会太多的。这样一来,就可以直接使用CPU号加上节点编号来唯一定位一个mcs_spinlock结构体,使用16位就够了。
每个CPU上的队列自旋锁节点定义如下(代码位于kernel/locking/qspinlock.c中):
#define MAX_NODES 4
......
struct qnode {
struct mcs_spinlock mcs;
......
};
......
static DEFINE_PER_CPU_ALIGNED(struct qnode, qnodes[MAX_NODES]);
所谓节点其实就是一个mcs_spinlock结构体。定义了一个每CPU数组变量qnodes,该数组只有四个元素。
- 第1个等待自旋锁的CPU直接在锁自身上面自旋等待,而不是在自己的mcs_spinlock结构体上自旋等待。这个优化带来的好处是,当锁被释放的时候,不需要访问mcs_spinlock结构体的缓存行,相当于减少了一次缓存不命中的情况。这是通过qspinlock结构体内的pending位来实现的,第1个等待自旋锁的CPU简单地设置pending位,然后自旋等待locked域变为0。如果这时候又有一个处理器想要获得这个自旋锁,它会看到pending位已经被设置了,才会开始创建等待队列,在自己的mcs_spinlock结构体的locked字段上自旋等待。
所以,队列自旋锁的结构如下所示:
为了方便分析代码,我们把一个qspinlock结构体对应的tail、pending和locked字段作为一个三元向量,使用(tail, pending, locked)的形式来表示,而且pending和locked字段取值只能是0或者1。
下面我们正式分析一下队列自旋锁加锁的queued_spin_lock函数(代码位于include/asm-generic/qspinlock.h中):
static __always_inline void queued_spin_lock(struct qspinlock *lock)
{
u32 val = 0;
if (likely(atomic_try_cmpxchg_acquire(&lock->val, &val, _Q_LOCKED_VAL)))
return;
queued_spin_lock_slowpath(lock, val);
}
atomic_try_cmpxchg_acquire是一个原子操作,如果第一个参数指向的原子类型变量的值和第二个参数指向的变量的值相同,则将第一个参数指向的原子变量赋值成第三个参数的值,并返回真;否则,则读取第一个参数指向的原子变量的值,将其赋值给第二个参数指向的变量,并返回假。
这个函数的功能很简单,如果本CPU发现当前锁的值是(0, 0, 0)的话,表示还没有任何别的CPU获得过这个自旋锁且没有任何别的CPU在这个锁上自旋等待,那么直接将其值改成(0, 0, 1),表示当前CPU已经持有了这把锁,并且直接返回。但是,如果本CPU发现当前锁的值不是(0, 0, 0)的话,表示已经有别的CPU持有了这把锁了,当前CPU没有第一次“抢”到,那就将调用queued_spin_lock_slowpath函数,进入所谓的“慢路径”处理(代码位于kernel/locking/qspinlock.c中),我们来分段阅读一下:
void queued_spin_lock_slowpath(struct qspinlock *lock, u32 val)
{
struct mcs_spinlock *prev, *next, *node;
u32 old, tail;
int idx;
......
/* 如果前面自旋锁的值是(0, 1, 0) */
if (val == _Q_PENDING_VAL) {
int cnt = _Q_PENDING_LOOPS;
/* 自旋一次等待状态转变 */
val = atomic_cond_read_relaxed(&lock->val,
(VAL != _Q_PENDING_VAL) || !cnt--);
}
/* 如果检测到竞争走队列模式 */
if (val & ~_Q_LOCKED_MASK)
goto queue;
看似非常简单的自旋锁,没想到会牵涉到这么多代码,这是因为在很多地方都有可能出现多个CPU竞争的状态,必须得小心的处理。但是,这里的共享资源是qspinlock自旋锁本身,因此在对其locked域、pending位和tail域进行原子更改的时候,要仔细分清楚当前CPU是否会和别的CPU产生竞争。
先说明一下,在进入queued_spin_lock_slowpath函数的时候,当前自旋锁的值可能是任何值,即(*, *, *),也就是说,有可能是(0, 0, 0)。你可能会问,进入这个函数之前不是已经通过原子操作判断过锁的值不是0了嘛。这是因为,在当前CPU判断过不是0之后,决定调用queued_spin_lock_slowpath的时候,可能那个持有自旋锁的CPU就已经把锁释放了,所以当前自旋锁的值会变成(0, 0, 0)。还有,可能有不止一个CPU也刚好正在调用queued_spin_lock_slowpath函数。
程序一开始先判断锁的状态是不是(0, 1, 0),处于这个状态说明只有一个CPU在等待这个自旋锁,并且持有这个自旋锁的CPU已经将其释放了,后面会提到过不了多久那个等待这个自旋锁的CPU就会将锁的状态改成(0, 0, 1)。这时候,当前CPU就可以自旋等待一会,如果锁的状态真的变成了(0, 0, 1),那就可以“抢”到pending位,就不用再走队列模式了。这是一个小小的优化,如果没这个判断也不会有什么问题,最多损失一点性能。自旋等待的次数由_Q_PENDING_LOOPS宏定义,当前内核将其设置成1:
#ifndef _Q_PENDING_LOOPS
#define _Q_PENDING_LOOPS 1
#endif
这里自旋等待的是锁的状态变成不是(0, 1, 0),并不是非要锁的状态变成(0, 0, 1)才返回。执行完这段优化代码后,当前自旋锁的状态任然是可能为任何值,可能不止一个CPU也执行到了这个点上。如果前一步读取到锁的tail字段不是0或者pending位是1,说明已经有处理器在等待自旋锁,那么跳转到标号queue的代码处,将本CPU加入到等待队列中。但这个val值其实是前一步读到的,当前锁的值可能是任何值,因此有可能现在已经变成(0, 0, *)了,本来有机会可以“抢”pending位的,却只能走队列模式了。这也没办法,原子操作不是锁,只能保证变量本身不被弄乱,且还可以保证其可见性,但是不能锁临界区。
我们接着来读下一段:
/* 尝试抢pending状态位 */
val = queued_fetch_set_pending_acquire(lock);
/* 如果竞争pending位失败则走队列模式 */
if (unlikely(val & ~_Q_LOCKED_MASK)) {
/* 如果“抢”之前pending状态位没设置则要还原 */
if (!(val & _Q_PENDING_MASK))
clear_pending(lock);
goto queue;
}
queued_fetch_set_pending_acquire直接调用了atomic_fetch_or_acquire函数:
static __always_inline u32 queued_fetch_set_pending_acquire(struct qspinlock *lock)
{
return atomic_fetch_or_acquire(_Q_PENDING_VAL, &lock->val);
}
这个函数会原子的将锁的pending位设置成1,并且返回没设置之前锁的值。执行这个操作之前,锁的状态可能是任何值(*, *, *),执行完这个操作之后,锁的值可能是(*, 1, *)。如果抢这个pending位之前锁的值的pending位或tail域就已经不是0了,就表明一定有一个别的CPU已经抢到过pending位了(要么pending位是1,表示有CPU在自旋等待在锁上;要么pending位是0,但是tail不是0,表示还有CPU在自旋等待,在这之前肯定还有一个CPU抢到过pending位,但现在释放了。),当前CPU竞争失败,这时将跳到队列模式去处理。但是,如果在原子设置这个pending位之前,pending位是0的话,说明这个pending位被当前CPU误“抢”了,原来的状态是(n, 0, *),队列不为空,为了保证自旋锁的次序,本CPU不能“抢”pending位。所以,还需要将设置的pending位再清空成0,不过为什么能直接这样操作,没有竞争吗?这是因为最多只会有一个CPU监测到这种pending位从0到1的跳变。因此,能执行clear_pending函数的只能有一个CPU,不会和别的CPU竞争,所以这里只需要保证原子操作和可见性就行了:
static __always_inline void clear_pending(struct qspinlock *lock)
{
WRITE_ONCE(lock->pending, 0);
}
如果能顺利执行完上面这些代码,不跳转到队列模式,说明当前CPU已经“抢”到锁的pending位了,我们接着看:
/*
* 只有一个CPU能“抢”到pending位
* 当前状态可能是(*, 1, *)
*
* 如果自旋锁的locked域不为0则自选等待其变成0
*/
if (val & _Q_LOCKED_MASK)
atomic_cond_read_acquire(&lock->val, !(VAL & _Q_LOCKED_MASK));
/*
* 获得自旋锁
* 将自旋锁的值从(*, 1, 0)变成(*, 0, 1)
*/
clear_pending_set_locked(lock);
......
return;
执行这段代码之前,当前锁的状态有可能是(*, 1, *),pending位一定是1,但其它域的值可以是任意的。不过,因为只能有一个CPU能“抢”到pending位,也就是说这段代码只可能同时被一个CPU执行,所以不存在竞争的问题。“抢”到了pending位就简单了,只需要自旋等待持有该自旋锁的CPU释放掉该自旋锁,也就是将自旋锁的locked域设置成0,然后将自旋锁的状态从(*, 1, 0)变成(*, 0, 1)就行了:
static __always_inline void clear_pending_set_locked(struct qspinlock *lock)
{
WRITE_ONCE(lock->locked_pending, _Q_LOCKED_VAL);
}
所以,持有pending位的CPU,是自旋等待锁的locked域变成0,不需要任何CPU节点。当锁的持有者释放锁的时候,迅速设置锁的locked域,并清空pending位,从而变成该锁的持有者。
以上这些部分就是队列自旋锁对MCS自旋锁做的一些小优化,下面将正式进入队列模式的处理代码:
queue:
......
pv_queue:
/* 获得当前CPU上的第一个节点 */
node = this_cpu_ptr(&qnodes[0].mcs);
/* 累加该节点的count值 */
idx = node->count++;
/* 根据CPU号和节点下标值编码成16位值 */
tail = encode_tail(smp_processor_id(), idx);
/* 如果自旋锁嵌套层数超过4层则退化成普通自旋锁 */
if (unlikely(idx >= MAX_NODES)) {
......
while (!queued_spin_trylock(lock))
cpu_relax();
goto release;
}
/* 获得队列的节点 */
node = grab_mcs_node(node, idx);
......
/* 编译器屏障 */
barrier();
/* 初始化节点数据 */
node->locked = 0;
node->next = NULL;
......
进入这段代码,当前自旋锁的值可能是任意值(*, *, *),也可能有不止一个CPU刚好在执行这段代码。不过这时每个CPU操作属于自己的节点,因此不存在竞争的问题。代码首先会获得当前CPU上的第一个节点,然后获得该节点的count值并累加。count值记录了当前CPU上空闲节点的起始下标,并且这个值只记录在CPU上第一个节点的count域里,其它剩下3个节点的count域其实并没什么实际的作用。接着,会调用encode_tail函数,将当前CPU的编号和节点下标“编码”成一个16位的值:
static inline __pure u32 encode_tail(int cpu, int idx)
{
u32 tail;
tail = (cpu + 1) << _Q_TAIL_CPU_OFFSET;
tail |= idx << _Q_TAIL_IDX_OFFSET;
return tail;
}
CPU的编号要加1之后再编码,否则无法区分到底是tail域没有指向任何节点,还是指向了第0个CPU的第0个节点。
接着,函数会检查当前CPU上自旋锁嵌套的层数,也就是如果获得的节点下标大于等于4,表示当前CPU上自旋锁嵌套层数已经超过了4。这种情况极少出现,要么就是哪里用错了,要么就是当前平台上还支持不可屏蔽(NMI)中断的嵌套调用。如果出现了这种情况,则退化成普通自旋锁,不停循环调用queued_spin_trylock函数,直到获得了自旋锁为止:
static __always_inline int queued_spin_trylock(struct qspinlock *lock)
{
/* 原子的读取自旋锁的值 */
u32 val = atomic_read(&lock->val);
/* 如果自旋锁的值不为0则返回假 */
if (unlikely(val))
return 0;
/* 竞争获取自旋锁 */
return likely(atomic_try_cmpxchg_acquire(&lock->val, &val, _Q_LOCKED_VAL));
}
可以看到,其实就是等待自旋锁的值变为0后,和其它CPU竞争将锁的值设置成(0, 0, 1)。这时候,自旋锁将不能保证顺序,也不能保证性能。
如果嵌套调用还在限定的范围内,将调用grab_mcs_node函数,获得一个空的节点。函数的参数base是节点数组的起始地址,idx是要获得节点的下标:
static inline __pure
struct mcs_spinlock *grab_mcs_node(struct mcs_spinlock *base, int idx)
{
return &((struct qnode *)base + idx)->mcs;
}
获得当前CPU上对应的节点后,要将其初始化,locked域设置成0(会自旋等待其变成1),next域设置成NULL(表示是最后一个节点)。特别值得注意的是在初始化节点之前需要放一个编译器屏障(barrier()),这又是为什么呢?前面提到过,在同一个CPU上,最多可以有4个不同的自旋锁嵌套调用,嵌套的层数是由CPU上第一个节点的count域记录的,这个域会在队列模式开头进行读取和累加操作,这个操作虽然写在一行代码里,但却不是一步就会完成了,而是可以分解为读取和累加两个操作。读取操作会读出节点下标值,这个值后面会用来计算tail的值,和后面的操作相关,因此编译器不会对其进行重排序。但是,累加操作就不一样了,它和后面的操作没有什么相关性,很有可能被编译器优化到任何位置,因此如果没有编译器屏障的话,代码可能被重排成如下顺序:
idx = node0->count;
......
node->locked = 0;
node->next = NULL;
node0->count = node0->count + 1;
为了方便理解,这里特别将第一个节点标记成node0。虽然实际代码中都用了局部变量node,但这仍然不能阻止编译器优化。
假设出现了一个非常小概率的情况,在当前CPU上正在加锁的过程中,调用到这个累加操作之前,刚好又来了一个中断调用,也需要走队列模式。那么会将同一个节点再初始化一遍,然后接着正常使用,使用完了后直接就将count值减1,不会再将节点值清空了,最后会返回到被中断之前的那个点上,也就是累加操作之前,然后会累加,继续往下走。但是,这时候节点的值已经不是空的,继续执行就会出问题。解决这个问题的方法很简单,只要保证累加操作一定在节点的初始化操作之前完成就行了。因此,就需要加一个编译屏障,保证累加操作一定不会被编译器优化到初始化操作之后。而且,编译屏障就够了,并不需要内存屏障,因为是在一个CPU上执行的,CPU本身会保证执行顺序。
我们接着看下面的代码,从现在开始就需要考虑竞争的问题了,因为所有CPU都可以更改自旋锁:
if (queued_spin_trylock(lock))
goto release;
/* 写内存屏障 */
smp_wmb();
/* 更新自旋锁的tail值为当前CPU上的空闲节点 */
old = xchg_tail(lock, tail);
next = NULL;
一开始先再尝试一下获得自旋锁,因为当前经过上面这些操作后,有可能获得和等待该自旋锁的CPU都已经释放了,当前的自旋锁是空闲的状态。这种情况下,当前CPU就可以轻易获得自旋锁,可以一定程度上提高效率。这是一个优化,当然也可以坚持走队列模式,功能上说也是正常的。接着,会调用xchg_tail函数,原子的读取自旋锁的tail域,并将自己节点更新上去:
static __always_inline u32 xchg_tail(struct qspinlock *lock, u32 tail)
{
/* 原子的交换tail值 */
return (u32)xchg_relaxed(&lock->tail,
tail >> _Q_TAIL_OFFSET) << _Q_TAIL_OFFSET;
}
在调用xchg_tail函数之前,有一个写内存屏障,它的作用是保证将当前节点更新到自旋锁之前,一定要保证这个自旋锁节点已经完成了初始化。也就是说,可以保证,当本CPU通过xchg_tail函数读到上一个节点的时候,它一定已经初始化好了。也就是要在两个CPU上保证如下的执行次序(结合后面的代码):
CPU 1 | CPU 2 |
node1->locked = 0; | |
node1->next = NULL; | |
<write memory barrier> | |
tail0 = xchg_tail(lock, tail1); | tail1 = xchg_tail(lock, tail2); |
node1->next = node2; |
如果没有那个写内存屏障的话,两个CPU上的执行次序可能被重排序成如下次序:
CPU 1 | CPU 2 |
node1->locked = 0; | |
tail0 = xchg_tail(lock, tail1); | tail1 = xchg_tail(lock, tail2); |
node1->next = node2; | |
node1->next = NULL; |
这样就会造成CPU1上永远也看不见下一个节点,那么CPU2上的自旋锁将永远“解”不了。
我们接着看下面的代码:
/* 如果之前队列不为空 */
if (old & _Q_TAIL_MASK) {
/* 获得前一个节点 */
prev = decode_tail(old);
/* 将当前节点链接到等待队列中 */
WRITE_ONCE(prev->next, node);
......
/* 自旋等待本节点的locked域变为1 */
arch_mcs_spin_lock_contended(&node->locked);
/* 尝试读取下一个节点 */
next = READ_ONCE(node->next);
if (next)
prefetchw(next);
}
/*
* 当前正处在队列的首节点
* 自旋锁的值是(n, *, *)
* 自旋等待其变成(n, 0, 0)
*/
......
val = atomic_cond_read_acquire(&lock->val, !(VAL & _Q_LOCKED_PENDING_MASK));
如果当前CPU发现在将自己的节点更新到自选锁之前,自旋锁值的tail域不是0的话,那么说明当前节点前面应该还有别的CPU上的节点也在等待自旋锁,这时候就需要更新前一个节点的next域,让其指向本节点,从而将队列链接完整。要获得前一个节点的指针,需要对锁的16位tail域的值进行“解码”:
static inline __pure struct mcs_spinlock *decode_tail(u32 tail)
{
int cpu = (tail >> _Q_TAIL_CPU_OFFSET) - 1;
int idx = (tail & _Q_TAIL_IDX_MASK) >> _Q_TAIL_IDX_OFFSET;
return per_cpu_ptr(&qnodes[idx].mcs, cpu);
}
链接完成后,调用arch_mcs_spin_lock_contended函数,自旋等待自己节点上的locked域变成非0(也就是1):
#ifndef arch_mcs_spin_lock_contended
#define arch_mcs_spin_lock_contended(l) \
do { \
smp_cond_load_acquire(l, VAL); \
} while (0)
#endif
后面会看到,如果前一个节点“抢”到了自旋锁后,在撤销之前,会将后一个节点的locked域变为1。因此,自旋等待如果返回了,就证明当前CPU的节点已经是队列的头节点了。然后,它就只需要自旋等待自旋锁的locked域和pending位都变成0后就可以“抢”锁了。所以,总结一下,队列的头节点自旋等待锁的locked域和pending域都变成0,队列中后面的节点自旋等待节点内的locked域变成1。
为什么在将当前节点链接到等待队列中的时候需要使用WRITE_ONCE宏?为什么不使用内存屏障?使用WRITE_ONCE是保证当前代码编译后的次序不会改变,也就是当前CPU链接到队列的操作会在自旋等待之前执行,如果没有WRITE_ONCE宏,由于这两条指令之间没有什么关系,有可能被编译器优化后造成先自旋等待再链接,那么前一个节点将永远看不到下一个节点,会造成死锁。但是,只使用WRITE_ONCE宏保证本CPU的执行次序就够了,对另一个CPU来说没有什么执行依赖关系,只要最终能看到这个next结果就行了,因此没必要使用内存屏障。
接下来的代码,当前CPU已经等到了前面所有CPU都释放了自旋锁,终于可以合法占有自旋锁了:
locked:
/* 是否是自旋锁队列的最后一个节点 */
if ((val & _Q_TAIL_MASK) == tail) {
/* 试着将自旋锁的值改成(0, 0, 1) */
if (atomic_try_cmpxchg_relaxed(&lock->val, &val, _Q_LOCKED_VAL))
/* 没有发生竞争则释放本节点 */
goto release;
}
/* 拥有自旋锁 */
set_locked(lock);
/* 等待后一个节点“出现” */
if (!next)
next = smp_cond_load_relaxed(&node->next, (VAL));
/* 将下一个节点的locked值设置成1 */
arch_mcs_spin_unlock_contended(&next->locked);
......
release:
/* 释放本节点 */
__this_cpu_dec(qnodes[0].mcs.count);
}
EXPORT_SYMBOL(queued_spin_lock_slowpath);
首先要处理一种特殊情况,就是如果当前节点已经是队列中的最后一个节点了,就会调用atomic_try_cmpxchg_relaxed函数原子的将自旋锁的值修改成(0, 0, 1)。atomic_try_cmpxchg_relaxed函数返回1表示没有竞争发生,修改成功,直接撤销该节点就行了;如果返回0,表示有竞争的情况发生,也就是说从上面自旋等待锁变成(n, 0, 0),到正式将其修改为(0, 0, 1)之间,有别的CPU已经修改了自旋锁的状态(哪怕只隔了一行代码),代码还要接着往下走。
先是调用set_locked函数,将自旋锁的locked域变成1,表示当前CPU已经占有了这个自旋锁:
static __always_inline void set_locked(struct qspinlock *lock)
{
WRITE_ONCE(lock->locked, _Q_LOCKED_VAL);
}
然后,自旋等待当前节点的next域变为非NULL,等待下一个节点“出现”。因为如果发生了竞争,就一定意味着有一个新节点会“出现”在后面。最后,调用arch_mcs_spin_unlock_contended函数,将下一个节点的locked域设置成1:
#ifndef arch_mcs_spin_unlock_contended
#define arch_mcs_spin_unlock_contended(l) \
smp_store_release((l), 1)
#endif
一切完成后,就会撤销当前节点,就是将当前CPU首节点的count值减1。但是,这个不用的节点并没有清0,因为反正再次使用的时候会将其初始化。
所以,总结一下,队列自旋锁的加锁步骤如下:
- 如果qspinlock整体val为0,说明锁空闲,则当前CPU设置qspinlock的locked位为1后直接持锁;
- 第1个等锁的CPU设置pending位后,自旋等待locked位变成0;
- 第2个等锁的CPU将
tail设置为
指向本CPU变量的mcs_spinlock节点,然后自旋等待locked域和pending位都变成0;
- 第N个等锁的CPU也将
tail
设置为
指向本CPU变量的mcs_spinlock节点,并将之前队尾节点的next指向自己,然后自旋转等待本CPU变量的mcs_spinlock节点中的locked域变成1。
相较于获得自旋锁,释放自旋锁的代码就非常简单了:
static __always_inline void queued_spin_unlock(struct qspinlock *lock)
{
/* 将自旋锁的locked域设置成0 */
smp_store_release(&lock->locked, 0);
}
可以看到,只是简单的将自旋锁的locked域设置成0就行了。
ARM64架构下的自旋锁
在Linux的5.4内核代码中,如果针对某个处理器架构的代码没有单独定义,队列自旋锁将是通用自旋锁的默认实现方式。在Arm64架构下,没有例外,就是使用的队列自旋锁,可以看一下spin_lock的调用顺序(代码位于include/linux/spinlock.h中):
#define raw_spin_lock(lock) _raw_spin_lock(lock)
......
static __always_inline void spin_lock(spinlock_t *lock)
{
raw_spin_lock(&lock->rlock);
}
就是调用了raw_spin_lock宏定义的函数,也就是_raw_spin_lock函数。spinlock_t是自旋锁的结构体,其定义如下(代码位于include/linux/spinlock_types.h中):
typedef struct raw_spinlock {
arch_spinlock_t raw_lock;
......
} raw_spinlock_t;
......
typedef struct spinlock {
union {
struct raw_spinlock rlock;
......
};
} spinlock_t;
就是包含了一个arch_spinlock_t结构体,而这个结构体在Arm64架构下就被指向了前面说的qspinlock。
我们接着看_raw_spin_lock函数的实现(代码位于kernel/locking/spinlock.c中):
void __lockfunc _raw_spin_lock(raw_spinlock_t *lock)
{
__raw_spin_lock(lock);
}
EXPORT_SYMBOL(_raw_spin_lock);
就是调用了__raw_spin_lock函数,其在SMP模式下的定义如下(代码位于include/linux/spinlock_api_smp.h中):
static inline void __raw_spin_lock(raw_spinlock_t *lock)
{
preempt_disable();
......
LOCK_CONTENDED(lock, do_raw_spin_trylock, do_raw_spin_lock);
}
可以看到,该函数禁止了内核抢占。宏LOCK_CONTENDED定义如下(代码位于include/linux/lockdep.h中):
#define LOCK_CONTENDED(_lock, try, lock) \
lock(_lock)
因此,__raw_spin_lock函数宏扩展开来等价于:
static inline void __raw_spin_lock(raw_spinlock_t *lock)
{
preempt_disable();
......
do_raw_spin_lock(lock);
}
do_raw_spin_lock函数定义如下(代码位于include/linux/spinlock.h中):
static inline void do_raw_spin_lock(raw_spinlock_t *lock) __acquires(lock)
{
......
arch_spin_lock(&lock->raw_lock);
......
}
arch_spin_lock宏在Arm64平台下定义如下(代码位于include/asm-generic/qspinlock.h中):
#define arch_spin_lock(l) queued_spin_lock(l)
所以,就是使用的前面分析的队列自旋锁。顺便说一句,在Arm32平台下,默认的自旋锁实现就不是队列自旋锁,而是所谓的入场券(Ticket)自旋锁,这里就不再分析了。
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)