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

相关推荐
有毒的教程1 小时前
Ubuntu 虚拟机磁盘空间不足完整解决教程
linux·运维·ubuntu
小樱花的樱花2 小时前
C++ new和delete用法详解
linux·开发语言·c++
APIshop3 小时前
Java获取京东商品详情接口(item_get)实战指南
java·linux·数据库
Cx330❀3 小时前
一文吃透Linux System V共享内存:原理+实操+避坑指南
大数据·linux·运维·服务器·人工智能
薛定谔的悦3 小时前
储能系统(EMS)核心架构解析:充放电控制、防逆流、防过载与 PID 调节
linux·运维·架构
3GPP仿真实验室4 小时前
【MATLAB源码】CSI-RS:测量链路
linux·网络·matlab
阿 才4 小时前
WSL2 + TFTP + 网络启动(Linux开发板与WSL2建立网络连接)
linux·运维·网络
IMPYLH4 小时前
Linux 的 false 命令
linux·运维·服务器·bash
小江的记录本5 小时前
【Linux】《Linux常用命令汇总表》
linux·运维·服务器·前端·windows·后端·macos
一匹电信狗5 小时前
【Linux我做主】进程程序替换和exec函数族
linux·运维·服务器·c++·ubuntu·小程序·开源