3. 内核旁路:DPDK与RDMA技术实战

聊到极致延迟优化,就绕不开一个核心问题:操作系统内核

我刚开始做高频交易系统那会儿,总觉得Linux内核挺靠谱的。直到有一次,我在生产环境里抓包分析,发现一笔订单从网卡到用户态程序,光是在内核协议栈里就转了将近10微秒。10微秒啊,在纳秒级竞争的交易市场里,这简直是天文数字。

为什么会这样?说白了,内核为了通用性,做了太多我们不需要的事:中断处理、内存拷贝、上下文切换、协议栈解析……每一层都在吃延迟。那怎么办?两个思路:绕过它,或者绕过它。嗯,就是DPDK和RDMA。

3.1 为什么需要内核旁路?

先看传统网络路径:

  1. 网卡收到数据包 → DMA到内核缓冲区
  2. 网卡触发硬件中断 → 内核中断处理程序
  3. 内核协议栈处理(IP/TCP/UDP校验、重组)
  4. 数据从内核态拷贝到用户态(recvfrom/syscall)
  5. 上下文切换回到用户态程序

每一步都有代价。我实测过,一个64字节的UDP包,走完整条路径大概需要5-15微秒。而DPDK能做到多少?1微秒以内

核心思想:让用户态程序直接操作网卡硬件,绕过内核。网卡收到数据后,直接DMA到用户态预先分配的内存区域,零拷贝、零系统调用。

3.2 DPDK:用户态网卡驱动

DPDK(Data Plane Development Kit)是Intel开源的一套库和驱动。它做的事情很简单:把网卡的控制权从内核手里抢过来,交给用户态

3.2.1 核心组件

组件 作用
UIO/VFIO 将网卡硬件中断映射到用户态,绕过内核驱动
HugePages 使用2MB/1GB大页,减少TLB miss,提升内存访问效率
rte_ring 无锁环形队列,用于核间通信,延迟极低
rte_mbuf 内存缓冲区管理,避免频繁malloc/free
Poll Mode Driver (PMD) 轮询模式驱动,代替中断,减少上下文切换

我个人习惯把DPDK的架构理解成三句话:

  • 轮询代替中断:CPU一直问网卡“有数据吗?”,而不是等网卡来通知。
  • 零拷贝:数据从网卡直接到用户态,不经过内核。
  • CPU亲和性:每个网卡队列绑定一个固定的CPU核,避免核间切换。

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. 分配内存池
    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());

    // 3. 配置网卡(端口0)
    uint16_t port = 0;
    rte_eth_dev_configure(port, 1, 0, &port_conf_default);
    rte_eth_rx_queue_setup(port, 0, RX_RING_SIZE,
                           rte_eth_dev_socket_id(port),
                           NULL, mbuf_pool);
    rte_eth_dev_start(port);

    // 4. 轮询收包
    struct rte_mbuf *bufs[BURST_SIZE];
    while (1) {
        uint16_t nb_rx = rte_eth_rx_burst(port, 0, bufs, BURST_SIZE);
        if (nb_rx > 0) {
            // 处理数据包
            for (int i = 0; i < nb_rx; i++) {
                // 你的业务逻辑在这里
                rte_pktmbuf_free(bufs[i]);
            }
        }
    }
    return 0;
}
避坑指南:我曾经在配置HugePages时踩过坑。DPDK要求系统预留大页内存,但如果你忘了在grub里配置default_hugepagesz=1G hugepagesz=1G hugepages=4,程序启动时会直接报错。嗯,第一次搞的时候我排查了整整一个下午。

3.3 RDMA:远程直接内存访问

DPDK解决了本机收发包的延迟问题。但如果你的交易系统需要跨机器通信呢?比如行情网关到交易引擎之间。这时候,RDMA就登场了。

RDMA(Remote Direct Memory Access)允许一台机器的应用程序直接读写另一台机器的内存,不需要经过CPU、不需要经过内核、不需要经过协议栈。延迟可以做到1-3微秒,而传统TCP在同样网络条件下至少10微秒起步。

3.3.1 RDMA的三种实现

技术 特点 适用场景
InfiniBand 原生RDMA,性能最强,但需要专用网卡和交换机 超低延迟交易网络
RoCE v2 基于以太网的RDMA,兼容现有网络 大多数交易系统首选
iWARP 基于TCP的RDMA,性能稍弱 兼容性要求高的场景

我个人推荐RoCE v2。为什么?你想想看,交易机房里大部分已经是25G/100G以太网了,RoCE v2可以直接跑在上面,不需要额外买InfiniBand交换机。成本省一大截,延迟也只比InfiniBand多几百纳秒。

3.3.2 RDMA的核心概念

  • QP(Queue Pair):通信的基本单元,包含一个发送队列和一个接收队列。
  • MR(Memory Region):注册到RDMA网卡的内存区域,网卡可以直接读写。
  • CQ(Completion Queue):完成事件通知队列,告诉应用程序操作已完成。
  • WR(Work Request):工作请求,描述一次发送或接收操作。

说白了,RDMA的编程模型就是:你注册一块内存,告诉网卡“这块内存你可以直接读写”,然后发一个WR给QP,网卡自己就把活干了。CPU全程不参与数据搬运。

3.3.3 实战:RDMA发送接收示例

#include <infiniband/verbs.h>

struct rdma_resources {
    struct ibv_context *ctx;
    struct ibv_pd *pd;
    struct ibv_mr *mr;
    struct ibv_cq *cq;
    struct ibv_qp *qp;
    char *buf;
};

// 初始化RDMA资源
int setup_rdma(struct rdma_resources *res) {
    // 1. 打开设备
    res->ctx = ibv_open_device(ibv_get_device_list(NULL)[0]);
    
    // 2. 创建保护域
    res->pd = ibv_alloc_pd(res->ctx);
    
    // 3. 注册内存区域
    res->buf = malloc(4096);
    res->mr = ibv_reg_mr(res->pd, res->buf, 4096,
                         IBV_ACCESS_LOCAL_WRITE |
                         IBV_ACCESS_REMOTE_WRITE);
    
    // 4. 创建完成队列
    res->cq = ibv_create_cq(res->ctx, 100, NULL, NULL, 0);
    
    // 5. 创建QP
    struct ibv_qp_init_attr attr = {
        .send_cq = res->cq,
        .recv_cq = res->cq,
        .cap = { .max_send_wr = 10, .max_recv_wr = 10,
                 .max_send_sge = 1, .max_recv_sge = 1 },
        .qp_type = IBV_QPT_RC  // 可靠连接
    };
    res->qp = ibv_create_qp(res->pd, &attr);
    
    return 0;
}

// 发送数据
int rdma_send(struct rdma_resources *res, const char *data, int len) {
    memcpy(res->buf, data, len);
    
    struct ibv_sge sge = {
        .addr = (uint64_t)res->buf,
        .length = len,
        .lkey = res->mr->lkey
    };
    
    struct ibv_send_wr wr = {
        .wr_id = 0,
        .sg_list = &sge,
        .num_sge = 1,
        .opcode = IBV_WR_SEND,
        .send_flags = IBV_SEND_SIGNALED
    };
    
    struct ibv_send_wr *bad_wr;
    ibv_post_send(res->qp, &wr, &bad_wr);
    
    // 等待完成
    struct ibv_wc wc;
    ibv_poll_cq(res->cq, 1, &wc);
    return 0;
}
注意:RDMA的QP状态机比较复杂。从INIT到RTR再到RTS,每一步都需要对端配合。我曾经在生产环境里因为QP状态没切到位,导致丢包了半小时才排查出来。建议封装一个状态管理模块,每次切换都打印日志。

3.4 DPDK vs RDMA:怎么选?

很多同学问我:到底用DPDK还是RDMA?我的回答是:看场景

维度 DPDK RDMA
通信范围 本机收发包 跨机器内存访问
延迟 0.5-2微秒 1-3微秒
CPU开销 轮询占用一个核 几乎零CPU参与
编程复杂度 中等 较高
硬件要求 普通网卡+Intel CPU 专用RDMA网卡

我个人的经验是:行情接收用DPDK,订单发送用RDMA。为什么?行情数据量大、频率高,DPDK的轮询模式能保证不丢包。而订单需要跨机器发送到交易所,RDMA的零CPU拷贝能省下宝贵的计算资源给策略引擎。

3.5 知识体系总览

下面这张图是我自己总结的,把DPDK和RDMA在整个交易系统里的位置画清楚了。

内核旁路技术架构图 传统:内核协议栈 DPDK:用户态驱动 RDMA:远程内存访问 物理网卡(NIC) 传统应用 DPDK应用 RDMA应用 延迟:5-15微秒 延迟:0.5-2微秒 延迟:1-3微秒 DPDK:轮询+零拷贝,适合本机高频收包 RDMA:远程内存直读,适合跨机器订单传输

你看,传统路径要经过内核,DPDK直接绕过去了,RDMA更是连本机CPU都不需要。这就是延迟从10微秒降到1微秒的秘密。

我的建议:如果你刚开始接触内核旁路,先从DPDK入手。它比RDMA简单,社区文档也丰富。等你把DPDK玩熟了,再搞RDMA会轻松很多。我当年就是先啃了三个月DPDK,然后花了两周就上手了RDMA。

好了,DPDK和RDMA的核心思路就这些。说白了就是一句话:别让内核掺和网络IO。下一节我们会聊到CPU亲和性与中断绑定,那是另一个能挤出几百纳秒延迟的技巧。


公众号:蓝海资料掘金营,微信deep3321