3. 共享内存同步:信号量机制、互斥锁在共享内存中的使用、条件变量与屏障
共享内存这玩意儿,速度快是真快,但坑也多。你想想看,多个进程同时往同一块内存里写数据,那不乱套了?我早年做视频处理系统的时候,就吃过这个亏——两个进程同时更新帧缓冲区,结果画面撕裂得一塌糊涂。从那以后,我算是彻底明白了:共享内存不配同步机制,就是一颗定时炸弹。
今天咱们就聊聊,怎么给共享内存上几道保险。说白了,就是信号量、互斥锁、条件变量和屏障这四件套。
3.1 信号量机制:最古老的同步工具
信号量这东西,Dijkstra 老爷子在 1965 年就提出来了。别看它老,但管用。我个人的习惯是:能用信号量解决的问题,绝不整花里胡哨的。
信号量本质上就是一个整数,加上两个原子操作:P(也叫 wait、sem_wait)和 V(也叫 post、sem_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 实战建议
说了这么多,最后给几条我自己的经验:
- 优先用互斥锁。能解决 80% 的同步问题,而且性能最好。
- 信号量用在需要计数的场景。比如限制同时访问共享内存的进程数。
- 条件变量一定要配合互斥锁使用。这是标准用法,别自己瞎搞。
- 屏障别乱用。它会让所有进程互相等待,一旦某个进程挂了,整个系统就瘫了。
- 所有同步对象都要放在共享内存里。这个我强调过,但还是要再说一遍——放错了地方,同步就变成了「各自为政」。
最后一个小建议:调试多进程同步问题,用 strace 或者 gdb 的 attach 功能。我曾经靠 strace -p 抓到一个进程卡在 futex 系统调用上的问题,发现是锁没初始化。嗯,工具用对了,事半功倍。