[toc]

kernel/locking/spinlock.c 自旋锁(Spinlock) 内核中最基础的高性能对称多处理器(SMP)锁

历史与背景

这项技术是为了解决什么特定问题而诞生的?

自旋锁(Spinlock)是为了解决**对称多处理器(SMP)**系统中最基础的并发控制问题而诞生的:如何保护一段极短的、在任何执行上下文中都可能被访问的临界区

在单处理器(UP)系统中,要保护一段临界区,最简单的方法是禁用本地中断。这样可以确保在执行临界区代码时,不会被中断处理程序打断,从而避免了并发。

然而,在多处理器(SMP)系统中,禁用本地CPU的中断并不能阻止另一个CPU同时执行并访问同一块共享数据。因此,需要一种CPU之间的互斥机制。自旋锁就是为此设计的最底层、最高效的锁。它主要解决:

  • SMP互斥:提供一种在多个CPU之间进行互斥的机制。
  • 原子上下文(Atomic Context)保护:中断处理程序、软中断、tasklet等上下文是不能睡眠的。自旋锁通过“忙等待”而不是“睡眠”来等待锁,因此它是唯一可以在这些原子上下文中安全使用的锁类型。

它的发展经历了哪些重要的里程碑或版本迭代?

Linux的自旋锁实现为了追求更高的性能和公平性,经历了多次重要演进。

  • 简单的Test-and-Set锁:最早的实现基于简单的原子测试和设置指令。这种锁存在严重的公平性问题,在多CPU高竞争下,一个CPU可能被其他CPU持续“插队”,导致饥饿。
  • 票据自旋锁(Ticket Spinlocks):这是一个关键的里程碑,解决了公平性问题。它引入了一种类似银行排队叫号的机制,每个尝试获取锁的CPU都会领取一个“票号(ticket)”,然后等待“叫号器”叫到自己的号码。这保证了锁的获取是**先进先出(FIFO)**的,避免了饥饿。这是很长一段时间内Linux内核的标准实现。
  • 队列自旋锁(Queued Spinlocks - qspinlocks):在拥有大量CPU的现代NUMA(非统一内存访问)系统上,票据锁会导致所有等待的CPU都在同一个内存地址(锁变量)上自旋,造成严重的缓存行争用(cache-line bouncing),影响扩展性。为了解决这个问题,内核引入了qspinlock。它让每个等待的CPU在一个per-CPU的变量上自旋,只有一个CPU负责监视主锁,形成了一个隐式的等待队列,极大地降低了总线流量和缓存争用。这是当前Linux内核的默认实现。
  • 调试支持(CONFIG_DEBUG_SPINLOCK:增加了强大的调试功能,用于检测常见的自旋锁误用,如初始化前使用、重复加锁、解锁一个未被自己持有的锁等。

目前该技术的社区活跃度和主流应用情况如何?

自旋锁是Linux内核的基石,是实现所有其他更高级同步原语的基础。其代码位于内核最核心、对性能最敏感的部分。社区的活跃度主要体现在:

  • 针对新的处理器架构,持续优化其原子操作的实现。
  • 在极端的并发场景下,对其性能和扩展性进行微调。

自旋锁被内核中所有对性能要求极致、且不能睡眠的路径所使用,例如:

  • 中断处理程序
  • 调度器核心逻辑
  • 内存管理子系统(如页分配器)
  • 网络数据包处理路径

核心原理与设计

它的核心工作原理是什么?

自旋锁的核心原理是忙等待(Busy-Waiting)。当一个CPU尝试获取一个已经被其他CPU持有的自旋锁时,它不会像互斥锁那样进入睡眠状态,而是会执行一个紧凑的循环(“自旋”),不断地检查锁是否已经被释放。

以票据自旋锁(Ticket Spinlock)为例:

  1. 数据结构:锁变量包含两个部分:
    • owner: 当前持有锁的“票号”。
    • next: 即将被分发的下一个“票号”。
  2. 加锁 (spin_lock)
    a. 一个CPU想要获取锁,它会原子地执行“获取并加一”操作在next字段上,得到自己的票号。
    b. 然后,它进入一个循环,不断地读取owner字段,直到owner的值等于它自己手中的票号。
  3. 解锁 (spin_unlock)
    a. 持有锁的CPU完成临界区的工作后,它只需简单地将owner字段加一。
    b. 这个操作会使得下一个正在自旋等待的CPU(持有新owner票号的那个)退出其自旋循环,从而成功获取锁。

它的主要优势体现在哪些方面?

  • 极高的性能:在无竞争或低竞争的情况下,加锁和解锁操作通常只需要一两个原子指令,开销极小。
  • 可在任何上下文中使用:因为它从不睡眠,所以它是唯一可以在中断上下文(硬中断、软中断、tasklet)安全使用的锁。这是它与mutex最根本的区别。

它存在哪些已知的劣势、局限性或在特定场景下的不适用性?

  • 浪费CPU资源:在等待锁时,自旋的CPU持续消耗100%的CPU时间,没有做任何有意义的工作。因此,自旋锁绝对不能用于保护可能耗时较长的临界区。
  • 严禁睡眠:持有自旋锁的临界区内绝对不允许调用任何可能导致睡眠的函数。如果一个CPU持有锁并进入睡眠,其他需要该锁的CPU将会永远自旋下去,导致整个系统死锁。
  • 与中断的死锁风险:如果在进程上下文中获取了一个自旋锁,而此时一个中断发生,并且该中断处理程序也尝试获取同一个自旋锁,那么系统会立即死锁。因为中断处理程序会抢占进程上下文,而它又在等待进程上下文释放锁。为了避免这种情况,当需要同时保护数据不被中断和其他CPU访问时,必须使用spin_lock_irqsave() / spin_unlock_irqrestore(),它们会在加锁时禁用本地中断。

使用场景

在哪些具体的业务或技术场景下,它是首选解决方案?

自旋锁是为临界区极短、严禁睡眠的场景量身定做的。

  • 中断处理程序:这是自旋锁最经典、最正当的使用场景。中断处理程序必须快速完成,且不能睡眠,因此只能使用自旋锁来保护其共享数据。
  • 调度器和定时器:内核调度器和定时器子系统的核心数据结构(如运行队列)可能会在中断上下文和进程上下文中被同时访问,且操作必须极快,因此使用自旋锁。
  • 工作队列和Tasklet:这些下半部机制在执行时也处于不能睡眠的原子上下文,因此它们内部的同步也依赖自旋锁。

是否有不推荐使用该技术的场景?为什么?

  • 临界区较长:如果持有锁的时间可能超过几个微秒,就应该使用mutex,以避免其他CPU长时间空转。
  • 临界区内可能睡眠:绝对禁止。例如,临界区内需要调用kmalloc(GFP_KERNEL)copy_from_user()msleep()或任何可能导致I/O阻塞的操作,必须使用mutex
  • 高竞争且临界区不极短:如果一个锁被频繁地竞争,并且持有时间不是瞬时的,那么让等待者睡眠(使用mutex)可能比让多个CPU同时自旋对系统整体吞吐量的影响更小。

对比分析

请将其 与 其他相似技术 进行详细对比。

特性 自旋锁 (Spinlock) 互斥锁 (Mutex)
基本行为 自旋 (忙等待,占用CPU)。 睡眠 (放弃CPU)。
使用上下文 任何上下文 (硬中断、软中断、进程)。 仅进程上下文 (可以睡眠)。
临界区约束 绝对不能睡眠,必须极短 可以睡眠,可以较长。
性能(无竞争) 非常高 (通常是单个原子指令)。 非常高 (通过快速路径原子操作)。
性能(有竞争) (浪费CPU,可能导致总线争用)。 中等 (有上下文切换的开销,但CPU可用于其他任务)。
中断安全 需要手动通过_irqsave版本来禁用本地中断。 不能在中断上下文中使用。
死锁风险 较高 (与中断、抢占相关的死锁风险)。 较低 (调试机制更完善)。
典型场景 中断处理、调度器、高性能数据路径。 设备驱动中的ioctlread/write,文件系统。

include/linux/spinlock_api_up.h

__LOCK

1
2
3
4
5
6
7
8
9
10
/*
*在 UP-nondebug 的情况下,没有真正的锁定,
* 所以我们唯一需要做的就是保持清晰的抢占计数和 irq 标志,
* 抑制未使用的锁变量的编译器警告,并添加适当的检查器注释:
*/
#define ___LOCK(lock) \
do { __acquire(lock); (void)(lock); } while (0)

#define __LOCK(lock) \
do { preempt_disable(); ___LOCK(lock); } while (0)

__UNLOCK

1
2
3
4
5
#define ___UNLOCK(lock) \
do { __release(lock); (void)(lock); } while (0)

#define __UNLOCK(lock) \
do { preempt_enable(); ___UNLOCK(lock); } while (0)

arch/arm/include/asm/spinlock_types.h

1
2
3
4
5
typedef struct {
u32 lock;
} arch_rwlock_t;

#define __ARCH_RW_LOCK_UNLOCKED { 0 }

include/linux/spinlock.h

DEFINE_LOCK_GUARD_1 raw_spinlock_irqsave

1
2
3
4
5
6
7
8
DEFINE_LOCK_GUARD_1(raw_spinlock_irqsave, raw_spinlock_t,
raw_spin_lock_irqsave(_T->lock, _T->flags),
raw_spin_unlock_irqrestore(_T->lock, _T->flags),
unsigned long flags)

DEFINE_LOCK_GUARD_1_COND(raw_spinlock_irqsave, _try,
raw_spin_trylock_irqsave(_T->lock, _T->flags))

bit_spinlock 位自旋锁

bit_spin_lock 是一个基于位的自旋锁实现,用于在多核或多线程环境中实现低开销的同步机制。它通过操作特定内存地址中的单个位来实现锁的获取和释放。以下是对代码的详细解释:

bit_spinlockspinlock 的异同点

相同点

  1. 基本原理

    • 两者都属于自旋锁,通过忙等待的方式实现锁的获取和释放。
    • 都用于保护共享资源,防止多个线程或 CPU 同时访问导致数据竞争。
  2. 无阻塞特性

    • 两者都不会引起线程的阻塞,而是通过循环检查锁的状态来等待锁的释放。
  3. 适用场景

    • 都适用于短时间的临界区保护,避免因上下文切换导致的性能开销。
    • 都可以在中断上下文中使用。
  4. 多核支持

    • 两者都支持多核系统中的并发同步。

不同点

特性 bit_spinlock spinlock
锁的粒度 基于单个位的锁,每个位表示一个独立的锁。 基于整个变量的锁,通常是一个整数或布尔值。
内存占用 占用更少的内存,可以在同一变量的不同位上实现多个锁。 每个锁需要一个独立的变量。
实现复杂度 需要操作特定位,逻辑稍微复杂。 实现相对简单,直接操作整个变量。
性能 在锁竞争较少的情况下性能更高,适合细粒度锁。 在锁竞争较多的情况下性能更稳定。
使用场景 适用于需要多个小锁的场景,例如位图操作。 适用于保护单一资源或较大临界区的场景。
调试支持 调试支持较少,难以跟踪锁的状态。 提供丰富的调试支持(如死锁检测)。

各自的使用场景

bit_spinlock 的使用场景

  1. 位图操作

    • 当需要对位图中的单个位进行加锁时,bit_spinlock 是理想选择。例如,管理内存页的分配状态。
  2. 细粒度锁

    • 需要多个小锁来保护不同的资源时,bit_spinlock 可以通过操作同一变量的不同位来实现,节省内存。
  3. 高性能场景

    • 在锁竞争较少的情况下,bit_spinlock 的性能更高,适合性能敏感的场景。
  4. 嵌入式系统

    • 在内存资源有限的嵌入式系统中,bit_spinlock 的低内存占用是一个优势。

spinlock 的使用场景

  1. 单一资源保护

    • 当需要保护一个共享资源时,spinlock 是更简单和直接的选择。
  2. 中断上下文

    • 在中断处理程序中,spinlock 可以与 spin_lock_irqsave 等接口结合使用,确保中断安全。
  3. 多核系统中的同步

    • 在多核系统中,spinlock 可以用于同步多个线程或 CPU 对共享资源的访问。
  4. 调试和开发

    • 如果需要丰富的调试支持(如死锁检测),spinlock 是更好的选择。

总结

  • bit_spinlock 更适合细粒度锁和内存占用敏感的场景,例如位图操作和嵌入式系统。
  • spinlock 更适合保护单一资源或需要调试支持的场景,例如中断上下文和多核同步。
  • 在实际开发中,应根据具体需求选择合适的锁机制,以在性能和复杂性之间取得平衡。