Kernel Bypass(内核旁路)是一种经典的系统设计哲学。

操作系统内核为了通用性,引入了大量的上下文切换、锁竞争、数据拷贝等开销,将原本属于内核的服务(如网络协议栈、文件系统、设备驱动、调度等)直接“上移”至用户态,让业务应用程序直接与底层数据进行交互,从而带来了极致的性能,这便是 Kernel Bypass 的核心理念。

本文将从几个不同的模块出发,介绍 Linux 内核中常见的 Kernel Bypass 基本设计理念。受限于篇幅的原因,本文不会对每个方案的具体技术细节(如传输协议等)进行描述。

设备驱动

UIO 与 VFIO

UIO(Userspace IO) 与 VFIO(Virtual Function IO) 常用于编写自定义硬件加速卡驱动,且是下文将介绍的 DPDK/SPDK 的底层支撑。

它的基本原理是:传统驱动必须实现在内核中。而 UIO/VFIO 机制使得内核只负责最基础的资源分配,而把硬件的设备寄存器通过 mmap 暴露给用户态,让用户程序能够直接读写设备寄存器来控制设备。VFIO 更是结合了 IOMMU 硬件,不仅实现了旁路,还保证了多个用户态驱动之间、以及与宿主机之间的内存安全隔离。

UIO 是最简单的用户态驱动模型,核心设计就是将设备寄存器地址映射到用户态,然后由用户程序自行编写驱动逻辑。

Q:DMA 安全如何保证?DMA 缓冲区是如何确定?

A:UIO 完全无法保证 DMA 安全,理论上用户态驱动可以直接向设备寄存器写入错误的物理地址发起 DMA 请求,从而可能导致内核崩溃。DMA 缓冲区由用户缓冲区来提供,但是问题在于用户自己所持有的是虚拟地址,而 DMA 需要的是物理地址。解决方法主要是:

  • mmap(hugetlbfs) + 读取/proc/self/pagemap 从而获得相应的物理地址;

  • 借助内核中的 UIO 驱动来分配 DMA 缓冲区,然后 mmap 给用户态。

而 VFIO 基于 IOMMU 引入了一层 iova -> phys 的间接层,允许用户态传入 (vaddr, iova) 的 VFIO 的请求,内核需要:

  1. 在页表建立 vaddr -> phys 的映射;
  2. 在 IOMMU 页表建立 iova -> phys 的映射。

Q:VFIO 的 DMA 映射为什么包括 (vaddr, iova),直接将 vaddr -> phys 的映射插入到 IOMMU 页表中不行吗?为什么要选择插入 iova -> phys 的映射?

A:有以下几个原因:

  1. VFIO 最初为虚拟机设备直通设计,而在虚拟机设备直通中,DMA 地址转换逻辑是:gpa/iova -> hpa,而虚拟机内存则是 VMM(如 QEMU) 通过 mmap 映射的一段用户态内存,该内存不满足 va == gpa/iova
  2. 如果不考虑虚拟机,由于一些较老的设备仅支持 32 位寻址,在 64 位系统下,用户态虚拟地址通常都位于高位,超过了 32 位的地址空间,导致设备无法发起对该地址的 DMA 访存。

网络

DPDK

DPDK(Data Plane Development Kit)的基本原理是:借助 UIO/VFIO 技术将网卡的寄存器和 DMA 缓冲区直接映射到用户态,并在用户态实现一套网络协议栈,最后基于 LD_PRELOAD 等方式将原本 POSIX API 中的 socket 操作替换为 DPDK 的版本。它使用轮询模式驱动(Poll Mode Driver, PMD)代替传统的中断机制,用户态在一个循环中不断主动读取网卡队列,实现了真正的零拷贝和无系统调用收发包。

Q:DPDK 的 PMD 机制需要特定的网卡支持吗?

A:不需要,而是需要 PMD 驱动来支持特定的网卡。例如为了用 PMD 模式代替原本网卡设备的中断驱动模式,PMD 驱动需要在配置网卡设备时,通过写入网卡的特定设备寄存器,将网卡的中断触发功能给关闭。

XDP/AF_XDP

XDP(eXpress Data Path)的基本原理是:在 Linux 内核网络协议栈中,sk_buff 分配与流转的性能开销非常巨大。XDP 的作用就是借助 eBPF 机制,在 Linux 网络协议栈中插入一个 hook,该 hook 位于网卡驱动程序中、网卡刚刚通过 DMA 将数据拷贝到内存,且内核还没来得及分配 sk_buff 前。此时数据包还是一串裸的字节流,XDP 允许在该阶段运行一段自定义程序,自定义程序主要完成下列工作:

  • eBPF 程序可以直接读取这串字节流,完成诸如解析以太网头、IP 头、TCP/UDP 头等操作,并可以直接在原内存地址上进行写入。
  • 处理完包后,向网卡驱动返回一个执行指令(Return Code),告诉网卡接下来怎么做。共有 5 种:
    • XDP_DROP(丢弃):直接在网卡驱动层把包丢掉。不分配 sk_buff,不进内核。主要用于 DDoS 防护(如 Cloudflare)。
    • XDP_PASS(放行):把包还给 Linux 内核,按照正常的流程分配 sk_buff,走传统 TCP/IP 协议栈。(如把 SSH 管理流量放行)。
    • XDP_TX(原路返回):修改完数据包后,直接从当前收包的网卡返回。可用于做负载均衡器。
    • XDP_REDIRECT(重定向):把包重定向到另一张网卡、另一个 CPU,或者重定向到用户态的 AF_XDP Socket。
    • XDP_ABORTED(异常终止):程序出错,丢弃数据包并记录错误。

XDP 相比 DPDK 的优势主要在于:Linux 内核原生支持,且不用接管物理网卡,比如可以在 eBPF 中添加简单的规则对网络进行分流,选择是否走 AF_XDP 或是走内核网络协议栈,这样其他的应用程序将不受 XDP 的影响。

RDMA

RDMA(Remote Direct Memory Access)技术在近年迎来了爆发式的应用和增长,主要用于高性能计算、分布式存储、AI 训练集群等场景。

其基本原理是:在传统的 TCP/IP 网络下,本机虽然有 DMA 来完成内存到网卡的数据搬运,但是 CPU 仍然需要完成协议解析等繁重工作。RDMA(Remote DMA)则相当于是 DMA 设计理念的“跨主机推广”,直接实现主机 A 内存 -> 主机 A 网卡 -> 主机 B 网卡 -> 主机 B 内存。网卡硬件直接处理网络协议,并直接读写远端机器的用户态内存区域,从而带来了极低的延迟。

存储与文件系统

SPDK

SPDK(Storage Performance Development Kit)的原理与 DPDK 类似,利用 UIO/VFIO 将 NVMe 驱动移到了用户态,采用无锁、轮询、零拷贝的设计。它绕过了内核的 VFS、Page Cache、BIO 调度、中断处理等,用户态应用直接向 NVMe 硬件提交 I/O 请求。

io_uring

io_uring 是 Linux 内核近年引入的一个新的 I/O 模型,它并非严格的 Kernel Bypass。它的基本原理是:借助 mmap,使得内核态和用户态共享 ring buffer(提交队列 SQ 和完成队列 CQ),应用程序直接从中发起 I/O 请求和检测 I/O 请求是否完成,从而避免频繁的系统调用导致的上下文切换开销。听上去与 Virtio 设计的核心思想十分类似:利用共享内存,把“跨边界通信”从“陷入驱动”变成“内存驱动”。只不过 io_uring 优化的是用户态与内核态的边界,而 Virtio 优化的是 guest 与 hypervisor 的边界。

Q:SQ/CQ 队列中的请求是什么层次的?是 BIO 请求吗?

A:并不是,如果是 BIO,就相当于 Bypass 掉了文件系统层,还需要和 SPDK 一样在用户态实现文件系统。实际上 io_uring 的请求条目只是相当于对原本 read/write 等系统调用的打包。总结来说就是:io_uring 只绕过了控制面,而没有绕过数据面。

Direct I/O

Direct I/O 属于部分 Kernel Bypass,其基本原理是:在进行文件读写时,绕过内核的 Page Cache 层,在文件系统将文件读写请求“翻译”为设备读写请求后,直接对存储设备进行读写,不用先经过 Page Cache 缓存。应用场景如数据库、自研存储引擎等,这些存储系统通常会在用户态实现自己的高性能缓存,因此内核中的 Page Cache 缓存就显得多余了,Direct I/O 正是提供了这样 Bypass 的能力。

DAX

DAX(Direct Access)是一种“让存储像内存一样被访问”的机制,结合非易失性内存(如 Intel Optane)来使用。它可以使用类似内存的编程模型(直接使用访存指令进行读写而不用 read/write 系统调用),来对持久化设备进行操作。路径大概是:访存 -> 文件系统 -> 设备,绕过了 Page Cache 和 Block 层。

任务调度

协程

协程(Coroutines)广泛应用在高并发网络服务中,用以替代线程,称为任务调度的基本单位。这主要是因为线程在面对搞并发场景时,会面临性能和内存的双重开销:一方面,每个线程都有自己独立的线程栈(Linux 中通常为 2MB ~ 8MB)。如果同时启动 1 万个线程,栈内存就要占用数十 GB。另一方面,当线程发生阻塞(例如等待网络读写)被操作系统挂起并切换到另一个线程时,必须经历用户态/内核态切换所带来的开销,在高并发场景下,大量这样的开销堆积起来所带来的总开销是不可接收的。

协程(也被称为用户态线程、虚拟线程、绿色线程等)的基本原理是:将上下文的切换转移到用户态来完成,因此相较于需要陷入内核的线程切换,协程切换仅需保存恢复极少量的寄存器,绕过了内核 clone 系统调用和用户态/内核态上下文切换开销,同时还减少了大量线程栈内存空间的资源浪费。操作系统内核只负责分配少量的物理线程,而成千上万个协程的创建、休眠、唤醒和销毁,全部由用户态(协程库)负责管理。

思考

前面讨论了这么多的 Kernel Bypass 方案,核心理念都是将内核中的某些模块(设备驱动、网络协议栈等)搬到用户态来完成。似乎和微内核的理念比较类似?既然这些 Kernel Bypass 方案的追求是高性能,那为什么大量 OS 服务位于用户态的微内核 OS 的性能却一直为人所诟病?

需要注意的是,“在用户态执行”本身并不能带来高性能,真正带来高性能的,是“消除边界”。Kernel Bypass 是在 消除边界 ,而微内核是在 制造更多的边界 。对于 Kernel Bypass 而言,业务应用程序与驱动、网络协议栈等 OS 服务位于同一个地址空间中,因此它们之间原本由用户态/内核态带来的边界被消除,应用程序与 OS 服务位于同一个地址空间中,它们之间的通信直接通过函数调用(function call)来完成。而对于微内核 OS 来说,这样的边界非但没有被消除,反而带来了更长的调用路径:应用程序要想请求 OS 服务,必须经过 IPC(进程间通信),而该 IPC 仍然需要经过底层的微内核,应用 -> 微内核 -> OS 服务,特权级切换/上下文切换甚至比传统的宏内核更多更频繁。

实际上,如果仔细分析会发现,二者的哲学目标也完全不同:

  • Kernel Bypass 的哲学是“为了绝对的性能,可以牺牲隔离性”。在 SPDK 框架中,如果实现的 NVMe 驱动代码出现了一个野指针引发段错误,整个业务应用程序可能随之一并崩溃。它放弃了操作系统的容错保护,换取了极致的速度。
  • 而微内核的哲学是“为了绝对的安全、稳定和模块化,可以牺牲部分性能”。在微内核 OS 中,如果网卡驱动(一个用户态进程)出现 BUG 崩溃了,微内核可以将其杀死并重启,而不会影响到其他的 OS 服务模块,为操作系统带来了更高的安全性和稳定性。

真正和 Kernel Bypass 类似的操作系统架构其实是 Unikernel,它的设计理念是一个操作系统只运行一个应用程序,从而可以将操作系统视作“静态链接库”,直接和应用程序打包在一起,运行在裸机上。近年最为著名的 Unikernel 当属 Unikraft

参考资料