文章目录
- [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 实现类似于银行的叫号系统:
- 要办理业务先取号,即在 spin_lock() 为锁请求者分配一个号码;
- 如果当前没有业务在办理(没有占有锁),叫当前持有最前面号的人来办理业务(当前持有最前面号的请求者上锁成功);如果当前有业务在办理(锁被占住),等当前办理业务的人办理完成,叫下一个号(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::owner 和 arch_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 次取锁或者锁被释放的场景;
- 如果当前锁被占用,那自旋直到叫号到自己取锁成功为止。
接下来看 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 的抢占,所以并发的场景有两种:
- 使用相同 spinlock 的当前 CPU 中断
- 使用相同 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。