C语言并发编程实战:顺序控制、生产者消费者与读写锁

陆遥
陆遥
发布时间:2026-10-16| 8人浏览过
原创

在多线程开发中,如何保证执行顺序、避免资源竞争导致的死锁是核心难点。本文通过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。用于通知foo2 Step 1已完成。
  • step2_done:初始值为0。用于通知foo1 Step 2已完成。

操作原理如下:

  1. foo1 执行完Step 1后,调用 V(&step1_done)(V操作/Post)增加信号量值,唤醒等待该信号的foo2。
  2. foo2 阻塞等待 P(&step1_done),收到通知后执行Step 2,完成后调用 V(&step2_done)。
  3. 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. 释放互斥锁
}

死锁场景分析:

  1. 缓冲区已满(slots=0)。
  2. 生产者线程 T1 成功获取 mutex。
  3. T1 尝试获取 slots,但因无空闲空间被挂起(Blocked)。
  4. 此时,因为 mutex 仍在 T1 手中,消费者线程 T2 无法进入临界区执行 sem_post(&sp->slots)。
  5. 结果:T1 等 T2 释放资源,T2 等 T1 释放锁,双方互相等待,系统死锁。

正确实践:先资源后锁

解决思路非常直接:**在进入临界区之前,先确认是否有资源可用**。这样可以将“等待资源”的时间移出互斥锁保护范围。

  1. 生产者:先 wait slots(空闲槽位),确保有位置后再申请 mutex 进入修改缓冲区。
  2. 消费者:先 wait items(已生产物品),确保有数据取后再申请 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 读者行为逻辑

  1. 进入:获取 mutex 保护计数器。readcnt + 1。如果是第一个进入的读者,需要获取 w 锁,阻止后续写者进入。
  2. 离开:获取 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语言 并发编程的内容请搜索本站以前的文章或继续浏览下面的相关文章!

免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。

Python 相关产品和应用

全部下载
Selenium

Selenium

4.51.0 · Windows

用于浏览器自动化和 Web 测试的 Python 工具。

scikit-learn

scikit-learn

1.9.1 · Windows

常用 Python 机器学习算法与模型评估工具库。

Black

Black

26.10.1 · Windows

强调一致性的 Python 自动代码格式化工具。

Ruff

Ruff

0.17.1 · macOS

由 Rust 编写的高速 Python 代码检查和格式化工具。

pytest

pytest

9.1.1 · Windows

简洁而强大的 Python 自动化测试框架。

FastAPI

FastAPI

0.146.0 · Windows

高性能 Python API 框架,支持类型提示和自动接口文档。

Flask

Flask

3.1.3 · Windows

轻量灵活的 Python Web 应用框架。

Django

Django

6.1.2 · Windows

成熟的 Python Web 框架,适合构建内容站点和业务系统。

Spyder

Spyder

6.1.7 · Windows

面向科学计算与数据分析的 Python 集成开发环境。

SciPy

SciPy

1.18.1 · Windows

面向科学、工程和技术计算的 Python 算法库。