C语言并发编程实战:顺序控制、生产者消费者与读写锁
在多线程开发中,如何保证执行顺序、避免资源竞争导致的死锁是核心难点。本文通过C语言实例,深入讲解信号量控制的顺序执行、生产者消费者模型的正确加锁顺序,以及读者优先的读写锁实现逻辑,附带完整源码与关键原理解析。
1、使用信号量控制线程输出顺序
在多任务处理中,虽然线程调度具有不确定性,但通过同步原语可以强制特定的执行逻辑。假设我们需要两个线程协作:foo1负责打印Step 1和Step 3,foo2负责打印Step 2。目标是确保最终屏幕输出严格遵循 "1 → 2 → 3" 的顺序。
第1步:设计同步逻辑
由于foo1必须等待foo2完成Step 2后才能进行Step 3,而foo2必须等待foo1完成Step 1后开始执行,因此需要引入两个信号量:
step1_done:初始值为0。用于通知foo2Step 1已完成。step2_done:初始值为0。用于通知foo1Step 2已完成。
操作原理如下:
foo1执行完Step 1后,调用V(&step1_done)(V操作/Post)增加信号量值,唤醒等待该信号的foo2。foo2阻塞等待P(&step1_done),收到通知后执行Step 2,完成后调用V(&step2_done)。foo1随后阻塞等待P(&step2_done),确认Step 2结束后执行Step 3。
第2步:编写线程函数与初始化
使用 csapp.c(CSAPP教材常用库)中的 Sem_init, Pthread_create 等封装函数可简化底层pthread调用的复杂度。以下是完整实现代码:
#include "csapp.c"
// 定义两个二进制信号量用于流程控制
sem_t step1_done, step2_done;
void* foo1() {
printf("test1 is done\n"); // Step 1
V(&step1_done); // 通知:Step 1结束,允许执行Step 2
P(&step2_done); // 等待:直到Step 2执行完毕
printf("test3 is done\n"); // Step 3
return NULL;
}
void* foo2() {
P(&step1_done); // 等待:直到Step 1执行完毕
printf("test2 is done\n"); // Step 2
V(&step2_done); // 通知:Step 2结束,允许执行Step 3
return NULL;
}
int main()
{
pthread_t tid1, tid2;
// 初始化信号量:共享进程(0),初始计数为0
Sem_init(&step1_done, 0, 0);
Sem_init(&step2_done, 0, 0);
// 创建线程
Pthread_create(&tid1, NULL, foo1, NULL);
Pthread_create(&tid2, NULL, foo2, NULL);
// 主线程阻塞,等待子线程全部回收
Pthread_join(tid1, NULL);
Pthread_join(tid2, NULL);
exit(0);
}
2、生产者消费者模型:避免死锁的关键
生产者-消费者问题是并发编程中最经典的场景。缓冲区容量有限,当缓冲满时生产者需等待,空时消费者需等待。在此过程中,互斥锁(Mutex)管理临界区访问,而资源信号量(Slots/Items)管理可用资源数。
常见错误:错误的加锁顺序导致死锁
许多初学者倾向于先获取互斥锁,再检查资源数量。以下代码存在严重缺陷:
// 错误的插入逻辑
void sbuf_insert_wrong(sbuf_t* sp, int item) {
sem_wait(&sp->mutex); // 1. 先上互斥锁
sem_wait(&sp->slots); // 2. 再等空闲槽位
// ... 放入数据 ...
sem_post(&sp->items); // 3. 增加物品信号量
sem_post(&sp->mutex); // 4. 释放互斥锁
}
死锁场景分析:
- 缓冲区已满(
slots=0)。 - 生产者线程 T1 成功获取
mutex。 - T1 尝试获取
slots,但因无空闲空间被挂起(Blocked)。 - 此时,因为
mutex仍在 T1 手中,消费者线程 T2 无法进入临界区执行sem_post(&sp->slots)。 - 结果:T1 等 T2 释放资源,T2 等 T1 释放锁,双方互相等待,系统死锁。
正确实践:先资源后锁
解决思路非常直接:**在进入临界区之前,先确认是否有资源可用**。这样可以将“等待资源”的时间移出互斥锁保护范围。
- 生产者:先
waitslots(空闲槽位),确保有位置后再申请mutex进入修改缓冲区。 - 消费者:先
waititems(已生产物品),确保有数据取后再申请mutex进入提取数据。
此外,解锁顺序通常建议与加锁顺序相反,这有助于保持代码逻辑的一致性(尽管在现代POSIX信号量中不严格按此顺序也能工作,但这是一个良好的编程习惯)。
完整实现代码
以下修正后的代码展示了标准的结构体定义及正确的加锁时序:
#include <stdio.h>
#include <stdlib.h>
#include <semaphore.h>
typedef struct sbuf {
int *buf; // 堆内存存储的数据
int n; // 缓冲区容量
int front; // 读取指针
int rear; // 写入指针
sem_t mutex; // 互斥锁
sem_t slots; // 空闲槽位数
sem_t items; // 已生产商品数
} subf_t;
// 初始化缓冲区与信号量
void sbuf_init(subf_t* sp, int n) {
sp->n = n;
sp->buf = (int *)calloc(n, sizeof(int));
sp->front = 0;
sp->rear = 0;
sem_init(&sp->mutex, 0, 1); // 初始互斥量为1
sem_init(&sp->slots, 0, n); // 初始空槽为N
sem_init(&sp->items, 0, 0); // 初始商品数为0
}
// 插入数据(生产者)
void sbuf_insert(subf_t* sp, int item) {
// 【关键点】先判断资源,再获取锁
sem_wait(&sp->slots); // 1. 等待有空位
sem_wait(&sp->mutex); // 2. 进入临界区
// 业务逻辑
sp->buf[(++sp->rear) % (sp->n)] = item;
// 退出临界区并更新状态
sem_post(&sp->mutex); // 3. 释放锁
sem_post(&sp->items); // 4. 增加商品信号量
}
// 移除数据(消费者)
int sbuf_remove(subf_t* sp) {
int item;
// 【关键点】先判断资源,再获取锁
sem_wait(&sp->items); // 1. 等待有商品
sem_wait(&sp->mutex); // 2. 进入临界区
item = sp->buf[(++sp->front) % (sp->n)];
// 退出临界区并更新状态
sem_post(&sp->mutex); // 3. 释放锁
sem_post(&sp->slots); // 4. 增加空位信号量
return item;
}
3、读者-写者问题:实现读者优先策略
在多读者并发环境中,我们通常希望尽可能多地并行处理读操作(Reader-Priority)。这意味着只要有读者在进行写,新的读者就可以直接进入;只有当没有读者且写者就绪时,才允许写者执行。这种策略可以提高读吞吐量,但可能导致写者长期等待(饥饿)。
3.1 核心机制解析
为了维护这一逻辑,我们需要两类锁:
w(Writer Lock):用于排斥其他写者或“第一个”读者,确保同一时刻只有一个写者或读者组。它实际上是临界区的排他锁。mutex(Count Lock):专门用于保护readcnt(当前读者计数变量)的自增/自减操作,防止并发计数错误。
3.2 读者行为逻辑
- 进入:获取
mutex保护计数器。readcnt+ 1。如果是第一个进入的读者,需要获取w锁,阻止后续写者进入。 - 离开:获取
mutex保护计数器。readcnt- 1。如果自己是最后一个离开的读者,则释放w锁,允许等待的写者进入。
3.3 写者行为逻辑
写者不需要关心有多少个读者在读,也不需要维护计数器。它只需要尝试获取 w 锁。如果获取成功,说明当前既无写者也无读者;否则必须等待。
int readcnt = 0;
sem_t mutex = 1; // 保护reader计数的锁
sem_t w = 1; // 实际上的读写排斥锁
// 读者函数
void reader() {
while (1) {
P(&mutex); // 申请保护计数器的锁
readcnt++; // 读者数加一
if (readcnt == 1) { // 如果是第一个读者,要拦住写者
P(&w); // 上锁,让writer阻塞
}
V(&mutex); // 释放计数锁
/* --- 执行读取操作(Critical Section for Readers) --- */
P(&mutex); // 再次申请保护计数器的锁
readcnt--; // 读者数减一
if (readcnt == 0) { // 如果是最后一个读者,放行写者
V(&w); // 解锁,允许writer继续
}
V(&mutex); // 释放计数锁
}
}
// 写者函数
void writer() {
while (1) {
P(&w); // 试图获取全局排他锁
/* --- 执行写入操作(Critical Section for Writers) --- */
V(&w); // 释放排他锁
}
}
总结
本文覆盖了C语言并发编程的三个基础支柱:顺序控制、死锁规避与读写分离。关键在于理解信号量的物理意义(不仅仅是锁,更是资源计数器)以及在什么时机持有锁。以上就是关于C语言通过案例讲解并发编程模型的详细内容,更多关于C语言 并发编程的内容请搜索本站以前的文章或继续浏览下面的相关文章!
免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。









