术道经纬
首发于术道经纬
Linux中的spinlock机制[二] - MCS Lock

Linux中的spinlock机制[二] - MCS Lock

上文提到,每当一个spinlock的值出现变化时,所有试图获取这个spinlock的CPU都需要读取内存,刷新自己对应的cache line,而最终只有一个CPU可以获得锁,也只有它的刷新才是有意义的。锁的争抢越激烈(试图获取锁的CPU数目越多),无谓的开销也就越大。

第三种实现 - MCS Lock

如果在ticket spinlock的基础上进行一定的修改,让每个CPU不再是等待同一个spinlock变量,而是基于各自不同的per-CPU的变量进行等待,那么每个CPU平时只需要查询自己对应的这个变量所在的本地cache line,仅在这个变量发生变化的时候,才需要读取内存和刷新这条cache line,这样就可以解决上述的这个问题。

要实现类似这样的spinlock的「分身」,其中的一种方法就是使用MCS lock。试图获取一个spinlock的每个CPU,都有一份自己的MCS lock。

先来看下per-CPU的MCS lock是由哪些元素构造而成的(代码位于/kernel/locking/mcs_spinlock.h):

struct mcs_spinlock {
	struct mcs_spinlock *next;
	int locked; 
};

每当一个CPU试图获取一个spinlock,它就会将自己的MCS lock加到这个spinlock的等待队列,成为该队列的一个节点(node),加入的方式是由该队列末尾的MCS lock的"next"指向这个新的MCS lock。

"locked"的值为1表示该CPU是spinlock当前的持有者,为0则表示没有持有。

  • 加锁

对于一个锁的实现来说,最核心的操作无非就是「加锁」和「解锁」。先来看下MCS lock的加锁过程是怎样的:

void mcs_spin_lock(struct mcs_spinlock **lock, struct mcs_spinlock *node)
{
	// 初始化node
	node->locked = 0;
	node->next   = NULL;

        // 找队列末尾的那个mcs lock
	struct mcs_spinlock *prev = xchg(lock, node);
	// 队列为空,立即获得锁
	if (likely(prev == NULL)) {
		return;
	}

	// 队列不为空,把自己加到队列的末尾
	WRITE_ONCE(prev->next, node);

	// 等待lock的持有者把lock传给自己
	arch_mcs_spin_lock_contended(&node->locked);
}

前面说过,加入队列的方式是添加到末尾(tail),所以首先需要知道这个「末尾」在哪里。函数的第一个参数"lock"就是指向这个末尾的指针,之所以是二级指针,是因为它指向的是末尾节点里的"next"域,而"next"本身是一个指向"struct mcs_spinlock"的一级指针。

第二个参数"node"是试图加锁的CPU对应的MCS lock节点。

"xchg()"的名称来源于x86的XCHG指令,其实现可简化表示成这样:

xchg(*ptr, x)
{
	ret = *ptr;
	*ptr = x;
	return ret;
}

它干了两件事,一是给一个指针赋值,二是获取了这个指针在赋值前的值。

对应着上面的这个mcs_spin_lock(),通过xchg()获得的"prev"就是"*lock"最初的值(prev = *lock)。如果这个值为"NULL",说明队列为空,当前没有其他CPU持有这个spinlock,那么试图获取这个spinlock的CPU可以成功获得锁。同时,xchg()还让lock指向了这个持有锁的CPU的node(*lock = node)。

这里用了"likely()",意思是在大部分情况下,队列都是空的,说明现实的应用场景中,一个spinlock的争抢通常不会太激烈。

前面说过,"locked"的值为1表示持有锁,可此刻CPU获取锁之后,竟然没有把自己node的"locked"值设为1?这是因为在队列为空的情况,CPU可以立即获得锁,不需要基于"locked"的值进行spin,所以此时"locked"的值是1还是0,根本就无所谓。除非是在debug的时候,需要查看当前持有锁的CPU,否则绝不多留一丝「赘肉」。

如果队列不为空,那么就需要把自己这个"node"加入等待队列的末尾,"WRITE_ONCE()"的作用是赋值,在这篇文章里已经介绍过了。

具体的等待过程是调用arch_mcs_spin_lock_contended(),它等待的,或者说"spin"的,是自己MCS lock里的"value"的值,直到这个值变为1。而将这个值设为1,是由它所在队列的前面那个node,在释放spinlock的时候完成的。

#define arch_mcs_spin_lock_contended(l)					
do {									
	smp_cond_load_acquire(l, VAL);					
} while (0)
  • 解锁

那基于MCS lock的实现,释放一个spinlock的过程是怎样的呢?来看下面这个函数:

void mcs_spin_unlock(struct mcs_spinlock **lock, struct mcs_spinlock *node)
{
	// 找到等待队列中的下一个节点
	struct mcs_spinlock *next = READ_ONCE(node->next);

        // 当前没有其他CPU试图获得锁
	if (likely(!next)) {
		// 直接释放锁 
		if (likely(cmpxchg_release(lock, node, NULL) == node))
			return;
                // 等待新的node添加成功
		while (!(next = READ_ONCE(node->next)))
			cpu_relax();
	}

	// 将锁传给等待队列中的下一个node
	arch_mcs_spin_unlock_contended(&next->locked);
}

两个参数的含义同mcs_spin_lock()类似,"lock"代表队尾指针,"node"是准备释放spinlock的CPU在队列中的MCS lock节点。

大概率还是没有锁的争抢,"next"为空,说明准备释放锁的CPU已经是该队列里的最后一个,也是唯一一个CPU了,那么很简单,直接将"lock"设为NULL就可以了。

"cmpxchg_release()"中的"release"代表这里包含了一个memory barrier。如果不考虑这个memory barrier,那么它的实现可简化表示成这样:

cmpxchg(*ptr, old, new)
{
	ret = *ptr;
	if (*ptr == old)
		*ptr = new;
	return ret;
}

跟前面讲到的"xchg()"差不多,也是先获取传入指针的值并作为函数的返回值,区别是多了一个compare。结合mcs_spin_unlock()来看,就是如果"*lock == node",那么"*lock = NULL"。

如果"*lock != node",说明当前队列中有等待获取锁的CPU……等一下,这不是和前面的代码路径相矛盾吗?其实不然,两个原因:

  • 距离函数开头获得"next"指针的值已经过去一段时间了。
  • 回顾前面加锁的过程,新的node加入是先让"*lock"指向自己,再让前面一个node的"next"指向自己。

所以,在这个时间间隔里,可能又有CPU把自己添加到队列里来了。于是,待新的node添加成功后,才可以通过arch_mcs_spin_unlock_contended()将spinlock传给下一个CPU。

#define arch_mcs_spin_unlock_contended(l)				
	smp_store_release((l), 1)

传递spinlock的方式,就是将下一个node的"locked"值设为1(next->locked = 1)。

如果在释放锁的一开始,等待队列就不为空,则"lock"指针不需要移动:

可以看到,无论哪种情况,在解锁的整个过程中,持有锁的这个CPU既没有将自己node中的"locked"设为0,也没有将"next"设为NULL,好像清理工作做的不完整?

事实上,这已经完全无所谓了,当它像「击鼓传花」一样把spinlock交到下一个node手里,它就等同于从这个spinlock的等待队列中移除了。多一事不如少一事,少2个无谓的步骤,效率又可以提升不少。

所以,分身之后的spinlock在哪里?它就在每个MCS lock的"locked"域里,像波浪一样地向前推动着。"locked"的值为1的那个node,才是spinlock的「真身」。

使用MCS lock,就实现了上文那个银行叫号的例子所提出的设想,对于20号来说,不用再听大堂的广播,让19号办理完业务告诉你就行了。

  • 存在的问题

MCS lock的实现保留在了Linux的代码中,但是你却找不到任何一个地方调用了它的lock和unlock的函数。

因为相比起Linux中只占4个字节的ticket spinlock,MCS lock多了一个指针,要多占4(或者8)个字节,消耗的存储空间是原来的2-3倍。spinlock可是操作系统中使用非常广泛的数据结构,这多占的存储空间不可小视,而且spinlock常常会被嵌入到结构体中,对于像"struct page"这种对结构体大小极为敏感的,根本不可能直接使用MCS lock。

所以,真正在Linux中使用的,是下文将要介绍的,在MCS lock的基础上进行了改进的qspinlock。研究MCS lock的意义,不光是理解qspinlock的必经之路,从代码的角度,可以看出其极致精炼的设计,绝没有任何多余的步骤,值得玩味。


参考:

LWN - MCS locks and qspinlocks


原创文章,转载请注明出处。

编辑于 2020-01-07

文章被以下专栏收录