Linux 线程同步:读写锁
课程:尚硅谷《嵌入式 Linux 应用层开发》第 4 章线程处理
依据:2026-09-29 19:30 录音转写,整理到15:02;课程 PDF 第 150—158 页。
本节边界:讲清读写锁原理、基础 API、未加写锁的错误示例、加写锁后的修正,以及读写线程执行顺序实验的准备。第 158 页开始的“写饥饿”对应录音 17:56 以后,留到下一节。
1. 为什么互斥锁之后还要学习读写锁
互斥锁不区分读和写。只要一个线程持有互斥锁,其他线程都要等待。
但很多共享数据具有“读得多、写得少”的特点:
- 多个线程只读取同一份稳定数据时,可以并发执行;
- 写线程修改数据时,必须独占;
- 写入期间不能让读线程看到一半旧、一半新的状态。
读写锁(read-write lock)用两种加锁方式表达这种规则:
读锁:共享占用,允许多个读者并发 写锁:独占占用,只允许一个写者进入2. 读锁与写锁的兼容关系
| 当前状态 | 新读者申请读锁 | 新写者申请写锁 |
|---|---|---|
| 没有线程持锁 | 可以获得 | 可以获得 |
| 一个或多个线程持有读锁 | 通常可继续获得读锁 | 必须等待全部读锁释放 |
| 一个线程持有写锁 | 必须等待 | 必须等待 |
“通常可继续获得读锁”还受调度策略以及是否已有写者等待等因素影响,不能据此假定读者永远优先。
3. “多个线程同时读没问题”的适用条件
录音用“大家一起看已经写好的黑板”解释读锁。这个比喻成立需要满足:
- 读者只读取,不修改;
- 对象在读取期间仍然存在;
- 没有写者绕过锁修改;
- 所有读者和写者使用同一把读写锁。
如果共享数据可能被写线程修改,读线程也必须获取读锁。否则读线程可能与写线程形成数据竞争,或看到不一致的组合状态。
4.pthread_rwlock_t的使用原则
读写锁类型声明为:
#include<pthread.h>pthread_rwlock_trwlock;教材第 151 页展示了 glibc 内部的联合体定义。应用程序应把pthread_rwlock_t当作不透明类型:
- 只通过
<pthread.h>使用; - 不访问内部成员;
- 不复制读写锁对象;
- 给相关函数传入同一个对象的地址。
5. 初始化与销毁
5.1 静态初始化
默认属性下,可以在定义时初始化:
staticpthread_rwlock_trwlock=PTHREAD_RWLOCK_INITIALIZER;这种方式不再调用pthread_rwlock_init()。
5.2 动态初始化
pthread_rwlock_trwlock;interror=pthread_rwlock_init(&rwlock,NULL);第二个参数是属性对象。传NULL表示使用默认属性。
5.3 销毁
interror=pthread_rwlock_destroy(&rwlock);销毁前必须确认:
- 所有使用它的线程已经结束或不再访问它;
- 当前没有读者或写者持锁;
- 不会再有线程等待这把锁。
静态初始化只表示不需要调用pthread_rwlock_init()。如果锁与进程同寿命,程序退出时系统会回收进程资源;如果对象有独立、可重复的生命周期,则应在安全时显式销毁。
6. 本节五个核心 API
intpthread_rwlock_init(pthread_rwlock_t*restrict rwlock,constpthread_rwlockattr_t*restrict attr);intpthread_rwlock_rdlock(pthread_rwlock_t*rwlock);intpthread_rwlock_wrlock(pthread_rwlock_t*rwlock);intpthread_rwlock_unlock(pthread_rwlock_t*rwlock);intpthread_rwlock_destroy(pthread_rwlock_t*rwlock);| 函数 | 作用 | 获取不到时 |
|---|---|---|
pthread_rwlock_rdlock | 获取读锁 | 有写者阻挡时等待 |
pthread_rwlock_wrlock | 获取写锁 | 只要还有读者或写者,就等待 |
pthread_rwlock_unlock | 释放当前线程持有的一次读锁或写锁 | 不适用 |
同一把读写锁的读锁和写锁都用pthread_rwlock_unlock()释放。
Pthreads 函数成功返回0,否则返回错误号。基础检查写法:
interror=pthread_rwlock_rdlock(&rwlock);if(error!=0){fprintf(stderr,"pthread_rwlock_rdlock: %s\n",strerror(error));}7. 第一个实验:写操作没有加锁
教材第 152—154 页故意让两个写线程修改同一个变量,却不给写操作加锁:
staticintshared_data=0;staticvoid*writer_without_lock(void*arg){inttemp=shared_data+1;sleep(1);shared_data=temp;printf("%s 完成写入,shared_data = %d\n",(char*)arg,shared_data);returnNULL;}sleep(1)是为了放大两个写线程发生交错的机会:
| 步骤 | 写线程 1 | 写线程 2 | shared_data |
|---|---|---|---|
| 1 | 读取 0,算出temp=1 | 0 | |
| 2 | 睡眠 | 读取 0,算出temp=1 | 0 |
| 3 | 睡眠 | 0 | |
| 4 | 写回 1 | 1 | |
| 5 | 写回 1 | 1 |
预期加两次得到 2,实际可能得到 1。这与上一节number++的丢失更新本质相同。
7.1 只有读线程加读锁,为什么仍然错误
教材错误示例中的读线程这样写:
pthread_rwlock_rdlock(&rwlock);printf("shared_data = %d\n",shared_data);pthread_rwlock_unlock(&rwlock);读线程之间可以互相协调,但两个写线程完全没有使用这把锁。读写锁只有在所有访问者共同遵守时才有效。
读者使用 rwlock + 写者绕过 rwlock ↓ 同步协议不完整8. 修正实验:写线程获取写锁
教材第 154—156 页给写操作补上写锁:
staticvoid*lock_writer(void*arg){pthread_rwlock_wrlock(&rwlock);inttemp=shared_data+1;sleep(1);/* 仅为演示持锁期间其他线程会等待 */shared_data=temp;printf("%s 完成写入,shared_data = %d\n",(char*)arg,shared_data);pthread_rwlock_unlock(&rwlock);returnNULL;}执行过程变成:
两个写线程不再同时进入临界区,最终值稳定为 2。
9. 基础完整练习:两个写者和三个读者
下面的例子保留录音中的主要现象,同时补上返回值检查。读者看到 0、1 或 2 都有可能,因为调度顺序不确定;但每次读取都发生在读锁保护下,最终值应为 2。
#include<pthread.h>#include<stdio.h>#include<stdlib.h>#include<string.h>#include<unistd.h>staticintshared_data=0;staticpthread_rwlock_trwlock=PTHREAD_RWLOCK_INITIALIZER;staticvoidcheck_pthread(interror,constchar*operation){if(error!=0){fprintf(stderr,"%s: %s\n",operation,strerror(error));exit(EXIT_FAILURE);}}staticvoid*reader(void*arg){constchar*name=arg;check_pthread(pthread_rwlock_rdlock(&rwlock),"pthread_rwlock_rdlock");printf("%s 读取:%d\n",name,shared_data);sleep(1);/* 演示多个读者可以同时持有读锁 */check_pthread(pthread_rwlock_unlock(&rwlock),"pthread_rwlock_unlock");returnNULL;}staticvoid*writer(void*arg){constchar*name=arg;check_pthread(pthread_rwlock_wrlock(&rwlock),"pthread_rwlock_wrlock");++shared_data;printf("%s 写入:%d\n",name,shared_data);check_pthread(pthread_rwlock_unlock(&rwlock),"pthread_rwlock_unlock");returnNULL;}intmain(void){pthread_tthreads[5];check_pthread(pthread_create(&threads[0],NULL,reader,"reader1"),"pthread_create");check_pthread(pthread_create(&threads[1],NULL,reader,"reader2"),"pthread_create");check_pthread(pthread_create(&threads[2],NULL,writer,"writer1"),"pthread_create");check_pthread(pthread_create(&threads[3],NULL,reader,"reader3"),"pthread_create");check_pthread(pthread_create(&threads[4],NULL,writer,"writer2"),"pthread_create");for(inti=0;i<5;++i){check_pthread(pthread_join(threads[i],NULL),"pthread_join");}printf("最终值:%d\n",shared_data);check_pthread(pthread_rwlock_destroy(&rwlock),"pthread_rwlock_destroy");returnEXIT_SUCCESS;}编译运行:
gcc-Wall-Wextra-pthreadrwlock_basic.c-orwlock_basic ./rwlock_basic9.1 观察重点
多运行几次,输出顺序可能改变:
- 先创建的线程不保证先执行;
- 多个读者可以同时持有读锁;
- 写者必须等所有当前读者释放;
- 写者持锁时,新的读者和其他写者都不能进入;
- 所有线程结束后,最终值为 2。
10. 为什么创建顺序不等于执行顺序
教材第 156—158 页把第二个写线程放到多个读线程之间创建,用来观察读写顺序:
创建 writer1 创建 reader1、reader2、reader3 创建 writer2 创建 reader4、reader5、reader6这只是向系统提交线程的先后顺序。线程何时真正获得 CPU、何时开始申请锁,由调度器和当时的锁状态共同决定。
因此不能写出这样的业务假设:
因为 reader1 先 pthread_create,所以它一定先于 writer2 读取如果业务需要固定顺序,应使用条件变量、信号量、队列或明确的join关系,不能依赖创建顺序和sleep()。
11.sleep()在录音示例中的作用
录音使用sleep()有两个教学目的:
- 放大未加锁写操作的交错,更容易观察丢失更新;
- 延长持有读锁或写锁的时间,更容易看清其他线程是否被阻塞。
sleep()不能提供同步保证。机器负载、调度策略和运行环境改变后,线程顺序仍可能变化。
12. 什么时候读写锁比互斥锁合适
读写锁更适合:
- 共享数据读多写少;
- 一次读取工作并非极短;
- 多个读者并发确实能提高吞吐量;
- 能接受更复杂的锁规则和潜在调度问题。
普通互斥锁可能更合适:
- 写操作频繁;
- 临界区很短;
- 并发读带来的收益很小;
- 更重视代码简单和容易验证。
读写锁并不天然比互斥锁快,应通过实际负载测量。
13. 常见错误
| 错误 | 后果 | 正确做法 |
|---|---|---|
| 读者加锁,写者不加锁 | 同步协议失效,仍有数据竞争 | 所有读写者使用同一把锁 |
写线程误用rdlock | 多个写者可能同时进入 | 修改共享状态必须用wrlock |
| 读取可变数据时完全不加锁 | 可能看到不一致状态 | 获取读锁后再读取 |
忘记unlock | 后续线程长期阻塞 | 每条退出路径都释放锁 |
未持锁却调用unlock | 未定义行为 | 只释放当前线程实际持有的锁 |
| 锁仍被持有时销毁 | 未定义行为 | 所有线程结束并释放后销毁 |
用sleep规定执行顺序 | 结果随调度变化 | 使用正式同步机制 |
| 认为先创建必然先执行 | 业务顺序不可靠 | 显式表达依赖关系 |
| 在读锁中修改共享数据 | 破坏多个读者并发的前提 | 修改时获取写锁 |
14. 录音与教材表述校正
- 转写中的“图形锁”“图像处理”在本节多数应为“读写锁”。
- 读者能够并发,不表示读共享内存永远安全;如果存在写者,读者也必须参与同步。
pthread_rwlock_t的内部结构属于具体实现,应用代码不应依赖其字段。- 动态初始化必须传两个参数:
pthread_rwlock_init(&rwlock, NULL)。 - 静态初始化使用
PTHREAD_RWLOCK_INITIALIZER,不能再对同一对象重复初始化。 - 读锁和写锁都通过同一个
pthread_rwlock_unlock()释放。 - 两个未加锁写线程得到 1 是教学中常见现象,但数据竞争属于未定义行为,不能保证每次都得到同样结果。
- 创建写线程后调用
sleep(3)只能增加随后读到最终值的概率,不能构成严格同步。 - 读写锁释放后究竟由读者还是写者先获得,不能依赖普通程序中的固定猜测。
15. 本节最低掌握标准
学完后应能回答:
- 读锁和写锁分别允许哪些线程并发?
- 为什么读线程也要加锁?
- 为什么写线程必须使用
pthread_rwlock_wrlock()? - 静态初始化和动态初始化怎样写?
- 读锁和写锁分别怎样释放?
- 为什么
pthread_create()的先后顺序不等于实际执行顺序? sleep()为什么不能替代同步机制?- 什么情况下读写锁可能比互斥锁更合适?
最低实践要求:
- 独立写出一个读线程和一个写线程;
- 正确使用
rdlock → 读取 → unlock; - 正确使用
wrlock → 修改 → unlock; - 等待所有线程结束后再销毁读写锁;
- 能解释未加写锁时发生的丢失更新。
16. PDF 页码与录音时间索引
| 主题 | PDF 页码 | 录音时间 |
|---|---|---|
| 读写锁工作原理 | 150 | 00:05—01:00 |
| 类型、初始化与销毁 | 151 | 01:00—04:23 |
rdlock、wrlock、unlock | 151—152 | 02:00—04:23 |
| 未加写锁的实验 | 152—154 | 04:24—11:45 |
| 给写操作添加写锁 | 154—156 | 11:46—13:55 |
| 读写操作顺序实验准备 | 156—158 | 13:56—15:02 |
| 写饥饿 | 158 页起 | 本次截止点之后,下一节学习 |
进一步核对资料:
- POSIX Programmer’s Manual:读写锁初始化与销毁
- POSIX Programmer’s Manual:获取读锁
- POSIX Programmer’s Manual:获取写锁
- POSIX Programmer’s Manual:释放读写锁
17. 下一节预习边界
第 158 页后半开始讨论写饥饿:读者持续到来时,等待中的写者可能长时间无法获得写锁。这个主题对应录音 17:56 以后,不属于本次 15:02 前的学习范围。
继续学习时重点关注:
- 为什么已有写者等待时,新读者是否还能进入会影响公平性;
- Linux 中读写锁属性怎样改变读写优先策略;
- 为什么“读者并发越多越好”并不总是成立。