Linux 内核的排队自旋锁(FIFO Ticket Spinlock)

简介: 排队自旋锁(FIFO Ticket Spinlock)是 Linux 内核 2.6.25 版本中引入的一种新型自旋锁,它解决了传统自旋锁由于无序竞争导致的“公平性”问题。本文详细介绍了排队自旋锁的设计原理和具体实现,并与 Windows 操作系统采用的类似技术进行比较。最后讨论可能的扩展排队自旋锁的一些想法。


自旋锁(Spinlock)是一种 Linux 内核中广泛运用的底层同步机制。自旋锁是一种工作于多处理器环境的特殊的锁,在单处理环境中自旋锁的操作被替换为空操作。当某个处理器上的内核执行线程申请自旋锁时,如果锁可用,则获得锁,然后执行临界区操作,最后释放锁;如果锁已被占用,线程并不会转入睡眠状态,而是忙等待该锁,一旦锁被释放,则第一个感知此信息的线程将获得锁。

长期以来,人们总是关注于自旋锁的安全和高效,而忽视了自旋锁的“公平”性。传统的自旋锁本质上用一个整数来表示,值为1代表锁未被占用。这种无序竞争的本质特点导致执行线程无法保证何时能取到锁,某些线程可能需要等待很长时间。随着计算机处理器个数的不断增长,这种“不公平”问题将会日益严重。

排队自旋锁(FIFO Ticket Spinlock)是 Linux 内核 2.6.25 版本引入的一种新型自旋锁,它通过保存执行线程申请锁的顺序信息解决了传统自旋锁的“不公平”问题。排队自旋锁的代码由 Linux 内核开发者 Nick Piggin 实现,目前只针对 x86 体系结构(包括 IA32 和 x86_64),相信很快就会被移植到其它平台。

传统自旋锁的实现与不足

Linux 内核自旋锁的底层数据结构 raw_spinlock_t 定义如下:


清单 1. raw_spinlock_t 数据结构
                
typedef struct {
	unsigned int slock;
} raw_spinlock_t;

slock 虽然被定义为无符号整数,但是实际上被当作有符号整数使用。slock 值为 1 代表锁未被占用,值为 0 或负数代表锁被占用。初始化时 slock 被置为 1。

线程通过宏 spin_lock 申请自旋锁。如果不考虑内核抢占,则 spin_lock 调用 __raw_spin_lock 函数,代码如下所示:


清单 2. __raw_spin_lock 函数
                
static inline void __raw_spin_lock(raw_spinlock_t *lock)
{
	asm volatile("\n1:\t"
		     LOCK_PREFIX " ; decb %0\n\t"
		     "jns 3f\n"
		     "2:\t"
		     "rep;nop\n\t"
		     "cmpb $0,%0\n\t"
		     "jle 2b\n\t"
		     "jmp 1b\n"
		     "3:\n\t"
		     : "+m" (lock->slock) : : "memory");
}

  1. LOCK_PREFIX 的定义如下:



    清单 3. LOCK_PREFIX宏
                            
    #ifdef CONFIG_SMP
    #define LOCK_PREFIX \
    		".section .smp_locks,\"a\"\n"	\
    		_ASM_ALIGN "\n"			\
    		_ASM_PTR "661f\n" /* address */	\
    		".previous\n"			\
    		"661:\n\tlock; "
    
    #else /* ! CONFIG_SMP */
    #define LOCK_PREFIX ""
    #endif

    在多处理器环境中 LOCK_PREFIX 实际被定义为 “lock”前缀。

    x86 处理器使用“lock”前缀的方式提供了在指令执行期间对总线加锁的手段。芯片上有一条引线 LOCK,如果在一条汇编指令(ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, XCHG)前加上“lock” 前缀,经过汇编后的机器代码就使得处理器执行该指令时把引线 LOCK 的电位拉低,从而把总线锁住,这样其它处理器或使用DMA的外设暂时无法通过同一总线访问内存。

    从 P6 处理器开始,如果指令访问的内存区域已经存在于处理器的内部缓存中,则“lock” 前缀并不将引线 LOCK 的电位拉低,而是锁住本处理器的内部缓存,然后依靠缓存一致性协议保证操作的原子性。

  2. decb 汇编指令将 slock 的值减 1。由于“减 1”是“读-改-写”操作,不是原子操作,可能会被同时申请锁的其它处理器上的线程干扰,所以必须加上“lock”前缀。

  3. jns 汇编指令检查 EFLAGS 寄存器的 SF(符号)位,如果为 0,说明 slock 原来的值为 1,则线程获得锁,然后跳到标签 3 的位置结束本次函数调用。如果 SF 位为 1,说明 slock 原来的值为 0 或负数,锁已被占用。那么线程转到标签 2 处不断测试 slock 与 0 的大小关系,假如 slock 小于或等于 0,跳转到标签 2 的位置继续忙等待;假如 slock 大于 0,说明锁已被释放,则跳转到标签 1 的位置重新申请锁。

线程通过宏 spin_unlock 释放自旋锁,该宏调用 __raw_spin_unlock 函数:


清单 4. __raw_spin_unlock函数
                
static inline void __raw_spin_unlock(raw_spinlock_t *lock)
{
	asm volatile("movb $1,%0" : "+m" (lock->slock) :: "memory");
}

可见 __raw_spin_unlock 函数仅仅执行一条汇编指令:将 slock 置为 1。

尽管拥有使用简单方便、性能好的优点,自旋锁也存在自身的不足:

  1. 由于传统自旋锁无序竞争的本质特点,内核执行线程无法保证何时可以取到锁,某些执行线程可能需要等待很长时间,导致“不公平”问题的产生。这有两方面的原因:

    1. 随着处理器个数的不断增加,自旋锁的竞争也在加剧,自然导致更长的等待时间。

    2. 释放自旋锁时的重置操作将无效化所有其它正在忙等待的处理器的缓存,那么在处理器拓扑结构中临近自旋锁拥有者的处理器可能会更快地刷新缓存,因而增大获得自旋锁的机率。

  2. 由于每个申请自旋锁的处理器均在全局变量 slock 上忙等待,系统总线将因为处理器间的缓存同步而导致繁重的流量,从而降低了系统整体的性能。

排队自旋锁的设计原理

传统自旋锁的“不公平”问题在锁竞争激烈的服务器系统中尤为严重,因此 Linux 内核开发者 Nick Piggin 在 Linux 内核 2.6.25 版本中引入了排队自旋锁:通过保存执行线程申请锁的顺序信息来解决“不公平”问题。

排队自旋锁仍然使用原有的 raw_spinlock_t 数据结构,但是赋予 slock 域新的含义。为了保存顺序信息,slock 域被分成两部分,分别保存锁持有者和未来锁申请者的票据序号(Ticket Number),如下图所示:


图 1. Next 和 Owner 域
Ticket Number

如果处理器个数不超过 256,则 Owner 域为 slock 的 0-7 位,Next 域为 slock 的 8-15 位,slock 的高 16 位不使用;如果处理器个数超过 256,则 Owner 和 Next 域均为 16 位,其中 Owner 域为 slock 的低 16 位。可见排队自旋锁最多支持 216=65536 个处理器。

只有 Next 域与 Owner 域相等时,才表明锁处于未使用状态(此时也无人申请该锁)。排队自旋锁初始化时 slock 被置为 0,即 Owner 和 Next 置为 0。内核执行线程申请自旋锁时,原子地将 Next 域加 1,并将原值返回作为自己的票据序号。如果返回的票据序号等于申请时的 Owner 值,说明自旋锁处于未使用状态,则直接获得锁;否则,该线程忙等待检查 Owner 域是否等于自己持有的票据序号,一旦相等,则表明锁轮到自己获取。线程释放锁时,原子地将 Owner 域加 1 即可,下一个线程将会发现这一变化,从忙等待状态中退出。线程将严格地按照申请顺序依次获取排队自旋锁,从而完全解决了“不公平”问题。

排队自旋锁的实现

排队自旋锁没有改变原有自旋锁的调用接口,该 API 是以 C 语言宏的形式提供给开发人员。下表列出 6 个主要的 API 和相对应的底层实现函数:


表 1. 排队自旋锁 API
底层实现函数 描述
spin_lock_init 将锁置为初始未使用状态(值为 0)
spin_lock __raw_spin_lock 忙等待直到 Owner 域等于本地票据序号
spin_unlock __raw_spin_unlock Owner 域加 1,将锁传给后续等待线程
spin_unlock_wait __raw_spin_unlock_wait 不申请锁,忙等待直到锁处于未使用状态
spin_is_locked __raw_spin_is_locked 测试锁是否处于使用状态
spin_trylock __raw_spin_trylock 如果锁处于未使用状态,获得锁;否则直接返回

下面介绍其中 3 个底层函数的实现细节,假定处理器个数不超过 256。

  1. __raw_spin_is_locked



    清单 5. __raw_spin_is_locked 函数
                            
    static inline int __raw_spin_is_locked(raw_spinlock_t *lock)
    {
    	int tmp = *(volatile signed int *)(&(lock)->slock);
    	return (((tmp >> 8) & 0xff) != (tmp & 0xff));
    }

    1. 此函数判断 Next 和 Owner 域是否相等,如果相等,说明自旋锁处于未使用状态,返回 0;否则返回1。

    2. tmp 这种复杂的赋值操作是为了直接从内存中取值,避免处理器缓存的影响。

  2. __raw_spin_lock



    清单 6. __raw_spin_lock 函数
                            
     static inline void __raw_spin_lock(raw_spinlock_t *lock)
    {
    	short inc = 0x0100;
    
    	__asm__ __volatile__ (
    		LOCK_PREFIX "xaddw %w0, %1\n"
    		"1:\t"
    		"cmpb %h0, %b0\n\t"
    		"je 2f\n\t"
    		"rep ; nop\n\t"
    		"movb %1, %b0\n\t"
    		/* don't need lfence here, because loads are in-order */
    		"jmp 1b\n"
    		"2:"
    		:"+Q" (inc), "+m" (lock->slock)
    		:
    		:"memory", "cc");
    }

    1. LOCK_PREFIX 宏在前文中已经介绍过,就是“lock”前缀。

    2. xaddw 汇编指令将 slock 和 inc 的值交换,然后把这两个值相加后的和存到 slock 中。也就是说,该指令执行完毕后,inc 存有原来的 slock 值作为票据序号,而 slock 的 Next 域被加 1。

    3. comb 比较 inc 变量的高位和低位字节是否相等,如果相等,表明锁处于未使用状态,直接跳转到标签 2 的位置退出函数。

    4. 如果锁处于使用状态,则不停地将当前的 slock 的 Owner 域复制到 inc 的低字节处(movb 指令),然后重复 c 步骤。不过此时 inc 变量的高位和低位字节相等表明轮到自己获取了自旋锁。

  3. __raw_spin_unlock



    清单 7. __raw_spin_unlock 函数
                            
    static inline void __raw_spin_unlock(raw_spinlock_t *lock)
    {
    	__asm__ __volatile__(
    		UNLOCK_LOCK_PREFIX "incb %0"
    		:"+m" (lock->slock)
    		:
    		:"memory", "cc");
    }

    1. 在 IA32 体系结构下,如果使用 PPro SMP 系统或者启用了 X86_OOSTORE,则 UNLOCK_LOCK_PREFIX 被定义为“lock”前缀;否则被定义为空。

    2. incb 指令将 slock 最低位字节也就是 Owner 域加 1。


你可能感兴趣的:(Linux 内核的排队自旋锁(FIFO Ticket Spinlock))