| 本篇位置 | 有了锁之后,怎么把它用对。这一篇讲的是性能,不是正确性 |
| 运行环境 | 容器里的 Linux,gcc |
一、先加上锁,正确性就有了
给任何一个数据结构加锁,有一个万能做法:
每个公开函数一进来就上锁,一出去就解锁。
链表的 insert、lookup、delete 各自锁住同一把锁,正确性立刻成立——同一时刻只有一个线程在动这个结构。
这叫粗粒度锁(coarse-grained locking)。它有个非常大的优点:基本不会写错。
然后你把它放到 8 核机器上,发现完全跑不起来。
打个比方
接着第 16 篇那个钱箱。
一把大锁,相当于整个银行只有一个柜台。 存款、取款、查余额、办卡,全部排一条队。
只有一个客户时,这个柜台效率极高。但你雇十个柜员也没用——柜台只有一个,另外九个只能站着看。
⭐ 更糟的是:十个人挤在一个柜台前,光是"谁下一个"的争执就要花时间。这不是比喻——下一节的实测里,加线程真的会让总吞吐下降。
二、跑一遍看看:加线程反而更慢
一个用 pthread_mutex 保护的计数器,每个线程加一百万次:
if (MODE == 0) { /* 一把大锁 */
pthread_mutex_lock(&glock); global++; pthread_mutex_unlock(&glock);
}
一把大锁 1 线程 总数 1000000 用时 0.004 秒 吞吐 27592.3 万次/秒
一把大锁 2 线程 总数 2000000 用时 0.061 秒 吞吐 3292.6 万次/秒
一把大锁 4 线程 总数 4000000 用时 0.183 秒 吞吐 2191.4 万次/秒
一把大锁 8 线程 总数 8000000 用时 0.225 秒 吞吐 3551.4 万次/秒
一个线程 2.76 亿次/秒。加到两个线程,掉到 3292 万次/秒——慢了八倍。
⭐ 不是"加速比不理想",是绝对性能下降。 你多雇了一个人,总产出反而少了。
原因有两层:
- 序列化。 临界区里只能有一个线程,所以吞吐的上限就是单线程的速度——加人不可能变快。
- 竞争的开销。 抢锁本身要付钱:原子指令、缓存行在核之间来回搬、拿不到锁的线程要陷入内核挂起再被唤醒。人越多,这笔钱越贵。
第 2 条就是"绝对下降"的来源。单线程时没有竞争,一分钱不用付。
三、第一级解法:把锁拆细
既然一把锁是瓶颈,那就多来几把。
并发链表:与其锁整个链表,不如每个节点一把锁。遍历时按"抓住下一个再放开当前"的方式挪动(hand-over-hand locking)。
⚠️ 但这个例子恰好说明拆锁不总是划算:每挪一步就要一次加锁解锁,而链表遍历本来只是一次指针跳转。锁的开销比被保护的操作还大。 实测中它常常还不如一把大锁。
并发哈希表:这个就漂亮多了。
哈希表天然分成很多桶,每个桶一把锁。两个线程只要哈希到不同的桶,就完全不互相干扰。
⭐ 哈希表是并发场景里的明星,不是因为它查得快,是因为它天然可分。 而链表天然不可分——它是一条链,你必须顺着走。
⭐ 这给出一条很实用的判据:拆锁划算不划算,取决于这个数据结构本身能不能被切成互不相干的块。
四、另一种拆法:拆操作,不拆数据
上一节那条判据里藏着一个前提:数据能被切开。 那数据切不开的时候怎么办?
一份全局配置、一张路由表、一个缓存的元数据——它就是一整块,没有"桶"可分。但它有另一个性质:
绝大多数访问只是读它,很少有人改它。
而两个人同时读,本来就不会互相干扰。 冲突只发生在"有人在写"的时候。互斥量看不见这个区别——它把每一次访问都当成潜在的冲突,于是十个只想读的线程被排成一队,而它们本来可以一起进去。
读写锁(reader-writer lock)就是把这个区别告诉锁:
读锁:可以同时被很多人持有。 写锁:独占,和所有人互斥(包括其他写者)。
⭐ 注意这一节和上一节是两个不同的切法:上一节按数据切(每个桶一把锁),这一节按操作类型切(读的一起进,写的自己来)。数据切不开的时候,还可以试试切操作。
打个比方
还是那家银行。
一把大锁是整个银行只有一个柜台,办什么业务都排一条队。
读写锁是:“查余额"的人可以一起进大厅看那块公告牌——十个人同时看一块牌子,谁也不碍着谁。但要改牌子上的数字时,得先把所有人请出去。
跑一遍看看:它值多少
8 个线程反复访问同一份数据,每次在临界区里扫 1024 个槽。只改一件事:其中多大比例是写。
if (是写操作) { pthread_rwlock_wrlock(&rw); /* 改一个槽 */ pthread_rwlock_unlock(&rw); }
else { pthread_rwlock_rdlock(&rw); /* 扫一遍 */ pthread_rwlock_unlock(&rw); }
8 个线程反复访问同一份数据,每次在临界区里扫 1024 个槽,各跑 2 秒
写的比例 互斥量(次/秒) 读写锁(次/秒) 倍率
0% 1931960 4961675 2.57x
5% 1891664 2827384 1.49x
20% 1917667 2828080 1.47x
50% 1857207 3088926 1.66x
读写锁确实赢了,但赢得不多——而且比你预期的少得多。
⚠️ 先看第一行那个 0%:全是读者,8 个线程理论上可以完全并行,你会期望 8 倍。实测只有 2.57 倍。
为什么?⭐ 因为读写锁本身没有被拆开。 八个读者虽然进了临界区就互不干扰,但它们都得先去改同一个读者计数器——那是一次原子操作,落在同一条缓存行上。第 17 篇那个"缓存行在核之间乒乓"的问题,一个字没少地又出现了一次。
读者之间不再互斥了,但它们仍然在同一个点上排队。
⚠️ 再看后面三行:只要掺进 5% 的写,倍率就从 2.57 掉到 1.49。因为一个写者进来,所有读者都得等,攒下来的并行度一次清空。
⭐ 所以读写锁的适用条件比大多数人以为的窄得多:
读必须占压倒性多数,而且临界区要足够长——长到值回那次原子操作的钱。
临界区很短的时候,读写锁往往还不如互斥量——加读锁本身的开销,比它保护的那点活儿还大。这和上一节链表的结论是同一句话。
⭐ 更麻烦的问题:写者会饿死
性能只是小事。读写锁真正的坑在公平性上。
想一想:读者可以同时进去。那么当一个写者在门口等的时候,新来的读者要不要放行?
- 放行(读者优先):写者要等到一个读者都不剩的那一刻。而如果读者源源不断地来,这一刻永远不会到来。
- 不放行(写者优先):新读者也得排在写者后面等。写者不会饿死了,但读的吞吐掉下来。
这不是理论担忧。glibc 默认是读者优先,我们让它自己演示后果:7 个读者不停地抢读锁,1 个写者每毫秒尝试写一次,跑 3 秒。唯一的变量是那个策略标志:
pthread_rwlockattr_setkind_np(&at, PTHREAD_RWLOCK_PREFER_READER_NP); /* 默认 */
pthread_rwlockattr_setkind_np(&at, PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP);
7 个读者不停抢读锁,1 个写者每毫秒想写一次,跑 3 秒
读者优先(默认) 成功写入 3 次 中位等待 780.267 ms 最坏 2157.194 ms
写者优先 成功写入 200 次 中位等待 0.006 ms 最坏 0.034 ms
⭐ 3 秒里,那个写者只挤进去了 3 次。中位等待 780 毫秒——而写者优先时是 0.006 毫秒。差了十三万倍。
(我跑了三遍,读者优先那一行分别是 3 次、1 次、3 次。有一次它整整 3 秒一个字都没写进去,最坏等待就是全程。)
⚠️ 请注意这里没有任何 bug。两行代码都是对的,锁也没坏,读者们全都正常工作着。只是那个写者永远排不上号——这就是第 17 篇提过的饥饿,而这次它不是自旋锁的小毛病,是一个默认配置带来的、能让写入彻底停摆的后果。
回到银行:看公告牌的人排着队源源不断地进大厅,而想改牌子的人必须等到大厅一个人都没有。 只要还有人在往里走,他就永远等下去。
⭐ 所以用读写锁的时候,“读者优先还是写者优先"是一个你必须显式决定的问题,不是一个实现细节。而绝大多数人从来没想过这件事——他们拿到的是默认值,也就是会饿死写者的那个。
结论
读写锁看起来像是"免费的并行”,实际上它是这么一笔账:
| 换来 | 付出 | |
|---|---|---|
| 读者并发 | 读多写少时吞吐上升 | 读者仍在同一条缓存行上排队,远达不到线性 |
| 区分读写 | 语义更精确 | 多了一个公平性决策,选错就饿死一方 |
| — | — | 临界区短时不如互斥量 |
⭐ 一句话:读写锁不是"更好的互斥量”,它是一个赌注——赌你的负载读远多于写,而且临界区足够长。赌对了赢一点,赌错了亏,而且它还塞给你一个必须回答的公平性问题。
⚠️ 这也是为什么这一篇的三级解法里,它排在"拆锁"之后而不是之前:先确认你的数据真的切不开,再来考虑切操作。
五、第二级解法:换掉锁
有些结构可以完全不用锁,只靠第 17 篇那条 CAS 指令。
比如一个无锁栈的 push:
1. 读当前栈顶 old
2. 把新节点的 next 指向 old
3. CAS(栈顶, old, 新节点)
—— 成功就完事;失败说明有人插队了,回到第 1 步重来
好处是没有线程会被阻塞。一个线程被调度器换下去,不会挡住别人(而拿着锁被换下去就会)。
代价很实在:
- 写起来极难,验证更难。 内存顺序、ABA 问题(值变回原来的样子,CAS 以为没变过),每一个都能坑死人。
- 高竞争时不一定更快——大家都在重试,做的都是无用功。
⚠️ 实践建议很简单:优先用标准库里现成的无锁结构,不要自己写。
六、⭐ 第三级解法:改需求
前两级都在优化"怎么实现"。第三级换个问题:
你真的需要这个计数器随时都精确吗?
大多数时候不需要。统计信息、监控指标、缓存命中率——你要的是趋势,不是每一刻的精确值。
近似计数器(也叫可扩展计数器)就利用了这一点:
- 每个线程一个自己的局部计数(各自一把锁,或者干脆无锁)。
- 加一时只动自己的。
- 攒够一个阈值(比如 1024),才去把它并进全局计数一次。
pthread_mutex_lock(&local[id].l);
local[id].v++;
if (local[id].v >= THRESH) {
pthread_mutex_lock(&glock); global += local[id].v; pthread_mutex_unlock(&glock);
local[id].v = 0;
}
pthread_mutex_unlock(&local[id].l);
同样的活儿,同样的机器:
近似计数器 1 线程 总数 1000000 用时 0.004 秒 吞吐 26990.2 万次/秒
近似计数器 2 线程 总数 2000000 用时 0.004 秒 吞吐 50099.2 万次/秒
近似计数器 4 线程 总数 4000000 用时 0.004 秒 吞吐 90557.0 万次/秒
近似计数器 8 线程 总数 8000000 用时 0.008 秒 吞吐 94397.6 万次/秒
并排看这两组:
| 线程数 | 一把大锁 | 近似计数器 |
|---|---|---|
| 1 | 27592 万/秒 | 26990 万/秒 |
| 2 | 3292 万/秒 | 50099 万/秒 |
| 4 | 2191 万/秒 | 90557 万/秒 |
| 8 | 3551 万/秒 | 94398 万/秒 |
- 单线程时两者一样快(近似计数器没有额外收益)。
- 4 线程时差了 41 倍。
- 而且近似计数器几乎是线性扩展的:1 → 2 → 4 线程,吞吐接近翻倍再翻倍。
代价明明白白:全局值最多可能落后 线程数 × 阈值。 8 个线程、阈值 1024,最多落后 8192。
⭐ 你用"精确性"换了"可扩展性",而且这笔交易的价码是你自己定的——阈值调小,误差小、扩展性差;调大,反过来。
回到银行:这就是让每个柜员自己记流水,攒够一千笔再去总账房登记一次。 总账房前面不再排长队了,代价是总账上的数字总比现实慢一拍。
⚠️ Linux 内核里到处是这个模式:percpu 计数器、per-CPU 的内存分配缓存(第 11 篇的 tcache 是同一思想)、per-CPU 的调度队列(第 7 篇)。“每个核一份,偶尔汇总"是内核可扩展性的第一原则。
七、代价与取舍
这三级解法换来的东西是递进的: 拆锁换来并行度、无锁换来不阻塞、近似换来近乎线性的扩展。
它们各自花了什么:
- 拆锁:多把锁 = 多个加锁顺序 = 死锁风险(第 21 篇)。而且锁本身的开销变多了——链表那个例子就得不偿失。
- 无锁:难写、难验、ABA 陷阱,高竞争时不一定快。
- 近似:读到的值不精确。
它们共同放弃了什么: 简单。
⭐ 所以有一条实践顺序值得记牢:
先用一把大锁把它写对,测出来真的慢,再拆。
因为大多数数据结构根本没到会成为瓶颈的程度。为一个每秒调用一百次的函数做无锁优化,是纯亏——你付出了正确性风险,换来了看不见的收益。
先测量,再优化。 上面那张表就是"测量"长什么样。
八、小结
- 粗粒度锁(每个函数进来上锁、出去解锁)正确性容易保证,但会成为瓶颈。
- ⭐ 实测:一把大锁的计数器,1 线程 2.76 亿次/秒,2 线程掉到 3292 万——加了一个线程慢了八倍。原因是序列化(上限就是单线程)加竞争开销(人越多越贵)。
- 第一级:拆锁。 ⚠️ 但不总划算——链表拆成每节点一把锁,锁的开销比被保护的操作还大。⭐ 判据是这个结构本身能不能切成互不相干的块:哈希表能(每桶一锁),链表不能。
- 数据切不开时,还可以切操作——读写锁让读者并发、写者独占。⚠️ 但它比想象中差得多:实测全是读者时也只有 2.57 倍(八个读者仍在同一条缓存行上排队),掺进 5% 的写就掉到 1.49 倍,临界区短时还不如互斥量。
- ⭐ 读写锁真正的坑是公平性:读者优先就会饿死写者。实测 glibc 默认配置下,7 个读者不停读时,写者 3 秒只挤进去 3 次、中位等待 780 毫秒;换成写者优先是 200 次 / 0.006 毫秒——差十三万倍,而两边代码都没有 bug。⚠️ “读者优先还是写者优先"是你必须显式回答的问题,默认值会饿死写者。
- 第二级:无锁,只靠 CAS 重试。好处是不阻塞,代价是难写难验、有 ABA 陷阱、高竞争时不一定快。用标准库的,别自己写。
- ⭐ 第三级:改需求。 近似计数器每线程自己记、攒够阈值再汇总一次。实测 4 线程时快 41 倍,且近乎线性扩展;代价是全局值最多落后
线程数 × 阈值——价码由你自己定。 - ⚠️ “每个核一份,偶尔汇总"是内核可扩展性的第一原则:percpu 计数器、tcache、per-CPU 调度队列都是它。
- ⭐ 先用一把大锁写对,测出来真的慢,再拆。
思考题
- 第二节里,8 线程的吞吐(3551 万)反而比 4 线程(2191 万)高。这不符合"人越多越贵"的说法。可能是什么原因?(提示:这台机器的核不一样快,而且测量本身有噪声——你该怎么设计实验来分辨?)
- 近似计数器的误差上界是
线程数 × 阈值。如果这个计数器是用来做限流的(超过 N 就拒绝请求),这个误差可以接受吗?如果是用来统计 QPS 呢? - 哈希表每桶一把锁。那么扩容(rehash)怎么办?扩容时要动所有桶——这一下是不是又回到一把大锁了?
- 银行那个比方里,“每个柜员自己记流水,攒够一千笔报一次总账”。那么,如果行长随时要看准确的总额,该怎么办?代价是什么?
- 第四节实测:全是读者时读写锁也只快 2.57 倍,而不是 8 倍。瓶颈是那个读者计数器所在的同一条缓存行。照第六节"每个核一份"的思路,你会怎么改造这个计数器?改造之后,写者要付出什么代价?(这个想法真的有人做了,叫 per-CPU 读写锁,Linux 内核里就有。)
- 写者优先能防饿死写者,但它反过来会不会饿死读者?如果一个写者接一个写者源源不断地来呢?想一个两边都不饿死的策略。