ticket spinlock巧妙的解决了锁的公平性问题,但它在锁竞争方面还不够完美,linux-4.2内核引入了queued spinlock。
queued spinlock由Waiman Long和Perter Zijlstra 发起,补丁集经过了16个版本,并入了主线。
https://lkml.org/lkml/2015/4/24/631
https://lore.kernel.org/lkml/[email protected]/
前面介绍的ticket spinlock锁机制不是挺好的么,为啥又搞一个queue spinlock。它有什么缺点呢?
在当今普遍的SMP多核处理器架构和NUMA系统中,ticket spinlock存在一个比较严重的性能问题:由于多个CPU线程均在同一个共享变量lock.val上自旋,而申请和释放锁的时候必须对lock.val进行修改,这将导致所有参与排队自旋锁操作的处理器的缓存变得无效。如果排队自旋锁竞争比较激烈的话,频繁的缓存同步操作会导致繁重的系统总线和内存的流量,从而大大降低了系统整体的性能。
如下,ticket锁基本实现:
struct spinlock_t {
union {
atomic_t val;
struct {
int current_ticket;
int next_ticket;
}
}
}
void spin_lock(spinlock_t *lock)
{
int t;
t = atomic_fetch_and_inc (&lock->next_ticket);
while (t != lock->current_ticket)
; /* spin */
}
void spin_unlock(spinlock_t *lock)
{
lock->current_ticket++; //对变量val写入
}
多CPU竞争条件下,当线程调用spin_unlock释放锁,共享变量lock.val会被刷新。而其他CPU上的等待者必须刷新自己的cache才能看到最新值,这导致了所有其他等待CPU的cache line失效,可想而知,当遇到频繁锁竞争场景,必定会造成共享变量val在多个CPU的cache上弹跳,从而导致性能下降。
在介绍qspinlock之前,必须要先了解MCS lock,因为qspinlock是基于MCS lock设计的,MCS lock是一种解决多CPU并发竞争的自旋锁实现方法。
MCS单词啥意思?没啥意思!其实就是这两位作者的简称...,John M. Mellor-Crummey and Michael L. Scott,所以MCS并不是一个算法名称,也和自旋锁本身没啥关系,so 神奇!
每个锁的等待者,在本CPU上自旋(访问本地变量),而不是全局的spinlock变量,而当持有锁线程释放锁时,由锁的释放者更新下一个等待者的本地自旋变量,完成通知与同步。MCS锁可以消除锁所经历的大部分缓存弹跳,尤其是在多竞争的情况下。
http://locklessinc.com/articles/locks/
MCS锁在学术界实现了很多种变种,我们来看一个最简单的伪代码实现,只为表达MCS锁的核心设计思想。
/*
* struct mcs_node结构体用于描述本地节点,mcs_node中包含2个变量,next指针用于指向下一个等待者,而另外一个变量,则用于自旋锁的自旋。
* 很明显,mcs_node结构可以让所有等待者变成一个单向链表。
*/
struct mcs_node {
struct mcs_node *next; /* 指向下一个等待者 */
int is_locked; /* 本地自旋变量 */
}
/*
* 全局spinlock中含有一个mcs_node指针,指向最后一个锁的申请者。而当锁处于空闲时,该指针为NULL。
*/
struct spinlock_t {
mcs_node *queue; /* 等待者队列 */
}
//加锁函数
mcs_spin_lock(spinlock_t *lock, mcs_node *my_node)
{
my_node->next = NULL; -----[1]
mcs_node *predecessor = fetch_and_store(lock->queue, my_node); -----[2]
if (predecessor != NULL) { -----[3]
my_node->is_locked = true; -----[4]
predecessor.next = my_node; -----[5]
while (my_node->is_locked) -----[6]
cpu_relax();
}
}
//放锁函数
mcs_spin_unlock(spinlock_t *lock, mcs_node *my_node)
{
if (my_node->next == NULL) { -----[7]
if (compare_and_swap(lock->queue, my_node, NULL) { ----[8]
return;
}
else {
while (my_node->next == NULL) -----[9]
cpu_relax();
}
}
my_node->next->is_locked = false; -----[10]
}
spin_lock()解读:
1、当一个线程申请锁时,会传入一个本地变量my_node,并默认将其next域置为NULL,认为我是最后一个申请者。
2、这里使用fetch_and_store原子语句,先将全局spinlock->queue指向自己(my_node),代表我是最后的申请者,同时该函数返回当前持锁人predecessor。
3、若predecessor==NULL,则说明无人持锁,该线程即拿到锁,直接return。若predecessor!=NULL,则说明遇到竞争,需要自旋等待。
4-5、将本地变量my_node->is_locked置为true,同时将当前持锁者的next域指向自己。
6、在本地变量上疯狂自旋。
spin_unlock()解读:
7、若my_node->next等于NULL,说明没有锁竞争,需要将全局锁spinlock->queue置空。
8、使用原子指令,将全局lock->queue置空,如果成功置则return。
9、如果没有置空成功,说明有人已经抢先一步将spinlock->queue赋值,所以下一个锁的申请者一定会排在我的后面,这里等待my_node->next域被赋值完成。
10、将下一个等待者的is_locked置为false,完成解锁。
如此MCS lock介绍完成,linux借鉴了MCS锁的设计思想,实现了queued spinlock自旋锁。但你会发现,该MCS锁代码并不能与linux完美融合,其主要原因有两个:
1、当spin_lock及spin_unlock时,要额外传递了一个node参数。无法和现有spinlock API兼容。
2、若将mcs_node结构移至spinlock结构中,可以变为单参数,但是自旋锁经常被嵌入到许多内核结构中,其中一些(尤其是struct page等结构)不能容忍大小的增加。
实现queued spinlock远比你想象的要复杂的多,linux内核实现时,考虑了在无竞争,双cpu竞争,及多个cpu竞争等各种复杂情况,并对其分别优化。
如果你看过queue spinlock代码,你会发现当一个CPU申请spin_lock时,在spin_lock函数会有多个spin位置,根据当前锁的不同情况,CPU可能在不同的位置spin。并且同时spin_lock也会有多个返回出口,这都是为了优化spinlock多核竞争而编写。由于queue spinlock实现稍有复杂,下次再聊。
参考:
https://lwn.net/Articles/590243/
https://www.cs.rochester.edu/~scott/papers/1991_TOCS_synch.pdf
https://0xax.gitbooks.io/linux-insides/content/SyncPrim/linux-sync-2.html