qspinlock 自旋锁的原理以及实现

qspin-lock的原理以及实现

 ticket spinlock已经有大量完善的分析文章,arm64 在kernel在4.16rc6之后就将默认的spinlock改为了qspinlock,目前分析的文章较少,并且看了下还是很难理解,我自己还是啃代码看完的,所以这里做个记录。

另外,还没遇到过qspinlock引起的问题,没看到运行时的相关数据结构,这篇文章只是分析代码得来的,没有实战经验不确定是否全部正确。

没啥用的前言介绍

 如果已经了解相关背景只想看原理请跳过这段废话,如果并不清楚背景知识还请看一看,了解设计原理有利于理解程序的实现。

第一个版本 其实,kernel很早就实现了spinlock,之前的lock叫做ticket-spinlock,最早的ticket lock非常简单,自旋锁spinlock用一个整数值来表示,表明了锁是否可用,初值设为1。spin_lock()函数通过递减val(原子方式),然后查看是否为0,若为0则成功拿锁,若为负数则代表锁已属于他人,所以它进入spin状态,不断查询val值直到变为1。当锁的拥有者完成critical section的执行,将val置为1,即释放锁。这是最原始的实现,实际上就是原子变量,坏处在于没有排队机制,等待时间最长的CPU不一定能最先拿到锁。
第二个版本 后来,就是最常见的owner与next叫号机制实现的spinlock,这种机制的问题在于假设一个CPU持锁,7个CPU等待,等待的7个CPU都必须不停地读cache,当持锁者解锁,只有最先等待的CPU能够拿到,与其他的CPU无关,但是所有等待的CPU都必须重新刷新cache,因为解锁的时候会修改结构体成员,数据发生了变化。
第三个版本 考虑到这种性能浪费,就有人设计另一种锁 MCS-lock:为等待的CPU分配锁的副本,每个CPU只等待自己所属的副本发生改变(下文会把这种情况叫做在自己的副本自旋),这样避免cache刷新的问题。这种锁始终没有在kernel得到广泛使用(只有x86会有它的接口),因为他的结构里面多了一个指针导致结构体变大了,多了一个指针成员,而spinlock很多情况下都是内嵌在一些结构体里面使用,这样必然影响很多结构体的大小,内核对某些结构体的大小非常敏感,所以此方案几乎无人使用。
ARM 4.16内核之后使用的版本 最终,终于有人将MCS的结构压缩,在没有影响结构体大小的情况下实现【副本自旋】这一思想,这就是qspin-lock。本文不会介绍MCSlock相关,因为很容易把这两个锁搞混反而不利于学习,只要知道qspinlock利用了MCS的思想,甚至是数据结构,所以不要奇怪为啥会出现MCS的结构体。

数据结构介绍

首先,明确以下概念:
1.压缩结构,这是内核常用的手段,这往往意味着结构体里面含有联合体,以及程序里面大量的位操作
2.副本,这意味着除了锁的本体之外,有副本结构,而且需要某种手段将锁的本体与副本结构联系起来,通常是使用指针成员但是这里为了省空间并没有用指针
3.等待队列,要实现先到先得,等待最久的CPU最先拿到锁,就必须有等待队列

锁结构

在4.16版本,也就是刚刚引入qspinlock作为默认spinlock的版本,它的定义简直让人摸不着头脑:

typedef struct qspinlock {
	atomic_t	val;
} arch_spinlock_t;

就一个整数如何记录锁以及副本的信息?幸好后来的版本比如4.19做了切割,至少结构体容易读懂一些,后面的代码都会以4.19版本为例

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;

这里使用的是联合体,锁的本质仍然是一个整数,与tick-spinlock一样。宏__LITTLE_ENDIAN是用来区分大小端,保证不同的机器上结构的内存分布一样,多数机器都是小端的,因此它的内存结构和人类的读写习惯是不同的。并且根据CPU的数目不同,切割方式也不一样,这里只讨论常规情况,即 NR_CPUS < 16K(我觉得这么多的CPU大多数人是不会接触到的…)
val
locked:用来表示这个锁是否被人持有,只会有1和0两种情况,即1被人持有,0无人持锁
pending:可以理解为最优先持锁位,即当unlock之后只有这个位的CPU最先持锁,也只会有1和0
tail:有idx+CPU构成,用来标识等待队列最后一个节点。
idx:就是index,它作为数组下标使用,介绍CPU副本的时候会一起介绍
CPU:用来表示CPU的编号+1,为何要+1?因为规定tail为0的时候表示等待队列中没有成员,如果不+1,0号CPU在等待队列中并且idx为0的情况下,tail也就是0了,那就无法区分队列中到底有没有人.

CPU副本

 工作队列大家都知道,每次进程要将自己加入队列,都是临时建立一个结构体,将自己的信息写入结构体然后将结构体加入队列,qspinlock是否也是临时建立结构体作为副本?
 答案是 否。工作队列是针对进程的,因此需要临时分配。而spinlock和其他的锁不同,它虽然在进程当中,但是其实对一个CPU而言它的数目是固定的。当一个进程在一个CPU上等待spinlock,此CPU能否持有其他spinlock?切换到另一个进程拿新的spinlock?当然不行,spinlock是关抢占的,此时切换进程属于非法调度。中断?是的只有中断可以,中断分为三种:softirq, hardirq和nmi。因此一个CPU最多最多只能等待四个spinlock,所以它最多处于4个spinlock的等待队列,它最多需要4个spinlock的副本。
于是,我们可以用一个数组来满足一个CPU的需求,如何满足所有CPU的需求?per-cpu数组即可。

struct mcs_spinlock {
	struct mcs_spinlock *next;
	int locked; 
	int count;  
};
static DEFINE_PER_CPU_ALIGNED(struct mcs_spinlock, mcs_nodes[MAX_NODES]);

locked:用来通知这个CPU,该你持锁了
count:每个CPU的第一个数组成员,即mcs_nodes[0].count才有作用,其余的成员的count值无意义。它用来统计这个CPU在多少spinlock的等待队列里面。听起来有点绕?前面我们说过,一个CPU最多最多只能等待四个spinlock,当它需要将自己的副本加入spinlock的等待队列时,mcs_nodes[4]有4个成员,如何决定将哪一个成员加入?有两种方法:1.将每种状态对应一个成员,比如进程上下文对应mcs_nodes[0],然后在获取spinlock时检测自己处于哪种状态,将对应的副本加入。看起来有点繁琐是不是?2.每次需要将自己加入等待队列,就将mcs_nodes[0].count +1,第一次将mcs_nodes[0]加入,第二次将mcs_nodes[1]加入,离开等待队列时将mcs_nodes[0].count -1。这样不用区分自己在哪种状态,只要保证每次加入的节点不会重复就行,kernel使用的就是第二种。
next:构成等待队列的链表成员。

在这里插入图片描述
这张图是很久之前画的,有一点错误,tail应该指向队列尾而不是队首,还请注意一下

代码分析

数据结构看完了,我们知道锁是struct qspinlock,他被切成了一块一块的,CPU将自己的副本加入等待队列,副本是定义好的per-cpu数组mcs_nodes[4],想要获取更详细的信息,就必须看代码。
代码也很简单,对于锁的操作就四种:初始化,加锁,解锁,try_lock。

初始化

唯一复杂的就是一对绕来绕去的宏,这里就不列出了,简单来说在不开启debug的情况下,就是struct qspinlock所有成员初始化为0。

加锁

简单来说,持锁失败分为三种情况:
1.进来发现锁处于特殊交接状态,等这个状态完成
2.只有1个CPU持锁,无人等待,那么我们持有pending位,在lock结构spin等待locked被清零,等待完成之后我们持锁
3.我们进来,发现pending位有人了或者tail不为0,说明有人在等,需要加入等待队列
 a.等待队列已经有人了,我们加入等待队列,在自己的node spin等待lock被上一个节点置位,也就是上一个节点持锁通知我们,我们是队首了
 b.pending位有人,但是等待队列为空,我们加入等待队列就是队首
 成为队首之后,在lock结构spin等待locked和pending都被清零,等待完成之后我们持锁,如果有下个节点通知下个节点他是队首了

下面来看看代码,这里会有很多的位操作来赋值取值,我把这些宏简化出来放在这里
mask就是用来与lock->val相与获取对应字段的值,offset就是该字段与第一个bit的距离,VAL表示只设置该字段之后lock->val的值

#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		0xff

#define _Q_PENDING_OFFSET	8
#define _Q_PENDING_BITS		8
#define _Q_PENDING_MASK		0xff00
#define _Q_TAIL_IDX_OFFSET	16
#define _Q_TAIL_IDX_BITS	2
#define _Q_TAIL_IDX_MASK	0x3ff00

#define _Q_TAIL_CPU_OFFSET	18
#define _Q_TAIL_CPU_BITS	14
#define _Q_TAIL_CPU_MASK	0xfffc0000

#define _Q_TAIL_OFFSET		16
#define _Q_TAIL_MASK		0xffff

#define _Q_LOCKED_VAL		0x1
#define _Q_PENDING_VAL		0x100
typedef struct qspinlock {
	union {
		atomic_t val;
		struct {
			u8	locked;
			u8	pending;
		};
		struct {
			u16	locked_pending;
			u16	tail;
		};
} arch_spinlock_t;

需要注意的是struct qspinlock,一定要记住联合体的内存是共享的,lock->val取出的值是locked + pending + tail

从设计者的注释可以看出,设计者将整个锁分为三部分 (queue tail, pending bit, lock value),使用 x,x,x这一组数表示三个部分对应的值

//在没有debug的情况下,加锁的函数就只有这一步
static __always_inline void queued_spin_lock(struct qspinlock *lock)
{
	u32 val;

	val = atomic_cmpxchg_acquire(&lock->val, 0, _Q_LOCKED_VAL);
	//atomic_cmpxchg_acquire 比较lock->val与0,相同则设置lock->val = _Q_LOCKED_VAL,返回val的旧值,否则直接返回val。
	//lock->val是联合体的所有成员的结合,这里判断如果三个部分都为0,即无人持锁,无人持有pending,无人在等待队列,那么直接获取锁
	//即设置val的locked为1
	if (likely(val == 0))
		return;
	queued_spin_lock_slowpath(lock, val); //这里就是直接获取锁失败的情况,需要进行等待了
}

atomic_cmpxchg_acquire这种类型的函数是spinlock里面常用的用于原子操作的函数,往往代码中没有功能注释,网络上也很难查找,直接看代码是十几层嵌套的宏定义难以查看,我会尽量把这些函数的功能与返回值都注释出来。

void queued_spin_lock_slowpath(struct qspinlock *lock, u32 val)
{
	struct mcs_spinlock *prev, *next, *node;
	u32 old, tail;
	int idx;

------------------第一种情况:进入时为特殊的交接状态-----------------
//刚进来,发现val == _Q_PENDING_VAL,这意味着locked = 0,tail = 0,但是pending = 1
//无人持锁,等待队列为空,pending位被人持有
//之前说过pending持有者是最先等待锁的人,那么此时处于特殊的交接状态
//持锁者unlock,我们前面有人等待,但是继承人pending持有者还没反应过来没有拿锁
//这时候就等待这个特殊状态过去
	if (val == _Q_PENDING_VAL) {
		int cnt = _Q_PENDING_LOOPS;
		val = atomic_cond_read_relaxed(&lock->val,
					       (VAL != _Q_PENDING_VAL) || !cnt--);
		//循环读取lock->val,直到脱离这个状态,可以是pending位被清零,也可是locked被置位,也可以是等待队列中出现成员
		//但是此处VAL是哪来的?C语言是区分大小写的,此文件中没有定义VAL。答案在问题1。
	}
	/*
	 * 如果除了locked还有其他位被置(tail或者pending)说明有其他的竞争者
	 * 我们此时肯定不是第一个等待者,跳转到需要加入等待队列的情况
	 */
//代码段1开始  <-----------------------
	if (val & ~_Q_LOCKED_MASK)
		goto queue;
------------------第一种情况结束-----------------




------------------第二种情况:我们是第一个等待者-----------------
	/*
	 * 来到这里意味着只有locked被置位,pending和tail都为0
	 * 此时我们就是第一个等待者,设置pending位(持有pending位),基于locked位进行自旋等待直到其为0
	 * val为锁的旧值
	 */
	val = queued_fetch_set_pending_acquire(lock);
//代码段1结束
	/*
	 * 代码段1 开始已经判断了val & ~_Q_LOCKED_MASK,为何此处还要重复一次?
	 * 因为queued_fetch_set_pending_acquire是一个等待的过程,这个过程之后val的值可能发生变化
	 * 如果我们设置了pending位之前有其他CPU进入了等待队列,即tail发生了改变,存在竞争情况
	 * 清除设置的pending位,进入需要加入等待队列的情况
	 */
	if (unlikely(val & ~_Q_LOCKED_MASK)) {
		if (!(val & _Q_PENDING_MASK))
			clear_pending(lock);
		goto queue;
	}

	/*
	 * 到达此处时,我们持有了pending位,并且tail位0,即等待队列为空
	 */
	if (val & _Q_LOCKED_MASK)
		//atomic_cond_read_acquire,不停的读取lock->val,直到locked位被清零,即在此处spin等待locked位
		atomic_cond_read_acquire(&lock->val, !(VAL & _Q_LOCKED_MASK));

	/*
	 * 到达此处,locked位也被清零了,我们此时持有pending位
	 * 现在,该我们持锁了,清除pending位,表明我们不需要再等待,设置locked位持锁,正式拿到锁
	 */
	clear_pending_set_locked(lock);
	qstat_inc(qstat_lock_pending, true);
	return;
------------------第二种情况结束-----------------



------------------第三种情况:需要加入等待队列-----------------
queue:
	qstat_inc(qstat_lock_slowpath, true);
pv_queue:
	node = this_cpu_ptr(&mcs_nodes[0]);
	//还记得上面对于count值的解释嘛?就是就是将每个CPU的mcs_nodes[0].count作为计数器,记录次CPU加入了多少spinlock的等待队列
	//第一次使用mcs_nodes[0]节点加入,第二次就使用mcs_nodes[1]
	idx = node->count++;
	
	//将本CPU的CPU编号+1的值和idx,也就是节点编号制作成tail,(idx 2bit   CPU num 14bit)
	tail = encode_tail(smp_processor_id(), idx);

	node += idx; //实际上等价于node = mcs_nodes[idx]

	node->locked = 0;
	node->next = NULL;

	/*
	 * 看看在我们做准备的这么长时间里,spinlock会不会已经被解锁了,如果被解锁了
	 * 那就直接持锁,然后mcs_nodes[0].count-- 退出就可以了
	 */
	if (queued_spin_trylock(lock))
		goto release;

	//lock->tail设置为我们生成的新的tail,old为返回的旧的val
	old = xchg_tail(lock, tail);
	next = NULL;

	/*
	 * 如果旧的tail不为0,说明等待队列里面已经有节点了
	 */
	if (old & _Q_TAIL_MASK) {
		prev = decode_tail(old);//从旧的tail中拿到之前的等待队列的尾结点

		/* 链表的尾插,新的节点加入链表 */
		WRITE_ONCE(prev->next, node);
		//不停的读取直到locked位被清零,即在此处spin等待locked位
		//加入等待队列并且非链表头的节点在自己的CPU副本上自旋等待自己的locked成员被置位
		arch_mcs_spin_lock_contended(&node->locked);

		/*
		 * 到达此处时,我们自己的node->locked被其他人置位了,这是通知我们现在我们是等待链表的第一个了
		 */
		next = READ_ONCE(node->next);
		if (next) 
		//我们现在还是不是尾结点?不是的话提前加载下一结点的cache,后面需要操作下一节点的数据的,这里是优化处理
			prefetchw(next);
	}
	
	//到达此处时,可能是等待链表为空因此我们就是第一个节点
	//也可能是等待我们等到了node->locked被置位,这是前一个节点告诉我们现在我们是等待链表第一个节点了
	//等待链表的第一个节点等待lock的pending位和locked位全部被清零
	//atomic_cond_read_acquire  在lock中spin等待pending位和locked位全部被清零
	//为什么这里不是等待pending位清零然后持有pending位再去等待locked?问题二
	val = atomic_cond_read_acquire(&lock->val, !(VAL & _Q_LOCKED_PENDING_MASK));

locked:
	/*
	 * 现在,lock中spinpending位和locked位全部被清零
	 * 我们是等待队列的第一个节点,该我们持锁了
	 */
	
	//如果tail还是我们设置的,说明我们同时是等待队列的最后一个节点,后面没人了
	//设置lock->val = 1(_Q_LOCKED_VAL),这是设置locked位,同时清零tail,因为我们是最后一个,等我们持锁等待队列就为空了
	if (((val & _Q_TAIL_MASK) == tail) &&
	    atomic_try_cmpxchg_relaxed(&lock->val, &val, _Q_LOCKED_VAL))
		goto release; /* No contention */

	/* 如果tail发生了改变,说明后面有新的节点,这里先持锁 lock->locked = 1 */
	set_locked(lock);

    //读取下一节点
	if (!next)
		next = smp_cond_load_relaxed(&node->next, (VAL));
	//还记得上面我们在等待队列中是等待什么?是等待自己node->locked被置位
	//这个是谁置位的?答案出来了,上一个节点持锁完成之后干的
	//这里就是将下一个节点的node->locked设置为1,通知他,恭喜,你是等待队列的第一个了
	arch_mcs_spin_unlock_contended(&next->locked);

release:
	/*
	 * 现在我们离开了等待队列,mcs_nodes[0].count用来表示我们在多少个spinlock的等待队列里面
	 * 现在该把它-1了
	 */
	__this_cpu_dec(mcs_nodes[0].count);
------------------第三种情况结束-----------------
}

解锁与尝试解锁

解锁很简单,设置lock->locked = 0就行了,不需要去通知谁。因为后面的人都盯着呢,一旦数据发生改变就会自行上位,不需要你通知。
尝试解锁,也就是try_lock,它的本质就是看一下是否处于 无人持有pending+等待队列为空+locked为0 的没有持锁没人等待的状态,是的话就拿锁,不回去等锁直接返回。

一些问题

问题一:谁人定义的VAL?宏里面为什么会突然多出一个这个?
以arch_mcs_spin_lock_contended(&node->locked)为例,这里是在等待locked被清零,其实现如下:

#define arch_mcs_spin_lock_contended(l)					\
do {									\
	smp_cond_load_acquire(l, VAL);					\
} while (0)
#endif

#define smp_cond_load_acquire(ptr, cond_expr)				\
({									\
	typeof(ptr) __PTR = (ptr);					\
	typeof(*ptr) VAL;						\
	for (;;) {							\
		VAL = smp_load_acquire(__PTR);				\
		if (cond_expr)						\
			break;						\
		__cmpwait_relaxed(__PTR, VAL);				\
	}								\
	VAL;								\
})

smp_cond_load_acquired的入参突然多出一个VAL。
这个VAL在后续的函数中相当于cond_expr,用来判断何时等待结束。其实这里传入VAL是宏函数的特殊玩法,宏函数不是函数,他的传参方式不是值传递,而是直接替换。上面的宏相当于:

arch_mcs_spin_lock_contended(&node->locked)
({									\
	__PTR = (&node->locked);					\
	typeof(*ptr) VAL;						\
	for (;;) {							\
		VAL =(__PTR);				\
		if (VAL)						\
			break;						\				\
	}								\
	VAL;								\
})

即传入VAL相当于将读取的结果作为判断循环结束的条件。同理,其他宏中突然多出的VAL也是起到代替入参的值的作用。

问题二:不是等待pending位清零然后持有pending位再去等待locked?
从代码来看pending位较为特殊,它仅仅是作为第二个CPU等待时无需加入等待队列的一种优化,来提高效率。它不是如同locked位一样必须有人占的位置,其他条件下,都不会去动pending位。

我们到底在等待什么?

1.对于持有pending位的,在lock结构spin等待locked位
2.对于等待队列的队首,在lock结构spin等待locked位 && pending位
3.对于等待队列的非队首节点,在自己的node副本spin等待node->lock位。
所以qslock真正能够防止cache刷新问题的,其实只在第四个CPU生效,对于核心数较少的机器,比如4核-8核的设备,由于多了链表操作qspinlock与ticket-spinlock性能谁强还不一定。

qspinlock debug的一些技巧

通常遇到spinlock需要debug的情况,往往都是死锁问题,或者内存问题。内存问题熟悉数据结构即可。
例如我们要判断一块内存是否是一个正常的struct qspinlock结构,可以看以下几点:

  1. lock和pending结构是否是0或者1,因为多数情况下只能是这两个数值
  2. tail结构能否还原成标准的cpu | index,然后根据这个找到对应的cpu的mcs_node

死锁类问题需要解决的无非是谁持锁,谁等锁

  1. 通常可以在出现问题,例如发生soft lockup的cpu栈上获取到需要的spinlock地址
  2. 要想找到所有的等锁进程,有两种方式,cpu数目不多的情况下遍历所有在cpu上活跃的进程的栈,看看是否是在等待spinlock即可
    如果是的话,就可以在栈里面拿到它在等待的spinlock地址。
    cpu数目较多,例如108核的机器,则使用tail去寻找mcs_node组成的链表。
  3. 找到所有的等锁者不难,最难的问题是如何找到owner。
    在未开启debug的情况下,spinlock不会记录owner,并且owner也不会在任何链表里面
    目前也没有特别好的方案,可以尝试
    a) 如果使用的是crash tool之类的工具分析vmcore,试试search -t在所有进程栈上检索spinlock的地址。因为一般使用spin_lock函数持有spinlock之后会马上退栈运行其他函数,所以spinlock的地址不一定在持锁者的进程栈上,但是还是有概率瞎猫碰到死耗子,所以建议试试
    b) 同样中断栈也可能会有类似的情况,可以也试试
    c) 持锁者之前是有概率在等锁的,所以它的mcs_spinlock可能会残留有持锁时的信息
    d) 实在没办法,就只能尝试复现或者加入debug信息了。例如某个spinlock只在固定的几个流程里面被获取,就可以使用kprobe在获取之后打印一个信息,这种方式在用户态就能完成,无需修改内核,可以根据打印的cpu快速定位到持锁者。

最简单的方法还是开启debug选项复测,开启之后就会记录owner了

typedef struct raw_spinlock {
	arch_spinlock_t raw_lock;
#ifdef CONFIG_DEBUG_SPINLOCK
	unsigned int magic, owner_cpu;
	void *owner;
#endif
#ifdef CONFIG_DEBUG_LOCK_ALLOC
	struct lockdep_map dep_map;
#endif
} raw_spinlock_t;

禁止其他CSDN用户转载,现在搜的全是各种转载的,同平台的就别转了
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值