4. 信号量(Semaphore):不只是计数的锁

信号量,这个名字听起来有点学术。说白了,它就是一个带计数的门禁系统。

我刚开始接触内核时,总觉得信号量和自旋锁差不多。后来踩了坑才明白——它们俩的使用场景完全不同。今天咱们就把信号量彻底聊透。

4.1 信号量的原理与计数特性

信号量的核心思想很简单:维护一个整数计数器,控制同时访问共享资源的线程数量

你可以把它想象成一个停车场门口的显示屏:

  • 显示屏上写着"剩余车位:N"
  • 每进来一辆车,数字减1
  • 每出去一辆车,数字加1
  • 数字为0时,门口的栏杆就不抬起来了

这就是信号量的本质——计数信号量

根据初始计数值的不同,信号量分两种:

类型 初始值 行为
二进制信号量 1 相当于互斥锁,同一时刻只允许一个线程访问
计数信号量 N (N>1) 允许最多N个线程同时访问

我个人习惯把二进制信号量看作"互斥锁的轻量版"。但要注意,它和真正的互斥锁有区别——信号量没有"所有者"的概念。谁都可以 down,谁都可以 up。这既是灵活性,也是风险点。

关键点:信号量的计数值反映了可用资源的数量。down 操作请求资源,up 操作释放资源。

4.2 信号量的 API:down 与 up

Linux 内核中,信号量的核心操作就两个:downup

先看数据结构:

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 知识体系总览

下面这张图总结了信号量的核心知识点:

信号量(Semaphore)知识体系 信号量 原理:计数器 + 等待队列 类型:二进制信号量(值1) | 计数信号量(值N) API:down / down_interruptible / down_trylock / up 对比自旋锁:睡眠 vs 自旋 | 长持有 vs 短持有 应用:生产者-消费者 | 阻塞上下文 计数特性 初始化 核心操作 选型决策 实战场景

信号量是个老牌同步机制,从 Unix 时代就在用。虽然现在内核社区更推荐用 mutexcompletion,但信号量在计数场景下仍然不可替代。理解它的原理,对掌握整个内核锁机制都有帮助。

好了,信号量就聊到这里。记住一句话:信号量让你睡得着,自旋锁让你睡不着。选哪个,看你等不等得起。


专注资料整理