在 Linux 内核生态中,BPF(Berkeley Packet Filter)程序因其高性能与安全性,已被广泛用于扩展网络、安全追踪及可观测性等多个内核子系统。然而,长久以来 BPF 留有一个底层硬性限制:BPF 程序必须在启动时的同一个上下文中"一鼓作气"地运行至结束。
这一限制极大增加了编写某些复杂、长时运行任务的难度。在 2026 年的 Linux 存储、文件系统、内存管理和 BPF 峰会(LSFMM+BPF)上,开发者 Kumar Kartikeya Dwivedi 分享了他正在推进的颠覆性工作:允许将 BPF 程序改写为协程(Coroutines)。这一处于实验阶段的改变,有望打破 BPF 的上下文枷锁,让长时运行的 BPF 任务编写变得显著简单。
一、 传统 BPF 痛点:被时空割裂的逻辑
Dwivedi 解释说,在实际的内核扩展需求中,单个逻辑任务通常在空间和时间上都是分散的。执行流会在不同的位置之间跳跃,计算可能会被挂起。如果能在 BPF 中直接表达这种"走走停停"的逻辑,内核功能扩展将容易得多。
以收集堆栈跟踪(Stack Traces)任务为例,内核拥有追踪设施来收集内核和用户空间代码的组合堆栈跟踪。
-
最佳的高效时机: 紧接着内核部分运行完毕后,立即挂起计算,等到内核执行上下文切换、准备返回用户空间的前一刻,再收集用户空间部分的堆栈。
-
现状与阵痛: 由于目前没办法挂起一个正在执行的 BPF 程序,开发者被迫将这一个完整的逻辑拆分到多个独立的函数中,利用挂钩(Hooks)和回调(Callbacks)来断断续续地拼凑。
类似的问题在实现用户空间网络 时同样存在。例如,尝试使用 sendmsg() 发送数据包最终会调用 qdisc_run(),而该函数也可以为其他线程工作,发送它们排队的内容。这种后续工作在与主任务不同的时间和地点完成的案例比比皆是。
虽然传统的挂钩和回调可以表达内核开发人员所需的所有语义,但它逼着程序员不得不手动编写复杂的、自定义的"挂起/恢复"状态逻辑,将本该连贯的代码拆得七零八落。
二、 破局者:引入无栈协程(Stackless Coroutines)
为了彻底解决这一痛点,Dwivedi 提出的解决方案是将协程引入 BPF:即允许函数在运行到一半时主动挂起、将控制权交还给内核,并在稍后时机成熟时,从上次"存档"的地方无缝继续往下执行。
在具体实现上,最佳的解决方案是无栈协程(Stackless Coroutines)。
-
基本原理: 协程不需要像线程或传统函数那样占用独立的、大块的内存栈。相反,它依赖编译器在编译阶段把直线型代码重写为一个状态机。
-
对验证器(Verifier)的利好: 从 BPF 验证器的角度来看,这种机制不需要做出太大改变。恢复一个协程看起来就像一个普通的间接函数调用,编译器会自动处理保存和加载中间值的细节。
C++ 的实现与原型探索
由于 BPF 工具链(如 LLVM/Clang)紧密围绕 C/C++ 构建,C++ 自然成为了 Dwivedi 编写首个技术原型的试验场。
C++ 依托于 C++20 引入的协程标准(co_await、co_return 等)来实现这一机制。在底层,C++ 会创建一个结构体,其中包含两个可用于 resume()(恢复)或 destroy()(销毁)协程的函数指针:
-
resume()使用存储在同一结构体中的索引(Index,记录协程上次挂起的位置),通过一个类似switch的语句直接跳回当时挂起的那一行代码。 -
任何需要跨挂起点保存的变量都存储在同一个结构体中;而不需要跨挂企点的局部变量则留在系统栈上,在任务挂起时隐式丢弃。
这两个函数指针始终作为协程结构体的前两个元素。这意味着通用代码和内核验证器无需了解协程帧的内部秘密(如大小或具体布局),只需要通过固定的前两个指针去调用 resume() 即可。
Rust 的异步承接与优势
Rust 凭借其出色的生态(如 Aya 框架),已成为现代 BPF 开发的宠儿。在底层,Rust 的异步(Async/Await)天然就是无栈协程 ,它将异步函数编译为基于 Future 的状态机匿名枚举。
虽然 Rust 默认通过其类型系统(Trait 机制,如 Future::poll)来隐式传递恢复和销毁的行为,但 Dwivedi 在峰会上指出:"Rust 编译器可以非常容易地被'强迫'产生一个与 C++ 相同布局的结构体。"
这意味着,只需在 Rust 编译时将 Future 结构体的内存布局强制对齐 C++(即在头部加上 resume 和 destroy 两个回调指针),内核验证器就能用同一套逻辑同时支持这两种语言。此外,Rust 严苛的生存期(Lifetimes)约束,能让开发者在用户电脑编译时就提前拦截诸如"跨挂起点持有锁"等违规行为,编写体验会更加优秀。
三、 严苛的底线:内核验证器的安全守卫
将协程放进内核,安全是绝对的红线。BPF 验证器除了检查常规的约束外,还需要对协程增加多重审计:
-
指针与索引防篡改: 确保
resume()和destroy()函数指针不被覆盖,且跳转索引只取有效值。 -
生命周期合规: 确保在协程挂起时,随时调用
destroy()都是合法的(以防程序中途被卸载)。 -
内核资源清理: 验证器对"确保内核资源被释放而不是被遗忘"的常规检查,会防止 BPF 程序在挂起时将锁或引用计数结构遗留在内存中。验证器必须确保锁不能跨挂起点持有,同时可能需要使跨挂起点持有的某些特定类型的 Map 值失效。
关于"死循环"的思辨
针对开发者 Andrii Nakryiko 提出"如何防止协程通过篡改索引进入死循环"的担忧,Dwivedi 解释道,协程的主体仍然会被完全验证,每个挂起点也都会被审计,验证器可以用发现现有循环的相同方式来识别它们。
更重要的是,Dwivedi 指出,当前 BPF 限制指令数(少于 100 万条已验证指令)的本质目的,是为了防止可能导致死锁的循环,以确保系统能够继续向前推进,而不是为了阻止 BPF 程序浪费 CPU 时间(因为现在用户完全可以写一个合法但高耗时的程序挂载到所有钩子上把系统拖慢)。只要验证器能确保在每个挂起状态下程序都可以被安全销毁和恢复,且每次挂起时都必须释放锁交还控制权,它就无法破坏内核的死锁红线。
四、 未来展望
目前,Dwivedi 的原型实现仍在紧锣密鼓地推进中。接下来他打算进一步Polish当前工作,包括为协程程序扩展 BTF 调试信息,以及在验证器中解锁对复合返回类型(Aggregate Return Types)的支持,以便让 C++ 的测试程序完美运行。在此之后,全面添加 Rust 支持也将顺理成章。
此外,该项目还有一个更具颠覆性的长期愿景:允许挂起的计算在用户空间和内核空间之间任意切换。尽管这一原型目前还远未成熟,但如果未来能够实现,它将允许应用程序在用户空间做完基础设置后,无缝过渡到内核使用原生 BPF 功能,甚至再切回用户空间。这无疑将彻底模糊用户空间与内核之间的传统界限。
在近期内,引入协程并不会在技术上赋予 BPF 过去绝对做不到的新功能,但它将极大地解放生产力------让 BPF 程序能够以极简、直观的线性代码,轻而易举地融入那些无法被简化为单一挂钩或回调的复杂内核世界。
