首页 / 资讯中心 / 文章详情

RCU适用边界分析:读多写少场景下如何选择同步机制

RCU适用边界分析:读多写少场景下如何选择同步机制 ★ FEATURED ARTICLE
做并发编程的人应该都尝过锁竞争的苦头——一个全局哈希表读的人特别多偶尔有人写一下结果所有读线程都得跟着排队。很多人第一反应是上读写锁但读写锁的读者同样要去抢同一个计数器热点一上来照样 cache line 互相打架。直到我看到 Linux 内核里 RCU 机制的实现才意识到并发控制还有另一条路让读者几乎零成本把复杂度和代价全部转移给写者。RCU 全称是 Read-Copy Update读-复制-更新。它解决的问题很直白当一个数据结构读多写少、写操作又能通过“换指针”完成时能不能让读者不碰任何锁、不做任何原子操作只靠一次内存加载就把数据安全拿到手。答案是能而且在 Linux 内核里已经大规模使用了几十年。这篇文章不打算把 RCU 的每个实现细节都铺开而是想聊一个更实际的问题RCU 的核心适用边界到底在哪。也就是说什么场景用它收益最大什么场景用它反而是给自己挖坑。我会结合经典 RCU、Tree RCU、SRCU、用户态 URCU 等几种实现加上我在内核源码阅读和用户态并发项目里的实际经验把 RCU 的选型判断标准梳理清楚。适合对并发编程有兴趣、正在设计读多写少数据结构、或者想搞明白内核同步机制怎么选型的人。1. RCU 的核心设计思路把成本全部转移到写者1.1 三个动作读、复制、更新RCU 的思想和写时复制Copy-on-Write有不少相似之处。一个被 RCU 保护的数据结构读者不走锁直接读当前指针指向的版本写者不能原地改数据而是要先把当前版本复制一份在新的副本上完成修改然后发布新指针。发布不是简单的赋值必须保证其他 CPU 在看见新指针的时候副本里的所有修改都已经全局可见。等所有还在读旧版本的读者都退出之后旧版本才能被安全回收。这个流程里最容易被忽略的是“等所有读者离开”这一步。在 RCU 术语里这个等待阶段叫 grace period也就是宽限期。写者并不需要知道具体谁在读、读了多少次它只需要一个保证当我说“可以回收了”的时候任何可能还持有旧指针引用的读者都已经不在临界区里了。Linux 内核是通过 quiescent state 来判断的每个 CPU 只要发生了上下文切换、进入了空闲状态或者用户态执行就认为它不可能停留在 RCU 读临界区里。用户态执行的 CPU 不可能是读者这是整个推断体系的基石。1.2 为什么读者侧可以做到几乎零成本很多人初次接触 RCU 都会有个疑问读者不用锁那么多核心同时读怎么办答案是“不需要怎么办”。读者只是加载一下指针多个 CPU 同时读取同一个地址不会发生竞争cache line 保持在共享状态也不会被反复换入换出。真正的 cache line 乒乓只发生在多个 CPU 同时写一个地址时而 RCU 的读者路径完全不写共享状态。再看读端临界区的接口。在经典 RCU 实现中rcu_read_lock 和 rcu_read_unlock 在非抢占内核里可能什么都不做在可抢占内核里无非是改一下当前线程的抢占计数。这两个动作都只影响本地 CPU不产生任何跨核同步。这个特性带给读者的延迟收益非常可观。我在内核里用读写锁和 RCU 分别保护同一个哈希表做过对比热点路径上 RCU 的读者端耗时能低一个数量级这并不夸张。读写锁的读者即使再轻量也要对共享的读计数做原子加和减多个 CPU 同时进入读模式时这个计数器所在的缓存行就是天然的瓶颈。1.3 发布、依赖与回收是 RCU 实现的三根支柱RCU 实现的关键其实在写者一侧主要体现在三件事第一发布新指针通常用 rcu_assign_pointer 完成保证副本里的数据修改先行可见第二等待宽限期synchronize_rcu 会同步阻塞写者call_rcu 则是注册一个异步回调等宽限期结束后触发第三回收旧版本的内存。读者侧则使用 rcu_dereference 来读取指针确保通过指针解析对象内容时不会拿到半初始化的状态。理解了这三根支柱再回头看适用边界就会顺很多。RCU 的实现核心并不是“读”而是“证明旧版本没人用了”。这也是为什么每次聊 RCU都会扯到内存屏障、依赖序、静止状态检测这些底层概念。凡是不满足“可以通过换指针完成更新”的数据结构RCU 就很难用上力气。2. RCU 适用的边界五条可量化的判断标准2.1 读多写少要量化读:写比例至少 100:1RCU 的第一条适用边界是读写比例。读者免费但写者要复制整个对象、等待宽限期、再做回收。如果写操作非常频繁这个代价甚至比持锁写还要贵。业界比较常见的经验值是读:写达到 100:1 或更高才值得考虑。更准确地说应该是读路径必须是热路径而写路径可以容忍相对较高的延迟和额外内存开销。这里有一个特别好的例子内核路由表FIB。数据包转发路径上每个报文都要做一次路由查找这是绝对的读热点而路由增删可能每秒也就几十到几百次相对于数百万的包转发频率写者的高成本完全可以被摊薄。反过来如果你要保护一个每秒被更新几十万次的全局计数器每次修改都复制一份计数器结构复制成本会直接失控RCU 显然就不适用。2.2 读者临界区必须“短平快”第二条边界是临界区长度。RCU 读者虽然在临界区内无锁但绝对不能长时间待在里边更不能睡眠除非使用 SRCU。原因是任意一个读者不离开临界区所有后续写者的 synchronize_rcu 都会被卡住内存回收也会一直被推迟。临界区每延长一点写者延迟和内存积压就不可控一分。我给自己定了一个习惯把 RCU 读临界区控制在一两个函数调用之内里面尽量只做指针加载、对象字段读取和简单的本地计算。如果你发现在 RCU 临界区里要做磁盘 IO、要拿其他锁、要睡眠那基本可以断定这个场景不适合普通 RCU应该考虑 SRCU或者干脆换一种同步机制。2.3 数据结构必须满足“指针发布”模型这条边界最容易被误解。RCU 不保护“被指对象内容的原位修改”它保护的是“指针本身”。对象一旦发布给读者内容就不能再被原地变更后续的修改必须产生一个新对象然后通过更新指针来完成。如果你的业务是读者拿到对象后还要原地改它的几个字段同时允许别人读到半新半旧的状态那 RCU 就帮不上忙。这也是 RCU 和原子变量、自旋锁最本质的区别RCU 不提供对共享可变状态的原子修改只提供“安全替换整个对象快照”的能力。判断方法很简单问自己一个问题这次更新能不能通过构造一个完整的新对象然后一次性切换指针来完成如果能RCU 有戏如果不能就得另想办法。2.4 内存冗余和延迟释放要能接受每次写操作都会产生一个新版本旧版本要等所有读者退出后才释放。这意味着写操作密集的时候内存里会同时存在多代旧副本。如果对象很大、写得又比较频繁内存压力会非常明显。RCU 适用本质上就意味着你接受“空间换并发”的取舍用一定的内存冗余换取读者侧几乎无损的读性能。这个边界在内核里相对友好因为内核对象通常比较小回收也走专用 slab 缓存但用户态如果用 URCU 保护一个动辄几 MB 的大结构体就必须认真评估峰值内存。我的建议是如果单个对象超过多个 cache line同时更新频率又不低先把副本积压的上界算清楚再动手。2.5 一致性要求指针级一致但不是事务级一致最后一条边界跟一致性模型有关。RCU 的读者要么看到完整的旧版本要么看到完整的新版本不会看到中间状态。但它不能保证读者同时看到两个相关对象的一致组合。假设数据结构是 A 指向 B而你要让读者永远看到“A 和 B 来自同一版本”那每次更新就得同时发布两个指针而 RCU 并没有原子的双指针切换原语。所以在跨字段、跨对象的强一致要求下RCU 往往要搭配 seqlock 或者版本号才能工作。内核里的路径查找就是典型例子用 RCU 保证 dentry 对象不会被释放同时用 d_seq 序列号保证目录项内容的一致性。两者配合才把问题彻底解决。3. 边界之外这些场景慎用 RCU3.1 写密集场景grace period 会放大全部代价有很多人听到“无锁”两个字就兴奋以为任何并发结构都能用 RCU 优化。但 RCU 的写路径是相当贵的复制完整副本、发布指针、等待宽限期、释放旧版本每一环都要花钱。写密集时不但每个操作的绝对成本高而且写者会被旧读者拖住。如果读者临界区平均 1 微秒每秒又有 10 万次写那每次写都要等平均 1 微秒的宽限期写吞吐直接受限。我在用户态项目里就见过这种情况。有人把一个高频更新的业务缓存用 URCU 保护结果写线程延迟显著增大最后排查下来问题不在 RCU 本身而在于并发模型根本不适合读:写比例接近 1:1 的场景。对症的做法是缩小写粒度把大结构拆成多个小槽位每个槽位单独用 RCU或者用 percpu 变量分担写热度。3.2 需要多字段强一致更新的场景前面说过如果一次更新必须让读者同时看到 A、B、C 三个字段的同一版本RCU 做不到原子切换除非你把这三个字段打包成一个结构体用一次指针发布完成。但如果你希望保留现有对象原地改其中某些字段同时保证其他读者不会看到修改了一半的中间状态RCU 就完全无能为力了。这种场景应该用 seqlock通过读者重试模型来保证一致性。我自己踩过类似的坑。有一段时间想把一个运行时常变化的配置块用 RCU 保护配置块有十几个字段业务上经常单独改其中一个。结果改造后读者偶尔会读到新旧字段混搭的配置语义上完全不可接受。后来改成一个完整配置块快照 指针发布的模式才解决本质上还是回到了“整体替换”的老路上。3.3 读者路径允许睡眠或阻塞的场景普通 RCU 读临界区不允许睡眠和阻塞。睡眠中的线程如果停留在临界区宽限期会被无限推迟写者只能一直等下去。这个问题的标准解法是 SRCU也就是可睡眠 RCU。SRCU 给每个读临界区分配了独立的计数槽读者睡眠时不会影响静止状态的统计写者只要等待每个计数槽归零即可。但 SRCU 的代价是读者操作比经典 RCU 重很多读锁和读解锁需要访问 per-CPU 计数器大体上是几个原子操作的量级。如果你只是想让读者快速读取一个热点结构完全没必要上 SRCU。我在用户态开发里也遇到过类似情况后台线程随时可能进入临界区并长期阻塞结果写者延迟每隔一段时间就出现一次尖峰。这种场景必须先想清楚临界区执行时间的上界。3.4 写者需要极低延迟的场景RCU 写者等待宽限期的最大时间是不可预测的。虽然大多数宽限期都在毫秒级甚至更快但在 CPU 被虚拟机抢占、系统负载异常或者遭遇极端调度的情况下宽限期会出现明显抖动。对普通业务这无所谓但对实时场景和硬截止时间场景synchronize_rcu 的不可预测性相当麻烦。内核为这类需求提供了 expedited RCU通过强制让其他 CPU 报告静止状态来加速宽限期本质上是发送 IPI 打断别人代价不小而且同样不能做到硬实时。如果你对写者延迟有极端要求更合理的方案是尽量使用 call_rcu 异步回调把写者延迟和宽限期解耦让前台写路径只是发布指针回收的事情交给后台慢慢做。4. RCU 实现变体与落地从内核到用户态4.1 经典 RCU 到 Tree RCU解决扩展性问题经典 RCU 是 Linux 内核早期的实现全局维护一张位图记录每个 CPU 的静止状态宽限期推进依赖一个全局状态机。在几十个 CPU 的机器上跑没问题但 CPU 数量达到几百个以后全局扫描和锁竞争就变得难以忍受。Tree RCU 就是为解决这个问题出现的把 CPU 按层级分组每个节点汇总子树内的静止状态quiescent state 从叶子节点逐层向上传播宽限期的推进不再需要扫描所有 CPU。从适用边界的角度看实现变体不影响使用原则只是扩展性和性能特征的差异。内核普通开发者一般不需要关心底层是 classic 还是 tree但心里要清楚一点CPU 规模越大Tree RCU 的宽限期传播越平缓。这也是为什么它在现代内核里成了默认方案。4.2 SRCU为需要睡眠的读者开一扇窗SRCU 是 RCU 家族里“读者可以睡觉”的特例。它把计数下放到每个读临界区读锁调用时操作 per-CPU 计数器写者等待所有计数器归零。这样读者想睡就睡不会把整个世界连带阻塞住。代价是读者侧固定开销明显增加而且必须显式分配 struct srcu_struct。如果你的读者路径本来就短到几十纳秒那给这个热点配 SRCU 就是在浪费性能只有读者真的非睡眠不可时才值得用。从我个人阅读代码的经验看SRCU 最常见的落地位置是文件系统和网络子系统里那些可能触达页错误或锁竞争的长路径。4.3 用户态 URCU内核思想向外延伸用户态实现 liburcu 把同样的思想带到了普通进程里。但用户态没有内核那样可靠的静止状态来源没有上下文切换信息可以用所以它通过信号或者线程主动上报安全点来模拟 quiescent state。urcu-signal 依赖信号打断每个线程检查它是否在临界区外urcu-qsbr 要求每个线程自己报告静止点性能最好但对使用约束要求也最高urcu-mb 用完整内存屏障保证正确性兼容性最好开销也最高。我在用户态服务里试过 liburcu它的发布和回收语义与内核几乎一致常见坑也一样读者临界区里不能发呆否则写者永远等不到宽限期。如果你在用户态做高性能缓存我建议先认真读一下 liburcu 的文档理解不同变体对线程模型的假设再决定用哪个。4.4 变体选型速查变体读者特点写者等待方式适用场景经典/Tree RCU几乎零开销不能睡眠synchronize_rcu / call_rcu读极热、写少的链表或哈希表SRCU可睡眠开销中等synchronize_srcu读者可能持锁或睡眠的长临界区Tasks RCU针对任务切换点等待目标任务经过调度点追踪、BPF 等特殊场景URCU 默认变体用户态依赖信号检查synchronize_rcu用户态缓存、配置发布URCU QSBR应用自行上报静止点快速宽限期读线程完全可控的用户态服务5. RCU 与其他同步机制的正面对比别把并发工具箱做成一把锤子5.1 读写锁 vs RCU读者成本天差地别读写锁看起来最适合读多写少但读者获取锁时一样要做原子加法来增加读计数。多个 CPU 同时进入读模式的瞬间这个共享计数所在的 cache line 会被反复独占读流量越大竞争越明显。RCU 读者的操作只落在本地栈和寄存器不写任何共享内存。这带来两个直接结果CPU 数量增多时 RCU 读者性能几乎线性扩展读者不会因为写者排队而等待。当然读写锁并不是一无是处。它能在写者更新过程中直接阻止读者进入保证读者一定看到一致状态而且天然支持“原地修改”的语义。对某些数据结构锁依然是正确且简单的选择。RCU 是“读者为尊”的极端设计不该指望它反向替代所有锁。5.2 Seqlock vs RCU一个锁读者一个锁版本Seqlock 是另一种读优化方案读者无锁读但必须先读序号读完后再读一次序号如果中间发生过写就重试整个读取过程。它天然适合读一个多字段结构体且写者极少的情况比如系统时钟。Seqlock 不能让读者完全免于重新读取只能让读者在冲突时重试RCU 读者则只需要读一次指针不需要重试。两者在一致性能力上也不同seqlock 能保证多字段版本的强一致RCU 只保证单个指针的发布一致。所以内核里经常把两者组合使用用 RCU 管对象生命周期用 seqlock 管内容版本一致性。这是一个很值得借鉴的组合套路。5.3 引用计数 vs RCU延迟释放与即时释放的较量引用计数kref、shared_ptr 这类也能实现对象生命周期安全但每次获取引用都是一次原子 RMW 操作在热点对象上会造成不小的竞争。RCU 读者不需要动引用计数生命周期问题交给宽限期统一处理代价是无法立刻释放内存。这里的两条经验是如果你需要对象在 RCU 临界区之外仍然被安全使用引用计数是必须的如果所有对对象的使用都限制在一个很短的临界区内RCU 能帮你省掉一整轮原子操作。很多时候两者还能搭配使用RCU 解决读热路径引用计数解决跨越临界区的引用保活。5.4 选型决策表信号推荐方案读极热、写很少、单对象快照RCU读热、写极多、内容频繁原地改Seqlock / per-cpu读写都不少、需要强一致rwsem / mutex对象生命周期需要跨临界区保存引用计数可叠加 RCU读者必须睡眠SRCU表格给的是一个参考起点真正的边界还要结合临界区长度、内存压力和延迟要求综合判断不能只看读写比一个指标就拍板。6. 常见误区和避坑经验6.1 误区一把 RCU 当成万能无锁方案最常见的坑是只看“读者无锁”四个字于是任何读多写少的地方都想上 RCU。实际上 RCU 成功的前提是更新能整体替换。比如一个常年变化的链表节点在不停增删你想用 RCU 保护某个节点内部字段那是不成立的。RCU 保护的是链表的入口指针节点一旦发布出去就只能作为不可变快照来读。我在代码评审时经常问一个问题你的更新是“改对象”还是“换对象”只要答案是“改对象”RCU 就不太合适。6.2 误区二读者临界区想待多久就待多久很多人写完 RCU 代码后内存迟迟不见回收写线程延迟越来越高查来查去发现是有读者在临界区里做了耗时操作。宽限期一长所有旧版本都挤压在那里释放不掉。一个经验法则在用户态使用 URCU 时如果发现宽限期经常超过预期先把读临界区缩到最短再不行就用异步 call_rcu 或 URCU 的 defer 机制替换同步等待让写者不要阻塞在宽限期上。6.3 误区三误把被指对象内容当成 RCU 保护另一个高发 bug 是在 RCU 临界区里读取对象字段后跑到临界区外继续使用这个对象。这时候对象可能已经被回收或者替换轻则读到脏指针重则直接段错误。必须遵守一条铁律从 rcu_dereference 拿到的指针只在本临界区内使用出了临界区要么重新加载指针要么先用引用计数把对象钉住。6.4 实操调优建议RCU 写者侧的批处理非常重要。不要每次更新都调用 synchronize_rcu 等宽限期那样写者延迟会非常难看。内核里更常见的做法是用 call_rcu 把释放回调攒起来多个更新共用一次宽限期回收节奏交给系统统一安排。用户态 URCU 也提供了类似机制思路完全一致。另一个经验是尽量缩小旧版本的生命周期。如果写频率和宽限期错不开可以考虑合并写操作比如把多次小更新积累成一批统一发布一个新快照。这不仅能减少宽限期次数还能减轻内存积压。最后发布时一定用 rcu_assign_pointer读时一定用 rcu_dereference不要图省事用普通赋值或裸指针。这不仅是规范问题在弱内存模型机器上直接关系到正确性。我早期在 x86 上写“裸指针也能跑”移植到其他弱内存架构后就出现过诡异问题最后排查发现就是依赖序没有处理好。就我自己的感受RCU 是一个把延迟留给写者、把速度交给读者的设计。理解它的适用边界比背 API 更重要。我现在每次考虑用 RCU都会先做三连问读路径是不是极热更新能不能整体换成新指针读者临界区能不能短到纳秒级三个都回答“是”才动手。这些年看过不少项目在读写比并不高的场景里硬上 RCU最后写者延迟爆炸、内存还持续增长回过头来换成锁反而清爽很多。工具没有绝对好坏边界意识才是关键。如果你下次面对读多写少的数据结构时能先判断边界再选型那这篇文章就没白看。
阅读完成 · 觉得有帮助?
咨询建站