这个系列写到第三篇,终于可以聊信号量了。之前两篇分别讲了自旋锁和互斥锁,后台一直有读者问:semaphore 到底什么时候用?它跟 mutex 区别在哪?为什么内核代码里经常看到 struct semaphore,又听大佬说新代码别用它?这些问题其实都指向同一个核心:信号量是一种“带计数的睡眠锁”,它解决的问题模型和 mutex 有本质差异,不适合无脑替换,但也绝没有过时到可以忽视的程度。
本文会从信号量的设计动机、数据结构、核心 API 语义、加解锁的完整流程、实际应用场景一直讲到踩坑经验,尽量让一篇讲透。适合正在读内核驱动源码、或者写内核模块时纠结“该用哪种锁”的开发者,也适合想把并发基础补扎实的应用层同学。信号量里藏着不少值得品味的细节,比如它为什么能睡眠、为什么可以被信号打断、为什么明明有 mutex 内核还留着它。把这些搞明白,Linux 锁家族的图谱基本就拼完整了。
1. 信号量的本质与设计定位
1.1 用停车场模型理解信号量
我每次给新人讲 semaphore,都会先用停车场的例子。一个停车场有 5 个车位,门口有个计数器,显示剩余车位数量。车来的时候,保安看一眼计数:如果大于 0,放行并且计数器减 1;如果等于 0,对不起,排队等,有车出来计数器加 1 再放行下一辆。这个“计数器 + 排队”的机制,就是信号量的全部精髓。
Linux 内核里的 struct semaphore 干的事和停车场保安完全一样。count 字段就是剩余资源数,wait_list 就是排队的车队。每次 down 操作(拿锁)就是“申请一个资源”,count 减 1;如果 count 已经是 0,当前任务就要挂起等待。每次 up 操作(释放锁)就是“归还一个资源”,count 加 1,如果有人在等,就唤醒其中一个。
关键的区别在于:mutex 的“车位”只有 1 个,semaphore 允许 N 个。所以信号量的本质是一个“可计数的资源池管理器”,它天然适合“限制并发数量”这类场景,而不只是简单的互斥。
1.2 信号量在锁家族中的坐标
很多初学内核并发的人会把锁理解成“一个 bool 变量加一个等待队列”,这其实把问题想窄了。Linux 里的同步原语各有各的脾气:自旋锁适合短临界区、不允许睡眠;mutex 是睡眠互斥锁,有 owner 概念,支持优先级继承;信号量则是更古老、更灵活的计数睡眠锁。对比一下就能看得很清楚。
| 特性 | 自旋锁 spinlock | 互斥锁 mutex | 信号量 semaphore |
|---|---|---|---|
| 临界区等待方式 | 忙等待(spin) | 睡眠等待 | 睡眠等待 |
| 是否允许在中断上下文使用 | 允许(配合 irq 版本) | 禁止 | 禁止 |
| 是否允许持有者睡眠 | 禁止 | 允许 | 允许 |
| 计数能力 | 1 | 1 | N |
| 是否有 owner 记录 | 无 | 有 | 无 |
| 是否支持优先级继承 | 无 | 支持 | 不支持 |
| 适合场景 | 短临界区、无睡眠 | 互斥、睡眠 | 计数资源、条件同步 |
从这个表能看出,semaphore 和 mutex 最像,但两个致命短板让它在新代码里失宠:没有 owner 记录,出了问题很难排查;不支持优先级继承,高优先级任务可能被低优先级任务长时间阻塞,这在实时场景下非常要命。
1.3 有了 mutex,为什么还要保留信号量
这是新手最常见的问题:既然 mutex 在互斥场景全面优于 semaphore,为什么内核不干脆删了它?答案有两层。
第一层是历史兼容。内核里海量驱动代码还在用 semaphore,删除它等于把整个驱动生态翻个底朝天,代价不可接受。第二层更本质:mutex 只能解决“互斥”,解决不了“资源计数”。你想限制某个设备的并发打开数最多 3 个?mutex 做不到,semaphore 天生就是干这个的。还有一类“两个任务互相通知”的场景(你在等我把数据准备好,我在等你有空拿),虽然也能用信号量实现,但这种条件同步用更现代的手段会更安全,后面我会单独说。
所以我的观点是:信号量不是一个“要被淘汰的锁”,而是一个“要选对场景的锁”。把它用在该用的地方,它依然是内核同步工具箱里不可替代的一件工具。
2. 核心数据结构与 API 语义
2.1 struct semaphore 长什么样
源码不复杂,就是老三样:锁保护、计数器、等待队列。
struct semaphore { raw_spinlock_t lock; unsigned int count; struct list_head wait_list; };count 是当前可用的资源数量,wait_list 是正在等待资源被释放的任务队列。lock 这个自旋锁保护的是 count 和 wait_list 这两个字段的并发访问。这里有个非常值得注意的设计:信号量本身是睡眠锁,但它内部用了一个 raw_spinlock_t 来保护自己的元数据。这意味着 down 和 up 操作的快速路径虽然极短,但依然是关抢占的原子操作,只是临界区非常小,只在几个变量上动刀,所以性能开销可控。
从语义上讲,count 永远不会变成负数。down 操作只有在 count 大于 0 时才能成功减一,否则去睡觉;up 操作把 count 加一,但如果有等待者,实际上 count 应该保持 0(把资源直接交给唤醒的任务),这样就保证了不出现负数。这个细节虽然小,但理解它对后面的源码阅读有帮助。
另外注意 raw_spinlock_t 是“raw”版本,意味着它不经过 PREEMPT_RT 补丁的自动转换,这对一个睡眠锁内部的短临界区来说是必须的——否则在 RT 内核下这把锁可能被变成另一个睡眠锁,那语义就彻底乱了。
2.2 初始化信号量的几种方式
静态初始化推荐 DECLARE_SEMAPHORE_GENERIC,它需要两个参数:信号量名字和初始 count 值。
DECLARE_SEMAPHORE_GENERIC(dev_sem, 1);这里 dev_sem 是变量名,1 是初始资源数。初始化成 1 的时候它就是个互斥锁,初始化成 N 就是计数信号量。
动态初始化用 sema_init:
struct semaphore dev_sem; sema_init(&dev_sem, 1);还有两个显式表明用途的变体:如果是为命名实体(比如某个设备体)做静态声明,可以用 DEFINE_SEMAPHORE(name),它等价于初始 count 为 1;若想静态声明一个计数信号量,则用 DECLARE_SEMAPHORE_GENERIC(name, count)。
这里给个小建议:团队里如果代码风格审查较严,建议统一用 DECLARE_SEMAPHORE_GENERIC 加注释说明 count 的含义,别让读者猜 1 到底是“互斥”还是“碰巧只有一个资源”。
2.3 down 与 up 家族 API 全解析
信号量的 P/V 操作在内核里叫 down/up,语义对应请求锁和释放锁。实际代码里,down 家族提供了多个变体,刚接触的人非常容易搞混,我一个个说清楚。
void down(struct semaphore *sem); int down_interruptible(struct semaphore *sem); int down_killable(struct semaphore *sem); int down_trylock(struct semaphore *sem); int down_timeout(struct semaphore *sem, long timeout); void up(struct semaphore *sem);down():最朴素的拿锁方式,会睡,睡了之后不能被信号唤醒。也就是说当前任务一旦进入等待队列,只能等持有者 up 才能醒来,没有别的途径打断它。这会让任务处于 TASK_UNINTERRUPTIBLE 状态,从 ps 命令看进程是 D 状态。虽然简单,但在驱动里用多了会导致“kill 不掉”的进程,所以我建议能不用则不用。
down_interruptible():同上,但等待时可以被信号打断。返回值是 0 表示成功拿到锁;返回 -EINTR 表示等待过程中被信号叫醒了,锁没拿到。注意,这个返回值必须检查!我看到过很多新人驱动代码里直接忽略返回值,结果信号打断后继续往下跑,像个拿着假钥匙的强盗一样进了临界区,数据被踩得稀烂。
down_killable():介于前两者之间,只允许 fatal signal 打断,适合那些“你能杀我但不能 Ctrl+C 打我”的场景。驱动程序里比较少见,但内核其他地方有用到。
down_trylock():非阻塞尝试。不管资源有没有,立刻返回,拿到锁返回 0,拿不到返回 1。适合那些“有空就干一把,没空就做别的”的轮询场景,或者中断上下文想尝试拿锁但不能睡眠的场合。
down_timeout():最多等 timeout 个 jiffies,超时返回 -ETIME。有界等待在某些锁竞争严重的路径上非常有用,能避免无限期睡眠。
up():释放资源。唯一需要注意的是,up 可以在非持有者上下文中调用,这点和 mutex 不一样;甚至可以在软中断里调用,因为唤醒逻辑不睡眠。但正常使用还是建议谁 down 谁 up,别搞成跨任务释放锁,不然代码逻辑别人根本看不懂。
写代码时,我个人的习惯是:默认情况下首选 down_interruptible,因为它让进程可被 Ctrl+C 杀掉,调试体验好;如果确认等待时间极短(比如几毫秒内必有 up),可以用 down;如果只是探测资源状态,绝对不用 down 系列而用 down_trylock。
3. 加解锁的完整实现之旅
3.1 down_interruptible 的快速路径与慢速路径
信号量的 down 操作实现得精妙,它的核心思想是“多快好省地抢,慢条斯理地等”。先说快速路径,这也是大多数情况下(资源不紧张的场景)走的路径。
static inline int sem_trylock(struct semaphore *sem) { unsigned long flags; int count; raw_spin_lock_irqsave(&sem->lock, flags); count = sem->count; if (likely(count > 0)) sem->count = count - 1; raw_spin_unlock_irqrestore(&sem->lock, flags); return (count > 0) ? 0 : 1; }代码很短,关键在流程:关中断 + 拿自旋锁,检查 count 大于 0 则减一,释放锁。整个过程是原子的,没有任何占用时间长的操作。如果没有竞争,这次 down 调用只花几十纳秒,跟自旋锁的一次拿放差不多。
慢速路径发生在 count 已经等于 0 的时候,这就要调用 __down_interruptible,把调用者塞进 sem->wait_list。一次完整的睡眠拿锁通常包含这些步骤:
- 把当前任务的状态设置为 TASK_INTERRUPTIBLE;
- 把任务的等待队列节点(struct waiter)加入 sem->wait_list;
- 检查是否还能再抢一把 count(防止 up 操作恰好在加入队列之前发生,错过唤醒);
- 调用 schedule_timeout 主动让出 CPU;
- 被唤醒后,把自己的任务状态改回 TASK_RUNNING;
- 从 wait_list 中摘下等待节点;
- 如果是因为信号醒来(而非 up 唤醒),返回 -EINTR;否则循环再试,重新检查 count。
上面第 3 步是防止“丢失唤醒”的关键,也体现了信号量严格依赖“检查条件-睡眠”这两个动作在同一个锁保护下的原子性。这类问题叫“lost wakeup”,非常隐蔽,自己写类似的等待机制时一定要记得学这个模式。
3.2 up 操作的源码级理解
up 操作看起来只是计数器加一,实际上要处理“唤醒谁”的决策,流程比直觉中复杂一点。
void up(struct semaphore *sem) { unsigned long flags; raw_spin_lock_irqsave(&sem->lock, flags); if (likely(list_empty(&sem->wait_list))) sem->count++; else { struct semaphore_waiter *waiter = list_first_entry(&sem->wait_list, struct semaphore_waiter, list); list_del(&waiter->list); waiter->up = true; wake_up_process(waiter->task); } raw_spin_unlock_irqrestore(&sem->lock, flags); }这个实现很有意思:如果没有等待者,直接给 count 加 1;如果有等待者,并不加 count,而是直接把等待队列里第一个任务唤醒,把资源“直接移交”给那个任务。这样一来 count 始终不会因为等待者的存在而虚增,也避免了“count 先变成 1,然后一堆任务同时醒来抢 1 个资源,其他人又得睡回去”的惊群效应。
注意 up 里的 wake_up_process 在持有 raw_spin_lock 的时候执行,而不是释放锁之后。这也是经过考量的:先唤醒还是先释放锁,对正确性没影响,但先唤醒可以让等待者立刻进入就绪状态,等 up 的持有者把锁放开后,幸运的等待者已经在跑道上热身了。这个顺序能让平均唤醒延迟略低一点,属于细节雕琢的功夫。
3.3 为什么信号量能睡眠而自旋锁不能
这是内核并发初学者必问的问题,答案其实都在门禁上。自旋锁的临界区里,持有者可能在任意 CPU 上占着锁,其他 CPU 只能原地打转。如果你在持有自旋锁的时候睡眠,会发生两件事:你带着锁睡过去了,没人能释放锁;然后所有抢锁的 CPU 都在傻转,整个系统等于锁死。
信号量允许睡眠的根本原因是:它的等待机制不依赖“原地打转”,而是把任务挂到 wait_list 上,然后调用调度器主动切换出去。睡眠期间,调度器可以运行其他任务,CPU 不空转。当然,代价是上下文切换的开销很大,比自旋锁的忙等重得多。所以内核开发的黄金法则是:短临界区用自旋锁,长临界区用睡眠锁。什么叫短?一个判断加几次内存访问,通常不超过几十纳秒的临界区。什么叫长?涉及 I/O、网络收发、等待硬件完成,几微秒起步的临界区。信号量属于睡眠锁,所以支持的临界区可以很长,但也正因为能长,持锁时间过长的风险也比自旋锁大得多。
这里必须强调一个红线:绝对不能在中断上下文(hardirq / softirq / 中断处理线程之外)调用 down 系列函数。中断上下文没有进程上下文,没法睡眠调度。如果在中断处理函数里试图 down 一个被占用的信号量,内核会直接报 BUG,系统崩给你看。信号量能保护的是进程上下文之间的资源竞争,不是中断上下文与进程上下文之间的竞争,后者应该用 spin_lock_irqsave 这类带中断屏蔽的自旋锁。
4. 典型应用场景与驱动实操
4.1 场景一:限制设备的并发打开数
信号量最经典、最贴合的用法是在驱动里限制同时打开设备的进程数。很多字符设备用 open 时就要检查“我有没有资格拿一个资源”,release 时归还。
#include <linux/semaphore.h> static struct semaphore dev_open_count; static atomic_t open_total = ATOMIC_INIT(0); static int dev_open(struct inode *inode, struct file *filp) { if (down_interruptible(&dev_open_count)) return -ERESTARTSYS; /* 被信号打断,回到用户态重试 */ atomic_inc(&open_total); return 0; } static int dev_release(struct inode *inode, struct file *filp) { atomic_dec(&open_total); up(&dev_open_count); return 0; } static int __init dev_init(void) { sema_init(&dev_open_count, 2); /* 最多 2 个进程同时持有 */ ... }这个例子里的 down_interruptible 返回值处理就特别典型:-ERESTARTSYS 表示“系统调用被信号打断”,驱动层把它原样返回给 VFS,VFS 会让用户态的 open 系统调用自动重试(如果你的信号处理没干坏事)或返回 EINTR。这个细节如果处理错,用户态进程会看到诡异的 open 失败,或者更糟,根本没被阻塞直接闯进去,把两个进程的并发访问变成了四五个进程在临界区里乱窜。
有人可能会问:限制并发数我用 atomic_t 加判断不行吗?不行。atomic 只能做“看一眼计数器,减一”,没法把没抢到资源的任务挂到队列里去睡。信号量把“资源计数”和“排队等资源”一次全解决了,这是在应用层用信号量(或者带计数的并发原语)控制连接数、限流一个道理,只不过内核里是真正的挂起等待,不是用户态的占位符。
4.2 场景二:驱动程序里的互斥访问
再看一个更常见但其实是“下位替代”的场景:驱动里保护一块关键硬件寄存器区域,防止两个进程同时操作。这种需求 mutex 是正解,但很多存量驱动就是用 semaphore 初始化成 1 来做的,读别人的代码时你会经常撞见这种写法。
static struct semaphore reg_lock; static ssize_t mydev_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos) { int ret; if (down_interruptible(®_lock)) return -ERESTARTSYS; /* 这里对硬件寄存器做一串读改写操作,可能耗时几十微秒 */ ret = write_regs_from_user(buf, count); up(®_lock); return ret; }如果是我新起一个项目,我会在同样的位置用 mutex 而不是 semaphore。原因很简单:mutex 有 owner 记录,lockdep 能帮忙检测死锁;semaphore 没有,死锁了只能靠人肉翻代码。但对于读代码的人来说,看到这种初始化为 1 的 semaphore,只需要知道“它在这段代码里扮演的就是互斥锁”就够了,不用大惊小怪。
4.3 一个完整的模块级示例:固定并发资源池
把前面两个需求合到一起,我写过一个小的内核模块,模拟“一个硬件池里有 3 个可用资源槽,进程需要申请槽位才能执行工作”。这个例子完整展示了计数信号量的工作方式:
#include <linux/module.h> #include <linux/kernel.h> #include <linux/semaphore.h> #include <linux/delay.h> #include <linux/kthread.h> static struct semaphore pool_sem; static struct task_struct *workers[5]; static int worker_thread(void *arg) { int id = (long)arg; int ret; while (!kthread_should_stop()) { ret = down_interruptible(&pool_sem); if (ret < 0) break; pr_info("worker %d: acquired a resource slot\n", id); msleep(100 + id * 30); /* 模拟干活 */ up(&pool_sem); pr_info("worker %d: released a resource slot\n", id); msleep(200); } return 0; } static int __init pool_init(void) { int i; sema_init(&pool_sem, 3); for (i = 0; i < 5; i++) workers[i] = kthread_run(worker_thread, (void *)(long)i, "worker%d", i); return 0; } static void __exit pool_exit(void) { int i; for (i = 0; i < 5; i++) kthread_stop(workers[i]); }这个模块的日志会清楚地展示:任意时刻最多 3 个 worker 同时处于“已获取资源”状态,另外两个在 down_interruptible 里挂着。如果把 semaphore 换成 mutex,结果就是同时只能 1 个 worker 工作,其余 4 个干瞪眼——完全不是我们想要的行为。这种“限制并发数但不限制排队人选”的需求,只有计数信号量最顺手。
4.4 读写信号量:先说后不写,读不互斥
聊 semaphore 的时候,可能你在代码里还看到过 struct rw_semaphore,也就是读写信号量。它不是 semaphore 的简单升级,而是允许“多个读者同时进入”的特殊版本。
核心语义:写者拿锁时独占全部读者和写者;读者拿锁时,如果当前没有写者在等,那可以和别的读者一起进入。这种“读写之分”在数据库、文件系统、硬件描述表这类“读多写少”的场景里效率很高。
用法上和 semaphore 稍有不同:
down_read(&rw_sem); up_read(&rw_sem); down_write(&rw_sem); up_write(&rw_sem);读者和写者都用同一个 rw_semaphore 实例,只是拿锁函数不同。内核里很多进程地址空间的 mmap_lock(以前叫 mmap_sem)就是这么用的,文件读路径拿 read lock,修改映射时拿 write lock,堪称最经典的读写信号量实践。
不过要注意:读写信号量和普通计数信号量的关系不大,它本质是“互斥锁 + 读者并发”的组合体,在 5.x 内核里已经被专门优化的内核实现接手了,如果你在代码里需要这种语义,直接学习使用 first/last reader 优化版本即可,不需要再蹭 semaphore 的概念。这里提到它,是为了避免你在看代码时把这两种东西搞混。
5. 常见问题与排查技巧实录
5.1 警告:中断上下文使用信号量会直接死给你看
这是我在审查新人驱动代码时最常抓到的问题。中断处理函数中禁止调用任何可能睡眠的函数,down、down_interruptible 都是典型“可能睡眠”的代表。中断上下文没有进程背景,调度器无法安全切换,内核检测到后直接进入 BUG() 流程。
有位朋友跟我描述过一次他的事故:他在网卡驱动的 hardirq 处理函数里写了个 down_interruptible,想着等一个资源就绪再处理包。结果一加载驱动,整个机器直接卡死,串口控制台刷出大段“BUG: scheduling while atomic”信息。排查到最后,就是这一个函数调用引起的。正确的做法是:如果中断里要通知进程,用 tasklet/workqueue 把工作推后到进程上下文执行,或者用原子操作的标志位先存着,让进程主动查询。
排查小技巧:如果系统随机死机且 dmesg 里有“scheduling while atomic”字样,先搜代码里所有中断上下文路径中的 down 和 mutex_lock。别急着查内存,大概率是锁用错了地方。
5.2 down_interruptible 返回值必须检查
代码审查中第二高频问题。新手写驱动,最常见的错误写法是:
down_interruptible(&sem); /* 直接开始改共享数据 */看起来没问题,因为大多数人测试时信号不会自来,这段代码正常得不得了。可一旦你按下 Ctrl+C,进程收到 SIGINT,正在等待的 down_interruptible 立刻返回 -EINTR,锁没拿到,可你的代码不管三七二十一直接进了临界区。更糟的是,另一个进程可能同时也在临界区里改同一份数据,数据竞争或者死锁都是分分钟的事。
握手正解是:
if (down_interruptible(&sem)) return -ERESTARTSYS;这里返回 -ERESTARTSYS 而不是 -EINTR,微妙但很重要。前者告诉内核“这个系统调用可以被信号重启”,VFS 会在合适的时机自动重跑一次系统调用;后者则是直接把 EINTR 糊到用户脸上。驱动程序里的锁等待被打断,通常都希望用户能再试一次,所以 -ERESTARTSYS 是更有礼貌的应答。
5.3 我什么时候仍然会选用信号量
虽然新代码推荐 mutex,但几个场景下我会破例仍旧用 semaphore:
第一,明确需要计数资源池,且等待者需要排队。例如驱动里控制硬件稀缺资源的并发槽位,这是 semaphore 的甜点区,mutex 根本做不了。
第二,处理存量代码。如果一段驱动原本就用 semaphore,逻辑已经经过了长时间验证、测试也通过,那“能不动就不动”,别为了炫技把正常的代码改成 mutex,引入新 bug 的风险远高于风格上的受益。
第三,需要 down_trylock 这种非阻塞探测但又不想引入复杂状态机时。mutex 也有 trylock,但如果你想表达的是“资源可用才做,不可用就算了”,semaphore 的计数含义更贴合语义。
第四,正好要在某个文件的全局范围内做初始化过程中一次性锁。比如模块探头阶段的设备初始化锁,用 DECLARE_SEMAPHORE_GENERIC(name, 1) 就够了,不必拉来 mutex 的一大套调试设施。
5.4 排查死锁与资源泄漏的手段
信号量没有 owner 记录,死锁排查比 mutex 痛苦得多。lockdep 能对部分场景给出警告,但别指望它覆盖全部。我一般按这套路径来排查:
第一步,先看内核配置有没有开 CONFIG_DEBUG_MUTEXES、CONFIG_PROVE_LOCKING。虽然名字里带 mutex,但 lockdep 覆盖信号量的 down/up 配对。
第二步,查 up 是否真的被调用了。信号量资源泄漏最典型的症状是 down 一次、up 零次,count 越来越小直到归零,随后所有任务都卡在 down。用 ftrace 或者 trace_sched_wakeup 跟踪相关线程,能很快定位到某些路径没执行 up。
第三步,检查错误路径。比如 down 成功之后做硬件操作失败,返回错误之前有没有把 up 补上。这种 return 前忘记 up 的代码,我见过不下十次,几乎都是临时加错误检查时漏掉的。
第四步,根据 count 推算。如果初始化 count=1,但某时刻 count=0 且等待队列长长排着一串,那大概率先查持有者为什么迟迟不 up,而不是查竞争者为什么抢不到。因为如果是竞争太猛,count 应该经常变成 0,但持有者们也会轮流释放,队列不会长期排满。
这些排查手段里,最实用的其实还是 code review 时多留个心眼:所有持锁路径上的 return 必须配对 up,想把这句话刻进每个写驱动的同事脑子里。
5.5 性能基准与选择误区
常见误区是“担心信号量性能差,于是所有锁都用自旋锁”。这个担心方向反了。信号量的 down/up 快速路径其实很便宜:一次关中断、一次自旋锁维护、一次内存加减,加起来几十纳秒;只有真的发生竞争、要睡眠时,上下文切换才会带来微秒级开销。自旋锁的快速路径更便宜,但一旦偶发竞争,多个 CPU 在那空转烧电,持续时间一长,比睡眠锁浪费的性能多得多。
选型决策不需要玄学,直接估算临界区长度。如果临界区是“寄存器写几遍,几百纳秒内必完”,用自旋锁。如果临界区可能被硬件中断阻塞、可能等 DMA 完成、可能超过几微秒,就必须用睡眠锁,于是就轮到 mutex 或 semaphore。无论是哪种锁,快速路径差异都小于 100ns,真正影响系统性能的是“锁竞争引起的睡眠时长”,而不是锁本身。
做个简单测试就能体会到:在 X86 机器上,两个线程抢同一个 mutex,临界区空转 50 纳秒,用 perf 看大概每秒能完成几百万次加解锁。把 mutex 换成 semaphore 且 count=1,结果非常接近。真正拉开差距的是有人把持锁时间拉到 10 微秒,此时自旋锁会让另一个 CPU 白白烧掉同样 10 微秒的电,而睡眠锁在那个 CPU 上还能继续跑别的任务。选锁本质是在选“竞争代价的支付方式”。
6. 一个真实改造带来的体感
最后分享一次实际项目里的经历。某公司的老驱动里有一个 semaphore 用来保护一块双端口 RAM 的访问,count 初始化是 1,语义上就是互斥。但驱动在中断上下文里也有一个入口要碰那块 RAM,用的是 spin_lock_irqsave。一段时间后系统偶发死机,查了很久才发现:进程上下文拿了 semaphore 之后,又被同一个 CPU 上的网卡中断打断,中断里那段代码想拿自旋锁,而自旋锁和 semaphore 保护的是同一块 RAM 的不同数据,两个锁的获取顺序在外层根本没约定,形成了隐蔽的锁序冲突。
最后我们把整块 RAM 的访问统一成 spin_lock_irqsave 保护,semaphore 摘掉。因为临界区就是几次内存拷贝,短到根本不需要睡眠锁。这个案例给我的教训是:锁不是越多越好,更不是越高级越好,合适就一个字。读代码时看见混用多种锁保护同个资源,第一反应应该是怀疑,而不是接受。
信号量适合的边界我上面拆得很细了:当你的需求是“N 个资源一杯羹,谁先来谁先得”,它是标准答案;当你只是想互斥一段很短的操作,mutex 更现代;当你在中断上下文里蠢蠢欲动,请立刻把手从 semaphore 上拿开。理解了这些,你在 Linux 并发编程里就又多了一把能精确使用的工具。