自旋锁(Spinlock)是一种 Linux 内核中广泛运用的底层同步机制。自旋锁是一种工作于多处理器环境的特殊的锁,在单处理环境中自旋锁的操作被替换为空操作。当某个处理器上的内核执行线程申请自旋锁时,如果锁可用,则获得锁,然后执行临界区操作,最后释放锁;如果锁已被占用,线程并不会转入睡眠状态,而是忙等待该锁,一旦锁被释放,则第一个感知此信息的线程将获得锁。
长期以来,人们总是关注于自旋锁的安全和高效,而忽视了自旋锁的“公平”性。传统的自旋锁本质上用一个整数来表示,值为1代表锁未被占用。这种无序竞争的本质特点导致执行线程无法保证何时能取到锁,某些线程可能需要等待很长时间。随着计算机处理器个数的不断增长,这种“不公平”问题将会日益严重。
排队自旋锁(FIFO Ticket Spinlock)是 Linux 内核 2.6.25 版本引入的一种新型自旋锁,它通过保存执行线程申请锁的顺序信息解决了传统自旋锁的“不公平”问题。排队自旋锁的代码由 Linux 内核开发者 Nick Piggin 实现,目前只针对 x86 体系结构(包括 IA32 和 x86_64),相信很快就会被移植到其它平台。
回页首
Linux 内核自旋锁的底层数据结构 raw_spinlock_t 定义如下:
typedef struct { unsigned int slock; } raw_spinlock_t;
slock 虽然被定义为无符号整数,但是实际上被当作有符号整数使用。slock 值为 1 代表锁未被占用,值为 0 或负数代表锁被占用。初始化时 slock 被置为 1。
线程通过宏 spin_lock 申请自旋锁。如果不考虑内核抢占,则 spin_lock 调用 __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"); }
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 的电位拉低,而是锁住本处理器的内部缓存,然后依靠缓存一致性协议保证操作的原子性。
decb 汇编指令将 slock 的值减 1。由于“减 1”是“读-改-写”操作,不是原子操作,可能会被同时申请锁的其它处理器上的线程干扰,所以必须加上“lock”前缀。
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 函数:
static inline void __raw_spin_unlock(raw_spinlock_t *lock) { asm volatile("movb $1,%0" : "+m" (lock->slock) :: "memory"); }
可见 __raw_spin_unlock 函数仅仅执行一条汇编指令:将 slock 置为 1。
尽管拥有使用简单方便、性能好的优点,自旋锁也存在自身的不足:
由于传统自旋锁无序竞争的本质特点,内核执行线程无法保证何时可以取到锁,某些执行线程可能需要等待很长时间,导致“不公平”问题的产生。这有两方面的原因:
随着处理器个数的不断增加,自旋锁的竞争也在加剧,自然导致更长的等待时间。
释放自旋锁时的重置操作将无效化所有其它正在忙等待的处理器的缓存,那么在处理器拓扑结构中临近自旋锁拥有者的处理器可能会更快地刷新缓存,因而增大获得自旋锁的机率。
由于每个申请自旋锁的处理器均在全局变量 slock 上忙等待,系统总线将因为处理器间的缓存同步而导致繁重的流量,从而降低了系统整体的性能。
回页首
传统自旋锁的“不公平”问题在锁竞争激烈的服务器系统中尤为严重,因此 Linux 内核开发者 Nick Piggin 在 Linux 内核 2.6.25 版本中引入了排队自旋锁:通过保存执行线程申请锁的顺序信息来解决“不公平”问题。
排队自旋锁仍然使用原有的 raw_spinlock_t 数据结构,但是赋予 slock 域新的含义。为了保存顺序信息,slock 域被分成两部分,分别保存锁持有者和未来锁申请者的票据序号(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 和相对应的底层实现函数:
宏 | 底层实现函数 | 描述 |
---|---|---|
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。
__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)); }
此函数判断 Next 和 Owner 域是否相等,如果相等,说明自旋锁处于未使用状态,返回 0;否则返回1。
tmp 这种复杂的赋值操作是为了直接从内存中取值,避免处理器缓存的影响。
__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"); }
LOCK_PREFIX 宏在前文中已经介绍过,就是“lock”前缀。
xaddw 汇编指令将 slock 和 inc 的值交换,然后把这两个值相加后的和存到 slock 中。也就是说,该指令执行完毕后,inc 存有原来的 slock 值作为票据序号,而 slock 的 Next 域被加 1。
comb 比较 inc 变量的高位和低位字节是否相等,如果相等,表明锁处于未使用状态,直接跳转到标签 2 的位置退出函数。
如果锁处于使用状态,则不停地将当前的 slock 的 Owner 域复制到 inc 的低字节处(movb 指令),然后重复 c 步骤。不过此时 inc 变量的高位和低位字节相等表明轮到自己获取了自旋锁。
__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"); }
在 IA32 体系结构下,如果使用 PPro SMP 系统或者启用了 X86_OOSTORE,则 UNLOCK_LOCK_PREFIX 被定义为“lock”前缀;否则被定义为空。
incb 指令将 slock 最低位字节也就是 Owner 域加 1。
回页首
排队自旋锁并不是一个新想法,某些操作系统早已采用了类似概念,只是实现方式有所差别。例如在 Windows 操作系统中排队自旋锁被称为 Queued Spinlock。
Queued Spinlock 的工作方式如下:每个处理器上的执行线程都有一个本地的标志,通过该标志,所有使用该锁的处理器(锁拥有者和等待者)被组织成一个单向队列。当一个处理器想要获得一个已被其它处理器持有的 Queued Spinlock 时,它把自己的标志放在该 Queued Spinlock 的单向队列的末尾。如果当前锁持有者释放了自旋锁,则它将该锁移交到队列中位于自己之后的第一个处理器。同时,如果一个处理器正在忙等待 Queued Spinlock,它并不是检查该锁自身的状态,而是检查针对自己的标志;在队列中位于该处理器之前的处理器释放自旋锁时会设置这一标志,以表明轮到这个正在等待的处理器了。
与 Linux 的排队自旋锁相比,Queued Spinlock 的设计更为复杂,但是 Queued Spinlock 拥有自己的优势:
忙等待 Queued Spinlock 的每个处理器在针对该处理器的标志上旋转,而不是在全局的自旋锁上测试旋转,因此处理器之间的同步比 Linux 的排队自旋锁少得多。
Queued Spinlock 拥有真实的队列结构,因此便于扩充更高级的功能。
回页首
排队自旋锁设计简单、实现容易且性能优秀,因此肯定会受到开发人员的欢迎。本节讨论一下排队自旋锁未来可能有用的一些扩展功能:
超时(Timeout)
尽管排队自旋锁保证了内核执行线程严格按照申请顺序获取锁,但是由于锁的竞争剧烈(例如处理器个数达到64或更多),线程仍然可能会等待过长的时间。当该线程获得锁时,环境也许已发生变化而导致无法完成任务。因此申请线程可以预先指定一个等待阈值,一旦超过该阈值且尚未获得锁,则自动从等待队伍中退出,并返回代表超时的错误值。
优先级(Priority)
当前的实现中,所有的线程一律平等,严格按照申请顺序等待。某些执行关键操作的线程也许需要特殊对待,即赋予更高的优先级。一旦它们申请自旋锁,就把他们插入到等待队列的前部优先执行。