这一篇在干嘛?

内核代码不是排队执行的:系统调用、中断、软中断交织在一起,多个 CPU 还可能同时进内核。本章讲清”为什么会产生竞态条件”,以及 Linux 内核手里的每一件同步武器——Per-CPU 变量、原子操作、内存屏障、自旋锁、顺序锁、RCU、信号量——分别适合什么场景、底层是怎么实现的。看懂本章,后面各章里出现的锁就都不再是天书。

内核如何服务请求 | 内核抢占 | 何时需要、何时不需要同步 | Per-CPU 变量与原子操作 | 内存屏障 | 自旋锁 | 顺序锁与 RCU | 信号量家族 | 中断禁用与方案选择 | 实战案例

内核如何服务请求:服务员模型

把内核想象成一个餐厅里的服务员,他要同时应对两类请求:顾客的点单老板的召唤。服务员的行事规则是:

  1. 闲着的时候老板一叫,立刻去服务老板;
  2. 正在服务顾客时老板叫了,放下顾客去服务老板;
  3. 正在服务一个老板时另一个老板也叫了,转去服务新老板,完事再回来继续服务前一个老板;
  4. 老板甚至可以暗示服务员:别管这个顾客了,去接新的顾客。

这个比喻里的对应关系是:

  • 服务员在干活 = CPU 处于内核态执行内核代码;服务员闲着 = CPU 在用户态。
  • 老板的召唤 = 中断(异步,随时可能来)。
  • 顾客的点单 = 系统调用或异常(用户态进程主动发起,比如 80x86 上的 int $0x80sysenter 指令,触发异常把 CPU 切入内核态)。

前三条规则对应第 4 章讲过的”内核控制路径的嵌套执行”——中断可以打断异常处理程序,中断之间也能嵌套。第四条规则则是 Linux 2.6 最重要的新特性之一:内核抢占

为什么要用这么大开销的方式执行内核代码(随时可能被打断、被切走)?答案很直接:响应速度。按键、网卡来包这些事件不能等内核把手头的系统调用慢悠悠做完才处理。但代价是——内核代码的执行顺序不再可控,这就埋下了竞态条件(race condition)的隐患:如果两条内核控制路径交叉执行时访问同一份共享数据,而结果又依赖于交叉的具体方式,数据就可能被破坏。

图 1:第 5 章章首插图

内核抢占

“抢占式内核”这个概念看似好定义:只要一个进程在内核态执行内核函数的中途可能被另一个进程替换,内核就是抢占式的。但实际情况有几个微妙之处必须分清:

  • 计划内切换 vs 强制切换:无论内核是否抢占式,进程在内核态都可能主动让出 CPU(比如要睡眠等资源),这叫计划内进程切换;抢占式内核的独有之处是能对异步事件做出反应——比如中断处理程序唤醒了一个更高优先级的进程,从而触发强制进程切换
  • 所有进程切换都由 switch_to 宏完成。在非抢占式内核里,当前进程只有在即将返回用户态时才可能被替换;抢占式内核则允许它在内核函数执行的半途被换下。

两个例子感受一下区别:

  • 进程 A 在执行异常处理程序,此时更高优先级的进程 B 变为可运行(比如某个 IRQ 的处理程序唤醒了 B)。抢占式内核立即强制切换到 B,A 的异常处理程序被挂起、以后恢复;非抢占式内核则要等 A 做完处理程序或主动让出 CPU。
  • 进程 A 在异常处理程序里时间片耗尽。抢占式内核可能立刻换掉它;非抢占式内核只能等它出内核态。

抢占式内核的动机是降低用户态进程的分派延迟(dispatch latency)——从进程变为可运行到真正开始运行之间的延迟。播放器、硬件控制器、环境监控这类有实时性需求的程序受益最大。

那么 Linux 2.6 在哪些点允许抢占?规则在第 4 章出现过:只有 thread_info 描述符的 preempt_count 字段为 0 时才允许抢占。这个字段编码了三个计数器,大于零意味着以下至少一条成立:

  1. 内核正在执行中断服务例程;
  2. 可延迟函数被禁用(执行 softirq 或 tasklet 时恒为真);
  3. 通过抢占计数器显式禁用了内核抢占。

也就是说,只有异常处理程序(尤其是系统调用)执行期间、且未显式禁用抢占、且本地中断是打开的,才可能发生内核抢占。

操作抢占计数器的宏如下:

说明
preempt_count()选取 thread_info 描述符中的 preempt_count 字段
preempt_disable()抢占计数器加 1
preempt_enable_no_resched()抢占计数器减 1
preempt_enable()计数器减 1,若 TIF_NEED_RESCHED 标志已置位则调用 preempt_schedule()
get_cpu()相当于 preempt_disable(),并返回本地 CPU 号
put_cpu()相当于 preempt_enable()
put_cpu_no_resched()相当于 preempt_enable_no_resched()

preempt_enable() 减完计数器后检查 TIF_NEED_RESCHED 标志,若已置位说明有一次进程切换请求在等待,于是调用 preempt_schedule(),它本质上执行:

if (!current_thread_info->preempt_count && !irqs_disabled()) {
    current_thread_info->preempt_count = PREEMPT_ACTIVE;
    schedule();
    current_thread_info->preempt_count = 0;
}

可见抢占可能发生的时机包括:某条内核控制路径(通常是中断处理程序)结束时,或异常处理程序通过 preempt_enable() 重新打开抢占时,以及后文会看到的可延迟函数被重新启用时。

抢占不是免费的:调度判断、缓存污染都有开销,所以 Linux 2.6 提供了编译选项让用户选择是否启用内核抢占。

常见坑:以为关了抢占就万事大吉

抢占计数器为 0 只说明”这个 CPU 不会切走当前进程”,它管不住其他 CPU,也管不住中断。单靠禁用抢占保护共享数据,在多处理器上必然翻车——还得配合后文的其他原语。

何时需要、何时不需要同步

第 1 章针对进程讲过竞态条件和临界区,这两个概念对内核控制路径同样适用:

  • 竞态条件:计算结果依赖于两条以上内核控制路径交叉嵌套的方式时,就发生了竞态。
  • 临界区:一段代码,进入它的控制路径必须完整执行完,另一条控制路径才能进入。

举两个最简单的情形:单 CPU 系统上,两个中断处理程序共享一个数据结构(比如一个缓冲区加一个长度计数),只要访问期间关中断即可——因为控制路径的嵌套只可能在中断打开时发生;如果数据结构只被系统调用服务例程访问,关掉内核抢占就够了。但在多处理器系统上,多个 CPU 同时执行内核代码,“关抢占 + 没有中断碰它”远远不够。

另一方面,有些设计让同步根本不必要(这些约束都来自第 4 章):

  • 所有中断处理程序都会应答 PIC 并禁用该 IRQ 线,处理程序结束前不会再次发生同一中断;
  • 中断处理程序、softirq、tasklet 既不可抢占也不阻塞,不会长时间挂起;
  • 中断处理不会被可延迟函数或系统调用打断;
  • 同一个 CPU 上 softirq 和 tasklet 不会交错执行;
  • 同一个 tasklet 不会同时在多个 CPU 上运行。

由此可以推出几条”免同步”的简化:

  • 中断处理程序和 tasklet 不必写成可重入函数
  • 只被 softirq/tasklet 访问的 Per-CPU 变量不需要同步
  • 只被一种 tasklet 访问的数据结构不需要同步。

当同步不可避免时,内核提供了一整套原语,先看总览:

技术说明作用范围
Per-CPU 变量在各 CPU 间复制数据结构所有 CPU
原子操作对计数器原子地”读-改-写”所有 CPU
内存屏障避免指令重排本地或所有 CPU
自旋锁忙等的锁所有 CPU
信号量阻塞等待(睡眠)的锁所有 CPU
顺序锁基于访问计数的锁所有 CPU
本地中断禁用禁止单个 CPU 上的中断处理本地 CPU
本地软中断禁用禁止单个 CPU 上的可延迟函数本地 CPU
RCU通过指针无锁访问共享数据结构所有 CPU

注意”作用范围”一列:关中断只影响本 CPU,而原子操作、自旋锁这些是全局性的约定。

Per-CPU 变量与原子操作

Per-CPU 变量:最好的同步是不同步

每种显式同步原语都有可观的性能代价,所以最优解是在设计上就让同步变得不必要。Per-CPU 变量正是这一思想:把它声明成”每个 CPU 一份”的数组,每个 CPU 只读写自己的那一份,天然没有竞态。前提是数据在逻辑上确实可以按 CPU 拆分(比如各 CPU 的统计计数)。

两个重要细节:

  1. 数组元素在内存中对齐,使每个数据结构落在不同的硬件缓存行上,避免多 CPU 并发访问时昂贵的缓存行窥探与失效;
  2. Per-CPU 变量只防住”多 CPU 并发”这一种竞态——防不住异步函数(中断/可延迟函数)的访问,也防不住内核抢占:如果控制路径拿到自己那份的地址后被抢占、迁移到另一个 CPU,地址还指向原来那个 CPU 的元素。所以一般规则是:访问 Per-CPU 变量时要禁用内核抢占

常用接口:

宏/函数说明
DEFINE_PER_CPU(type, name)静态分配名为 name 的 per-CPU 数组
per_cpu(name, cpu)选取数组中 CPU cpu 对应的元素
__get_cpu_var(name)选取本地 CPU 的元素
get_cpu_var(name)先禁用内核抢占,再选取本地 CPU 的元素
put_cpu_var(name)重新启用内核抢占
alloc_percpu(type) / free_percpu(pointer)动态分配/释放 per-CPU 数组
per_cpu_ptr(pointer, cpu)返回动态数组中 CPU cpu 元素的地址

原子操作:芯片级的”读-改-写”

很多汇编指令属于”读-改-写”型:先读内存旧值,再写回新值,访问同一内存两次。设想两个 CPU 同时对同一内存单元做非原子的读-改-写:内存仲裁器会把两次读都放行,两个 CPU 读到相同的旧值,然后各自写回相同的新值——两次操作的效果变成了次操作,计数就这样悄悄丢了一个。

80x86 上原子性的判定规则:

  • 零次或一次对齐内存访问的指令是原子的;
  • incdec 这类读-改-写指令,若读和写之间没有其他处理器抢走内存总线,则是原子的(单处理器系统中总线抢占不会发生);
  • lock 字节前缀(0xf0) 的读-改-写指令在多处理器系统上也是原子的——控制单元会锁住内存总线直到指令结束;
  • rep 前缀(0xf2/0xf3)的指令不是原子的,控制单元在每次迭代前都会检查挂起的中断。

写 C 代码时你无法保证 a = a + 1 甚至 a++ 会被编译成原子指令,所以内核提供了专门的 atomic_t 类型和一组操作它的函数,每个函数都实现为单条原子汇编指令(多处理器上加 lock 前缀):

函数说明
atomic_read(v) / atomic_set(v,i)读 / 设置 *v
atomic_add(i,v) / atomic_sub(i,v)*v 加 / 减 i
atomic_sub_and_test(i,v)*v 减 i,结果为零返回 1
atomic_inc(v) / atomic_dec(v)*v 加 / 减 1
atomic_dec_and_test(v)减 1 后结果为零返回 1
atomic_add_negative(i,v)加 i 后结果为负返回 1
atomic_inc_return(v)加减并返回新值

另一类原子函数操作位掩码test_bitset_bitclear_bitchange_bit,以及”测试并设置/清除/反转”三兄弟 test_and_set_bittest_and_clear_bittest_and_change_bit(返回该位旧值),还有 atomic_clear_maskatomic_set_masktest_and_xxx 系列是后面自旋锁实现的基石。

内存屏障

使用优化编译器时,千万别以为指令会严格按源代码顺序执行:编译器可能为了充分利用寄存器而重排指令,现代 CPU 还会并行执行多条指令、重排内存访问。平时这是提速利器,但在同步场景下是灾难——如果同步原语之后的指令跑到原语之前执行,保护就形同虚设。因此所有同步原语都兼作优化屏障和内存屏障。

两个层次要分清:

  • 优化屏障:只管编译器。Linux 的 barrier() 宏展开为 asm volatile("":::"memory")——插入一条空汇编,volatile 禁止编译器把它与其它指令混合重排,"memory" 迫使编译器假定所有内存位置都可能被改过,从而放弃基于寄存器缓存旧值的优化。它管不住 CPU 的乱序执行
  • 内存屏障:管硬件。保证屏障前的操作完成后才开始屏障后的操作,像一堵汇编指令翻不过去的防火墙。

80x86 上充当内存屏障的”串行化”指令包括:所有操作 I/O 端口的指令、所有带 lock 前缀的指令、所有写控制寄存器/系统寄存器/调试寄存器的指令(如 clisti)、Pentium 4 引入的 lfence/sfence/mfence(分别实现读、写、读写屏障),以及 iret 等特殊指令。

Linux 提供的内存屏障原语:

说明
mb()内存屏障(单处理器与多处理器均有效)
rmb()读内存屏障(同上)
wmb()写内存屏障(同上)
smp_mb()仅多处理器有效的内存屏障
smp_rmb()仅多处理器有效的读屏障
smp_wmb()仅多处理器有效的写屏障

实现是架构相关的。80x86 上 rmb() 在 CPU 支持 lfence 时展开为 asm volatile("lfence"),否则展开为 asm volatile("lock;addl $0,0(%%esp)":::"memory")——往栈顶加零本身毫无意义,但 lock 前缀使它成为内存屏障。wmb() 更简单:由于现有 Intel 处理器从不重排访问,它直接展开为 barrier(),只防编译器。smp_xxx() 版本在单处理器系统上是空操作。

另外记住一条:多处理器系统上,所有带 lock 字节的原子操作本身就是内存屏障。

自旋锁

锁的比喻很直白:共享资源是一间上锁的房间,控制路径想进去得先”开门”(获得锁),只有没人占用时才开得了;用完把门锁打开,别人才能进。图示如下——五个控制路径 P0~P4 竞争三个临界区:P0 在 C1 里,P2、P4 排队等 C1;P1 在 C2 里,P3 等 C2;C3 没人用。P0 和 P1 完全可以并行。

图 2:用多把锁保护多个临界区(P0 在 C1 内,P2、P4 等待;P1 在 C2 内,P3 等待)

自旋锁是专为多处理器环境设计的锁:拿到锁就继续跑;发现锁被别的 CPU 上的控制路径占着,就在原地忙等(busy wait)——反复执行一小段紧凑指令循环,直到锁被释放。忙等看似浪费,实际上很多内核资源只被锁住不到一毫秒,这种情况下”释放 CPU、稍后再抢回来”的代价远高于原地打转。

一般规则:自旋锁保护的临界区内内核抢占是禁用的。单处理器系统上自旋锁本身没用(等待者忙等时持有者根本没机会释放),其原语退化为禁用/启用内核抢占。但注意:忙等阶段抢占是开着的,等锁的进程可能被更高优先级的进程换下。

每个自旋锁是 spinlock_t 结构:slock 字段编码状态(1 = 未锁,0 及负值 = 已锁),break_lock 字段(仅在同时支持 SMP 和内核抢占时存在)标志”有进程正在忙等这把锁”。六个核心宏:

说明
spin_lock_init()置 1(未锁)
spin_lock()循环直到锁变为 1,然后置 0
spin_unlock()置 1
spin_unlock_wait()等待锁变为 1
spin_is_locked()未锁返回 0,已锁返回 1
spin_trylock()尝试上锁,成功返回 1,失败返回 0

spin_lock 宏的完整流程(支持抢占的 SMP 内核)

  1. 调用 preempt_disable() 禁用内核抢占;
  2. 调用 _raw_spin_trylock()slock 做原子测试并置零,核心是两条指令:
movb $0, %al
xchgb %al, slp->slock

xchg 原子地交换 %al(存着 0)与锁字段的内容。若换回来的旧值为正(原来是 1,未锁),返回 1 表示抢锁成功。

  1. 旧值为正,宏结束——锁已到手;
  2. 否则调用 preempt_enable() 恢复抢占,准备进入等待(等待期间其他进程可以抢占当前进程);
  3. break_lock 置 1,让远端持锁的进程知道有人在等——它如果持锁太久,可以提前释放;
  4. 执行等待循环:
while (spin_is_locked(slp) && slp->break_lock)
    cpu_relax();

cpu_relax() 展开为 pause 指令(Pentium 4 引入),插入短延迟以优化自旋循环并降低功耗,向下兼容时等价于 rep;nop

  1. 回到第 1 步重新尝试抢锁。

不支持抢占时的紧凑实现

1:  lock; decb slp->slock
    jns 3f
2:  pause
    cmpb $0,slp->slock
    jle 2b
    jmp 1b
3:

lock; decb 原子地把锁减 1,符号标志为正(原值是 1,抢到了)就跳到标号 3 继续;否则在标号 2 的紧凑循环里自旋,直到锁变为正值——但此时不能直接走人,必须跳回标号 1 重新原子抢锁,因为另一个处理器可能刚好也盯着这把锁。

spin_unlock 只需一条写指令 movb $1, slp->slock 再加 preempt_enable()。写访问天然原子,所以不用 lock 前缀。

读/写自旋锁:让读者并行

普通自旋锁一次只放一个控制路径进临界区,但很多数据结构是”读多写少”的——多个读者同时看完全没有危害。读/写自旋锁因此而生:多个控制路径可以同时持有读锁,写锁则是排他的。

图 3:读/写自旋锁(R0、R1 同时读 C1,W0 等待写;W1 正在写 C2,R2、W2 排队)

每个读/写自旋锁是 rwlock_t 结构,其 32 位 lock 字段编码两件事:

  • 0~23 位:正在读的读者数的补码
  • 第 24 位:解锁标志——没有读者也没有写者时置位。

所以:空闲时 lock = 0x01000000;被写者持有 = 0x00000000;被 n 个读者持有 = 0x01000000 - n(如 1 个读者是 0x00ffffff)。

读锁的获取(支持抢占时)与 spin_lock 流程相同,只是核心动作换成 _raw_read_trylock()

int _raw_read_trylock(rwlock_t *lock)
{
    atomic_t *count = (atomic_t *)lock->lock;
    atomic_dec(count);
    if (atomic_read(count) >= 0)
        return 1;
    atomic_inc(count);
    return 0;
}

把计数减 1(读者数加 1)后若仍非负——说明之前没有写者——读锁到手;否则把计数加回去,返回失败。注意整个函数不是原子的,但它依然正确:只有减之前计数为正(0x01000000 或更高)函数才会返回 1。

不支持抢占时,read_lock 展开为紧凑汇编,失败则调用 __read_lock_failed()

__read_lock_failed:
    lock; incl (%eax)
1:  pause
    cmpl $1, (%eax)
    js 1b
    lock; decl (%eax)
    js __read_lock_failed
    ret

先把失败的减法撤销,然后自旋等待计数字段变正(写者放手的瞬间),再重新尝试减 1——因为 cmpl 之后写者可能又插进来。释放读锁只是一条 lock; incl rwlp->lockpreempt_enable()

写锁的获取调用 _raw_write_trylock()

int _raw_write_trylock(rwlock_t *lock)
{
    atomic_t *count = (atomic_t *)lock->lock;
    if (atomic_sub_and_test(0x01000000, count))
        return 1;
    atomic_add(0x01000000, count);
    return 0;
}

减去 0x01000000 会清掉解锁标志(第 24 位);结果为零说明没有任何读者,写锁到手;否则加回去撤销。释放写锁:lock; addl $0x01000000,rwlppreempt_enable()

常见坑:在中断处理程序里用普通自旋锁却不关中断

设想进程在 CPU A 上持有自旋锁,随后 CPU A 收到中断,中断处理程序也要拿同一把锁——它在自己的 CPU 上忙等,而持锁路径被自己打断、永远没机会释放,系统死锁。所以中断处理程序必须用”关中断 + 自旋锁”的合体宏(见后文 spin_lock_irqsave 家族)。

顺序锁与 RCU

顺序锁(Seqlock):写者优先

读/写自旋锁对读者和写者一视同仁:读者必须等写者,写者也必须等读者。顺序锁则把优先级大幅倾斜给写者——写者几乎从不等待(除非另一个写者在写),代价是读者可能要把同一份数据读好几遍才能读到一致的版本。

每个顺序锁是 seqlock_t 结构:一把 spinlock_t 加一个整数顺序计数器 sequence。写者进入前拿自旋锁并把计数器加 1,退出时再加 1——于是写者写的时候计数器是奇数,没人写的时候是偶数。读者的写法是:

unsigned int seq;
do {
    seq = read_seqbegin(&seqlock);
    /* ... 临界区:读数据 ... */
} while (read_seqretry(&seqlock, seq));

read_seqbegin() 返回当前顺序号;read_seqretry() 在两种情况下返回 1(需要重读):seq 是奇数(进入时就有写者在写),或 seq 与当前计数器不一致(读的过程中来了写者)。读者进临界区不需要禁用抢占;写者因为拿了自旋锁自动禁了。

顺序锁不是万能的,适用条件很苛刻:

  • 被保护的数据结构不含”写者修改、读者解引用”的指针(否则读者可能踩到刚被写者换掉的指针);
  • 读者临界区没有副作用(因为可能读多遍);
  • 读者临界区要短,写者要——否则反复重读的开销会很惨。

Linux 2.6 里典型的用法是保护与系统时间相关的数据结构(见 /linux内核/lk06)。

RCU:读侧零开销的无锁方案

读-复制-更新(RCU)面向”被多个 CPU 频繁读取”的数据结构,比顺序锁更进一步:多个读者和多个写者可以同时推进,而且完全无锁——不共享任何锁或计数器,从而避开读/写自旋锁和顺序锁都绕不开的缓存行窥探与失效开销。

RCU 能做到这点,靠的是把适用范围限得很死:

  1. 只能保护动态分配、通过指针引用的数据结构;
  2. 任何控制路径不得在 RCU 临界区内睡眠

读者做什么?几乎什么都不做:rcu_read_lock() 等价于 preempt_disable(),然后解引用指针开始读;读完执行 rcu_read_unlock()(等价于 preempt_enable())。期间不能睡眠。

写者的工作多一点:先把整个数据结构复制一份,在副本上修改,完成后原子地把指针改指向新副本。改指针是原子操作,所以每个读者要么看到旧副本、要么看到新副本,绝不会看到半新半旧。还需要一个内存屏障保证”其他 CPU 看到新指针时,数据一定已改完”——如果写者之间再用一把自旋锁互斥,屏障就隐含其中了。

真正麻烦的是旧副本什么时候能释放:更新开始时还在读旧副本的那些读者,可能还没读完。旧副本必须等到所有 CPU 上潜在的读者都执行过 rcu_read_unlock() 才能释放。内核规定每个潜在读者在以下三种情况之前必然执行过解锁宏:

  • CPU 发生进程切换(读者临界区内不能睡眠,所以切换前临界区一定结束了);
  • CPU 开始执行用户态代码;
  • CPU 执行 idle 循环。

满足以上任一情况,就说该 CPU 经历了一个静息状态(quiescent state)。

写者调用 call_rcu() 来善后:传入一个 rcu_head 描述符(通常内嵌在数据结构里)和一个回调函数地址。该函数把回调挂入 per-CPU 回调链表;内核每个 tick 检查本地 CPU 是否经历静息状态,当所有 CPU 都经历过后,由一个本地 tasklet 执行链表里的全部回调——回调通常就是释放旧副本。

RCU 是 Linux 2.6 的新增特性,用在网络层和虚拟文件系统中。

通关标准

能对着场景选锁:单整数计数用 atomic_t;内核态临界区极短且不会睡眠用自旋锁;可能睡眠(进程上下文)用信号量;读多写少且读侧不能有开销用 RCU;读多写极少、读者可重试用顺序锁。并说得出每种选择背后的代价(忙等浪费 CPU vs 睡眠切换开销 vs 重读/延迟释放)。

信号量家族

自旋锁的等待是忙等,而内核信号量的等待是睡眠:控制路径发现资源被占,对应进程被挂起,资源释放时再被唤醒。这一根本差异带来一条铁律:中断处理程序和可延迟函数绝不能使用信号量——它们不可睡眠。信号量只能被允许睡眠的函数(如系统调用服务例程)使用。注意区分:内核信号量供内核控制路径使用,System V IPC 信号量供用户态进程使用(见第 19 章)。

struct semaphore 有三个字段:

  • countatomic_t):大于 0 资源空闲;等于 0 资源忙但无人在等;小于 0 资源忙且至少一个进程在等;
  • wait:等待队列,挂所有睡眠等待的进程;
  • sleepers:标志是否有进程正睡在这个信号量上。

init_MUTEX() / init_MUTEX_LOCKED()count 初始化为 1 / 0(DECLARE_MUTEX 等宏则连变量一起静态分配)。count 也可以初始化为任意正数 n,表示最多允许 n 个进程并发访问资源。

释放:up()

释放信号量调用 up(),核心是原子地把 count 加 1 并检查符号:

movl $sem->count,%ecx
lock; incl (%ecx)
jg 1f                ; 结果 > 0:队列里没人睡,直接走
lea %ecx,%eax        ; 否则调用 __up 唤醒一个等待者
call __up
__attribute__((regparm(3))) void __up(struct semaphore *sem)
{
    wake_up(&sem->wait);
}

加 1 后大于 0 说明等待队列本来是空的,什么都不用做;否则唤醒一个睡眠者。

获取:down()

获取信号量调用 down():原子地把 count 减 1,若结果非负就顺利拿到资源;为负则调用 __down() 把当前进程挂起:

__attribute__((regparm(3))) void __down(struct semaphore *sem)
{
    DECLARE_WAITQUEUE(wait, current);
    unsigned long flags;
    current->state = TASK_UNINTERRUPTIBLE;
    spin_lock_irqsave(&sem->wait.lock, flags);
    add_wait_queue_exclusive_locked(&sem->wait, &wait);
    sem->sleepers++;
    for (;;) {
        if (!atomic_add_negative(sem->sleepers-1, &sem->count)) {
            sem->sleepers = 0;
            break;
        }
        sem->sleepers = 1;
        spin_unlock_irqrestore(&sem->wait.lock, flags);
        schedule();
        spin_lock_irqsave(&sem->wait.lock, flags);
        current->state = TASK_UNINTERRUPTIBLE;
    }
    remove_wait_queue_locked(&sem->wait, &wait);
    wake_up_locked(&sem->wait);
    spin_unlock_irqrestore(&sem->wait.lock, flags);
    current->state = TASK_RUNNING;
}

__down() 把进程状态改为 TASK_UNINTERRUPTIBLE,以排他方式挂入等待队列,并借队列自旋锁 sem->wait.lock 一并保护信号量的其他字段(用的都是 _locked 版本的队列函数,假定锁已拿到)。atomic_add_negative(sleepers-1, &count) 巧妙地把”撤销 down 的减法(当有前人在等时)“与”判断资源是否可用”合成一次原子操作。走几个典型状态验证:

  • 空闲信号量(count=1, sleepers=0):down 把 count 减到 0,符号非负,根本不会进 __down()
  • 忙但无人等(进入时 count=0, sleepers=0):count 已被减到 -1,循环里 add_negative(0-1=-0?) ——注意 sleepers=0 时加的是 -1 的相反数 0……准确说 atomic_add_negative(sleepers-1) 加 0,count 仍为 -1,为负 → 置 sleepers=1,schedule() 睡眠;被唤醒后再测,若期间有人 up() 过,count 已非负,于是 sleepers=0、退出并顺带 wake_up_locked(队列已空,无事发生),最终 count=0、sleepers=0,正好是”忙、无人等”的正确状态。
  • 忙且有人在等(进入时 count=-1, sleepers=1,减完 count=-2):函数借助 sleepers-1 = 0 的加法撤销 down 的减法使 count 回到 -1,若仍为负就置 sleepers=1 继续睡;若 up() 抢先发生使 count 非负,则 sleepers=0、拿到锁退出,同时唤醒队列里的下一个人——被唤醒者下一轮迭代会把 count 重新减回 -1 并把 sleepers 置回 1。所有状态都能自洽闭环。

up()wake_up() 至多唤醒一个进程,因为等待者都是排他的。整个实现的复杂度,一大半花在”快路径不跳转”上:信号量空闲时 down() 不执行任何跳转指令,队列空时 up() 同样——绝大多数调用都落在快路径上。

三个变体:

  • down_trylock():资源忙时立即返回而非睡眠——唯一可被中断处理程序/可延迟函数安全使用的变体;
  • down_interruptible():睡眠中收到信号时放弃获取、把 count 加回去并返回 -EINTR;正常拿到资源返回 0。设备驱动大量使用它,以便用户可以中断卡住的 I/O;
  • down() 本身不可被信号打断。

读/写信号量

读/写信号量之于普通信号量,正如读/写自旋锁之于自旋锁:多个读者可并发,写者排他,区别只在等待方式是睡眠而非自旋。内核按严格的 FIFO 顺序处理等待者:释放时先唤醒队首进程;若是写者,其余人继续睡;若是读者,则从队首一直唤醒到第一个写者之前的所有读者——排在写者后面的读者继续等。这避免了写者被源源不断的读者饿死。

结构体 rw_semaphore 包含:count(两个 16 位计数器:高 16 位是”非等待写者数(0/1) + 等待者数”的补码,低 16 位是活跃读者与非等待写者的总数)、wait_list(等待进程链表,元素为 rwsem_waiter,记录进程指针和读/写意图)、wait_lock(保护结构本身的自旋锁)。

API 与信号量对称:down_read()/down_write() 获取,up_read()/up_write() 释放,down_read_trylock()/down_write_trylock() 非阻塞尝试,另有 downgrade_write() 把写锁原子地降级为读锁。

完成量(Completions):为多核竞态而生的补丁

完成量专门解决一个很隐蔽的竞态:进程 A 分配了一个栈上的临时信号量,初始化为关闭的 MUTEX,把地址传给进程 B,然后 down() 睡眠等待;B 在另一个 CPU 上执行 up()。问题是现有实现允许 up()down() 在同一信号量上并发执行——A 可能刚被唤醒就销毁了临时信号量,而 B 的 up() 还没跑完,去访问一个已经不存在的数据结构。

修信号量实现本身(禁止并发)要给最热门的函数加指令,得不偿失,于是有了专用原语:

struct completion {
    unsigned int done;
    wait_queue_head_t wait;
};

对应 up() 的是 complete():拿等待队列自旋锁 → done 加 1 → 唤醒一个排他等待者 → 解锁。对应 down() 的是 wait_for_completion():检查 done,大于零直接返回;否则以排他方式入队睡眠(TASK_UNINTERRUPTIBLE),醒来后检查 done,为零就退出,否则继续睡。

与信号量的本质区别在自旋锁的用途:完成量用等待队列的自旋锁保证 complete()wait_for_completion() 互斥执行,从而杜绝上述竞态;信号量则用这把锁防止并发 down() 弄脏信号量结构。

中断禁用与同步方案选择

本地中断禁用

关中断是保护”也被中断处理程序访问的数据结构”的关键手段:控制路径继续执行,无视硬件发来的 IRQ。但它只管本地 CPU,多处理器上必须与自旋锁配合。

  • local_irq_disable() / local_irq_enable():对应 cli / sti 指令,清除/设置 eflags 的 IF 标志;
  • irqs_disabled():IF 标志被清除返回 1。

临界区结束时往往不能无脑 sti:中断可以嵌套,当前控制路径并不知道自己进入前中断是开还是关。正确姿势是保存/恢复:

  • local_irq_save(f):把 eflags 内容存入局部变量 f,再 cli
  • local_irq_restore(f):恢复 eflags 原值——只有进临界区前中断本来就开着,恢复后才是开的。

禁用/启用可延迟函数

可延迟函数的执行时机不可预测(基本都在硬件中断处理结束后),它们碰的数据结构也得保护。粗暴做法是关中断——中断处理程序不跑,softirq 自然也无法异步启动。但内核有时需要只禁可延迟函数、不禁中断:操纵 preempt_count 里的 softirq 计数器即可。

  • local_bh_disable():softirq 计数器加 1(可嵌套多次);
  • local_bh_enable():计数器减 1,减到与第一次 local_bh_disable() 配对时才真正恢复。

do_softirq() 从不执行 softirq 当计数器为正;tasklet 建立在 softirq 之上,所以这个计数器一禁就是全部可延迟函数。local_bh_enable() 在恢复时还做两件好事:

  1. 检查 hardirq 与 softirq 计数器是否都为零,且有待执行的 softirq——是则调用 do_softirq() 立即激活它们,避免积压;
  2. 检查 TIF_NEED_RESCHED 是否置位——是则调用 preempt_schedule() 触发抢占。

到底选哪把锁?

选择取决于哪些类型的控制路径访问数据结构,以及系统是单处理器(UP)还是多处理器(MP)。核心经验法则:始终让系统的并发度尽可能高——I/O 吞吐要求关中断的时间尽量短,CPU 效率要求尽量少用自旋锁(忙等烧 CPU、还污染缓存)。

访问数据结构的控制路径UP 保护MP 追加保护
仅异常信号量
仅中断本地中断禁用自旋锁
仅可延迟函数视情况(见表 5-10)
异常 + 中断本地中断禁用自旋锁
异常 + 可延迟函数本地软中断禁用自旋锁
中断 + 可延迟函数本地中断禁用自旋锁
异常 + 中断 + 可延迟函数本地中断禁用自旋锁

记住:无论拿自旋锁/读写锁/顺序锁/RCU 读锁、还是关本地中断、关本地软中断,内核抢占都会自动禁用。

仅异常访问(典型是系统调用服务例程):用信号量最合适——进程可以睡到资源可用,且单/多处理器通吃。抢占也不构成威胁:持信号量的进程被抢占后,新进程若也要这把锁只会睡下去,老进程终会释放。唯一例外是访问 Per-CPU 变量时要显式禁抢占。

仅中断访问:只有”上半部”访问且只有一个处理程序碰它 → 什么都不用(处理程序相对自身是串行化的)。多个处理程序共享时:UP 上必须关中断——信号量会睡(处理程序不能睡),普通自旋锁会死锁(持锁者被新中断打断、永远放不了锁)。MP 上关本地中断自旋锁:关中断挡住本 CPU 的干扰,自旋锁挡住其他 CPU;这里的自旋锁不会死锁,因为持锁的远端处理程序终会释放。

内核为”锁 + 关中断”的组合提供了成套宏(单处理器上它们退化为只开关中断和抢占):

等价组合
spin_lock_irq(l)local_irq_disable(); spin_lock(l)
spin_unlock_irq(l)spin_unlock(l); local_irq_enable()
spin_lock_irqsave(l,f)local_irq_save(f); spin_lock(l)
spin_unlock_irqrestore(l,f)spin_unlock(l); local_irq_restore(f)
spin_lock_bh(l)local_bh_disable(); spin_lock(l)
spin_unlock_bh(l)spin_unlock(l); local_bh_enable()

读写锁(read_lock_irq/write_lock_irqsave 等)和顺序锁(read_seqbegin_irqsavewrite_seqlock_irqsave 等)有同样的全家桶,规则一致。

仅可延迟函数访问:UP 上永远不需要任何原语——同一 CPU 上可延迟函数是串行的。MP 上分情况:

访问者SMP 上的保护
Softirq自旋锁(同一 softirq 可在多 CPU 并发)
单一 tasklet无(同类 tasklet 不会并发)
多种 tasklet自旋锁

异常 + 中断:UP 上关中断即可(中断不会嵌套进异常处理程序的关中断区间;若只有一种中断处理程序碰它,处理程序侧甚至不用关中断)。MP 上关中断 + 自旋锁。有时可用信号量替代自旋锁:处理程序用 down_trylock() 忙等式获取(等于把信号量当自旋锁用),而系统调用侧则可正常睡眠——这样系统调用等待期间 CPU 可以干别的,并发度更高。

异常 + 可延迟函数:异常侧只需 local_bh_disable(),不必关中断——中断还能继续被服务,并发度更高;MP 上再加自旋锁。

中断 + 可延迟函数:可延迟函数执行中可能被中断打断,反过来则不行,所以可延迟函数侧关本地中断即可;MP 上加自旋锁。

三者皆有:关本地中断 + 自旋锁几乎总是必需;无需显式禁可延迟函数——它们本来就靠中断处理程序收尾来激活,关中断已覆盖。

顺便一提”无锁插入”:向共享单链表插入元素只需两次指针赋值,系统调用侧插入、中断侧只读时,可以先 new->next = list_element->next;,插一道写内存屏障 wmb(),再 list_element->next = new;——中断处理程序无论在哪个瞬间看链表,看到的都是一致的(没有新元素或有新元素)。前提是读者只读不改。

实战案例

引用计数器

内核里最常用的防竞态手段之一:给每个资源(内存页、模块、文件……)挂一个 atomic_t 引用计数。控制路径开始使用资源时原子加 1,用完减 1;计数归零说明无人使用,可以释放。简单、无锁、天然适合多核。

大内核锁(BKL)

早期内核的”全局大锁”:Linux 2.0 中它是一把粗糙的自旋锁,保证同一时刻只有一个处理器能进内核态;2.2/2.4 改用大量细粒度自旋锁;到了 2.6,BKL 只用于保护老代码(主要是 VFS 和一些文件系统相关函数)。从 2.6.11 起 BKL 由名为 kernel_sem信号量实现(之前是自旋锁)。

每个进程描述符有 lock_depth 字段:-1 表示未持有;否则其值加 1 等于持锁次数。这允许同一进程递归申请 BKL——若没有它,异步函数在中断里再申请 BKL 就会死锁。获取与释放逻辑:

/* lock_kernel() */
depth = current->lock_depth + 1;
if (depth == 0)
    down(&kernel_sem);
current->lock_depth = depth;
 
/* unlock_kernel() */
if (--current->lock_depth < 0)
    up(&kernel_sem);

这两段 if 不需要原子性,因为 lock_depth 是每个进程自己的字段。有趣的是,持有 BKL 的进程允许调用 schedule() 主动让出 CPU:schedule() 会检查被换下进程的 lock_depth,若非负就自动释放 kernel_sem(切换回来时再重新获取)——没人能跨进程切换霸占 BKL。

被抢占就是另一回事了:抢占不是进程自愿的切换,不能释放信号量,否则别的进程会拿走 BKL、踩脏被抢占进程正在访问的数据结构。解决办法是 preempt_schedule_irq() 临时把被抢占进程的 lock_depth 置为 -1,骗过 schedule() 让它不释放信号量;进程被再次调度时恢复原值、继续在临界区里跑。BKL 改用信号量实现的主要原因正是允许在 BKL 临界区内抢占,改善系统响应时间。

内存描述符读/写信号量

每个 mm_struct 内存描述符都有 mmap_sem 字段(见 /linux内核/lk09),保护描述符不被共享它的多个轻量级进程弄脏。比如 do_mmap() 在创建/扩展内存区域时可能因内存不足而睡眠,此时共享同一描述符的其他进程开始运行——没有这把锁,它们做的任何需要访问内存描述符的操作(例如写时复制引发的缺页)都会造成数据损坏。用读/写信号量是因为缺页处理程序只需要扫描内存描述符(读)。

Slab 缓存链表信号量

slab 缓存描述符链表(见 /linux内核/lk08)由 cache_chain_sem 信号量保护:kmem_cache_create() 往链表里加元素时,kmem_cache_shrink()kmem_cache_reap() 可能正在顺序扫描。这些函数不在中断中调用、访问链表时也不阻塞,信号量在多处理器系统和支持抢占的单处理器系统上都起作用。

Inode 信号量

磁盘文件的信息存放在内存 inode 对象中(见 /linux内核/lk12),其 i_sem 字段是一把信号量。文件系统操作是竞态重灾区:每个文件都是全体用户共享的资源——所有进程都可能读写内容、改名、删除。比如进程 A 正列目录,每次磁盘操作都可能阻塞,其他进程随时可能修改同一目录;两个进程同时修改同一目录更是灾难。inode 信号量把这些竞态全部挡住。

最后提醒死锁:程序使用两把以上信号量时就有互相等待的隐患。Linux 很少遇到信号量死锁,因为内核控制路径通常一次只拿一把;但 rename() 系统调用必须同时拿两个 inode 的信号量——内核的对策是按预定义的地址顺序申请,破坏”循环等待”条件。

常见坑:死锁三要素

只要同时满足”互斥、持有并等待、循环等待”,锁就会咬死自己。内核的通用解法是固定加锁顺序(如 inode 按地址排序);自己写内核模块时也要遵守:全系统范围内为多把锁定一个全局顺序。