4. 信号量(Semaphore):不只是计数的锁
信号量,这个名字听起来有点学术。说白了,它就是一个带计数的门禁系统。
我刚开始接触内核时,总觉得信号量和自旋锁差不多。后来踩了坑才明白——它们俩的使用场景完全不同。今天咱们就把信号量彻底聊透。
4.1 信号量的原理与计数特性
信号量的核心思想很简单:维护一个整数计数器,控制同时访问共享资源的线程数量。
你可以把它想象成一个停车场门口的显示屏:
- 显示屏上写着"剩余车位:N"
- 每进来一辆车,数字减1
- 每出去一辆车,数字加1
- 数字为0时,门口的栏杆就不抬起来了
这就是信号量的本质——计数信号量。
根据初始计数值的不同,信号量分两种:
| 类型 | 初始值 | 行为 |
|---|---|---|
| 二进制信号量 | 1 | 相当于互斥锁,同一时刻只允许一个线程访问 |
| 计数信号量 | N (N>1) | 允许最多N个线程同时访问 |
我个人习惯把二进制信号量看作"互斥锁的轻量版"。但要注意,它和真正的互斥锁有区别——信号量没有"所有者"的概念。谁都可以 down,谁都可以 up。这既是灵活性,也是风险点。
关键点:信号量的计数值反映了可用资源的数量。down 操作请求资源,up 操作释放资源。
4.2 信号量的 API:down 与 up
Linux 内核中,信号量的核心操作就两个:down 和 up。
先看数据结构:
struct semaphore {
raw_spinlock_t lock;
unsigned int count;
struct list_head wait_list;
};
嗯,这里要注意——信号量内部其实用了一个自旋锁来保护自己的 count 和 wait_list。所以信号量并不是完全"无锁"的,只是对外表现为阻塞等待。
down 操作:请求资源
down 的语义是"我要拿一个资源"。如果 count > 0,直接减1返回;如果 count == 0,当前任务进入睡眠等待。
void down(struct semaphore *sem)
{
unsigned long flags;
raw_spin_lock_irqsave(&sem->lock, flags);
if (likely(sem->count > 0)) {
sem->count--;
raw_spin_unlock_irqrestore(&sem->lock, flags);
return;
}
__down(sem); // 这里会进入睡眠
raw_spin_unlock_irqrestore(&sem->lock, flags);
}
down 有几个变体,我列个表方便你对比:
| 函数 | 行为 | 适用场景 |
|---|---|---|
down() |
获取信号量,不可中断 | 必须拿到资源才能继续 |
down_interruptible() |
可被信号中断,返回 -ERESTARTSYS | 用户态进程等待,允许被 kill |
down_killable() |
只被致命信号中断 | 需要响应 SIGKILL 的场景 |
down_trylock() |
非阻塞尝试,失败立即返回 | 不想睡眠的上下文 |
down_timeout() |
带超时的等待 | 避免无限期阻塞 |
我的建议:在驱动开发中,99% 的情况下用 down_interruptible()。为什么?因为用户进程可能被 Ctrl+C 杀掉,如果用了不可中断的 down,进程会变成 D 状态( uninterruptible sleep),杀都杀不掉。我曾经在调试一个 USB 驱动时遇到过这个问题,用户拔掉设备后进程卡死在 down 里,最后只能重启机器。
up 操作:释放资源
up 的语义是"我还一个资源"。它会增加 count,如果有任务在等待队列上,就唤醒一个。
void up(struct semaphore *sem)
{
unsigned long flags;
raw_spin_lock_irqsave(&sem->lock, flags);
if (likely(list_empty(&sem->wait_list))) {
sem->count++;
raw_spin_unlock_irqrestore(&sem->lock, flags);
} else {
__up(sem); // 唤醒一个等待任务
raw_spin_unlock_irqrestore(&sem->lock, flags);
}
}
up 操作不会导致调用者睡眠,所以可以在任何上下文调用——包括中断上下文。但要注意,up 唤醒的任务会在当前 CPU 上运行,如果当前在中断上下文,可能会带来调度延迟。
4.3 信号量与自旋锁的对比
这是面试常考题,也是实际开发中容易搞混的地方。我直接画个对比表:
| 特性 | 信号量 | 自旋锁 |
|---|---|---|
| 等待方式 | 睡眠等待(进程切换) | 忙等待(原地自旋) |
| 持有时间 | 可以很长 | 必须很短(微秒级) |
| 中断上下文 | 不能使用(会睡眠) | 可以使用(不睡眠) |
| 上下文切换 | 会触发 | 不会触发 |
| 适用场景 | 长时间等待、I/O 操作 | 短时间保护、中断处理 |
| 并发度 | 可控制(计数信号量) | 互斥(同一时刻一个) |
你想想看,为什么自旋锁不能长时间持有?因为它在等的时候 CPU 一直在空转。如果持有 1 毫秒,在 2GHz 的 CPU 上就是 200 万个时钟周期浪费掉了。而信号量会让出 CPU,去做别的事。
反过来,为什么信号量不能在中断上下文用?因为中断上下文不允许睡眠。down 操作如果拿不到信号量,会调用 schedule() 切换进程——这在中断里是致命的。
警告:在中断处理函数、软中断、tasklet 中,绝对不要使用 down 操作。一旦信号量被占用,你的中断处理函数就会尝试睡眠,内核直接 panic。我曾经在调试网卡驱动时犯过这个错,花了整整两天才定位到问题。
4.4 信号量在阻塞上下文中的应用
信号量最典型的应用场景是生产者-消费者模型。我拿一个实际例子来说明。
假设我们有一个字符设备驱动,内核有一个缓冲区,用户态通过 read/write 来访问。多个进程可能同时读写,我们需要控制并发访问。
struct my_device {
char buffer[BUFFER_SIZE];
int read_pos, write_pos;
struct semaphore sem; // 保护缓冲区
struct semaphore empty; // 空槽位计数
struct semaphore full; // 满槽位计数
};
// 初始化
void my_device_init(struct my_device *dev)
{
sema_init(&dev->sem, 1); // 互斥访问缓冲区
sema_init(&dev->empty, BUFFER_SIZE); // 初始全部为空
sema_init(&dev->full, 0); // 初始没有数据
}
// 写操作(生产者)
ssize_t my_write(struct my_device *dev, const char *data, size_t count)
{
down(&dev->empty); // 等待空槽位
down(&dev->sem); // 互斥访问缓冲区
// 写入数据到缓冲区
dev->buffer[dev->write_pos] = *data;
dev->write_pos = (dev->write_pos + 1) % BUFFER_SIZE;
up(&dev->sem); // 释放缓冲区
up(&dev->full); // 增加满槽位计数
return 1;
}
// 读操作(消费者)
ssize_t my_read(struct my_device *dev, char *data, size_t count)
{
down(&dev->full); // 等待有数据
down(&dev->sem); // 互斥访问缓冲区
// 从缓冲区读取数据
*data = dev->buffer[dev->read_pos];
dev->read_pos = (dev->read_pos + 1) % BUFFER_SIZE;
up(&dev->sem); // 释放缓冲区
up(&dev->empty); // 增加空槽位计数
return 1;
}
这个例子中,三个信号量各司其职:
sem:保护缓冲区的互斥访问,相当于一把锁empty:计数信号量,记录还有多少空位可写full:计数信号量,记录有多少数据可读
当缓冲区满时,写进程会在 down(&dev->empty) 处睡眠。当缓冲区空时,读进程会在 down(&dev->full) 处睡眠。这就是信号量的阻塞特性——让 CPU 去做更有意义的事,而不是空转。
核心思想:信号量适合"等得起"的场景。如果临界区很短(几十条指令),用自旋锁。如果临界区涉及 I/O、等待硬件、或者可能长时间阻塞,用信号量。
4.5 知识体系总览
下面这张图总结了信号量的核心知识点:
信号量是个老牌同步机制,从 Unix 时代就在用。虽然现在内核社区更推荐用 mutex 和 completion,但信号量在计数场景下仍然不可替代。理解它的原理,对掌握整个内核锁机制都有帮助。
好了,信号量就聊到这里。记住一句话:信号量让你睡得着,自旋锁让你睡不着。选哪个,看你等不等得起。