信号量实战:从理论到落地的完整指南

大家好,我是你们的老朋友。今天咱们来聊聊信号量——这个在进程间通信里既基础又容易踩坑的东西。

说实话,我刚开始做嵌入式开发那会儿,对信号量的理解就停留在「嗯,就是个计数器」。直到有一次,我写的一个多进程采集系统在产线上跑着跑着就卡死了,排查了整整两天,最后发现是信号量的P/V操作顺序搞反了。从那以后,我对信号量就多了几分敬畏。

好,咱们正式开始。这一章我会把信号量的同步与互斥、死锁、超时、撤销、权限控制这些知识点串起来,最后给一个综合案例,让你看完就能上手用。

一、信号量的核心:同步与互斥

信号量说白了就是一个内核维护的整数,支持两种原子操作:P(申请资源,值减1)和V(释放资源,值加1)。

我习惯这么理解:互斥是「抢厕所」,同步是「接力赛」

1.1 互斥场景

多个进程要访问同一块共享内存,一次只能让一个进程操作。这时候信号量初始值设为1,每个进程进入前P一下,出来时V一下。

// 伪代码示例
sem_t mutex;
sem_init(&mutex, 1, 1);  // 第二个参数1表示用于进程间

// 进程A
sem_wait(&mutex);   // P操作
// 写共享内存...
sem_post(&mutex);   // V操作

// 进程B
sem_wait(&mutex);
// 读共享内存...
sem_post(&mutex);

我的经验:互斥信号量的P和V一定要成对出现。我曾经见过一个同事,在异常分支里忘了V操作,结果其他进程全饿死了。排查起来特别痛苦。

1.2 同步场景

进程A生产数据,进程B消费数据。A没生产完,B就得等着。这时候可以用两个信号量:

  • empty:初始值为缓冲区大小,表示空闲位置数
  • full:初始值为0,表示已填充数据数
// 生产者
sem_wait(&empty);  // 有空位吗?
// 放入数据...
sem_post(&full);   // 通知消费者

// 消费者
sem_wait(&full);   // 有数据吗?
// 取出数据...
sem_post(&empty);  // 通知生产者

你看,这就是典型的「接力赛」——生产者跑完一棒,消费者才能接上。

二、信号量的死锁问题

死锁这东西,说白了就是「你等我,我等你,谁都不放手」。我遇到过最典型的场景是两个进程各自持有一个信号量,然后去申请对方的。

避坑指南:我曾经在一个数据采集项目中,进程A持有信号量S1,等待S2;进程B持有S2,等待S1。结果两个进程都卡死了,看门狗都救不回来。最后只能手动kill进程。

避免死锁的几个原则:

  • 固定顺序:所有进程申请多个信号量时,按同样的顺序申请。比如先S1后S2,那就都这么干。
  • 使用trywait:申请不到就放弃,别死等。
  • 超时机制:这个下面会细说。

三、信号量的超时机制

很多时候,我们不能让进程无限期地等下去。比如一个传感器采集进程,如果等不到信号量,5秒后应该报错而不是死等。

POSIX信号量提供了sem_timedwait函数:

#include <semaphore.h>
#include <time.h>

struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
ts.tv_sec += 5;  // 超时5秒

int ret = sem_timedwait(&sem, &ts);
if (ret == -1 && errno == ETIMEDOUT) {
    // 超时处理
    printf("等了5秒没等到,不玩了\n");
}

我建议:所有可能阻塞的信号量操作,都加上超时。哪怕超时时间设得很长,也比死锁强。这是我在一个无人值守的嵌入式设备上学到的教训。

四、信号量的撤销操作

进程意外退出时,它持有的信号量资源需要被释放。System V信号量支持SEM_UNDO标志,进程退出时内核会自动撤销它的操作。

struct sembuf sb;
sb.sem_num = 0;
sb.sem_op = -1;   // P操作
sb.sem_flg = SEM_UNDO;  // 加上这个标志

semop(semid, &sb, 1);

POSIX信号量没有这个机制,所以你得自己处理。我一般用atexit注册清理函数,或者用信号处理器来释放信号量。

注意:SEM_UNDO不是万能的。如果进程被SIGKILL杀死,内核虽然会清理,但信号量的状态可能已经乱了。所以设计时尽量让进程优雅退出。

五、信号量的权限控制

信号量是内核对象,不同用户、不同进程能不能访问它,由权限位控制。System V信号量在创建时可以指定权限:

key_t key = ftok("/tmp", 'A');
int semid = semget(key, 1, IPC_CREAT | 0666);  // 所有用户可读写

权限位和文件权限一样:

权限含义
0600仅创建者可用
0644创建者可读写,其他人只读
0666所有人可读写

POSIX命名信号量也有权限参数:

sem_t *sem = sem_open("/mysem", O_CREAT, 0644, 1);

我的习惯:嵌入式设备上一般用0666,省得权限问题折腾。但在服务器上,一定要按最小权限原则来。

六、信号量的应用场景

我总结了几种常见场景:

  • 资源池管理:比如限制同时访问数据库的连接数,信号量初始值设为最大连接数。
  • 生产者-消费者:上面已经讲过了,用两个信号量配合。
  • 读写锁:可以用信号量实现,但更推荐直接用读写锁。
  • 任务调度:多个工作进程抢任务,信号量初始值设为任务队列长度。

七、信号量与共享内存的配合

这是最经典的组合。共享内存负责传数据,信号量负责同步。

我画了一张图来说明它们的关系:

进程A (生产者) 进程B (消费者) 共享内存 数据缓冲区 信号量 empty=1, full=0 写数据 读数据

流程很简单:

  1. 进程A先P(empty),检查共享内存是否有空位
  2. 有的话,写数据到共享内存
  3. 然后V(full),通知进程B有数据了
  4. 进程B这边,P(full)等数据,读到后V(empty)释放空间

八、综合案例:多进程日志系统

最后,我给大家一个完整的例子。这是一个多进程日志写入系统,多个进程同时写日志,用信号量保证日志文件不被写乱。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/sem.h>
#include <sys/shm.h>
#include <string.h>
#include <time.h>

#define LOG_SHM_KEY 0x1234
#define LOG_SEM_KEY 0x5678

// 共享内存结构
typedef struct {
    char data[1024];
    int  ready;  // 0: 空闲, 1: 有数据
} log_buf_t;

// 信号量操作封装
void sem_p(int semid) {
    struct sembuf sb = {0, -1, SEM_UNDO};
    semop(semid, &sb, 1);
}

void sem_v(int semid) {
    struct sembuf sb = {0, 1, SEM_UNDO};
    semop(semid, &sb, 1);
}

int main() {
    // 创建信号量
    int semid = semget(LOG_SEM_KEY, 1, IPC_CREAT | 0666);
    semctl(semid, 0, SETVAL, 1);  // 初始值1,互斥信号量
    
    // 创建共享内存
    int shmid = shmget(LOG_SHM_KEY, sizeof(log_buf_t), IPC_CREAT | 0666);
    log_buf_t *buf = (log_buf_t *)shmat(shmid, NULL, 0);
    buf->ready = 0;
    
    // 模拟多个进程写日志
    for (int i = 0; i < 5; i++) {
        pid_t pid = fork();
        if (pid == 0) {
            // 子进程
            srand(getpid());
            for (int j = 0; j < 3; j++) {
                sem_p(semid);  // 申请写权限
                
                // 写日志到共享内存
                time_t now = time(NULL);
                snprintf(buf->data, sizeof(buf->data),
                        "[%ld] 进程%d: 第%d条日志\n", now, getpid(), j);
                buf->ready = 1;
                
                printf("进程%d写入: %s", getpid(), buf->data);
                
                sem_v(semid);  // 释放写权限
                sleep(rand() % 2);  // 模拟随机间隔
            }
            exit(0);
        }
    }
    
    // 父进程等待所有子进程结束
    for (int i = 0; i < 5; i++) {
        wait(NULL);
    }
    
    // 清理
    shmdt(buf);
    shmctl(shmid, IPC_RMID, NULL);
    semctl(semid, 0, IPC_RMID);
    
    return 0;
}

关键点:这个例子中,信号量保证了同一时刻只有一个进程在写共享内存。如果没有信号量,多个进程同时写,日志就会混在一起,变成乱码。

嗯,这一章的内容就到这儿。信号量这东西,说白了就是一把锁,但用好了能解决很多并发问题。我建议大家动手跑跑上面的代码,改改参数,看看不同行为下的效果。

记住一句话:信号量操作一定要成对出现,P了就要V,不然等着你的就是死锁。


公众号:蓝海资料掘金营,微信deep3321