在 Linux 内核演进的历程中,性能与并发性始终是推动技术革新的核心动力。无论是高性能数据包处理、系统跟踪还是安全监控,内核都需要在极高并发的场景下完成数据读取与动态内存管理。
本文将分为两大部分:
基础理论:全面解析 Linux 内核的核心并发机制——RCU(Read-Copy-Update)锁、RCU 宽限期及其核心函数。
前沿演进:结合 2026 年 Linux 存储、文件系统、内存管理与 BPF 峰会(LSFMMMM/BPF Summit)的最新讨论,深度探讨 eBPF 动态内存分配的演进历史、原方案缺陷、kmalloc_nolock() 无锁分配机制的引入及其局限性。
第一部分:RCU 锁、宽限期与核心 API
1. 什么是 RCU(Read-Copy-Update)?
RCU(Read-Copy-Update,读-复制-更新) 是 Linux 内核中一种专为“读多写少”场景设计的无锁/高并发同步机制。
在传统的锁机制(如互斥锁 Mutex 或自旋锁 Spinlock)中,为了防止读写冲突,读操作和写操作通常是互斥的。而 RCU 的核心思想是解耦“读”与“写”:
-
对读者(Readers): 几乎零开销(Zero Overhead)。多个读者可以与写者同时并发访问同一块数据,读操作不会被任何锁阻塞,也不涉及跨 CPU 核心的内存总线锁(Lock Bus)。
-
对写者(Writers): 采用 “修改即复制” 策略。写者不能原地修改旧数据,而是先复制一份旧数据的副本(Copy),在副本上完成更新(Update),然后通过一个原子的指针交换(Publish)让后续的新读者看到新数据。
2. 核心概念:RCU 宽限期(Grace Period)与静止态(Quiescent State)
当写者将指针原子地指向新数据后,会面临一个核心难题:旧数据何时才能安全地被释放(kfree)?
指针交换的瞬间,可能仍有旧的读者正在读取旧内存。如果立即释放,就会导致空指针异常或野指针访问(Use-After-Free)。
时间轴 ─────────────────────────────────────────────────────────────►
读者 A (访问旧数据) ───────[ 临界区开始 ……………… 临界区结束 ]
│
写者 ───────┼─────► [交换指针] ───────► [ 等待宽限期 ] ───────► [ 安全释放旧内存 ]
│ ▲ ▲
读者 B (访问新数据) └─────────[ 临界区开始 …] │ │
│ │
宽限期开始 (Grace Period) 宽限期结束
什么是 RCU 宽限期(Grace Period)?
RCU 宽限期是指:从“写者更新指针/移除旧数据”开始,到“所有可能还在访问旧数据的旧读者全部退出临界区”为止的这段时间。只有宽限期结束后,写者才能确定没有任何读者在引用旧数据,从而安全地回收内存。
如何检测宽限期是否结束?(静止态 Quiescent State)
RCU 子系统不需要跟踪每一个具体的读者,而是通过静止态来推算:
临界区规则: 在经典 RCU 读临界区内,禁止发生上下文切换(Context Switch)、进入空闲(Idle)或返回用户空间。
静止态定义: 当某个 CPU 经历了上下文切换、CPU Idle 或返回用户空间时,就说明该 CPU 上的所有旧读者必然已经离开了临界区。这个时刻称为该 CPU 达到了静止态(Quiescent State)。
宽限期终点: 当系统中的每一个 CPU 都至少经历了一次静止态后,一个 RCU 宽限期即宣告结束。
3. 与宽限期相关的核心 API
RCU 的 API 明确划分为读者端与写者端(宽限期管理):
| 函数 API | 角色 | 是否阻塞 | 核心原理与作用 |
| rcu_read_lock() | 读者 | 否 | 标记 RCU 读临界区开始(经典 RCU 中仅关闭抢占 preempt_disable)。 |
| rcu_read_unlock() | 读者 | 否 | 标记 RCU 读临界区结束(preempt_enable)。 |
| rcu_dereference(p) | 读者 | 否 | 安全加载保护指针,确保内存顺序/屏障正确。 |
| synchronize_rcu() | 写者 | 是(阻塞) | 被动等待宽限期:阻塞当前线程,直到所有 CPU 报告静止态(耗时约数十毫秒)。 |
| call_rcu() | 写者 | 否(异步) | 注册异步回调:将释放回调函数挂入链表,不阻塞写者。宽限期结束后由后台自动触发回调。 |
| kfree_rcu() | 写者 | 否(异步) | call_rcu() 的简化版,专门用于“宽限期结束后仅执行 kfree”的场景。 |
| synchronize_rcu_expedited() | 写者 | 是(阻塞) | 加速宽限期:向所有 CPU 发送处理器间中断(IPIs),强迫它们立即报告静止态,将宽限期缩短至微秒级(开销较大)。 |
第二部分:会议译文与技术前沿(针对 RCU 与 eBPF)
前言上下文:Puranjay Mohan 在 2026 年 LSFMMMM/BPF 峰会上分享了他关于优化 RCU 性能的工作;他的演讲刚好为当天早些时候由 Harry Yoo 和 Alexei Starovoitov 主持的关于全新 kmalloc_nolock() 函数(允许从任意内核上下文进行无锁内存分配,并与 RCU 子系统交互)的议题提供了绝佳的背景。因此,以下内容将这两个议题结合在一起,以倒序方式呈现,以补充所需的背景知识。
议题一:更快的 RCU 周期(Faster RCU periods)
与许多与 RCU 相关的想法一样,Mohan 的这项性能优化工作也是始于和 Paul McKenney 的一次聊天。RCU 的工作原理是通过让写者(writers)对需要修改的数据创建一个新副本,然后原子地交换指针以指向新副本,从而保护指针背后的数据。读者(readers)此时可能仍在读取旧版本的数据,因此写者必须等到可以确定所有读者都已经离开后,才能释放旧版本。
这种情况发生在内核到达“静止态”(quiescent state)时:即每个 CPU 都经历了一次上下文切换、空闲(idle)、返回用户空间或其他能确保不再存在任何读者的状态转换。RCU 子系统会统计这些事件。当需要释放受 RCU 保护的资源的旧版本时,子系统会等待直到计数器足够高(比交换新副本时的计数器值大 2,以保证至少经历了一个完整的周期),然后再释放内存。这段等待时间被称为 RCU 宽限期(RCU grace period)。等待宽限期的工作是通过回调函数链表来管理的。
但是,宽限期有两种不同的类型。调用 synchronize_rcu() 会被动等待 CPU 报告它们已到达静止态,这可能需要几十毫秒。而调用 synchronize_rcu_expedited() 则会发送处理器间中断(IPI),强迫 CPU 比正常情况更快地到达并报告静止态。自然地,第二种宽限期被用于内核不希望等待太久的地方。
问题在于,这两种机制是并行运行的,并且彼此互不知情。等待正常 RCU 宽限期结束的回调函数,在加速宽限期结束时并不一定会运行,反之亦然。Mohan 在与 McKenney 讨论时提出的想法就是为了解决这个问题——允许正常的 RCU 回调函数在加速宽限期结束时立即执行。在某些工作负载下,特别是在系统面临内存压力时,加速宽限期会频繁发生,因此这一改变可能会使受 RCU 保护的资源被更快地释放。
这项修复在概念上很简单:让回调链表同时跟踪非加速宽限期编号和加速宽限期编号,只要其中任何一个编号足够高,就认为回调函数具备了运行条件。遗憾的是,具体细节要复杂得多,特别是考虑到 RCU 是内核中对性能要求极高的部分。内核内部有三个函数——rcu_exp_wait_wake()、rcu_pending() 和 rcu_core()——需要调整其逻辑来应对这一变化。
在代码正常工作后,Mohan 运行了一系列基准测试来评估这一改变的实际影响。他设置了 15 个线程在一个循环中创建和销毁套接字(socket),这些套接字使用 RCU 来释放内存。其他线程则故意触发加速宽限期。然后,他测量了加速宽限期的频率对内存使用量和套接字销毁延迟的影响。总体而言,在所有测试的加速宽限期频率下,分配的内存减少了 33% 到 41%(因为内存被更早地释放了)。synchronize_rcu() 调用的延迟也降低了,尽管它对加速宽限期的实际频率更为敏感。
Yoo 询问,即使在没有特殊需求的情况下,为了回收内存而主动请求加速宽限期是否有意义。Mohan 同意他的补丁集使这成为可能,但 Starovoitov 反对说,通用的最佳实践是不使用加速宽限期,除非确实需要,因为 IPI 的开销很昂贵。在内存不足(OOM)的情况下,使用加速宽限期往往为时已晚,起不到什么作用。Jakub Sitnicki 建议,加速宽限期可以作为直接回收(direct reclaim)的一部分;Starovoitov 则建议大家向 McKenney 和内存管理专家咨询这样做的后果。
台下的另一位听众询问 Mohan 是否知道在真实的工作负载中加速宽限期被触发的频率。Mohan 没有这阶段的生产数据,但预计这会因工作负载的不同而有很大差异,特别是取决于是否有任何 BPF 程序执行“map-in-map”更新,因为这种操作会稳定触发加速宽限期。这最后一个问题标志着本场会议的结束。
议题二:eBPF 中的无锁内存分配(kmalloc_nolock)深度解析
1. 为什么 eBPF 需要无锁内存分配?
eBPF(Extended Berkeley Packet Filter)程序运行在内核的核心关键路径上(如网络驱动驱动层 XDP、中断处理程序、跟踪点等)。这些上下文具有极高的执行约束:
-
不可睡眠(Non-sleepable):许多 eBPF 程序运行在不可中断或 RCU 读临界区内部。
-
绝对不能阻塞或加锁:传统的内核分配函数(如 kmalloc())在内存不足时可能会获取自旋锁或信号量,甚至触发休眠/页面回收。如果在硬中断或 RCU 临界区内调用 kmalloc(),可能会直接引发内核死锁或 Crash。
因此,eBPF 必须能够从任意不可睡眠、禁中断的上下文进行安全且无锁的内存分配。
2. 历史演进:过去 eBPF 是如何做内存分配的?其缺陷是什么?
阶段 1:静态预分配(Pre-allocation)
-
做法:在加载 eBPF Map(如 Hashmap)时,根据指定的上限(max_entries),提前一次性分配好所有节点所需的内存空间。
-
缺陷:极度浪费内存。如果用户声明了一个能容纳 100 万个元素的哈希表,但实际只插入了 10 个元素,剩余 99.99% 的内存将被永久占用而无法被内核其他模块使用。
阶段 2:专用的 BPF 内存分配器(BPF Allocator / bpf_mem_alloc)
-
做法:为了实现按需动态分配,内核开发人员专门为 BPF 编写了一套专属的内存分配器(基于 Slab/Per-CPU 预缓存池实现)。
-
缺陷:
-
巨大的维护负担:重新发明一套内存分配器极其复杂且容易引入 Bug。
-
作用域受限:这套分配器深陷在 BPF 子系统内部,无法被内核的其他通用模块复用。
3. 现在的解决方案:kmalloc_nolock() 如何弥补缺陷?
kmalloc_nolock() 的目标是:彻底废除专用的 BPF 分配器,同时保留按需动态分配的能力,且无需回归内存预分配。
-
无锁设计:提供在任意上下文(包括非睡眠/中断上下文)下均不需要获取锁的分配路径。
-
通用化:作为通用内核 API 提供,使其不仅服务于 BPF,还可用于内核其他对延迟敏感的模块。
-
类型安全回收(Typesafety-by-RCU):结合 RCU 机制实现内存快速复用。在许多场景下(例如高频的网络/跟踪计数),数据包被添加到 Map 后数纳秒就被删除。通过 Typesafety-by-RCU,即使读者还在访问旧对象,只要保证对象类型不变,写者就可以无需等待完整的 RCU 宽限期结束(毫秒级),立刻复用该内存,大大降低了内存抖动和分配开销。
议题二译文:无锁内存分配(Lock-free memory allocation)
Yoo 在开启关于 kmalloc_nolock() 的议题时解释道,在过去,使用 BPF map 的程序被要求预分配这些 map 的内存。这使得程序可以访问 map 而无需获取普通内存分配器所需的锁,但在程序没有填满 map 的情况下,这也浪费了内存。内核中引入 BPF 分配器是为了按需分配这部分内存,但它也带来了自身的挑战。首先,发明一个新的内存分配器并不好玩,它带来了 BPF 子系统本可以避免的维护负担。而且按照最初的设计,BPF 分配器无法在 BPF 子系统之外使用。最近关于 kmalloc_nolock() 的工作目标,就是移除 BPF 分配器,同时又不必带回 BPF map 的预分配机制。
棘手的地方在于,存在可以访问这块内存的“可睡眠”(sleepable)和“不可睡眠”(non-sleepable)两种 BPF 程序。不可睡眠的程序甚至可以从 RCU 临界区内部访问它。因此,要提供一个能替代 BPF 分配器的可行方案,任何解法都需要支持从可睡眠和不可睡眠的上下文(context)中分配内存,并且该内存有可能被另一种上下文访问。它还需要支持内存被立即释放,甚至是使用“通过 RCU 实现类型安全”(typesafety-by-RCU)的机制进行回收利用。
在 RCU 写者交换了指向 RCU 保护对象的指针之后、且在其等待释放旧版本期间,存在一种可能:写者需要分配另一个相同类型的对象。通常情况下,它需要创建一个新的分配,因为现有的读者可能仍在引用旧对象。但在某些情况下,重用现有的分配是安全的,即使读者可能仍在访问它。由于将该分配用于不同类型的对象可能会导致未定义行为,因此只有当任何读者都预期该对象具有完全相同的类型时,这样做才是安全的。RCU 子系统保证了它将是相同的类型,而不仅仅是相同大小的分配,因此得名“通过 RCU 实现类型安全”。
这项技术应用的地方之一是 BPF 哈希表(hashmap)。在内核中,几乎所有的哈希表都使用链表来作为哈希桶(hash bucket),链表中的每个元素都包含键(key)的副本和一些关联数据。实际的结构体定义更为复杂,但可以抽象为:
C
struct node {
long key;
void *data;
struct node *next;
};
当从哈希桶中移除一个元素时,写者知道任何读者都需要检查存储在该元素中的键是否是他们感兴趣的键。因此,在该对象从哈希桶中取消链接后,写者可以原子地将键更改为其他内容,然后更新数据,最后将该对象重新添加回哈希表相应的桶中。读者需要先加载数据指针,接着加载键,然后在利用数据指针之前验证键是否与他们想要的对象匹配。由于写者先更新键,而读者先获取数据指针,只要使用了适当的内存屏障/内存顺序(memory ordering),读者就永远不会读取到错误的数据。不过,他们可能会读取到错误的 next 指针,这会导致他们遍历错误的哈希桶。因此,每个哈希桶的末尾都有一个结构体,标明这是哪个桶的终点;如果读者到达了不同桶的终点标记,它就知道自己碰到了这种情况,需要重新扫描正确的桶。这整套精密的“舞蹈”避免了需要等待对象被返回给内核的通用内存池,从而降低了延迟、内存抖动和内存使用量。
一位听众询问,为什么分配器需要支持内存被“立即释放”。Starovoitov 解释说,某些 BPF 迭代器(iterator)和辅助函数(helper function)在某些情况下可以做到这一点。Yoo 的幻灯片中提到了一个与 kmalloc_nolock() 配套的 kfree_nolock()。Amery Hung 指出,在这些上下文中已经可以使用 kfree() 了,因此可能不需要单独的 kfree_nolock()。Yoo 解释说,需要这个独立的函数来支持“通过 RCU 实现类型安全”的高速缓存,这对于性能至关重要。
Starovoitov 进一步阐述道,“早在追踪(tracing)发展的早期”,人们会向 BPF map 中添加元素,然后在纳秒级的时间内将其移除。这在延迟分析中是一种特别常见的模式——从 map 中添加和移除时间戳。如果使用 free_rcu() 来释放它们,将经历一个完整的 RCU 周期,这可能比该对象实际被需要的时间长得多。使用“通过 RCU 实现类型安全”避免了这种开销,但 Starovoitov 表示,只要有某种方法可以做到这种即时重用,具体使用什么机制来回收释放的内存其实并不重要。
这引发了一场关于“该机制如何与运行 BPF 程序的不同 CPU 相互作用”的离题讨论;简而言之,BPF 程序在这里有可能出现与竞态条件(race-condition)相关的正确性 Bug,但不会影响到内核本身。
最终,Yoo 将话题带回到了 kmalloc_nolock() 上,并承认可能还需要一个 kfree_rcu_nolock() 来处理某些情况。随后 Starovoitov 问到了失败处理的问题:kmalloc_nolock() 在“绝大多数时候”都能成功,但有时就是没有在不加锁的情况下可用的内存。Yoo 表示,退回到伙伴分配器(buddy allocator)并申请一个新的 slab 可能是一个合理的做法,但 Starovoitov 指出这也可能会失败。他询问在这种情况下是否有任何办法来处理失败。
又一轮讨论最终产生了一个新设计,该设计应该对分配失败更加鲁棒(robust),并支持为对象附加析构函数(destructor)的能力,但依然无法完全避免分配失败的可能性。尽管如此,Starovoitov 对这次讨论感到很满意,他说:“我很高兴我们深入探讨了这个问题。”无论如何,这个新设计预计将是对现有的分配器的一次重大改进。
4. 总结:当前 kmalloc_nolock() 方法的缺陷与未来挑战
尽管 kmalloc_nolock() 极其出色,但峰会讨论也暴露出其目前的局限性与缺陷:
无法完全规避分配失败(Allocation Failures):
在无锁/无阻塞上下文中,分配器只能使用提前填充好的无锁内存池(Per-CPU 池或 Slab)。当突发流量导致无锁内存池耗尽时,分配器无法通过获取自旋锁或调用伙伴分配器(Buddy Allocator)去实时扩展内存(因为这可能导致死锁或休眠)。此时 kmalloc_nolock() 将直接返回失败(NULL)。
需要额外的 API 变体支持复杂的 RCU 缓存:
为了满足延迟敏感的类型安全重用,单纯有 kmalloc_nolock() 还不够,还需要引入配套的 kfree_nolock() 和 kfree_rcu_nolock(),进一步增加了 API 表面积。
潜在的并发正确性风险:
在跨 CPU 访问与高频即时重用时,BPF 程序若没有严格处理好内存屏障与数据逻辑,可能引发数据竞争(Race Conditions)。虽然这不会导致内核崩溃,但可能导致 BPF 程序读到脏数据。






