3、操作系统内核旁路:DPDK、RDMA、XDP的原理与实战,如何绕过内核协议栈
聊到低延迟,有个坎儿你绕不过去——操作系统内核。
我刚开始做高频交易系统那会儿,满脑子想的都是怎么把算法写得快一点。后来发现,算法再快,数据从网卡到应用手里,光是在内核里转一圈,几十微秒就没了。你想想看,几十微秒在交易世界里,黄花菜都凉了。
所以,我们得想办法绕过内核。说白了,就是不让操作系统瞎掺和。今天我就跟你聊聊三种主流的旁路技术:DPDK、RDMA 和 XDP。这三种我都踩过坑,也尝过甜头。
3.1 为什么非要绕过内核?
先说说内核协议栈为什么慢。传统的数据包处理路径是这样的:
- 网卡收到数据,触发硬件中断。
- 内核中断处理程序把数据拷贝到内核缓冲区。
- 内核协议栈开始干活:IP 校验、TCP 重组、路由查找……
- 数据从内核态拷贝到用户态。
- 系统调用(比如 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 以上。
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 微秒以内。
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 占用率几乎为零。
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 做深度处理。我在一个行情分发项目中就这么干过,效果出奇的好。