3. 共享内存同步:信号量机制、互斥锁在共享内存中的使用、条件变量与屏障

共享内存这玩意儿,速度快是真快,但坑也多。你想想看,多个进程同时往同一块内存里写数据,那不乱套了?我早年做视频处理系统的时候,就吃过这个亏——两个进程同时更新帧缓冲区,结果画面撕裂得一塌糊涂。从那以后,我算是彻底明白了:共享内存不配同步机制,就是一颗定时炸弹

今天咱们就聊聊,怎么给共享内存上几道保险。说白了,就是信号量、互斥锁、条件变量和屏障这四件套。

3.1 信号量机制:最古老的同步工具

信号量这东西,Dijkstra 老爷子在 1965 年就提出来了。别看它老,但管用。我个人的习惯是:能用信号量解决的问题,绝不整花里胡哨的

信号量本质上就是一个整数,加上两个原子操作:P(也叫 waitsem_wait)和 V(也叫 postsem_post)。

  • P 操作:把信号量减 1,如果结果小于 0,进程就阻塞等待。
  • V 操作:把信号量加 1,如果结果大于等于 0,唤醒一个等待的进程。

在共享内存里用信号量,关键一步是:信号量本身也要放在共享内存里。不然的话,每个进程各自持有一个信号量,那还同步个啥?

核心要点:POSIX 信号量有两种模式——命名信号量和基于内存的信号量。共享内存场景下,我们通常用 sem_init 的第二个参数设为非零值,表示在进程间共享。

#include <sys/mman.h>
#include <semaphore.h>
#include <fcntl.h>
#include <unistd.h>

// 创建共享内存
int shm_fd = shm_open("/my_shm", O_CREAT | O_RDWR, 0666);
ftruncate(shm_fd, sizeof(shared_data));

// 映射共享内存
shared_data *data = mmap(NULL, sizeof(shared_data), 
                         PROT_READ | PROT_WRITE, 
                         MAP_SHARED, shm_fd, 0);

// 初始化信号量(放在共享内存中)
sem_init(&data->mutex, 1, 1);  // 第二个参数 1 表示进程间共享

// 使用
sem_wait(&data->mutex);
// 临界区操作...
sem_post(&data->mutex);

我曾经踩过的坑:信号量初始化时,第二个参数忘了设成 1,结果只在单进程内有效。调试了整整一个下午,才发现是线程共享而非进程共享。嗯,这种低级错误,犯一次就够了。

3.2 互斥锁在共享内存中的使用

信号量虽然万能,但互斥锁用起来更顺手。特别是 POSIX 线程库里的 pthread_mutex_t,配合 PTHREAD_PROCESS_SHARED 属性,就能在进程间用了。

你可能会问:互斥锁和信号量有啥区别?说白了,互斥锁是信号量的一种特例——它只有 0 和 1 两个状态,专门解决互斥问题。而信号量可以管理多个资源。

我个人建议:如果只是保护临界区,用互斥锁就够了。信号量更适合生产者-消费者这种需要计数的场景。

#include <pthread.h>
#include <sys/mman.h>

typedef struct {
    pthread_mutex_t lock;
    int shared_counter;
} shared_data;

// 初始化互斥锁
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED);

shared_data *data = mmap(...);
pthread_mutex_init(&data->lock, &attr);

// 使用
pthread_mutex_lock(&data->lock);
data->shared_counter++;
pthread_mutex_unlock(&data->lock);

小技巧:互斥锁初始化后,记得检查返回值。我见过太多人忽略 pthread_mutex_init 的返回值,结果锁没初始化成功,程序跑起来就死锁。加一行 if (ret != 0) { perror("mutex init failed"); exit(1); },能省你半天调试时间。

3.3 条件变量:让进程学会等待

互斥锁解决了「同时访问」的问题,但解决不了「等待某个条件成立」的问题。比如:进程 A 要等进程 B 把数据准备好再读。用互斥锁轮询?那 CPU 占用率直接拉满。

这时候就需要条件变量了。它让进程可以在某个条件不满足时主动休眠,等条件变了再被唤醒。

条件变量在共享内存里的用法,和互斥锁类似——也要设置 PTHREAD_PROCESS_SHARED 属性。

typedef struct {
    pthread_mutex_t lock;
    pthread_cond_t cond;
    int data_ready;
} shared_data;

// 初始化
pthread_condattr_t cattr;
pthread_condattr_init(&cattr);
pthread_condattr_setpshared(&cattr, PTHREAD_PROCESS_SHARED);
pthread_cond_init(&data->cond, &cattr);

// 生产者
pthread_mutex_lock(&data->lock);
data->data_ready = 1;
pthread_cond_signal(&data->cond);  // 唤醒等待的消费者
pthread_mutex_unlock(&data->lock);

// 消费者
pthread_mutex_lock(&data->lock);
while (!data->data_ready) {
    pthread_cond_wait(&data->cond, &data->lock);  // 自动释放锁并等待
}
// 条件满足,继续执行
pthread_mutex_unlock(&data->lock);

注意这个 while 循环:条件变量可能被「虚假唤醒」(spurious wakeup),所以不能用 if,必须用 while 重新检查条件。这是 POSIX 标准规定的,不是 bug,是 feature。

3.4 屏障:让所有进程齐步走

屏障(Barrier)是个好东西。它让一组进程全部到达某个点之后,再一起继续往下走。有点像体育课上的「集合——所有人到齐了再开始训练」。

POSIX 屏障 pthread_barrier_t 也支持进程间共享,设置方法跟前面一样:

typedef struct {
    pthread_barrier_t barrier;
    // 其他共享数据...
} shared_data;

// 初始化
pthread_barrierattr_t battr;
pthread_barrierattr_init(&battr);
pthread_barrierattr_setpshared(&battr, PTHREAD_PROCESS_SHARED);
pthread_barrier_init(&data->barrier, &battr, NUM_PROCESSES);

// 每个进程执行到这里时等待
pthread_barrier_wait(&data->barrier);
// 所有 NUM_PROCESSES 个进程都到达后,一起继续

我在做分布式渲染系统时用过这个。4 个进程各自渲染一帧的 1/4,渲染完必须等所有进程都完成,才能拼合成完整画面。屏障在这里就特别合适。

注意事项:屏障的计数必须和实际进程数一致。少了一个进程,所有进程都会永远等下去。我曾经在动态创建进程的场景下用过屏障,结果进程数算错了,整个系统卡死——嗯,那是个难忘的加班夜。

3.5 四种同步机制对比

机制 适用场景 特点 注意事项
信号量 资源计数、生产者-消费者 通用性强,可管理多个资源 注意初始化时的进程共享标志
互斥锁 保护临界区 简单高效,只解决互斥 必须设置 PTHREAD_PROCESS_SHARED
条件变量 等待特定条件成立 避免轮询,节省 CPU 小心虚假唤醒,用 while 检查条件
屏障 多进程同步到达某个点 集体等待,一起出发 进程数必须准确,否则死锁

3.6 实战建议

说了这么多,最后给几条我自己的经验:

  1. 优先用互斥锁。能解决 80% 的同步问题,而且性能最好。
  2. 信号量用在需要计数的场景。比如限制同时访问共享内存的进程数。
  3. 条件变量一定要配合互斥锁使用。这是标准用法,别自己瞎搞。
  4. 屏障别乱用。它会让所有进程互相等待,一旦某个进程挂了,整个系统就瘫了。
  5. 所有同步对象都要放在共享内存里。这个我强调过,但还是要再说一遍——放错了地方,同步就变成了「各自为政」。

最后一个小建议:调试多进程同步问题,用 strace 或者 gdbattach 功能。我曾经靠 strace -p 抓到一个进程卡在 futex 系统调用上的问题,发现是锁没初始化。嗯,工具用对了,事半功倍。