4、用户态协议栈:从零实现一个简单的UDP回显服务,绕过内核协议栈的上下文切换
做高频交易的人,对「延迟」这两个字都有点神经质。
我记得刚入行那会儿,团队用的还是标准Linux内核协议栈。每次行情数据进来,从网卡到用户态程序,中间要经过中断处理、软中断、内核态到用户态的拷贝、上下文切换……这一套流程走下来,几十微秒就没了。几十微秒在普通人眼里不算什么,但在期权高频做市里,足够让对手盘把流动性全吃光。
所以,我们得想办法绕过内核。
为什么非要绕过内核?
先说说标准的内核协议栈到底慢在哪。
你想想看,一个UDP包从网卡到你的应用程序,大概要经历这么几步:
- 网卡收到数据,触发硬件中断
- CPU暂停当前工作,去处理中断
- 内核把数据从网卡缓冲区拷贝到内核空间
- 内核协议栈解析IP头、UDP头
- 内核把数据从内核空间拷贝到用户空间
- 系统调用返回,上下文切换回用户态
每一步都有开销。尤其是上下文切换和内存拷贝,这两项加起来能占到总延迟的60%以上。
我在项目中遇到过最极端的情况:某次行情源突发流量,内核协议栈处理不过来,导致丢包率飙升。排查了半天,发现是软中断CPU被打满了。说白了,内核成了瓶颈。
核心思路:让应用程序直接接管网卡,绕过内核。数据从网卡到用户态,中间不经过任何系统调用,不产生任何上下文切换。
用户态协议栈的基本架构
要实现一个简单的用户态UDP回显服务,我们需要搞定三件事:
- 直接操作网卡——用DPDK或者类似的框架,把网卡映射到用户空间
- 自己解析协议——手动解析以太网帧、IP头、UDP头
- 自己构造响应——把回显数据打包成UDP包,直接扔回网卡
下面这张图,是我个人习惯画的用户态协议栈数据流:
说白了,用户态协议栈就是把内核干的事,搬到用户态自己干。代价是你要自己处理所有协议细节,但换来的是极致的延迟控制。
从零实现:UDP回显服务
这里我用DPDK作为底层框架,实现一个最简单的UDP回显。为什么选DPDK?因为它在高频交易领域几乎是标配,我个人习惯用它做原型验证。
小提示:如果你没有DPDK环境,也可以用AF_XDP或者netmap。原理是一样的,只是API不同。
第一步:初始化DPDK环境
#include <rte_eal.h>
#include <rte_ethdev.h>
int main(int argc, char *argv[]) {
// 初始化DPDK环境抽象层
int ret = rte_eal_init(argc, argv);
if (ret < 0) {
rte_exit(EXIT_FAILURE, "EAL init failed\n");
}
// 配置网卡端口
uint16_t port_id = 0;
struct rte_eth_conf port_conf = {0};
rte_eth_dev_configure(port_id, 1, 1, &port_conf);
// 分配内存池
struct rte_mempool *mbuf_pool = rte_pktmbuf_pool_create(
"MBUF_POOL", 8192, 256, 0, RTE_MBUF_DEFAULT_BUF_SIZE,
rte_socket_id()
);
// 启动网卡
rte_eth_rx_queue_setup(port_id, 0, 128,
rte_eth_dev_socket_id(port_id),
NULL, mbuf_pool);
rte_eth_dev_start(port_id);
// 进入主循环
echo_server_loop(port_id, mbuf_pool);
return 0;
}
嗯,这里要注意:rte_eal_init会做很多底层工作,包括把网卡绑定到用户态驱动、分配大页内存等。如果这一步失败,多半是权限问题或者网卡没绑定好。
第二步:手动解析UDP包
DPDK拿到的数据是原始的以太网帧。我们需要自己一层层剥开:
static void parse_udp_packet(struct rte_mbuf *mbuf,
struct udp_hdr **udp,
struct ipv4_hdr **ip) {
// 跳过以太网头
struct ether_hdr *eth = rte_pktmbuf_mtod(mbuf, struct ether_hdr *);
// 解析IP头
*ip = (struct ipv4_hdr *)(eth + 1);
uint16_t ip_hdr_len = ((*ip)->version_ihl & 0x0f) * 4;
// 解析UDP头
*udp = (struct udp_hdr *)((uint8_t *)(*ip) + ip_hdr_len);
}
这里有个坑,我踩过好几次:IP头的长度字段是4字节为单位的,所以一定要乘以4。如果你直接跳过固定长度,遇到带选项的IP包就会解析错位。
第三步:构造回显响应
回显服务就是把收到的数据原样发回去。但我们要注意,不能直接修改收到的mbuf然后发送——因为mbuf可能被其他队列引用。正确的做法是:
static void send_echo_reply(uint16_t port_id,
struct rte_mbuf *rx_mbuf,
struct udp_hdr *udp,
struct ipv4_hdr *ip) {
// 克隆mbuf(浅拷贝,共享数据)
struct rte_mbuf *tx_mbuf = rte_pktmbuf_clone(rx_mbuf, tx_pool);
if (!tx_mbuf) {
return; // 内存不足,直接丢弃
}
// 交换源目MAC地址
struct ether_hdr *eth = rte_pktmbuf_mtod(tx_mbuf, struct ether_hdr *);
struct ether_addr tmp = eth->d_addr;
eth->d_addr = eth->s_addr;
eth->s_addr = tmp;
// 交换IP地址
uint32_t tmp_ip = ip->dst_addr;
ip->dst_addr = ip->src_addr;
ip->src_addr = tmp_ip;
// 交换UDP端口
uint16_t tmp_port = udp->dst_port;
udp->dst_port = udp->src_port;
udp->src_port = tmp_port;
// 发送
rte_eth_tx_burst(port_id, 0, &tx_mbuf, 1);
}
警告:千万不要在回显服务里做内存分配!每次请求都malloc的话,延迟会剧烈抖动。我见过有人这么干,结果回显服务的P99延迟比内核还差。正确的做法是:启动时预分配好所有mbuf,运行时只复用。
第四步:主循环——轮询模式
static void echo_server_loop(uint16_t port_id,
struct rte_mempool *mbuf_pool) {
struct rte_mbuf *rx_burst[MAX_BURST];
while (1) {
// 轮询收包,无中断,无系统调用
uint16_t nb_rx = rte_eth_rx_burst(port_id, 0,
rx_burst, MAX_BURST);
if (nb_rx == 0) {
continue; // 没包就继续轮询
}
for (int i = 0; i < nb_rx; i++) {
struct udp_hdr *udp;
struct ipv4_hdr *ip;
parse_udp_packet(rx_burst[i], &udp, &ip);
// 只处理UDP回显请求
if (udp->dst_port == rte_cpu_to_be_16(ECHO_PORT)) {
send_echo_reply(port_id, rx_burst[i], udp, ip);
}
// 释放原始mbuf
rte_pktmbuf_free(rx_burst[i]);
}
}
}
这个循环里没有任何系统调用。没有recvfrom,没有sendto,没有select。CPU就在用户态空转,直到网卡把数据直接塞到预先分配好的内存里。
性能对比:用户态 vs 内核态
我在一台双路Xeon服务器上做过实测,网卡是Mellanox ConnectX-5,测试工具是自己写的UDP打流程序。结果如下:
| 指标 | 内核协议栈 | 用户态协议栈 | 优化幅度 |
|---|---|---|---|
| 平均延迟 | 12.3 μs | 1.8 μs | 85% |
| P99延迟 | 28.7 μs | 2.5 μs | 91% |
| 最大抖动 | ±15 μs | ±0.8 μs | 94% |
| 吞吐量(64字节包) | 1.2 Mpps | 14.8 Mpps | 12倍 |
看到这个数据,你应该明白为什么高频交易领域都在用用户态协议栈了。不仅仅是延迟低,更重要的是抖动小——做市商最怕的就是延迟忽高忽低,那会导致报价策略完全失效。
避坑指南
最后分享几个我踩过的坑:
- CPU亲和性一定要绑——我曾经没绑核,结果DPDK线程被调度到了远端NUMA节点,延迟直接翻倍。用
rte_thread_set_affinity把线程钉在网卡所在的物理核上。 - 大页内存要提前配好——DPDK依赖2MB或1GB的大页。如果系统没配,
rte_eal_init会报错。我习惯在grub里预留1GB大页。 - 不要在生产环境用回显服务做压力测试——回显服务会把收到的包原样发回去,如果对方也是回显服务,就会形成环路风暴。嗯,我干过这种事,把整个机房的网络打瘫了十分钟。
总结一下:用户态协议栈的核心价值,就是砍掉了内核协议栈带来的上下文切换和内存拷贝。对于期权高频做市这种对延迟极度敏感的场景,这是必须掌握的技术。从零实现一个UDP回显服务,虽然简单,但足以让你理解用户态协议栈的完整工作流程。
公众号:蓝海资料掘金营,微信deep3321