BLOG

Record, summarize, and improve.

自旋锁

自旋锁用于处理器之间的互斥,适合保护很短的临界区,并且不允许在临界区睡眠。申请自旋锁的时候,如果自旋锁被其他处理器占有,本处理器自旋等待(也称为忙等待)。

进程、软中断和硬中断都可以使用自旋锁。

自旋锁的实现经历了3个阶段:

(1)最早的自旋锁是无序竞争的,不保证先申请的进程先获得锁。

(2)第2个阶段是入场券自旋锁,进程按照申请锁的顺序排队,先申请的进程先获得锁。

(3)第3个阶段是MCS自旋锁。入场券自旋锁存在性能问题:所有申请锁的处理器在同一个变量上自旋等待,缓存同步的开销大,不适合处理器很多的系统。MCS自旋锁的策略是为每个处理器创建一个变量副本,每个处理器在自己的本地变量上自旋等待,解决了性能问题。

入场券自旋锁和MCS自旋锁都属于排队自旋锁(queued spinlock),进程按照申请锁的顺序排队,先申请的进程先获得锁。

spinlock和raw_spinlock(原始自旋锁)有什么关系?

Linux内核有一个实时内核分支(开启配置宏CONFIG_PREEMPT_RT)来支持硬实时特性,内核主线只支持软实时。

对于没有打上实时内核补丁的内核,spinlock只是封装raw_spinlock,它们完全一样。如果打上实时内核补丁,那么spinlock使用实时互斥锁保护临界区,在临界区内可以被抢占和睡眠,但raw_spinlock还是自旋锁。

目前主线版本还没有合并实时内核补丁,说不定哪天就会合并进来,为了使代码可以兼容实时内核,最好坚持3个原则:

(1)尽可能使用spinlock。

(2)绝对不允许被抢占和睡眠的地方,使用raw_spinlock,否则使用spinlock。

(3)如果临界区足够小,使用raw_spinlock。

spin_lock

定义并且初始化静态自旋锁的方法是:

DEFINE_SPINLOCK(x);

在运行时动态初始化自旋锁的方法是:

spin_lock_init(x);

申请自旋锁的函数是:

(1)void spin_lock(spinlock_t *lock);

申请自旋锁,如果锁被其他处理器占有,当前处理器自旋等待。

(2)void spin_lock_bh(spinlock_t *lock);

申请自旋锁,并且禁止当前处理器的软中断。

(3)void spin_lock_irq(spinlock_t *lock);

申请自旋锁,并且禁止当前处理器的硬中断。

(4)spin_lock_irqsave(lock, flags);

申请自旋锁,保存当前处理器的硬中断状态,并且禁止当前处理器的硬中断。

(5)int spin_trylock(spinlock_t *lock);

申请自旋锁,如果申请成功,返回1;如果锁被其他处理器占有,当前处理器不等待,立即返回0。

释放自旋锁的函数是:

(1)void spin_unlock(spinlock_t *lock);

(2)void spin_unlock_bh(spinlock_t *lock);

释放自旋锁,并且开启当前处理器的软中断。

(3)void spin_unlock_irq(spinlock_t *lock);

释放自旋锁,并且开启当前处理器的硬中断。

(4)void spin_unlock_irqrestore(spinlock_t *lock, unsigned long flags);

释放自旋锁,并且恢复当前处理器的硬中断状态。

raw_spinlock

定义并且初始化静态原始自旋锁的方法是:

DEFINE_RAW_SPINLOCK(x);

在运行时动态初始化原始自旋锁的方法是:

raw_spin_lock_init (x);

申请原始自旋锁的函数是:

(1)raw_spin_lock(lock)

申请原始自旋锁,如果锁被其他处理器占有,当前处理器自旋等待。

(2)raw_spin_lock_bh(lock)

申请原始自旋锁,并且禁止当前处理器的软中断。

(3)raw_spin_lock_irq(lock)

申请原始自旋锁,并且禁止当前处理器的硬中断。

(4)raw_spin_lock_irqsave(lock, flags)

申请原始自旋锁,保存当前处理器的硬中断状态,并且禁止当前处理器的硬中断。

(5)raw_spin_trylock(lock)

申请原始自旋锁,如果申请成功,返回1;如果锁被其他处理器占有,当前处理器不等待,立即返回0。

释放原始自旋锁的函数是:

(1)raw_spin_unlock(lock)

(2)raw_spin_unlock_bh(lock)

释放原始自旋锁,并且开启当前处理器的软中断。

(3)raw_spin_unlock_irq(lock)

释放原始自旋锁,并且开启当前处理器的硬中断。

(4)raw_spin_unlock_irqrestore(lock, flags)

释放原始自旋锁,并且恢复当前处理器的硬中断状态。

ticket_spinlock

(1)锁拥有排队号和服务号,服务号是当前占有锁的进程的排队号。

(2)每个进程申请锁的时候,首先申请一个排队号,然后轮询锁的服务号是否等于自己的排队号,如果等于,表示自己占有锁,可以进入临界区,否则继续轮询。

(3)当进程释放锁时,把服务号加一,下一个进程看到服务号等于自己的排队号,退出自旋,进入临界区。

ARM64架构定义的数据类型arch_spinlock_t如下所示:

arch/arm64/include/asm/spinlock_types.h

typedef struct {

#ifdef __AARCH64EB__     /* 大端字节序(高位存放在低地址) */

u16 next;

u16 owner;

#else                    /* 小端字节序(低位存放在低地址) */

u16 owner;

u16 next;

#endif

} __aligned(4) arch_spinlock_t;

成员next是排队号,成员owner是服务号。

MCS_spinlock

入场券自旋锁存在性能问题:所有等待同一个自旋锁的处理器在同一个变量上自旋等待,申请或者释放锁的时候会修改锁,导致其他处理器存放自旋锁的缓存行失效,在拥有几百甚至几千个处理器的大型系统中,处理器申请自旋锁时竞争可能很激烈,缓存同步的开销很大,导致系统性能大幅度下降。

MCS(MCS是“Mellor-Crummey”和“Scott”这两个发明人的名字的首字母缩写)自旋锁解决了这个缺点,它的策略是为每个处理器创建一个变量副本,每个处理器在申请自旋锁的时候在自己的本地变量上自旋等待,避免缓存同步的开销。

传统的MCS自旋锁包含:

(1)一个指针tail指向队列的尾部。

(2)每个处理器对应一个队列节点,即mcs_lock_node结构体,其中成员next指向队列的下一个节点,成员locked指示锁是否被其他处理器占有,如果成员locked的值为1,表示锁被其他处理器占有。

结构体的定义如下所示:

typedef struct __mcs_lock_node {

struct __mcs_lock_node *next;

int locked;

} ____cacheline_aligned_in_smp mcs_lock_node;

typedef struct {

mcs_lock_node *tail;

mcs_lock_node nodes[NR_CPUS];/* NR_CPUS是处理器的数量 */

} spinlock_t;

其中“____cacheline_aligned_in_smp”的作用是:在多处理器系统中,结构体的起始地址和长度都是一级缓存行长度的整数倍。

当没有处理器占有或者等待自旋锁的时候,队列是空的,tail是空指针。

小巧型MCS_spinlock

传统的MCS自旋锁存在的缺陷是:结构体的长度太大,因为mcs_lock_node结构体的起始地址和长度都必须是一级缓存行长度的整数倍,所以MCS自旋锁的长度是(一级缓存行长度 + 处理器数量 * 一级缓存行长度),而入场券自旋锁的长度只有4字节。自旋锁被嵌入到内核的很多结构体中,如果自旋锁的长度增加,会导致这些结构体的长度增加。

配置宏CONFIG_PARAVIRT_SPINLOCKS用来启用半虚拟化的自旋锁,给虚拟机使用,本文不考虑这种使用场景。每个处理器需要4个队列节点,原因如下:

(1)       申请自旋锁的函数禁止内核抢占,所以进程在等待自旋锁的过程中不会被其他进程抢占。

(2)       进程在等待自旋锁的过程中可能被软中断抢占,然后软中断等待另一个自旋锁。

(3)       软中断在等待自旋锁的过程中可能被硬中断抢占,然后硬中断等待另一个自旋锁。

(4)       硬中断在等待自旋锁的过程中可能被不可屏蔽中断抢占,然后不可屏蔽中断等待另一个自旋锁。

综上所述,一个处理器最多同时等待4个自旋锁。

和入场券自旋锁相比,MCS自旋锁增加的内存开销是数组mcs_nodes。

其中成员next指向队列的下一个节点;成员locked指示锁是否被前一个等待者占有,如果值为1,表示锁被前一个等待者占有;成员count是嵌套层数,也就是数组mcs_nodes已分配的数组项的数量。

自旋锁的32个二进制位被划分成4个字段:

(1)       locked字段,指示锁已经被占有,长度是一个字节,占用第0~7位。

(2)       一个pending位,占用第8位,第1个等待自旋锁的处理器设置pending位。

(3)       index字段,是数组索引,指示队列的尾部节点使用数组mcs_nodes的哪一项。

(4)       cpu字段,存放队列的尾部节点的处理器编号,实际存储的值是处理器编号加上1,cpu字段减去1才是真实的处理器编号。

index字段和cpu字段合起来称为tail字段,存放队列的尾部节点的信息,布局分两种情况:

(1)       如果处理器的数量小于2的14次方,那么第9~15位没有使用,第16~17位是index字段,第18~31位是cpu字段。

(2)       如果处理器的数量大于或等于2的14次方,那么第9~10位是index字段,第11~31位是cpu字段。

把MCS自旋锁放进4个字节的关键是:存储处理器编号和数组索引,而不是存储尾部节点的地址

内核对MCS自旋锁做了优化:第1个等待自旋锁的处理器直接在锁自身上面自旋等待,不是在自己的mcs_spinlock结构体上自旋等待。这个优化带来的好处是:当锁被释放的时候,不需要访问mcs_spinlock结构体的缓存行,相当于减少了一次缓存没命中。后续的处理器在自己的mcs_spinlock结构体上面自旋等待,直到它们移动到队列的首部为止。

自旋锁的pending位进一步扩展这个优化策略。第1个等待自旋锁的处理器简单地设置pending位,不需要使用自己的mcs_spinlock结构体。第2个处理器看到pending被设置,开始创建等待队列,在自己的mcs_spinlock结构体的locked字段上自旋等待。这种做法消除了两个等待者之间的缓存同步,而且第1个等待者没使用自己的mcs_spinlock结构体,减少了一次缓存行没命中。

MCS自旋锁的配置宏是CONFIG_ARCH_USE_QUEUED_SPINLOCKS 和CONFIG_QUEUED_SPINLOCKS,目前只有x86处理器架构使用MCS自旋锁,默认开启MCS自旋锁的配置宏