信号量:POSIX信号量,生产者消费者问题,哲学家就餐问题

说到信号量,我脑子里第一个蹦出来的画面,是十年前调试一个多线程数据采集系统时的场景。那时候板子上的四个传感器线程抢共享缓冲区,抢得乱七八糟,数据错得一塌糊涂。后来我用信号量重构了同步逻辑,问题迎刃而解。嗯,信号量这东西,说白了就是一把「令牌」,谁拿到谁干活,拿不到就等着。

POSIX信号量:两种形态

POSIX信号量分两种:命名信号量匿名信号量。我个人习惯在进程间同步时用命名的,线程间同步时用匿名的。为什么呢?因为命名信号量有名字,不同进程可以通过名字找到同一个信号量;匿名信号量则直接放在共享内存里,轻量高效。

类型 创建函数 适用场景
命名信号量 sem_open() 多进程同步
匿名信号量 sem_init() 多线程同步

核心API就四个:sem_initsem_waitsem_postsem_destroy。你想想看,这跟买票进站一个道理——sem_wait是检票,票少了就等着;sem_post是退票,票多了后面的人就能进。

#include <semaphore.h>

sem_t sem;
sem_init(&sem, 0, 1);   // 初始值为1,相当于互斥锁
sem_wait(&sem);         // P操作:申请资源
// 临界区代码
sem_post(&sem);         // V操作:释放资源
sem_destroy(&sem);
我的小技巧:初始化信号量时,第二个参数填0表示线程间共享,填非0表示进程间共享。嵌入式Linux里我常用0,因为大部分场景是线程同步。

生产者消费者问题:经典中的经典

生产者消费者问题,我面试时必问。为什么?因为它考察的是你对「同步」和「互斥」的理解深度。一个缓冲区,多个生产者往里放数据,多个消费者往外取数据。你想想看,如果不同步会怎样?生产者可能覆盖了还没被消费的数据,消费者可能读到半残的数据。

解决方案需要三个信号量:

  • mutex:互斥锁,保护缓冲区访问,初始值1
  • empty:空槽位计数,初始值等于缓冲区大小
  • full:满槽位计数,初始值0
sem_t mutex, empty, full;
sem_init(&mutex, 0, 1);
sem_init(&empty, 0, BUFFER_SIZE);
sem_init(&full, 0, 0);

// 生产者
void producer() {
    while (1) {
        item = produce();
        sem_wait(&empty);   // 申请空槽
        sem_wait(&mutex);   // 进入临界区
        buffer[in] = item;
        in = (in + 1) % BUFFER_SIZE;
        sem_post(&mutex);   // 离开临界区
        sem_post(&full);    // 增加满槽
    }
}

// 消费者
void consumer() {
    while (1) {
        sem_wait(&full);    // 申请满槽
        sem_wait(&mutex);   // 进入临界区
        item = buffer[out];
        out = (out + 1) % BUFFER_SIZE;
        sem_post(&mutex);   // 离开临界区
        sem_post(&empty);   // 增加空槽
        consume(item);
    }
}
注意顺序:先申请资源信号量(empty/full),再申请互斥锁(mutex)。我曾经见过有人反过来写,结果死锁了——生产者拿着锁等空槽,消费者拿着空槽等锁,谁也动不了。

哲学家就餐问题:死锁的教科书

哲学家就餐问题,说白了就是五个哲学家围着一张圆桌,每两人之间放一根筷子。哲学家要么思考,要么吃饭。吃饭需要拿起左右两边的筷子。如果所有哲学家同时拿起左边的筷子,那就死锁了——谁也没法吃饭。

这个问题我当年在课堂上听的时候觉得挺简单,直到自己在项目中遇到类似的资源竞争场景,才真正体会到它的精妙。嗯,这里的关键是破坏死锁的四个必要条件之一

常见的解法有几种:

  • 最多允许四个哲学家同时就餐:用一个计数信号量限制并发数
  • 奇数哲学家先拿左边,偶数先拿右边:打破循环等待
  • 用互斥锁保护拿筷子的动作:原子化操作

我个人最推荐第三种,因为它最直观,也最容易移植到其他场景。

sem_t chopstick[5];
sem_t mutex;

void philosopher(int i) {
    while (1) {
        think();
        sem_wait(&mutex);           // 保护拿筷子动作
        sem_wait(&chopstick[i]);    // 拿左边
        sem_wait(&chopstick[(i+1)%5]); // 拿右边
        sem_post(&mutex);
        eat();
        sem_post(&chopstick[i]);
        sem_post(&chopstick[(i+1)%5]);
    }
}
我曾经踩过的坑:在实时系统中,如果某个哲学家吃饭时间过长,其他哲学家会饿死。这时候需要加超时机制,或者用优先级信号量。别问我怎么知道的——有一次工业相机采集线程因为等筷子超时,导致整条产线停了30秒。

知识体系总览

下面这张图是我自己梳理的信号量知识脉络,你看一眼就能明白整个章节在讲什么。

POSIX信号量 两种类型 命名信号量 匿名信号量 核心API sem_init sem_wait sem_post sem_destroy 经典问题 生产者消费者 哲学家就餐 常见陷阱 死锁(顺序错误) 饥饿(优先级问题) 资源泄漏(未销毁) 图:POSIX信号量知识体系

实际项目中的选择建议

我在嵌入式项目中用过不少同步机制,信号量、互斥锁、条件变量都用过。给你一个实用的选择标准:

  • 计数资源(比如有5个缓冲区可用)→ 用信号量
  • 互斥访问(一次只能一个人用)→ 用互斥锁
  • 等待特定条件(比如队列非空)→ 用条件变量

信号量特别适合「资源计数」的场景。比如一个串口DMA缓冲区有8个槽位,生产者放数据,消费者取数据,用信号量管理槽位数量,代码写起来干净利落。

一句话总结:信号量是计数的,互斥锁是二值的。别把信号量当互斥锁用,虽然它能做到,但语义不清晰,别人看你的代码会懵。

好了,信号量这块就聊到这儿。你写代码的时候,多想想「谁在等什么资源」,信号量的值就是那个资源的数量。想通了这一点,生产者消费者和哲学家就餐问题,其实就是一个计数游戏。


专注资料整理