前言
多线程开发中,竞态条件、线程阻塞、数据错乱是最常见的难题。为了让线程有序、安全访问共享资源,Linux POSIX 提供了三类主流同步工具:条件变量、POSIX无名信号量、读写锁。
本文围绕「如何合理选择同步工具、写出稳定无BUG的多线程代码」展开,从原理、API、易错点、实战案例(生产者消费者、读者写者)逐层讲解,搭配通俗举例与可运行C代码,夯实Linux并发基础。
一、基础概念铺垫
1.1 同步、竞态条件、饥饿
- 同步:在保证共享数据安全的前提下,约束线程执行顺序,避免线程长期拿不到资源饥饿卡死。
- 竞态条件:多线程时序混乱,并发读写共享数据,最终程序结果异常,是线程不安全的根源。
举例:两个线程同时执行 i++ ,指令被拆分穿插执行,数值被覆盖变少。 - 饥饿:某一线程长期无法获取锁、CPU资源,一直阻塞无法运行。
举例:大量读者持续占用读锁,写者一直拿不到写锁,永久等待,就是写者饥饿。
1.2 条件变量基础定义
条件变量本身不是锁,必须和互斥锁配合使用。
作用:线程条件不满足时,主动让出CPU进入休眠等待;其他线程修改条件后发送信号,唤醒等待线程。
生活化例子:食堂打饭,饭没做好(条件不满足),学生排队等待;厨师做好饭菜(修改条件),喊学生来打饭(发送信号唤醒)。
二、条件变量核心详解
2.1 全套POSIX核心API
| 函数 | 作用 | 关键注意事项 |
|---|---|---|
| pthread_cond_init | 动态初始化条件变量 | 动态初始化必须destroy销毁;静态初始化PTHREAD_COND_INITIALIZER无需销毁 |
| pthread_cond_destroy | 销毁条件变量 | 禁止销毁正在被线程等待的cond |
| pthread_cond_wait | 阻塞等待 | 原子操作:释放锁→阻塞休眠;唤醒后自动重新加锁;必须while包裹 |
| pthread_cond_timedwait | 限时等待 | 绝对时间超时自动返回,防止永久卡死 |
| pthread_cond_signal | 唤醒单个线程 | 无等待线程时信号丢失 |
| pthread_cond_broadcast | 唤醒全部线程 | 引发惊群效应,配合while规避逻辑错误 |
重点:pthread_cond_wait 原子三动作
- 原子释放绑定的互斥锁,让其他线程修改共享条件;
- 线程阻塞休眠等待唤醒;
- 被唤醒后,自动重新抢占互斥锁,函数返回。
标准使用范式(必背)
// 等待线程(消费者)模板pthread_mutex_lock(&mutex);// while 循环,解决虚假唤醒、惊群效应while(条件不成立){pthread_cond_wait(&cond,&mutex);}// 安全操作共享资源pthread_mutex_unlock(&mutex);// 唤醒线程(生产者)模板pthread_mutex_lock(&mutex);修改共享条件;pthread_cond_signal(&cond);pthread_mutex_unlock(&mutex);2.2 两个高频易错点
易错1:为什么必须搭配互斥锁?
如果解锁、等待分开执行,会出现信号丢失:
线程A判断条件不满足、解锁后,还没进入等待,线程B快速修改条件并发送信号,A错过信号,永久阻塞。
pthread_cond_wait 将「解锁+等待」合并为原子操作,彻底杜绝该问题。
易错2:为什么必须用while,不能用if?
- 虚假唤醒:系统偶尔会无信号自动唤醒线程,if只会判断一次,会直接错误执行逻辑;
- 惊群效应:broadcast唤醒所有线程,多个线程抢锁,一个线程执行完修改条件,其余线程条件再次失效,while会二次校验。
2.3 简单演示案例:单线程等待唤醒
#include<stdio.h>#include<unistd.h>#include<pthread.h>pthread_cond_tcond=PTHREAD_COND_INITIALIZER;pthread_mutex_tmutex=PTHREAD_MUTEX_INITIALIZER;void*wait_thread(void*arg){while(1){pthread_mutex_lock(&mutex);printf("等待唤醒信号\n");pthread_cond_wait(&cond,&mutex);printf("成功被唤醒\n");pthread_mutex_unlock(&mutex);}}void*signal_thread(void*arg){while(1){sleep(2);pthread_mutex_lock(&mutex);pthread_cond_signal(&cond);printf("发送唤醒信号\n");pthread_mutex_unlock(&mutex);}}intmain(){pthread_tt1,t2;pthread_create(&t1,NULL,wait_thread,NULL);pthread_create(&t2,NULL,signal_thread,NULL);pthread_join(t1,NULL);pthread_join(t2,NULL);return0;}编译运行:gcc test.c -o cond -lpthread,每2秒唤醒一次等待线程。
三、生产者消费者模型(两大主流实现)
生产者消费者是同步最经典场景:缓冲区解耦生产者、消费者,生产满阻塞生产者,消费空阻塞消费者。
方案一:互斥锁+双条件变量实现
- 设计:1把互斥锁保护缓冲区,not_empty 唤醒消费者、not_full 唤醒生产者;环形缓冲区牺牲一格区分空/满。
完整代码、编译方式前文已提供,核心要点: in==out:缓冲区为空; (in+1)%SIZE==out :缓冲区已满;- 所有等待均用while循环;
- 生产完成唤醒消费者,消费完成唤醒生产者。
方案二:POSIX无名信号量实现
3.1 信号量基础
信号量是非负计数器,自带计数不会丢失通知,分为:
- 无名信号量:同进程多线程同步(日常开发常用,pshared=0);
- 有名信号量:跨进程同步。
核心P/V操作:
sem_wait(P):计数-1,值为0则阻塞;sem_post(V):计数+1,唤醒等待线程。
3.2 生产者消费者信号量设计
三个信号量分工:
- empty:空位数量,生产者等待;
- full:数据数量,消费者等待;
- mutex:二元互斥信号量,保护缓冲区并发读写。
执行顺序:先等待资源计数,再加互斥锁,防止死锁。
3.3 条件变量VS信号量对比
| 对比项 | 条件变量+互斥锁 | POSIX信号量 |
|---|---|---|
| 计数 | 无计数器,信号易丢失 | 自带计数器,通知永久保存 |
| 锁依赖 | 强制绑定互斥锁 | 自带同步,共享资源仍需互斥 |
| 定位 | 事件等待、异步通知 | 资源数量限流、计数同步 |
| 适用场景 | 线程池、复杂事件调度 | 连接池、固定容量缓冲区 |
工程选型:业务逻辑复杂优先条件变量;单纯资源限流优先信号量。
四、读写锁与读者写者问题
4.1 读写锁特性
读写锁是优化版互斥锁,适配读多写少场景:
- 无锁:多个读者可同时加读锁,并发读取;
- 有读者在读:写者阻塞;
- 写者持有锁:所有读者、写者全部阻塞。
常用API
- 阻塞:pthread_rwlock_rdlock 读锁、 pthread_rwlock_wrlock 写锁、unlock 解锁;
- 非阻塞: tryrdlock/trywrlock 获取失败立刻返回EBUSY,不会卡死线程。
4.2 读者写者三大策略
- 读者优先(Linux默认):读者持续进入,写者饥饿;读多写少场景性能最优;
- 写者优先:写者优先抢占锁,保证数据新鲜,读者易饥饿;
- 公平策略:按申请顺序排队,实现复杂,极少使用。
4.3 实战案例:读者优先读写锁
多读者并发读取,写者独占修改,前文代码运行可直观观察:读者大批量同时打印,写者单独独占执行。
4.4 读写锁VS互斥锁
- 互斥锁:任何时刻仅一个线程执行,读写均衡、写密集场景使用;
- 读写锁:读多写少场景大幅提升并发性能。
五、Linux主流同步工具汇总
| 同步工具 | 核心特点 | 典型落地场景 |
|---|---|---|
| 互斥锁mutex | 同一时刻仅一个线程进入临界区 | 普通变量互斥保护、写密集并发 |
| 条件变量cond | 事件等待唤醒,无计数器,信号易丢失 | 线程池阻塞队列、异步事件 |
| POSIX信号量 | 自带计数,P/V操作,通知不丢失 | 资源池、固定容量缓冲区限流 |
| 读写锁rwlock | 读者并发、写者独占,默认读者优先 | 缓存、配置文件、读多写少业务 |
六、全文核心总结
- 线程不安全根源是竞态条件,同步工具本质就是约束线程时序、隔离临界区;
- 条件变量必须搭配mutex、while循环、原子wait操作,规避信号丢失、虚假唤醒;
- 生产者消费者两种实现:条件变量灵活通用,信号量计数稳定;
- 读写锁专为读多写少优化,读者优先为系统默认,需警惕写者饥饿;
- 选型口诀:普通互斥用mutex,等待唤醒用cond,数量限选用信号量,多读少写用读写锁。