3、操作系统内核旁路:DPDK、RDMA、XDP的原理与实战,如何绕过内核协议栈

聊到低延迟,有个坎儿你绕不过去——操作系统内核。

我刚开始做高频交易系统那会儿,满脑子想的都是怎么把算法写得快一点。后来发现,算法再快,数据从网卡到应用手里,光是在内核里转一圈,几十微秒就没了。你想想看,几十微秒在交易世界里,黄花菜都凉了。

所以,我们得想办法绕过内核。说白了,就是不让操作系统瞎掺和。今天我就跟你聊聊三种主流的旁路技术:DPDK、RDMA 和 XDP。这三种我都踩过坑,也尝过甜头。

3.1 为什么非要绕过内核?

先说说内核协议栈为什么慢。传统的数据包处理路径是这样的:

  1. 网卡收到数据,触发硬件中断。
  2. 内核中断处理程序把数据拷贝到内核缓冲区。
  3. 内核协议栈开始干活:IP 校验、TCP 重组、路由查找……
  4. 数据从内核态拷贝到用户态。
  5. 系统调用(比如 recvfrom)返回,应用终于拿到数据。

每一步都有开销。中断上下文切换、内存拷贝、锁竞争,这些都是延迟杀手。我曾在项目中测过,一个简单的 UDP 包,从网卡到应用,纯内核路径大概要 30-50 微秒。对于高频交易,这个数字是不可接受的。

核心观点: 内核旁路的本质,就是让应用直接接管硬件,省掉中间商赚差价。

3.2 DPDK:数据面开发套件

DPDK 是我用得最多的技术。它的思路很直接:把网卡的控制权从内核手里抢过来。

3.2.1 原理

DPDK 在用户态实现了一个轮询模式驱动(PMD)。网卡收到数据后,不触发中断,而是直接把数据通过 DMA 写入预先分配好的内存池。应用层通过轮询的方式,不断检查有没有新数据到来。

这样做的好处很明显:

  • 零拷贝: 数据从网卡到应用,全程不经过内核。
  • 无中断: 避免了中断上下文切换的开销。
  • 大页内存: 使用 2MB 或 1GB 的大页,减少 TLB miss。

嗯,这里要注意。DPDK 要求网卡支持。不是所有网卡都能跑 DPDK,Intel 的 82599、X710 这些是标配。我建议你买网卡前先查一下 DPDK 的兼容列表。

3.2.2 实战:一个简单的 DPDK 收包程序

下面是一个最简的 DPDK 初始化代码。我当年第一次跑通时,激动得差点拍桌子。

#include <rte_eal.h>
#include <rte_ethdev.h>

#define RX_RING_SIZE 1024
#define NUM_MBUFS 8191
#define MBUF_CACHE_SIZE 250
#define BURST_SIZE 32

static const struct rte_eth_conf port_conf_default = {
    .rxmode = { .max_rx_pkt_len = RTE_ETHER_MAX_LEN }
};

int main(int argc, char *argv[]) {
    // 1. 初始化 EAL
    int ret = rte_eal_init(argc, argv);
    if (ret < 0)
        rte_exit(EXIT_FAILURE, "EAL init failed\n");

    // 2. 获取可用端口
    uint16_t nb_ports = rte_eth_dev_count_avail();
    // 3. 配置端口
    struct rte_eth_conf port_conf = port_conf_default;
    rte_eth_dev_configure(0, 1, 0, &port_conf);
    // 4. 分配内存池
    struct rte_mempool *mbuf_pool = rte_pktmbuf_pool_create(
        "MBUF_POOL", NUM_MBUFS, MBUF_CACHE_SIZE, 0,
        RTE_MBUF_DEFAULT_BUF_SIZE, rte_socket_id());
    // 5. 启动端口
    rte_eth_rx_queue_setup(0, 0, RX_RING_SIZE,
                           rte_eth_dev_socket_id(0), NULL, mbuf_pool);
    rte_eth_dev_start(0);
    // 6. 轮询收包
    struct rte_mbuf *bufs[BURST_SIZE];
    while (1) {
        uint16_t nb_rx = rte_eth_rx_burst(0, 0, bufs, BURST_SIZE);
        if (nb_rx > 0) {
            // 处理数据包
            for (int i = 0; i < nb_rx; i++) {
                // 你的业务逻辑
                rte_pktmbuf_free(bufs[i]);
            }
        }
    }
    return 0;
}

这段代码的核心就一个函数:rte_eth_rx_burst。它直接从网卡硬件 FIFO 里批量拿数据,一次最多拿 32 个包。我在项目中测过,单核收包速率能到 1400 万 pps 以上。

避坑指南: 我曾经在 NUMA 架构的机器上踩过坑。DPDK 的内存池一定要绑定在网卡所在的 NUMA 节点上,否则跨节点访问内存,延迟会翻倍。用 rte_socket_id() 获取当前网卡的 socket ID,然后分配内存。

3.3 RDMA:远程直接内存访问

如果说 DPDK 是绕过了内核协议栈,那 RDMA 就是直接把内核给跳过了。它允许一台机器的应用直接读写另一台机器的内存,不需要经过 CPU 和操作系统。

3.3.1 原理

RDMA 的核心是 RNIC(RDMA 网卡)。它上面有个硬件引擎,负责处理传输层的逻辑。应用只需要注册一块内存区域,然后把地址告诉网卡。网卡自己就能完成数据的收发和确认。

RDMA 有两种主流实现:

  • InfiniBand: 专用网络,性能最好,但贵。
  • RoCE v2: 跑在标准以太网上,成本低,但需要无损网络支持。

我个人习惯用 RoCE v2,因为可以复用现有的以太网基础设施。但要注意,RoCE 对 PFC(优先级流控制)依赖很大。如果交换机不支持 PFC,丢包率会飙升。

3.3.2 实战:RDMA 写操作

下面是一个 RDMA 写操作的伪代码。我当年调试这个的时候,被 ibv_post_send 的返回值坑了好几次。

// 假设已经建立连接,获取了 qp(队列对)
struct ibv_qp *qp;
struct ibv_mr *mr;  // 注册的内存区域
char *buffer;       // 数据缓冲区

// 1. 准备发送请求
struct ibv_send_wr wr;
struct ibv_sge sge;

memset(&wr, 0, sizeof(wr));
wr.wr_id = 0;
wr.opcode = IBV_WR_RDMA_WRITE;
wr.send_flags = IBV_SEND_SIGNALED;
wr.wr.rdma.remote_addr = remote_addr;  // 远端内存地址
wr.wr.rdma.rkey = remote_rkey;         // 远端内存密钥

sge.addr = (uint64_t)buffer;
sge.length = 64;  // 写 64 字节
sge.lkey = mr->lkey;

wr.sg_list = &sge;
wr.num_sge = 1;

// 2. 提交请求
struct ibv_send_wr *bad_wr;
int ret = ibv_post_send(qp, &wr, &bad_wr);
if (ret) {
    // 处理错误
    printf("ibv_post_send failed: %d\n", ret);
}

// 3. 等待完成
struct ibv_wc wc;
ibv_poll_cq(send_cq, 1, &wc);

这段代码实现了零 CPU 开销的数据传输。数据从本机 buffer 直接 DMA 到远端内存,中间不经过任何 CPU 拷贝。延迟可以做到 1 微秒以内。

注意: RDMA 的编程模型和传统 socket 完全不同。它是异步的,你需要自己管理完成队列(CQ)。我见过很多新手把 RDMA 当成 TCP 来用,结果死锁得一塌糊涂。

3.4 XDP:快速数据路径

XDP 是 Linux 内核里的一把手术刀。它不像 DPDK 那样完全绕过内核,而是在内核的最早期(网卡驱动刚拿到数据时)就介入处理。

3.4.1 原理

XDP 使用 BPF(伯克利包过滤器)程序,挂载在网卡驱动层。数据包到达后,驱动会调用你的 BPF 程序,你可以决定:

  • XDP_PASS: 放行,交给内核协议栈。
  • XDP_DROP: 直接丢弃。
  • XDP_TX: 从原路返回(比如用于负载均衡)。
  • XDP_REDIRECT: 重定向到其他网卡或用户态程序。

XDP 的优势在于,它不需要修改应用代码。你可以在不重启服务的情况下,动态加载一个 BPF 程序来过滤流量。我在做 DDoS 防护时就用过这招,效果立竿见影。

3.4.2 实战:一个简单的 XDP 丢包程序

下面是一个用 C 写的 XDP 程序,专门丢弃 ICMP 包(也就是 ping 包)。

#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/icmp.h>

SEC("xdp")
int xdp_drop_icmp(struct xdp_md *ctx) {
    void *data_end = (void *)(long)ctx->data_end;
    void *data = (void *)(long)ctx->data;

    // 解析以太网头
    struct ethhdr *eth = data;
    if ((void *)(eth + 1) > data_end)
        return XDP_PASS;

    // 只处理 IPv4
    if (eth->h_proto != __constant_htons(ETH_P_IP))
        return XDP_PASS;

    // 解析 IP 头
    struct iphdr *ip = data + sizeof(*eth);
    if ((void *)(ip + 1) > data_end)
        return XDP_PASS;

    // 如果是 ICMP,直接丢弃
    if (ip->protocol == IPPROTO_ICMP)
        return XDP_DROP;

    return XDP_PASS;
}

char _license[] SEC("license") = "GPL";

编译后,用 ip link set dev eth0 xdp obj xdp_drop_icmp.o 挂载。瞬间,所有 ping 包都被挡在门外。CPU 占用率几乎为零。

我的经验: XDP 最适合做预处理。比如在金融交易中,你可以用 XDP 先过滤掉非交易报文,只把交易数据送给 DPDK 应用。这样既享受了 XDP 的低开销,又保留了 DPDK 的灵活性。

3.5 三种技术的对比

说了这么多,我帮你总结一下。这三种技术各有各的适用场景。

特性 DPDK RDMA XDP
绕过程度 完全绕过内核 完全绕过内核和 CPU 部分绕过(内核早期介入)
延迟 1-5 微秒 < 1 微秒 1-3 微秒
吞吐量 极高(单核 1400 万 pps) 极高(取决于链路速率) 高(受限于 BPF 复杂度)
编程复杂度 中(需要学习 DPDK API) 高(需要理解 RDMA 语义) 低(BPF 程序简单)
硬件要求 特定网卡 RNIC 或 RoCE 网卡 主流网卡(内核 4.8+)
典型场景 高频交易、NFV 分布式存储、HPC DDoS 防护、负载均衡

3.6 如何选择?

这个问题没有标准答案。我个人的建议是:

  • 如果你需要完全控制数据路径,并且不介意修改应用代码,选 DPDK
  • 如果你的瓶颈在跨机数据传输,而且你愿意为硬件花钱,选 RDMA
  • 如果你只想快速过滤或重定向流量,不想动现有架构,选 XDP

当然,你也可以组合使用。比如用 XDP 做第一道过滤,然后把数据交给 DPDK 做深度处理。我在一个行情分发项目中就这么干过,效果出奇的好。

最后说一句: 绕过内核不是银弹。它带来了性能,也带来了复杂度。你需要在延迟、吞吐、开发成本和维护难度之间找到平衡。嗯,这就是工程师的日常。

专注资料整理