本节摘要:以一次真实的双进程对撞故障为线索,完整走一遍竞态的取证、定位与修复,再系统整理原子操作、自旋锁、互斥锁、引用计数四类同步工具的适用边界——并发问题不可稳定复现,但完全可以推理。
故障现象先摆出来:一块网关设备的驱动里有个"闪烁配置"接口,两个管理进程先后设置闪烁周期——进程甲设为 200 毫秒,进程乙设为 1000 毫秒。正常顺序下灯应该按最后的设置闪。现场抓到的却是:灯按 200 毫秒闪,日志里却记录着"当前配置 1000 毫秒"。灯的状态与账本对不上,且重试二十次只复现三次。
先看嫌疑代码——配置接口里"读改写"三连:
static int blink_config; static unsigned long blink_jiffies; static long led_ioctl_set(int period_ms) { int half = period_ms / 2; /* 三步走:改计数、算周期、写日志 */ blink_config = period_ms; blink_jiffies = msecs_to_jiffies(half); pr_info("blink: 当前配置 %d 毫秒\n", blink_config); return 0; }
单线程看毫无问题。但两个进程同时进入这段代码时,指令交错出这样的时间线:
进程甲(设 200) 进程乙(设 1000) blink_config = 200; blink_config = 1000; ← 覆盖 blink_jiffies = 计算1000的值; blink_jiffies = 计算200的值; ← 覆盖回去 打印 blink_config → 1000 ← 账本记乙 定时器按 blink_jiffies 运行 ← 实际用 200 的值,灯按甲闪
对撞的机理水落石出:blink_config 与 blink_jiffies 是两个独立变量,两进程的写入交错执行,最终组合成"200 的周期值+1000 的账本值"这个从未被任何人设置过的状态。这类"读改写序列被交错"就是竞态的定义,它与代码对错无关,只与"谁可能同时进来"有关。

竞态的推理不需要复现,只需要回答三个问题。谁会进来?——本例是任意多个进程并发出入控制接口;中断处理函数也会碰这些变量(定时器按周期值启动),它是不请自来的第三方。碰什么?——两个共享变量的读改写序列。交错会发生什么?——上面的幽灵状态推演。三个问题答完,责任段已经锁定:共享数据的修改序列没有互斥保护。
最小修复是给整段读改写序列加锁,让"改两个变量+记账"成为不可分割的整体:
static DEFINE_MUTEX(blink_lock); static long led_ioctl_set(int period_ms) { int half = period_ms / 2; mutex_lock(&blink_lock); /* 进围栏:同刻只容一人 */ blink_config = period_ms; blink_jiffies = msecs_to_jiffies(half); pr_info("blink: 当前配置 %d 毫秒\n", blink_config); mutex_unlock(&blink_lock); return 0; }
锁内序列原子化后,两个进程的设置无论怎么交错,结果都严格等于"某个完整的 200 或某个完整的 1000"——账实一致。修复上线后重压一星期,对撞再未出现。
⚠️ 常见坑:只锁一个变量、或把"读改写"拆成"锁着读、放开写"。竞态保护的对象是操作序列而非单个变量——判断标准是"这组操作会不会被交错出非法中间态"。
修复用对了工具,但工具箱里还有三件常用家伙,边界要划清。
原子操作适合"单个变量的计数与位操作"——自增、置位、比较交换,一条指令或一小段不可打断序列完成。本例的事件计数(3.2 节)用它正合适;但"两个变量联动"它管不了,所以对撞修复没选它。
自旋锁适合"极短的临界区+可能在中断上下文访问的数据"。等待者原地自旋不睡眠,因此中断上下文唯一可选;代价是白烧处理器,临界区必须短到纳秒级。同一数据的进程与中断两侧都要访问时,进程侧必须用关中断版本的自旋锁,否则锁会被自己的中断处理打死。
互斥锁适合"较长的临界区+纯进程上下文"。等待者睡眠让路,临界区可以安心做复杂操作;代价是上下文切换开销,且中断上下文绝对禁用。本例的控制接口是进程上下文、临界区含日志打印,选它正确。
引用计数适合"对象生命周期管理"——设备被打开次数、缓冲被引用数,归零即释放。内核结构普遍内嵌引用计数接口,"谁使用谁加、谁用完谁减"的纪律配平了生命周期。
| 手段 | 能否睡眠 | 典型场景 | 本例适用性 |
|---|---|---|---|
| 原子操作 | 否 | 单变量计数与位操作 | 单变量可以,双变量联动不行 |
| 自旋锁 | 否 | 短临界区、中断上下文可用 | 可用但临界区含打印偏重 |
| 互斥锁 | 是 | 长临界区、进程上下文 | 适配:正确选择 |
| 引用计数 | 配合锁 | 对象生命周期 | 不针对此问题 |
多锁场景会引入新故障形态。两台设备各持一把锁,又都想拿对方的锁——两条等待链首尾相接成环,谁也走不下去,这就是死锁。预防靠全局锁序约定:给锁编号,任何代码路径必须按编号升序拿锁。内核的锁依赖检查器(第 7.2 节)运行期自动学习锁序,一旦发现潜在成环路径立即报警——死锁最好永远不靠复现来发现,那已经是事故了。
/* 锁序表:config_lock 编号 1,dev_lock 编号 2 */ mutex_lock(&config_lock); /* 先拿 1 */ mutex_lock(&dev_lock); /* 再拿 2:合规 */ /* 反面写法(另一路径反着拿):两条路径同时执行即成环 */ mutex_lock(&dev_lock); /* 先拿 2 */ mutex_lock(&config_lock); /* 再拿 1:违反锁序,埋雷 */
对 LED 驱动这种单锁场景,锁序还用不上;但随着驱动管理多台设备、多个资源池,锁的数量自然增长——每次新增一把锁时同步更新锁序表,是比"事后修死锁"便宜一个数量级的习惯。
追问一:竞态只有十几行窗口期,真的值得为此上锁吗? 窗口期的长短与触发概率是两回事。窗口短只说明"单次撞上的概率低",但系统调用是持续轰击的:板子交付后每天数万次写入,千分之一的碰撞概率意味着几天一次状态错乱——而且错乱后的状态会被后续操作继承,故障面越滚越大。更麻烦的是这类问题的"不可复现"属性:等它稳定出现再修,排查成本早已超过上锁成本的千百倍。锁的代价是纳秒级的,竞态的代价是按天计的。
追问二:锁中间调用了可能睡眠的函数,自旋锁会不会出事? 会,而且是教科书级的死锁配方:持有自旋锁的线程睡眠后,抢占它的线程在同一个锁上自旋等待——等的是一个永远醒不来的持锁者。这类问题静态分析工具能抓到一部分(锁内调用可睡眠函数有专门的检查项,7.2 节会看到),但工具只认识"函数名单",间接的睡眠路径仍靠人防。守则一句话:进自旋锁之前,把所有可能等待的操作(分配、拷贝、打印、延时)全部留在锁外。
对撞修好了,但闪烁功能还有个悬而未决的问题:msecs_to_jiffies 到底把毫秒变成了什么?下一节专门讲驱动里的时间。