1. 读写锁到底想解决什么问题
1.1 读者写者模型:先分清什么是“读多写少”
上周有个同事拿压测报告来找我,说网关程序吞吐量上不去,perf top里大半时间都耗在pthread_rwlock_rdlock上。我第一反应不是让他去优化锁本身,而是问他:你确认自己的场景真的适合读写锁吗?这问题不搞清楚,后面所有优化都是白忙。
读写锁面向的是一类经典并发模型:读者写者模型。在这个模型里,数据访问分两种角色——读者只读取不修改,写者要修改。多个读者之间天然可以并发,因为大家读的都是同一份数据,互不干扰;但只要有写者进来,就必须和所有读者、以及其他写者完全互斥,否则读到一半的数据就是脏的。
这种模型对应着大量真实业务:全局配置表(更新频率极低但每个请求都会读)、DNS路由表、指标计数器汇总、内存中的订阅关系表。这类数据的共同特征是读频率高出写频率几个数量级,我习惯用一个粗糙的量化指标来判断:读请求量 / 写请求量,如果这个比值小于 10:1,读写锁基本没什么优势;只有当读占绝对主导,比如 50:1、100:1 甚至更高,读写锁的共享读路径才真正值得投入。
判断“读多写少”不能只看线程数量。写线程只有 1 个、读线程有 20 个,不一定就是读多写少;要看真实请求频率和临界区执行时间。我见过一个项目,读线程每天就查几次配置,写线程在高峰期每秒更新上千次,这种场景是“写多读少”,用读写锁纯粹是给写路径加额外的判断开销。
1.2 为什么互斥锁在这类场景下不划算
如果不用读写锁,最朴素的方案就是互斥锁(mutex)保护整份数据。互斥锁的逻辑非常粗暴:同一时刻只有一个线程能进入临界区,其他线程统统排队。问题在于,这个“其他线程”里,绝大多数只是想来读一下数据,互相之间根本没有冲突,却被互斥锁强行串行化了。
打个比方:一堆人想看同一块公告栏,互斥锁的做法是只允许一个人站在公告栏前面看,其他人必须在后面排队,哪怕所有人只是想看同一行字;读写锁的做法是允许所有读者同时围上去看,只有当有人要贴新公告时,才把大家清场、独自更新。
互斥锁在这种场景下有两个层面的浪费。第一是排队延迟:读请求本身只需要微秒级甚至纳秒级的操作,但一个写请求或者另一个读者的临界区可能偶尔拖长,后面的读者全被无辜阻塞。第二是缓存一致性开销:多个读者轮流持有互斥锁,锁变量本身在一个缓存行上被不断改来改去,每次改都会触发其他核心上缓存行的失效,这种流量在高并发时非常可观。这些开销在低竞争时看不出差别,一压测到几百并发就开始暴露。
读写锁的引入就是想让读者们“解放出来”:读锁之间完全共享,只在读者和写者、写者和写者之间互斥。在理想情况下,读者的锁开销应当趋近于一次原子操作加一次计数递增,比互斥锁的那一套独占-唤醒-移交流程轻量得多。
2. 读写锁的工作原理与实现细节
2.1 一把锁里如何同时表达“读者共享”与“写者独占”
读写锁的底层数据结构远没有外部语义那么复杂。以pthread_rwlock_t为例,glibc 内部维护的是一组计数器:当前持有读锁的读者数量、正在等待的读者数量、正在等待的写者数量、当前持有写锁的写者标识。读锁的获取本质是“读者计数加一”,只要没有人持有写锁,任何读者都可以直接做原子递增然后进入临界区;写锁的获取则是“检查当前读者计数是否为 0、是否已有写者”,满足条件后打上写者标志,独占临界区。
这个设计有一个非常关键的隐含含义:读者之间不会互相阻塞,但写者的优先级完全由实现策略决定。因为读者只需要改一个计数器,修改期间必须保证原子性,所以读写锁内部实际还有一个更小的自旋锁或者原子操作序列来控制计数器的更新。这就是为什么地方小厂封装的“读写锁”经常被人吐槽性能不行——锁里面还套着一把更小的锁,处理流程稍微笨重一点,开销就上去了。
解读者拿锁时,首先要做一次原子读/写来判断写者是否在场。很多实现里,读者如果发现有写者正在等待,会主动让路,而不是强行进入。这个“让路”逻辑是公平性的核心,下面重点展开。
2.2 读优先还是写优先:调度策略决定公平性
读写锁实现策略通常分三类:读优先、写优先、公平策略。不同策略直接影响业务行为,而且很多坑都藏在这里。
读优先的含义是:只要当前没有写者持有锁,新来的读者即使知道后面有写者在排队,也可以直接进入临界区。长尾效应下,写者可能等很久都轮不到,因为读者源源不断地插队,写者无限期饥饿。早期很多实现默认读优先,因为实现起来简单,读者只关心“有没有写者持有锁”,不关心“有没有写者在排队”。如果你的业务里写者偶尔才来一次,读优先其实问题不大;一旦写请求定期出现且对延迟敏感,写者饥饿就是致命伤。
写优先策略则要求:一旦有写者在等待,新到的读者一律先阻塞,让写者尽快拿锁。代价是新读者本来可以安全并发,却被迫排队,稍许降低读吞吐,但换来了写者延迟的可控性。现代 glibc 较新版本重写后的pthread_rwlock_t默认就走这个路线,通过一个内部全局序号保证写者不会长期排不上。
公平策略更严格,所有读、写请求严格按照到达顺序处理,几乎等价于在锁内部维护一个 FIFO 队列,实现成本高,实际项目里用得少。选型时的判断标准很简单:如果你的系统里写操作有明确的延迟 SLA,优先选择写优先语义;如果写操作只是后台偶发同步,读优先带来的吞吐收益更明显。这件事要在锁选型阶段就定下来,否则上线后修改锁语义很痛苦。
2.3 glibc 与内核中的落地方式
用户态开发者接触最多的就是pthread_rwlock_t。早期 glibc 的内部实现使用一个自旋锁保护计数器和等待队列,在高并发读者场景下,读者要折叠地争抢内部自旋锁,表现为pthread_rwlock_rdlock里 CPU 占用率居高不下。较新的 glibc 重写了这套实现,本质上把锁状态分成多个 futex 变量,读者和写者分别走不同的等待队列,配合全局序号保证写者优先级。操作系统层面则基于 futex:先尝试在用户态用原子操作完成加锁,抢不到才进入内核休眠等待,避免每次加锁都做系统调用。
这套“用户态自旋 + 内核唤醒”的机制,决定了读写锁在小临界区、低竞争场景下非常快,因为原子操作在用户态一两个指令就完成了;在临界区超大或读者极多、缓存行冲突剧烈的场景下则可能掉得比互斥锁还狠。这个特性会在第 5 章单独展开。
Linux 内核内部也有一套自己的读写锁体系:传统的是rwlock_t,读者之间共享、写者独占;现代版本升级为排队读写锁(queued rwlock),把写者放进等待队列,读者仍然可以并发。内核态没有用户态虚拟机机制,用的是原子指令 + CPU 停顿指令 + 自旋等待的中断屏蔽等手段,语义和用户态的写优先/读优先类似,但在可重入、超时、进程共享等方面支持远不如 pthread 版本丰富。
顺带说一句,PJSIP 这类多媒体库,PJLIB 抽象层里封装的 rwmutex 在 Linux 平台上最终也是落到pthread_rwlock_t,很多嵌入式开发者找不到“读写锁”到底在哪里,就是因为中间隔了一层平台抽象。排查性能问题时,还是要直接看底层拿锁热点。
3. 应用场景选型:读写锁该用在哪儿
3.1 典型可用场景与判断标准
我实际项目里用读写锁最顺手的地方,其实是无状态配置热更新类模块。这类模块的特点是:写操作极其低频(业务发布时更新一次),读操作极其高频(每个请求进来都要读配置、做判断)。用互斥锁会让所有请求为一次更新排队,用读写锁则可以做到绝大多数请求零等待。
第二种典型场景是缓存字典/映射表。比如一个 IP 到机房信息的映射表,更新数据靠后台周期任务,读数据的是每个请求路径。如果你对一致性要求不太高,甚至可以用双缓冲或者版本号方案,但需要强一致时读写锁是很自然的选择。
第三种是读写比例明确且写路径可控的共享数据结构,比如路由表、订阅关系表。这类结构里写操作都是批量整体替换或者少数几项变动,读操作频繁遍历;使用读写锁可以保证读遍历期间数据不抖动,同时写操作不需要持有全局互斥。
判断一个场景该不该上读写锁,我在评审代码时只问三个问题:读频率和写频率的差距是否超过一个数量级?临界区长度是否在微秒级以内?写者延迟是否敏感?三个问题里有两个回答“是”,读写锁才值得考虑;否则直接互斥锁或者干脆无锁,别折腾。
3.2 哪些场景千万别用读写锁
有些场景用读写锁是纯粹的负优化,我踩过、也帮别人擦过多次屁股。
第一类是写操作本身就很频繁的场景。举个真实例子:一个实时计数器聚合器,多个线程在更新统计数据,只有后台审计任务偶尔读取。按 1.1 节的判断标准这已经是“写多读少”,读写锁在这种场景下把写者的临界区从一次原子加扩展成了两次原子操作加分支判断,还额外引入读者计数维护,写路径开销反而变高。直接std::atomic<uint64_t>累加就完了。
第二类是临界区大且 IO 密集的场景。网上很多半吊子文章教你“读复杂计算时用读锁”,实际上如果你的临界区里要做数据库查询、文件写入或者慢速网络 IO,读写锁不仅保护不了性能,还会把持锁时间拉长,导致其他读者大量排队,系统总吞吐断崖下跌。这种情况正确的第一反应是缩小临界区,而不是换锁。
第三类是可重入需求复杂的场景。同一个线程先拿了读锁,随后在临界区里又尝试拿写锁,这种“锁升级”在 pthread 语义下是未定义行为——可能阻塞,可能死锁。我曾经见过一个服务,外层读锁保护整个请求处理函数,内层某处异常路径想升级成写锁做配置回滚,压测 10 分钟就挂死。遇到这种需求别硬用读写锁,把两级操作拆开,或者把升级逻辑单独抽出去另做一把锁。
3.3 读写锁、RCU、分片锁怎么选
很多读者一上来就问“读写锁比互斥锁快吗”,这个问题没有答案,因为选型维度不只是一把锁的性能。我通常用一张简单决策表来跟同事对齐:
| 场景特征 | 优先方案 | 理由 |
|---|---|---|
| 读占比极高,写极少,强一致 | 读写锁 | 读者并发,写者可靠,语义直观 |
| 读占比极高,写极少,读可容忍短暂旧数据 | RCU / 双缓冲 / 版本号 | 读路径无锁,完全没有锁开销 |
| 写也不慢,读写比例中等 | 互斥锁 | 避免读写锁内部的计数开销和调度复杂化 |
| 数据可分区,各分区独立更新 | 分片锁 | 锁粒度细分,吞吐线性提升 |
| 只保护单个计数器/布尔标志 | 原子变量 | 一条指令的事,别上锁 |
RCU(Read-Copy-Update)的内核思想是把更新变成三阶段:复制一份新数据、修改、原子切换指针。读者整个路径上不碰任何锁,只需要防止在切换期间读到半截数据。用户态有 liburcu 可用,但线程生命周期管理、内存回收都要自己负责,比较重。实际项目中我见到最多的是“双缓冲 + 版本号”,实现极其简单,读路径只做一次指针读解引用,写路径先备一份新值再原子换指针,在配置管理场景下完全够用。
分片锁则是另一种思路:数据本身可以按 key 拆成 N 个分区,每个分区一把独立的锁,本来就没必要全局锁。比如聊天服务的会话在线状态表,按用户 ID 哈希分 64 片,每片一把互斥锁,压力分摊后读写的并发度都能上去,这比任何全局读写锁都更划算。锁永远服务于数据独立性,数据能拆就别整体锁。
4. 实操范例:从接口到压测的完整流程
4.1 pthread_rwlock 的常用接口与陷阱
Linux 用户态代码里直接跟pthread_rwlock_t打交道最多。接口本身不多:pthread_rwlock_init负责初始化并设置属性,pthread_rwlockattr_t两个常用属性是PTHREAD_PROCESS_SHARED(锁可用于多进程)和PTHREAD_PROCESS_PRIVATE(默认,多线程内使用)。pthread_rwlock_rdlock/pthread_rwlock_wrlock是阻塞锁,pthread_rwlock_tryrdlock/pthread_rwlock_trywrlock是非阻塞尝试,pthread_rwlock_timedrdlock/pthread_rwlock_timedwrlock带超时,最后pthread_rwlock_unlock统一释放。
几个容易踩的坑我列一下,都是生产环境里真实出过事的:
- 忘记 init 就直接用。静态初始化
PTHREAD_RWLOCK_INITIALIZER可以,但动态分配的锁必须pthread_rwlock_init,否则内部字段全是随机数,表现是偶发死锁。 - 读写锁不是递归锁。同一个线程不能重复拿读锁或写锁,拿两次就是自锁死局。
- 释放时的配对问题。谁加的锁谁负责解锁,跨线程解锁(拿锁线程和放锁线程不是同一个)在某些实现里会触发未定义行为,glibc 的写锁校验可能在
unlock时直接崩。 - 进程共享锁要放在共享内存里。很多人把
pthread_rwlock_t定义在普通全局区,然后fork后给子进程用,这种行为完全没有保障。正确做法是把锁放进mmap出的共享内存段,且所有进程用同一个地址映射。
身边人问我最多的还有一个:“加了PTHREAD_PROCESS_SHARED之后,为什么锁不灵了?”十有八九是共享内存映射位置不对,或者锁结构里带的工作指针没有在 fork 后重新初始化,排查半天其实是最基础的问题。
4.2 一个配置热更新模块的完整代码
直接看代码。下面这段是一个典型配置模块,一个全局配置对象,多个业务线程并发读取,管理线程定期用新配置覆盖。用读写锁保护,简洁可靠:
#include <pthread.h> #include <stdio.h> #include <string.h> #include <stdlib.h> typedef struct { char server_name[64]; int max_connections; int request_timeout_ms; } app_config; static app_config g_config; static pthread_rwlock_t g_cfg_lock = PTHREAD_RWLOCK_INITIALIZER; void config_update(const app_config *new_cfg) { pthread_rwlock_wrlock(&g_cfg_lock); memcpy(&g_config, new_cfg, sizeof(app_config)); pthread_rwlock_unlock(&g_cfg_lock); } app_config config_get(void) { app_config snapshot; pthread_rwlock_rdlock(&g_cfg_lock); memcpy(&snapshot, &g_config, sizeof(app_config)); pthread_rwlock_unlock(&g_cfg_lock); return snapshot; } int main(void) { app_config cfg = {0}; pthread_rwlock_init(&g_cfg_lock, NULL); for (int i = 0; i < 100; i++) { snprintf(cfg.server_name, sizeof(cfg.server_name), "node-%d", i); cfg.max_connections = 1000 + i; cfg.request_timeout_ms = 200; config_update(&cfg); app_config current = config_get(); printf("server=%s max_conn=%d\n", current.server_name, current.max_connections); } pthread_rwlock_destroy(&g_cfg_lock); return 0; }这段代码的读路径是“拿读锁、拷贝一份快照、释放读锁”。为什么我选择链 malloc 直接拷贝而不是在临界区里直接返回指针?因为不建议让业务线程持有锁去用数据——业务代码太长,容易在持锁期间走远程调用、sleep 或者触发回调,把锁的临界区无限拉长。快照语义虽然每次多一次拷贝,但拷贝的数据结构控制在几十字节内,代价可忽略,锁的粒度却变得极其干净:临界区只有一条memcpy。
如果配置结构很大,拷贝代价无法忽略,可以换成双缓冲方案:读者读g_current指针指向的对象,写者在另一个对象上改完后原子交换指针。两种情况不冲突,按实际大小选即可。
4.3 锁竞争性能评测的方法与评价指标
很多人张口就是“读写锁更快”,但怎么证明?我的建议是写一个专门的压力程序,而不是拿业务代码拍脑袋。测试里需要控制几个变量:总并发线程数、读者线程数与写者线程数比例、临界区操作耗时(加一个小的 CPU 计算循环模拟)、总执行时长。每个线程跑完后统计操作成功次数,最终算整体 QPS 和平均/尾延迟。
void *reader_worker(void *arg) { struct timespec ts = { .tv_sec = 0, .tv_nsec = 50 }; for (int i = 0; i < 1000000; i++) { app_config c = config_get(); nanosleep(&ts, NULL); // 模拟临界区内的读取处理 (void)c; } return NULL; }压测时我会重点观察几个指标:
- 总吞吐:单位时间完成的读+写操作总数。换锁前后对比,直接看收益。
- 锁等待时间:能在代码里埋点统计最准,分别记录写锁等待时间和读锁等待时间。写锁平均等待超过 1 毫秒就该警惕了。
- CPU 开销:
perf stat -e cache-misses,cache-references,context-switches配合perf record抓pthread_rwlock_*符号的 CPU 占比,能看到锁本身消耗了多少 CPU。本机压过一组数据:4 核虚机上,临界区 50 纳秒、读写比例 50:1 时,读写锁的读路径 CPU 开销大约是互斥锁的 60%,写路径则基本持平;当读写比例降到 5:1,读写锁整体已经不如互斥锁了。这个临界点因 CPU 架构而异,但趋势基本一致。
压测还有一个容易忽略的点:务必测试 NUMA 场景。如果你用的机器是多路 CPU,读者分散在不同 Node 上,读写锁内部每次原子操作都会触发跨 Node 缓存一致性流量,延迟增加很明显。跑到最后,你会发现读写锁在某些硬件上的表现甚至不如进程内的原子变量配合内存屏障。
5. 性能优化:读写锁之外还有更狠的路子
5.1 隐形成本:读路径的缓存一致性开销
很多人对读写锁有个误解:认为读锁之间不冲突,所以“并发读是零成本”。实际上,所有读者在进入临界区前都要执行一次“读者计数加一”,退出时要“读者计数减一”。这个计数器变量是全局共享的,每个读者核心上的原子操作都会让它所在缓存行失效,再重新拉取。多个读者同时操作同一个计数变量,本质上是把一份缓存行在核心之间来回踢皮球,这就是所谓的缓存行乒乓(cache line bouncing)。
后果是:临界区越短、读者越多,锁本身的缓存一致性开销占比就越高。极端情况下,一个只读一个整数的锁保护模块,读者之间互相踢缓存行的时间可能比真正读数据的时间还长。在高并发小临界区场景,读写锁并不是无锁的“廉价替代品”,而是一个隐形的带宽消耗者。
这就是为什么第 3.3 节我强调“数据能拆就别整体锁”。分片锁把全局计数拆成了 N 份,每份只被一部分线程触碰,缓存行冲突范围大幅缩小。如果数据结构允许,分片往往比读写锁更能提升吞吐。
5.2 两种立竿见影的替代方案:双缓冲与分片
先讲双缓冲。它的核心思想是准备两份数据区域,读线程只读其中一份,写线程在另一份上修改,修改完成后再把“当前有效版本”指针切换过去。读者整个路径上不需要任何锁,只需要做一次指针解引用;写者需要保证不会同时有两个写者修改不同副本,所以通常还需要一把极小的互斥锁或者直接约定只有一个写者。
我用双缓冲改过上面配置模块的实验模型,代码简洁而且性能提升明显:
typedef struct { app_config data; int active; } config_slot; static config_slot g_slots[2]; static volatile int g_active_id; app_config config_get_db(void) { return g_slots[g_active_id].data; } void config_update_db(const app_config *new_cfg) { int old_id = g_active_id; int new_id = old_id ^ 1; g_slots[new_id].data = *new_cfg; __sync_synchronize(); // 保证数据写入对读者可见 g_active_id = new_id; }这段代码的问题也很明确:读线程可能在写者切换版本的瞬间读到旧数据,如果你能容忍“配置变更后最迟几百毫秒内生效”,这种方案完美;如果要求所有读者要么看到新值要么看到旧值、不能看到中间状态,那么读取时还需要带上版本号校验。但即便如此,它的性能明显优于读写锁,完全值得根据业务容忍度去换。
分片锁则是把“锁的粒度”数据化落地。比如一个大的 hash map,按 key 哈希拆成 64 个 segment,每个 segment 维护一把普通的 mutex。读某个 key 只锁对应 segment,不同的 key 可以并发读写,不用全局互斥。内存空间从一份变成 64 小份,换来的是并发度的提升。这种方案适合“数据天然可按 key 隔离”的场景,不适合“必须整体读一份快照”的场景。
5.3 优化效果的量化对比与选型建议
在自己机器上跑过一组对比,场景是 8 个读线程 + 1 个写线程,读操作每次拷贝 64 字节结构,临界区内加一次nanosleep(50ns)模拟处理,分别用读写锁、双缓冲、分片锁实现。结果趋势是这样的:
| 方案 | 总吞吐趋势 | 写者延迟趋势 | 实现复杂度 |
|---|---|---|---|
| 互斥锁 | 基准 | 稳定可预期 | 最低 |
| 读写锁 | 读占比高时优于互斥锁 | 可能饥饿,需写优先实现 | 中 |
| 双缓冲 | 远高于前两者 | 切换瞬间有极短阻塞 | 中低 |
| 分片锁 | 随分片数近似线性提升 | 相对稳定 | 较高 |
这里的核心结论不是“双缓冲最牛逼”,而是没有万能方案,只有匹配的方案。写者延迟敏感且读可以容忍旧版本,选双缓冲;数据可分区,选分片;必须强一致、数据又不好拆,才回头考虑读写锁。为了用读写锁而用读写锁,是性能优化的常见反面教材。
持久化忍不住泼一盆冷水:很多业务系统里,锁竞争根本不是主要瓶颈,真正的敌人是数据库查询、网络 IO、序列化拷贝。我见过的案例里,有团队把一把 mutex 换成 rwlock 后 QPS 提升了 20%,于是沾沾自喜,可我一看 perf 数据,CPU 大头还是第三方协议解析。先把热点打出来,再决定优化哪里,这个顺序永远不能颠倒。
6. 高频问题与排查技巧实录
6.1 读者抢跑导致写者长时间饥饿
现象:写者线程的更新延迟从几毫秒一路涨到几秒,日志里能看到写锁等待超时频繁触发;读者线程平均延迟正常,但写者线程长时间拿不到锁。
原因九成是锁实现读优先,且读者持续不断地进入临界区。读者的“插队”行为把写者永远挤在队尾。排查方法很简单:在写锁获取代码前后打时间戳,统计写锁等待时长的分布,如果 P99 远高于 P50,基本就是饥饿。
解决方向有两个:换用写优先语义的锁实现(较新 glibc 默认往这个方向靠);或者直接在业务层做限制——比如写者线程加一个trywrlock,失败就延迟重试,避免无限排队。更彻底的方案是检查读者临界区长度,把大临界区拆小,让读者的持锁时间缩短,写者等待窗口自然变窄。
6.2 读写锁表现反而更差
现象:把互斥锁换成读写锁后,读性能没有改善,甚至平均延迟还升高了;perf看到pthread_rwlock_rdlock或者内部原子操作占了大头。
原因分几种,我列一个速查表:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 读路径 CPU 占比高 | 读者计数器的缓存行乒乓严重 | 减少并发读者数,或改用分片/双缓冲 |
| 写路径延迟增加 | 写者内部多做了一次读者计数检查 | 写比例高时回到互斥锁 |
| 总体吞吐没变 | 锁不是瓶颈,临界区外开销大 | 用 perf 看真实热点 |
实际遇到最多的是第一种。一开始就给读写锁套上“并发优化”的美名,但临界区太小、读者太多,原子操作的内耗超过了互斥锁的队伍切换成本。别挣扎,直接换双缓冲或者分片,比调锁强得多。
6.3 锁态异常与死锁的诊断方法
死锁是锁相关故障里最让人头疼的。读写锁的死锁常表现为:进程卡死,堆栈停在pthread_rwlock_rdlock或pthread_rwlock_wrlock上,用gdb attach能看到多个线程都在等锁。
诊断思路分三步:
第一步,看锁的持有者是谁。glibc 的pthread_rwlock_t内部结构里能找到当前持有写锁的线程标识(__writer字段)和读者数量(__nr_readers),用gdb直接打印即可判断是写者没释放,还是读者计数被卡住。
(gdb) p g_cfg_lock $1 = {__data = {__lock = 1, __nr_readers = 3, __readers_wakeup = 0, __writer_wakeup = 0, __nr_readers_queued = 0, __nr_writers_queued = 0, __writer = 0, __shared = 0, __pad1 = 0, __pad2 = 0}}第二步,检查是否有线程在持锁路径上发生了阻塞。常见情况是拿到读锁后,在临界区内调用了会等锁的函数(比如另一个线程要获取写锁,而写锁又在等读者退出),形成了 A 等 B、B 等 A 的死循环。用thread apply all bt拉出所有线程堆栈,人工对照加锁顺序。
第三步,用工具辅助:valgrind --tool=helgrind可以检测到明显的加锁序冲突,strace -f -e futex可以观察 futex 调用频率和等待状态,perf lock report能统计锁竞争次数。多数情况下,工具给出的线索足以定位到具体函数。
最后分享一个我常用的压箱底技巧:给每个锁加一层统计封装,记录“拿锁次数、等待时间、持锁时间”,定期打印。这些数字平时看着没用,出问题时是救命稻草,几秒钟就能定位到锁竞争最激烈的路径,比上线后拿 gdb 猜靠谱得多。我最近一个项目里,所有核心锁都套了这个封装,排查过一次诡异的读锁泄漏——某条异常分支漏了 unlock,读者计数永远不为 0,写者再也进不来。靠统计日志一页纸就锁定到了模块,省了整整一下午。读写锁这东西,原理不深,坑却不少,把它当做一个需要认真对待的并发组件看待,性能收益才真正拿得到。