Linux 锁 (1):ticket spinlock

文章目录

  • [1. 前言](#1. 前言)
  • [2. ticket spinlock 原理](#2. ticket spinlock 原理)
  • [3. ticket spinlock 实现](#3. ticket spinlock 实现)
    • [3.1 初始化](#3.1 初始化)
    • [3.2 上锁和下锁](#3.2 上锁和下锁)
    • [3.3 小结](#3.3 小结)

1. 前言

限于作者能力水平,本文可能存在谬误,因此而给读者带来的损失,作者不做任何承诺。

2. ticket spinlock 原理

在 ticket spinlock 之前,spinlock 使用 CAS(Compare-And-Swap) 指令实现,这种实现方式最大的缺陷是锁的公平性:锁释放后,谁抢到锁都有可能,导致有的锁用户等待了很长时间都获取不到锁。ticket spinlock 对 CAS spinlock 改进在于:谁先拿到号就先服务谁,严格按照拿号的先后顺序来服务。

ticket spinlock 实现类似于银行的叫号系统:

  1. 要办理业务先取号,即在 spin_lock() 为锁请求者分配一个号码;
  2. 如果当前没有业务在办理(没有占有锁),叫当前持有最前面号的人来办理业务(当前持有最前面号的请求者上锁成功);如果当前有业务在办理(锁被占住),等当前办理业务的人办理完成,叫下一个号(spin_unlock())。

这样就严格按照取号的先后顺序来使用 spinlock。

3. ticket spinlock 实现

先看 ticket spinlock 的数据结构:

c 复制代码
/* include/linux/spinlock_types.h */

typedef struct spinlock {
	union {
		struct raw_spinlock rlock;

		// 调试部分,不关注
		...
	};
} spinlock_t;

typedef struct raw_spinlock {
	arch_spinlock_t raw_lock;
	// 调试部分,不关注
	...
} raw_spinlock_t;

硬件架构强相关的定义部分(这里以 ARMv7 架构的定义为例):

c 复制代码
/* arch/arm/include/asm/spinlock_types.h */

typedef struct {
	/* 初始创建 1 个 spin lock 时,slock 值为 0 */
	union {
		u32 slock;
		struct __raw_tickets {
#ifdef __ARMEB__
			u16 next;
			u16 owner;
#else
			u16 owner; /* 当前叫号 或 正在办理业务的号 */
			/*
			 * 在不同上下文, 有不同含义:
			 * 1) 在被竞争的 spinlock_t 中, 表示最新可取的号;
			 * 2) 位于锁请求者的调用栈中定义时, 用于保存每个锁请求者取到
			 * 的号(定义于锁请求的调用栈空间, 是每个锁请求者独立的空间).
			 */
			u16 next;
#endif
		} tickets;
	};
} arch_spinlock_t;

3.1 初始化

初始化有 DEFINE_SPINLOCK()spin_lock_init() 两种途径。

先看 DEFINE_SPINLOCK()

c 复制代码
#define __SPIN_LOCK_INITIALIZER(lockname) \
	{ { .rlock = __RAW_SPIN_LOCK_INITIALIZER(lockname) } }

#define __SPIN_LOCK_UNLOCKED(lockname) \
	(spinlock_t ) __SPIN_LOCK_INITIALIZER(lockname)

#define DEFINE_SPINLOCK(x)	spinlock_t x = __SPIN_LOCK_UNLOCKED(x)
c 复制代码
#define __RAW_SPIN_LOCK_INITIALIZER(lockname)	\
	{					\
	.raw_lock = __ARCH_SPIN_LOCK_UNLOCKED,	\
	SPIN_DEBUG_INIT(lockname)		\
	SPIN_DEP_MAP_INIT(lockname) }
c 复制代码
#define __ARCH_SPIN_LOCK_UNLOCKED	{ { 0 } }

再看下 spin_lock_init()

c 复制代码
#define spin_lock_init(_lock)				\
do {							\
	spinlock_check(_lock);				\
	raw_spin_lock_init(&(_lock)->rlock);		\
} while (0)

# define raw_spin_lock_init(lock)				\
	do { *(lock) = __RAW_SPIN_LOCK_UNLOCKED(lock); } while (0)

可见,不管哪种方式,最终都是将 arch_spinlock_t::slock 赋值为 0,也即将 arch_spinlock_t::ownerarch_spinlock_t::next 赋值为 0

3.2 上锁和下锁

上锁和下锁操作即分别通过 spin_lock()spin_unlock() 获取或释放锁。本文只讨论多核场景,对单核场景感兴趣的读者,可自行阅读 Linux 内核源码分析。

先看 spin_lock()

c 复制代码
static __always_inline void spin_lock(spinlock_t *lock)
{
	raw_spin_lock(&lock->rlock);
}

#define raw_spin_lock(lock)	_raw_spin_lock(lock)

void __lockfunc _raw_spin_lock(raw_spinlock_t *lock)
{
	__raw_spin_lock(lock);
}

static inline void __raw_spin_lock(raw_spinlock_t *lock)
{
	preempt_disable(); /* 关闭当前 CPU 上抢占 */
	...
	LOCK_CONTENDED(lock, do_raw_spin_trylock, do_raw_spin_lock);
}

static inline void do_raw_spin_lock(raw_spinlock_t *lock) __acquires(lock)
{
	...
	arch_spin_lock(&lock->raw_lock);
}

spin_lock() 架构相关、也是核心部分:

c 复制代码
/* arch/arm/include/asm/spinlock.h */

static inline void arch_spin_lock(arch_spinlock_t *lock)
{
	unsigned long tmp;
	u32 newval;
	arch_spinlock_t lockval;

	prefetchw(&lock->slock);
	/*
	 * 1. 锁请求者取号, 然后更新最新可用号
	 *    1.1) 取号: 保存当前锁请求者调用栈局部的 lockval 中, 
	 *         即 lockval.tickets.next 中
	 *    1.2) 更新最新可用号: lock->next += 1
	 * 注: 叫号从 0 开始.
	 */
	__asm__ __volatile__(
"1:	ldrex	%0, [%3]\n" /* 读取当前号: lockval = { .slock = lock->slock } */
"	add	%1, %0, %4\n" /* newval = lockval.slock + (1 << TICKET_SHIFT) */
"	strex	%2, %1, [%3]\n" /* lock->slock = newval ==> lock->next += 1, tmp = strex 指令执行结果(1 成功, 0 失败) */
"	teq	%2, #0\n" /* if (tmp != 0) // tmp != 0 表示 strex 写没有成功,需继续尝试 */
"	bne	1b"		  /* 	goto 1b; */
	: "=&r" (lockval), "=&r" (newval), "=&r" (tmp)
	: "r" (&lock->slock), "I" (1 << TICKET_SHIFT) /* I: 立即数 */
	: "cc");

	/*
	 * 2. 当前锁请求者自旋等叫到自己的号:
	 * lockval.tickets.next != lockval.tickets.owner
	 * 表示锁当前已被占用,所以此次上锁请求需等待锁占有者释放,
	 * 直到叫到自己的号 @lockval.tickets.next。
	 *
	 * 从这里我们可以理解到,为什么要使用临时变量 @lockval
	 * 来复制锁 @lock 的内容,主要是保存的当前请求者的号牌,
	 * 因为每个上锁请求都会更新 @lock 的 @next ,所以每个请求者
	 * 必须使用临时变量记录自己的号牌。
	 */
	while (lockval.tickets.next != lockval.tickets.owner) {
		wfe(); /* 可以小憩一下, 等待被叫醒 */
		/* 读取当前叫号: arch_spin_unlock() 会更新它,相当于叫号机器 */
		lockval.tickets.owner = ACCESS_ONCE(lock->tickets.owner);
	}

	/*
	 * 3. 取锁成功.
	 * 插入一个内存屏障,使得之前对 spinlock 的读写操作立马对系统中其它 CPU 可见:
	 * 宣告 spinlock 已被持有,同时保证临界区的存储操作不会跨越到锁前,锁前的存储
	 * 操作也不能跨过锁进入临界区内,也即防止了锁定前后存储操作的乱序。
	 */
	smp_mb();
}

从上面分析可以看到,spin_lock() 首先取号,然后会在两种不同场景做出不同的操作:

  1. 如果当前锁没被占用,那直接取锁成功,这是第 1 次取锁或者锁被释放的场景;
  2. 如果当前锁被占用,那自旋直到叫号到自己取锁成功为止。

接下来看 spin_unlock() 的逻辑:

c 复制代码
static __always_inline void spin_unlock(spinlock_t *lock)
{
	raw_spin_unlock(&lock->rlock);
}

#define raw_spin_unlock(lock)		_raw_spin_unlock(lock)

void __lockfunc _raw_spin_unlock(raw_spinlock_t *lock)
{
	__raw_spin_unlock(lock);
}

static inline void __raw_spin_unlock(raw_spinlock_t *lock)
{
	...
	do_raw_spin_unlock(lock);
	preempt_enable(); /* 重新启用当前 CPU 上抢占 */
}

static inline void do_raw_spin_unlock(raw_spinlock_t *lock) __releases(lock)
{
	arch_spin_unlock(&lock->raw_lock);
	...
}

spin_unlock() 架构相关、也是核心部分:

c 复制代码
static inline void arch_spin_unlock(arch_spinlock_t *lock)
{
	/*
	 * 保证在 spinlock 锁释放前,让 spinlock 锁定的
	 * 临界区内的读写操作对系统中其它 CPU 可见。 
	 */
	smp_mb();
	lock->tickets.owner++; /* 业务办完了,叫号下一位 */
	dsb_sev(); /* 唤醒小憩的等待者 */
}

3.3 小结

spinlock 在上锁期间,禁用了当前 CPU 的抢占,所以并发的场景有两种:

  1. 使用相同 spinlock 的当前 CPU 中断
  2. 使用相同 spinlock 的其它 CPU

spinlock 要处理当前 CPU 的中断的并发,可以使用 spin_lock_irq*()/spin_unlock_irq*() 变种,这些变种 API 禁用当前 CPU 上抢占的同时,也禁用了当前 CPU 上的中断。

什么时候该用 spinlock?spinlock 相比其它类型的锁,其第一个特点等锁的用户不会睡眠,即不停的自旋直到抢到锁为止,这告诉我们可以在不能睡眠的上下文使用它;spinlock 的第二个特点是禁用了当前 CPU 上的抢占,那意味着等锁期间 CPU 上一直不会发生调度,而且 CPU 还一直自旋,所以很显然处于 spinlock 保护的临界区代码耗时要短,否则其它等锁的 CPU 上一直得不到调度。spinlock 的典型使用使用是中断处理程序,这是一个不能睡眠的场合,譬如进程和中断上下文共享的数据在 spinlock 保护下,如果在中断处理程序拿到 spinlock 后进入睡眠,将立马报告 BUG 导致系统崩溃,因为中断处理过程中,中断禁用、抢占也被 spinlock 禁用,则无法发生调度,而睡眠行为实际是进行调度,这是不允许的。另外,要注意的是,spinlock 是不允许嵌套使用的,即 spin_lock() 成功后再次在临界区调用 spin_lock() 的行为是错误的,这是显而易见的,因为 spinlock 已经被拿取,然后再次 spin_lock() 将永远自选而不会成功,即 spin_unlock() 永远没法被调用来释放锁。

ticket spinlock 改进了 CAS spinlock,保证了按取号的严格先后顺序用锁,这提升了公平性,这是它的优点;但同时也还存在缺陷,因为每次叫号时,事实上只有下一个紧挨的号码会获得锁,这些等锁的 CPU 都访问同一 spinlock 变量,锁的值发生变化时,这些等锁 CPU spinlock 变量关联的 cacheline 都会失效,但实际只需要是取锁成功的(即下一叫号)CPU 的 cacheline 失效即可,而重新从内存加载变量会引入不小的代价,这在核心很多的系统上引入了很大的开销,同时像 ARM 这样的架构,wfe 指令会将 CPU 进入小憩状态,spin_unlock()sev 将所有的 CPU 核唤醒,也对功耗不利的。所以针对这一问题,引入了 MCS 算法锁,Linux 没有 MCS 算法版本,而是直接进化到了使用 MCS 算法的 qspinlock

相关推荐
野指针YZZ2 小时前
GStreamer RKNN 插件自制
linux·音视频·rk3588·gstreamer
Darth Nihilus2 小时前
Raspberry Pi Compute Module Zero Development Board开发板(三)
linux·嵌入式硬件
qq_393060472 小时前
在 WSL2 的 Ubuntu 中安装中文字体
linux·运维·ubuntu
洛菡夕2 小时前
LNMP环境部署
linux·运维
AMoon丶2 小时前
C++新特性-智能指针
linux·服务器·c语言·开发语言·c++·后端·tcp/ip
蜕变的小白2 小时前
Linux系统编程-->高效并发服务器:IO多路复用详解
linux·运维·服务器
峥嵘life2 小时前
Android16 EDLA更新25-12补丁导致【CTS】CtsWindowManagerDeviceAnimations存在fail项
android·linux·学习
草莓熊Lotso2 小时前
手搓简易 Linux 进程池:从 0 到 1 实现基于管道的任务分发系统
linux·运维·服务器·数据库·c++·人工智能
YMWM_3 小时前
linux文件快速传windows
linux·运维·服务器