Linux 内核运行环境天然具有高度并发特征。多核 SMP 系统、内核抢占、硬件中断、软中断、工作队列、内核线程以及用户态系统调用路径,都可能在相近时间访问同一份共享数据。若缺少合适的同步机制,就会出现竞态条件、数据不一致、引用计数错误、链表损坏、设备状态异常等问题。
内核互斥技术的核心目标,是在并发执行环境中保护临界区,使共享数据在多个执行路径之间保持一致。所谓临界区,是指访问共享资源的一段代码区域。进入临界区前需要获得某种排他能力,离开临界区后释放该能力,从而避免多个执行路径同时修改共享状态。
Linux 内核提供了多层次的互斥与同步机制:
不同机制的适用场景、限制条件和错误模式差异很大。选错同步原语,轻则引发性能下降,重则导致死锁、内核挂死、数据损坏。下面从并发模型出发,系统梳理 Linux 内核中的互斥技术。
内核中的共享数据可能被多种执行路径访问:
+------------------+ | 共享数据结构 | +------------------+ ^ ^ ^ | | | 进程上下文 中断上下文 软中断/工作队列常见并发来源包括:
一个典型的竞态例子是计数器自增:
counter++;在高级语言中看似是一行代码,在机器指令层面通常包含:
读取 counter加 1写回 counter如果两个 CPU 同时执行上述过程,就可能出现更新丢失:
CPU0: 读取 counter = 10CPU1: 读取 counter = 10CPU0: 计算 10 + 1 = 11CPU1: 计算 10 + 1 = 11CPU0: 写回 counter = 11CPU1: 写回 counter = 11最终结果是只增加了一次,而不是两次。此类问题无法通过简单阅读代码发现,往往只在高并发、特定负载或特定硬件平台上偶发。
因此,内核互斥机制需要解决三个关键问题:
原子操作是最轻量的并发保护手段。它通常用于单个整型变量、计数器、标志位或引用计数。原子操作保证某次读-改-写操作不会被其它 CPU 打断,也不会出现中间状态。
Linux 内核提供 atomic_t、atomic64_t、atomic_long_t 等类型,并提供一组体系结构相关的原子操作接口。
基本头文件:
#include <linux/atomic.h>常见定义方式:
static atomic_t counter = ATOMIC_INIT(0);常见操作:
atomic_inc(&counter);atomic_dec(&counter);atomic_add(4, &counter);atomic_sub(2, &counter);int value = atomic_read(&counter);atomic_set(&counter, 0);int old = atomic_cmpxchg(&counter, 0, 1);典型引用计数示例:
static atomic_t refcnt = ATOMIC_INIT(0);void obj_get(struct obj *o){atomic_inc(&o->refcnt);}void obj_put(struct obj *o){if (atomic_dec_and_test(&o->refcnt)) release_object(o);}atomic_dec_and_test() 会将计数减一,并判断结果是否为 0。该操作整体是原子的,适合引用计数释放场景。
不同体系结构实现原子操作的方式不同:
LOCK 前缀的指令,例如 LOCK INC、LOCK ADD、LOCK CMPXCHG。内核将体系结构差异封装在统一 API 中,驱动和子系统代码通常只需使用标准接口。
需要特别注意的是,atomic_read() 与 atomic_set() 并不等同于“读-改-写”原子操作。它们通常对应 READ_ONCE() 与 WRITE_ONCE() 语义,主要保证单次访问不被撕裂,不构成完整的原子更新。
例如以下代码仍然存在竞态:
atomic_set(&counter, atomic_read(&counter) + 1);因为读取、加一、写回三步不是整体原子的。正确写法应为:
atomic_inc(&counter);或者:
atomic_add(1, &counter);因此,原子操作适合保护单个变量的单次修改,不适合保护由多个字段组成的复杂数据结构。
除了整型原子变量,内核还提供原子位操作接口,常用于标志位、位图和简单状态机。
头文件:
#include <linux/bitops.h>常见接口:
unsigned long flags;set_bit(0, &flags);clear_bit(0, &flags);change_bit(0, &flags);if (test_bit(0, &flags)) {/* bit 0 is set */}if (test_and_set_bit(0, &flags)) {/* bit 0 was already set */}test_and_set_bit() 会原子地测试某一位并设置为 1,返回旧值。该操作可用于实现简单的一次性锁标志:
if (test_and_set_bit(DEVICE_BUSY, &dev->flags))return -EBUSY;/* 进入独占处理流程 */clear_bit(DEVICE_BUSY, &dev->flags);原子位操作也有非原子版本,例如 __set_bit()、__clear_bit()、__test_and_set_bit()。这些接口性能更高,但只能在调用者已经保证不会并发访问时使用。
原子操作保证变量本身的原子性,但不一定自动提供完整的内存屏障。若原子变量还承担发布、通知、状态切换等职责,需要考虑内存顺序问题。
例如:
data = 100;atomic_set(&ready, 1);如果另一个 CPU 通过 atomic_read(&ready) 判断是否读取 data,在某些体系结构上可能看到 ready == 1,但尚未看到 data == 100。此时需要配合内存屏障,或使用更明确的同步机制。
常见做法包括:
smp_wmb();atomic_set(&ready, 1);以及读取侧:
if (atomic_read(&ready)) { smp_rmb(); value = data;}在复杂发布/订阅场景中,通常更推荐使用 RCU、互斥锁、completion、wait queue 等更高层次同步机制,而不是手工组合原子变量和内存屏障。
适合:
不适合:
原子操作的优势是开销低、使用简单;局限是表达能力有限。一旦临界区涉及多个共享字段,通常就需要锁。
自旋锁是内核中最常用的互斥机制之一。若一个执行路径持有自旋锁,其它试图获取该锁的 CPU 会原地自旋等待,直到锁被释放。
自旋锁适合保护非常短的临界区。由于等待者不会睡眠,而是持续占用 CPU,因此临界区必须尽快执行完毕。
基本类型:
#include <linux/spinlock.h>DEFINE_SPINLOCK(my_lock);动态初始化:
spinlock_t my_lock;spin_lock_init(&my_lock);加锁与解锁:
spin_lock(&my_lock);/* 临界区 */spin_unlock(&my_lock);在可抢占内核中,spin_lock() 会禁止本地 CPU 抢占。这样可以防止当前持有锁的执行路径被调度走,从而延长锁持有时间。
在 SMP 系统中,自旋锁还提供跨 CPU 的互斥能力。在单核非抢占内核中,自旋锁可能被编译为非常轻量的操作,甚至接近空操作,但代码仍必须遵守自旋锁语义。
持有自旋锁期间,不能执行任何可能睡眠的操作。常见禁止行为包括:
kmalloc(GFP_KERNEL)。copy_from_user() 或 copy_to_user()。schedule()。down() 获取信号量。might_sleep() 语义的函数。如果临界区需要分配内存,通常应使用 GFP_ATOMIC:
ptr = kmalloc(size, GFP_ATOMIC);GFP_ATOMIC 分配不会睡眠,但成功率低于 GFP_KERNEL,因此应谨慎使用。
自旋锁经常需要与中断处理共享数据。若进程上下文持有自旋锁时,本 CPU 发生中断,而中断处理函数又尝试获取同一把锁,就会形成死锁:
进程上下文: spin_lock(&lock); ---- 被中断 ----中断上下文: spin_lock(&lock); /* 永远等待 */由于中断发生在同一 CPU,而进程上下文无法继续执行释放锁,因此形成死锁。
解决方式是获取锁时屏蔽本地中断:
unsigned long flags;spin_lock_irqsave(&my_lock, flags);/* 临界区 */spin_unlock_irqrestore(&my_lock, flags);spin_lock_irqsave() 会保存当前中断状态并关闭本地中断;spin_unlock_irqrestore() 恢复之前的中断状态。
如果明确知道当前中断一定开启,并且只需要关闭中断而不需要保存状态,可以使用:
spin_lock_irq(&my_lock);spin_unlock_irq(&my_lock);但 spin_lock_irqsave() 更通用,也更适合中断状态不确定的路径。
如果临界区只与软中断共享数据,可以使用:
spin_lock_bh(&my_lock);spin_unlock_bh(&my_lock);spin_lock_bh() 会禁止本地软中断执行。
自旋锁死锁常见形式包括:
spin_lock(&lock);spin_lock(&lock); /* 死锁 */Linux 内核中的普通自旋锁不可递归,同一执行路径重复获取同一把自旋锁会死锁。
CPU0: lock A lock BCPU1: lock B lock A若 CPU0 已持有 A 并等待 B,CPU1 已持有 B 并等待 A,则双方永远无法继续执行。
进程上下文持有 lock本 CPU 中断触发中断处理函数尝试获取 lock进程上下文无法继续释放 lock此类问题在中断驱动设备驱动中尤其常见。
内核还提供读写自旋锁:
DEFINE_RWLOCK(my_rwlock);read_lock(&my_rwlock);/* 读临界区 */read_unlock(&my_rwlock);write_lock(&my_rwlock);/* 写临界区 */write_unlock(&my_rwlock);读写自旋锁允许多个读者并发访问,但写者独占。适合读多写少且临界区很短的场景。
不过,读写锁也可能带来写者饥饿、缓存行竞争等问题。若写操作频繁,或者临界区较长,读写锁未必优于普通自旋锁。
适合:
不适合:
自旋锁的核心原则是:短、快、不睡眠。
Linux 内核中的 struct mutex 是一种可睡眠互斥锁。与自旋锁不同,若 mutex 已被其它任务持有,当前任务通常会进入睡眠状态,等待锁释放后被唤醒。
基本头文件:
#include <linux/mutex.h>定义方式:
DEFINE_MUTEX(my_mutex);动态初始化:
struct mutex my_mutex;mutex_init(&my_mutex);加锁与解锁:
mutex_lock(&my_mutex);/* 可睡眠临界区 */mutex_unlock(&my_mutex);可中断版本:
int ret;ret = mutex_lock_interruptible(&my_mutex);if (ret)return ret;/* 临界区 */mutex_unlock(&my_mutex);mutex_lock_interruptible() 在等待锁时可被信号打断,返回非 0 值。系统调用路径中常用该接口,避免用户进程长时间处于不可中断状态。
mutex 通常包含快速路径和慢速路径:
mutex_lock() | +-- 快速路径:尝试原子获取锁 | +-- 获取失败 | +-- 乐观自旋:若锁持有者正在运行,可短暂自旋 | +-- 仍无法获取:加入等待队列并睡眠快速路径通常通过原子操作尝试获取锁。若失败,内核可能先进行乐观自旋,观察锁持有者是否正在运行。若持有者很快释放锁,等待者可以避免上下文切换。若短时间内无法获取,等待者会睡眠,从而避免浪费 CPU。
这种设计使 mutex 在长临界区场景下比自旋锁更合理。
mutex 只能在进程上下文中使用。以下场景不能获取 mutex:
错误示例:
spin_lock(&lock);mutex_lock(&mutex); /* 错误:持有自旋锁时不能获取 mutex */mutex_unlock(&mutex);spin_unlock(&lock);mutex 也不支持递归加锁:
mutex_lock(&mutex);mutex_lock(&mutex); /* 死锁 */此外,mutex 通常要求由获取锁的任务释放:
mutex_lock(&mutex);/* 临界区 */mutex_unlock(&mutex);若由其它任务释放,可能触发调试告警或导致锁状态异常。
由于 mutex 允许睡眠,临界区内可以执行更多操作:
mutex_lock(&dev->mutex);ptr = kmalloc(size, GFP_KERNEL);if (!ptr) { mutex_unlock(&dev->mutex);return -ENOMEM;}ret = copy_from_user(ptr, user_ptr, size);if (ret) { kfree(ptr); mutex_unlock(&dev->mutex);return -EFAULT;}list_add_tail(&ptr->node, &dev->list);mutex_unlock(&dev->mutex);这类代码不能放在自旋锁临界区中,因为 GFP_KERNEL 分配和用户态拷贝都可能睡眠。
mutex 死锁与自旋锁死锁在逻辑上类似,但由于 mutex 会睡眠,死锁往往表现为任务长时间阻塞,而不是 CPU 自旋占用。
常见场景:
mutex_lock(&m);do_something();mutex_lock(&m); /* 死锁 */若 do_something() 内部又获取同一把 mutex,就会死锁。
线程 A: lock mutex1 lock mutex2线程 B: lock mutex2 lock mutex1例如:
线程 A 持有 mutex1,等待线程 B 完成某项工作线程 B 需要获取 mutex1 才能完成工作这类等待关系会形成循环依赖。
临界区是否可能睡眠? | +-- 是 --> 使用 mutex | +-- 否 | +-- 是否在中断/软中断上下文? | | | +-- 是 --> 使用自旋锁 | +-- 临界区是否足够短? | +-- 是 --> 使用自旋锁 | +-- 否 --> 重新设计或使用 mutex一般原则:
Linux 内核中的信号量是一种计数型同步原语。它维护一个计数器,down() 操作减少计数,up() 操作增加计数。当计数为 0 时,down() 会睡眠等待,直到其它执行路径调用 up() 增加计数。
头文件:
#include <linux/semaphore.h>初始化:
struct semaphore sem;sema_init(&sem, 1);计数为 1 的信号量可作为二值信号量使用,功能类似互斥锁。但现代内核代码中,简单互斥更推荐使用 mutex。
基本操作:
down(&sem);/* 临界区或资源访问 */up(&sem);可被信号打断的版本:
int ret;ret = down_interruptible(&sem);if (ret)return ret;/* 临界区或资源访问 */up(&sem);down_interruptible() 在等待过程中若收到信号,会返回非 0 值。调用者必须处理该错误,不能继续进入临界区。
信号量与 mutex 的重要区别在于计数。
若初始化计数为 N:
sema_init(&sem, N);则最多允许 N 个执行路径同时进入临界区或访问资源。
例如,某硬件资源支持 4 个并发访问者:
sema_init(&sem, 4);down(&sem);use_hardware_resource();up(&sem);这种计数语义是 mutex 不直接提供的。
down() 可能睡眠,因此不能在中断上下文、软中断上下文或持有自旋锁时调用。
up() 不会睡眠,可以在中断上下文中调用。例如:
static irqreturn_t dev_irq_handler(int irq, void *data){struct my_device *dev = data;/* 中断中释放资源计数 */ up(&dev->sem);return IRQ_HANDLED;}这种模式可用于“中断释放资源、进程上下文等待资源”的同步场景。
+--------------+-----------------------+-----------------------+| 特性 | mutex | semaphore |+--------------+-----------------------+-----------------------+| 是否可睡眠 | 是 | 是 || 是否计数 | 否,通常为二值互斥 | 是 || 是否跟踪 owner | 是 | 否 || 是否适合简单互斥 | 推荐 | 不优先推荐 || 是否可在中断中 down | 否 | 否 || 是否可在中断中 up | 不适用 | 可以 || 调试支持 | 较强 | 相对较弱 |+--------------+-----------------------+-----------------------+对于简单互斥,mutex 更合适。信号量更适合真正需要计数语义的场景。
适合:
up()、进程上下文中 down() 的同步。不适合:
经典死锁通常需要同时满足四个条件:
内核中的锁机制基本满足前三个条件,因此防止死锁的关键通常是破坏循环等待。
ABBA 死锁是最典型的循环等待:
CPU0: lock A lock BCPU1: lock B lock A若执行顺序演化为:
CPU0: lock ACPU1: lock BCPU0: try lock B --> 等待 CPU1CPU1: try lock A --> 等待 CPU0两个 CPU 都无法继续执行。
解决方法包括:
trylock,失败后释放已有锁并重试。中断上下文死锁通常不是 ABBA,而是同一 CPU 上的重入等待:
进程上下文持有 lock硬件中断进入同一 CPU中断处理函数尝试获取 lock进程上下文无法继续执行解决方式:
spin_lock_irqsave()。spin_lock() 或 spin_lock_irqsave(),视具体路径而定。错误地在自旋锁临界区内获取 mutex,会造成严重问题:
spin_lock(&lock);mutex_lock(&mutex);mutex_unlock(&mutex);spin_unlock(&lock);问题在于:
mutex_lock() 可能睡眠。正确做法是调整锁层次:
mutex_lock(&mutex);spin_lock(&lock);/* 短临界区 */spin_unlock(&lock);mutex_unlock(&mutex);或者重新设计数据结构,避免同时持有两类锁。
严格来说,这类问题不一定是死锁,但会导致严重内核告警或系统挂起。
典型错误:
spin_lock(&lock);ptr = kmalloc(size, GFP_KERNEL); /* 错误 */spin_unlock(&lock);GFP_KERNEL 分配可能睡眠,而自旋锁临界区不允许睡眠。正确写法:
spin_lock(&lock);ptr = kmalloc(size, GFP_ATOMIC);spin_unlock(&lock);或者:
ptr = kmalloc(size, GFP_KERNEL);if (!ptr)return -ENOMEM;spin_lock(&lock);/* 使用 ptr */spin_unlock(&lock);lockdep 是 Linux 内核中的锁依赖检测工具。它不会等到系统真正挂死才报告问题,而是在运行时分析锁的获取顺序,提前发现潜在循环依赖。
lockdep 主要检测:
might_sleep() 相关的原子上下文睡眠风险,通常配合其它调试选项使用。lockdep 的核心思想是维护锁类和依赖图。每次获取锁时,lockdep 会记录当前已持有的锁与新获取锁之间的依赖关系。若发现依赖图中出现环,就会输出告警。
常见配置项包括:
CONFIG_LOCKDEP=yCONFIG_PROVE_LOCKING=yCONFIG_DEBUG_LOCK_ALLOC=yCONFIG_DEBUG_MUTEXES=yCONFIG_DEBUG_ATOMIC_SLEEP=yCONFIG_LOCK_STAT=y不同内核版本配置项名称可能略有差异,但核心思路一致。
其中:
CONFIG_LOCKDEP:启用锁依赖基础设施。CONFIG_PROVE_LOCKING:启用锁证明和依赖检查。CONFIG_DEBUG_LOCK_ALLOC:增强锁分配与初始化检查。CONFIG_DEBUG_MUTEXES:增强 mutex 调试。CONFIG_DEBUG_ATOMIC_SLEEP:检测原子上下文中调用可睡眠函数。CONFIG_LOCK_STAT:提供锁竞争统计信息。lockdep 告警通常出现在内核日志中,形式类似:
WARNING: possible circular locking dependency detectedCPU0: lock A is already held, trying to lock BCPU1: lock B is already held, trying to lock Aexisting dependency chain:A -> BB -> A实际输出会更复杂,通常包含:
解读 lockdep 报告时,需要重点关注:
lockdep 通过锁类识别锁。对于静态定义并正常初始化的锁,lockdep 通常可以自动建立合理锁类。
但对于动态分配的锁,例如每个设备对象都包含一把锁,若所有锁使用相同初始化方式,lockdep 可能将它们视为同一类锁。这可能导致误报,也可能掩盖真实问题。
因此,动态锁应设置独立的 lock class key,使 lockdep 能够区分不同对象中的锁。
常见思路:
为每类动态锁定义唯一 lock_class_key初始化锁后设置 lock class为锁命名,便于日志分析例如,每个网络设备、块设备、字符设备都可能有自己的锁。若所有设备锁共享一个锁类,lockdep 可能认为设备 A 的锁和设备 B 的锁是同一把锁,从而产生虚假依赖。
内核代码中常用 might_sleep() 标注可能睡眠的函数。若该函数在原子上下文中被调用,调试内核可能输出告警。
示例:
void my_sleepable_function(void){ might_sleep(); mutex_lock(&my_mutex);/* ... */ mutex_unlock(&my_mutex);}lockdep_assert_held() 可用于断言当前上下文已持有某把锁:
void update_state(struct device *dev){ lockdep_assert_held(&dev->lock); dev->state = NEW_STATE;}这类注解不会改变正常运行逻辑,但能在调试阶段提前暴露锁使用错误。
lockdep 主要检测潜在死锁,而不是必须等到死锁实际发生。
真正发生死锁时,系统可能表现为:
常见辅助检测机制:
hung task detector 检测任务长时间处于不可中断睡眠状态,默认阈值通常为 120 秒。mutex 死锁、资源等待死锁可能触发该告警。
soft lockup detector 检测 CPU 长时间在内态执行而未让出调度。自旋锁死循环、长时间持锁自旋可能触发该告警。
hard lockup detector / NMI watchdog 检测 CPU 长时间不响应中断,通常意味着严重挂死。
ftrace、bpftrace、perf 可用于追踪锁获取路径、函数调用关系和性能热点。
lock_stat 可用于统计锁竞争、持有时间、等待时间等信息,适合分析性能问题。
面对疑似死锁问题,建议按照以下步骤排查:
保存以下信息:
dmesg 完整日志。/proc/lockdep 相关信息。/proc/<pid>/stack,若任务仍可观察。判断问题涉及:
不同锁类型的错误模式不同。
若 lockdep 输出循环依赖,需要整理依赖链:
路径 1: lock A lock B路径 2: lock B lock A然后统一加锁顺序:
所有路径均先 lock A,再 lock B若无法统一顺序,需要考虑 trylock 或重新设计数据结构。
重点确认:
spin_lock_irqsave()。若日志中出现类似:
BUG: sleeping function called from invalid context需要检查是否在以下路径中调用了可睡眠函数:
死锁问题通常与并发时序有关。可通过以下方式提高复现率:
修复死锁后,应进行多轮验证:
固定锁获取顺序。 若多个锁经常同时出现,应定义全局锁层次。
减少锁持有时间。 临界区越短,竞争和死锁概率越低。
避免持锁等待。 尽量不要在持有锁时等待另一个锁、事件或资源。
谨慎使用嵌套锁。 嵌套越深,依赖越复杂。
区分中断上下文与进程上下文。 共享数据时,必须明确所有访问路径。
优先使用成熟同步设施。 例如 wait queue、completion、workqueue、RCU,而不是手工复杂状态机。
使用 lockdep 注解。 对关键锁使用断言和命名,提高可维护性。
不要为了消除告警而掩盖问题。 lockdep 告警通常说明真实设计缺陷,不应简单忽略。
+------------+----------+----------+------------+--------------+| 机制 | 是否睡眠 | 典型上下文 | 保护对象 | 典型场景 |+------------+----------+----------+------------+--------------+| 原子操作 | 否 | 任意 | 单个变量 | 计数、标志位 || 自旋锁 | 否 | 任意 | 短临界区 | 中断、快路径 || 互斥锁 | 是 | 进程上下文 | 长临界区 | 设备状态、文件操作 || 信号量 | 是 | 进程上下文 | 计数资源 | 资源数量控制 |+------------+----------+----------+------------+--------------+更细化的对比:
+----------------+------------------+------------------+------------------+| 对比项 | 自旋锁 | mutex | 信号量 |+----------------+------------------+------------------+------------------+| 等待方式 | 忙等待 | 睡眠等待 | 睡眠等待 || 是否可递归 | 否 | 否 | 否 || 是否允许中断中获取 | 是 | 否 | 否 || 是否允许中断中释放 | 是 | 否 | 可以 up || 是否支持计数 | 否 | 否 | 是 || 是否跟踪持有者 | 否 | 是 | 否 || 是否适合长临界区 | 否 | 是 | 视场景而定 || 是否推荐用于简单互斥 | 视上下文 | 推荐 | 不优先推荐 |+----------------+------------------+------------------+------------------+是否只修改单个变量或标志位? | +-- 是 --> 考虑原子操作或原子位操作 | +-- 否 | 当前是否在中断/软中断上下文? | +-- 是 --> 使用自旋锁 | +-- 否 | 临界区是否可能睡眠? | +-- 是 --> 使用 mutex | +-- 否 | 临界区是否足够短? | +-- 是 --> 使用自旋锁 | +-- 否 --> 使用 mutex 或重新设计若需要控制并发访问数量,则考虑信号量:
资源最多允许 N 个并发访问者? | +-- 是 --> 使用计数信号量 | +-- 否 --> 使用 mutex 或自旋锁若只是等待某个事件完成,通常 completion 比信号量更直观:
等待一个一次性事件完成? | +-- 是 --> 考虑 completion | +-- 否 --> 根据资源类型选择锁或等待队列错误:
spin_lock(&lock);p = kmalloc(size, GFP_KERNEL);spin_unlock(&lock);修正:
p = kmalloc(size, GFP_KERNEL);if (!p)return -ENOMEM;spin_lock(&lock);/* 使用 p */spin_unlock(&lock);或者:
spin_lock(&lock);p = kmalloc(size, GFP_ATOMIC);spin_unlock(&lock);错误:
static irqreturn_t handler(int irq, void *data){ mutex_lock(&dev->mutex);/* ... */ mutex_unlock(&dev->mutex);return IRQ_HANDLED;}修正方式:
static irqreturn_t handler(int irq, void *data){ spin_lock(&dev->lock);/* 短临界区 */ spin_unlock(&dev->lock); schedule_work(&dev->work);return IRQ_HANDLED;}复杂处理放到工作队列中执行,工作队列运行在进程上下文,可以获取 mutex。
错误:
atomic_set(&counter, atomic_read(&counter) + 1);修正:
atomic_inc(&counter);错误:
atomic_inc(&list_count);list_add_tail(&node->list, &head->list);原子计数无法保护链表结构。链表插入需要锁保护:
spin_lock(&head->lock);list_add_tail(&node->list, &head->list);head->count++;spin_unlock(&head->lock);或者使用 RCU 等适合链表的同步机制。
错误:
down_interruptible(&sem);use_resource();up(&sem);修正:
int ret;ret = down_interruptible(&sem);if (ret)return ret;use_resource();up(&sem);错误:
mutex_lock(&dev->mutex);do_long_operation();mutex_unlock(&dev->mutex);修正思路:
互斥机制不仅影响正确性,也影响性能。锁粒度过大,会导致大量执行路径串行化;锁粒度过小,会增加锁数量和依赖复杂度。
常见优化方向:
只保护真正共享的数据:
prepare_data(local);spin_lock(&lock);commit_data(local, shared);spin_unlock(&lock);不要在临界区内做无关计算。
若数据只在本 CPU 内频繁更新,最后才汇总,可以使用 per-CPU 变量减少全局锁竞争。
RCU 适合读多写少场景。读者通常无需传统锁,写者通过宽限期保证读者安全退出。
典型适用对象:
但 RCU 使用复杂度较高,需要理解宽限期、回调、内存释放时机等概念。
若多个 CPU 高频竞争同一把锁,即使临界区很短,也可能出现缓存行 bouncing 和性能下降。
可考虑:
Linux 内核互斥技术并不是单一工具,而是一套面向不同并发场景的机制集合。原子操作解决单变量原子修改问题,自旋锁解决短临界区不可睡眠互斥问题,mutex 解决进程上下文中可睡眠临界区问题,信号量提供计数型资源控制能力,lockdep 则帮助提前发现锁依赖错误和潜在死锁。
选型的核心在于明确执行上下文、临界区长度、是否允许睡眠以及共享数据的访问路径。原子操作最轻量,但不能保护复杂结构;自旋锁性能直接,但要求临界区短且不能睡眠;mutex 使用灵活,但只能用于进程上下文;信号量适合计数资源,但不应简单替代 mutex;死锁检测工具能显著提升调试效率,但不能替代正确的锁设计。
实际内核开发中,最可靠的方式不是依赖经验猜测并发问题,而是建立清晰的锁模型:明确每把锁保护哪些数据,明确每把锁的获取顺序,明确哪些路径可能进入中断或软中断,明确哪些函数可能睡眠。在此基础上配合 lockdep、debug mutexes、debug atomic sleep 等调试设施,可以在开发阶段尽早发现竞态和死锁隐患。
互斥机制的最终目标不是让代码“看起来线程安全”,而是在所有可能并发路径下保持数据结构一致、设备状态稳定、系统行为可预期。正确理解和选择 Linux 内核互斥技术,是编写高质量驱动、文件系统和内核子系统的基础能力。