目录
【C/C++】从网卡到 socket:一个 DPDK 用户态协议栈的整体架构
一块网卡持续吐出报文时,I/O 线程不能停下来等某条 TCP 连接推进状态;应用调用 recv() 时,也不关心 PMD 正在哪一轮轮询。awesome-stack 需要协调的,正是这几种不同的节奏:网卡按批次收发,协议线程按连接维护状态,应用则按 socket 和 epoll 语义工作。
沿着一条报文的路径看,串起这些动作的是两级队列和一组真实的 Linux fd。I/O lcore 与协议 lcore 通过全局 ring 交换 mbuf,协议线程再借助每个 socket 的 ring、TCP 缓冲和 eventfd,把结果交给应用。需要内核处理的报文和路由变化,则分别经过 KNI 与 Netlink。相比按应用层、协议层、物理层逐层罗列模块,这条路径更接近代码实际运行的方式。
运行环境与启动形态
项目 README 给出的基础环境是 Ubuntu 16.04 和 DPDK 19.08.2,构建沿用 DPDK 19.08 legacy make。构建产物有三类:build/awesome-stack 可以独立运行,build/libawesome_stack.so 向修改过源码的应用提供 n* API,build/libawesome_stack_hook.so 则通过 LD_PRELOAD 接管现有应用的标准 socket 调用。
Ubuntu 16.04
DPDK 19.08.2
legacy make
├── build/awesome-stack
├── build/libawesome_stack.so
└── build/libawesome_stack_hook.so
无论从哪个入口启动,数据面至少需要三个 EAL lcore。main lcore 完成 EAL、端口、共享 mbuf pool、KNI 和全局 ring 的初始化,然后选出两个 worker:一个运行 packet_processing_thread,另一个运行 io_thread。独立程序可以通过 build/awesome-stack -l 1-3 -n 4 启动;以 hook 方式运行 Redis、Nginx、QUIC 或 iperf3 时,第一次创建 AF_INET TCP/UDP socket 会触发 awesome_stack_start_from_string()。两种方式最终进入同一套数据面。
unsigned int packet_lcore = rte_get_next_lcore(rte_lcore_id(), 1, 0);
unsigned int io_lcore = rte_get_next_lcore(packet_lcore, 1, 0);
g_inout_ring->in = rte_ring_create(
"in_ring", 1024, rte_socket_id(),
RING_F_SP_ENQ | RING_F_SC_DEQ);
g_inout_ring->out = rte_ring_create(
"out_ring", 1024, rte_socket_id(), RING_F_SC_DEQ);
rte_eal_remote_launch(packet_processing_thread, g_mbuf_pool, packet_lcore);
rte_eal_remote_launch(io_thread, NULL, io_lcore);
main lcore 发起两个 worker 后,留在初始化与等待位置。至此,物理网卡 queue 0、两条全局 ring 和两个 worker 连成了最短的数据面闭环。
整体架构及 lcore 分工
io_thread 每轮只处理几项搬运工作:从网卡 queue 0 批量 RX,将 mbuf 放入 g_inout_ring->in;再从 g_inout_ring->out 取出待发送的 mbuf,批量 TX。它不查 TCP 状态,也不替应用寻找 socket。协议 worker 从全局入方向 ring 取包,交给 process_packet();完成收包处理后,还会轮询各 socket 的发送请求,并处理 KNI 请求。
全局 ring 负责两个 worker 之间的交接,应用侧还要按端点区分数据。因此,每个 socket_block_t 都有 in、out 两个 rte_ring,并保存地址、端口、协议、TCP 状态和缓冲。网卡侧传递的是 mbuf,越过 socket 边界时,传递的已是应用可以消费的对象或字节。
typedef struct socket_block {
fd_object_t fd_object;
struct rte_ring *in;
struct rte_ring *out;
uint32_t src_ip;
uint16_t src_port;
uint32_t dst_ip;
uint16_t dst_port;
int protocol;
tcp_status_t tcp_status;
uint32_t seq_una;
uint32_t seq_nxt;
uint32_t rcv_nxt;
uint16_t snd_wnd;
uint16_t rcv_wnd;
uint8_t recv_buffer[MAX_TCP_WINDOW];
segment_queue_t segment_queue;
/* keepalive、persist、cwnd、ssthresh 等字段略 */
} socket_block_t;
UDP 收包时,携带地址和 payload 的 udp_offload_t 会进入 socket 的 in ring,由 nrecvfrom() 出队;nsendto() 创建的发送请求进入 out ring,等待协议线程组包。TCP 的对象路径不同:发送侧使用 tcp_offload_t,收到的 payload 写入 recv_buffer,监听 socket 的已完成连接通知使用 in ring,发送请求使用 out ring,尚未确认的报文段则留在 segment_queue 中。这些对象各走各的路径,因此不能把所有 TCP 数据都理解成经过 socket in ring。
两级队列把共享状态收束在明确的位置。I/O lcore 持续搬运,应用线程不直接持有 mbuf,TCP 的状态、窗口和定时器由协议 lcore 推进。跨线程交接集中在几次 enqueue/dequeue 上,一条数据停在哪里,也就有了清楚的排查落点。
收包路径与协议分派
报文进入物理端口后,io_thread 用 rte_eth_rx_burst() 批量取回 mbuf,再调用 burst enqueue,把它们交给全局 in ring。ring 容量不足时,未能入队的尾部 mbuf 会当场释放。协议线程批量出队后,逐个调用 process_packet(),网卡轮询与协议分支因此不会挤在同一个循环里。
uint16_t nb_rx = rte_eth_rx_burst(gport_id, 0, rx, BURST_SIZE);
uint32_t nb_enqueued = rte_ring_sp_enqueue_burst(
g_inout_ring->in, (void **)rx, nb_rx, NULL);
for (uint32_t i = nb_enqueued; i < nb_rx; i++) {
rte_pktmbuf_free(rx[i]);
}
/* packet_processing_thread */
uint16_t count = rte_ring_mc_dequeue_burst(
g_inout_ring->in, (void **)rx, BURST_SIZE, NULL);
for (uint16_t i = 0; i < count; i++) {
process_packet(rx[i], mbuf_pool);
}
process_packet() 先检查 EtherType。ARP 报文进入 ARP 处理,协议栈也会从 ARP 以及收到的 TCP/UDP 以太网源地址中,学习 IP 到 MAC 的动态映射;非 IPv4 报文则释放。对于 IPv4,代码会结合目的地址和运行模式判断归属:应用模式下,用户态 socket 没有消费的报文可以交给 KNI;路由模式下,本机报文交给 KNI,非本机报文进入 FIB 查找。只有归用户态协议栈处理的 TCP/UDP,才会继续进入对应的四层函数。
UDP 找到匹配的 socket_block_t 后,会构造 udp_offload_t,并放入该 socket 的 in ring。只有数据成功入队,代码才会唤醒阻塞的接收者并报告 EPOLLIN;应用从等待中返回时,队列里已经有对象可取。
socket_block_t *local = find_localhost_by_ip_port(
ip_hdr->dst_addr, udp_hdr->dst_port, IPPROTO_UDP,
ip_hdr->src_addr, udp_hdr->src_port);
if (rte_ring_mp_enqueue(local->in, udp_offload) < 0) {
rte_free(udp_offload);
} else {
pthread_cond_signal(&local->cond);
notify_socket_readable(local->fd_object.fd);
if (ep != NULL)
nepoll_event_callback(ep, local->fd_object.fd, EPOLLIN);
}
TCP 收包还涉及连接查找和状态推进。process_tcp_packet() 校验 checksum 后,先以四元组从 DPDK hash 查找已连接 socket;没有命中时,再匹配 LISTEN socket。接下来,当前 tcp_status 决定报文进入 tcp_listen_handle()、tcp_syn_received_handle()、tcp_established_handle() 等处理函数中的哪一个。
连接进入 ESTABLISHED 后,payload 写入 recv_buffer,rcv_nxt 和接收窗口随之更新,之后才唤醒 recv() 与 epoll。ACK 到来时,协议线程释放已经确认的 segment,推进 seq_una,重启最早未确认段的重传计时,并按慢启动或拥塞避免更新 cwnd。重传、persist、keepalive、延迟 ACK 和快速恢复也都留在这条按连接推进的路径中,没有压到 I/O lcore 上。
socket、fd 与 epoll 的应用接口
报文进入 socket 状态后,还要以应用熟悉的方式交付出去。核心库提供 nsocket()、nbind()、naccept()、nsend()、nrecv() 等接口;hook 动态库拦截同名标准函数,只将 AF_INET 的 TCP/UDP socket 交给用户态协议栈。普通文件描述符、Unix socket 和其他 fd 继续走 libc,因此同一个进程可以同时持有内核对象和协议栈对象。
int socket(int domain, int type, int protocol)
{
LOAD_LIBC(socket);
int base_type = type & 0xf;
if (!hook_enabled() || stack_starting() || domain != AF_INET ||
(base_type != SOCK_STREAM && base_type != SOCK_DGRAM)) {
return libc_socket(domain, type, protocol);
}
if (ensure_stack_started() < 0)
return -1;
return nsocket(domain, type, protocol);
}
ssize_t recv(int fd, void *buf, size_t len, int flags)
{
if (is_stack_fd(fd))
return nrecv(fd, buf, len, flags);
LOAD_LIBC(recv);
return libc_recv(fd, buf, len, flags);
}
nsocket() 返回的同样是真实 Linux fd。get_next_fd() 创建一个 eventfd,将它放入 fd_table,再由 socket_block_t 或 eventpoll 持有。应用原有的 fd 范围检查与内核等待机制因而仍有可用对象;协议栈产生可读数据时,只需向这个 eventfd 写入计数,便能唤醒等待路径。
int get_next_fd(fd_object_t *block)
{
int fd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC);
if (fd < 0 || fd >= FD_TABLE_SIZE)
return -1;
block->fd = fd;
fd_table[fd] = block;
return fd;
}
void notify_socket_readable(int fd)
{
uint64_t value = 1;
(void)write(fd, &value, sizeof(value));
}
协议栈内部的 epoll 用红黑树保存注册项,用 ready list 保存已经就绪的项。UDP 数据报入队、TCP payload 写入缓冲或监听连接完成后,协议线程调用 nepoll_event_callback(),将对应的 epitem 放进 ready list,并通过条件变量和 eventfd 唤醒等待者。
混合 epoll 还增加了一层配对。hook 同时维护内核 epoll fd 与协议栈 epoll fd,并把后者的 eventfd 注册进内核 epoll。epoll_wait() 先在内核侧等待,读到 stack marker 后,再以 timeout=0 取用户态 ready list,最后把两边的事件一并交回应用。应用原来的事件循环因此无须预先判断一个 fd 属于哪一侧。
这组接口把协议线程里的状态变化转换成了应用认识的可读事件。真实 eventfd 也为 fcntl、ioctl(FIONBIO) 和混合 epoll 提供了衔接点。不过,当前源码重点实现和接管的是 epoll,不能据此推断 poll、select 已经完整实现。
发包、KNI 与路由模式如何合流
再沿反方向看应用发送。send() 交给协议栈的是一段字节;nsend() 先提交 socket 的 TCP 发送请求,协议线程再结合 snd_wnd、cwnd、MSS 和未确认数据切分 segment,补上序号与 ACK。nsendto() 则构造 udp_offload_t,放进 socket out ring。等 Ethernet、IPv4 和 TCP/UDP 头与 checksum 全部填好,发送对象才成为 mbuf,进入全局 out ring。
/* 应用线程:提交 UDP 发送请求 */
if (rte_ring_mp_enqueue(socket_block->out, udp_offload) < 0) {
rte_free(udp_offload);
return -1;
}
/* 协议线程:组包后提交给 I/O lcore */
if (rte_ring_mp_enqueue(g_inout_ring->out, send_mbuf) < 0) {
rte_pktmbuf_free(send_mbuf);
return -1;
}
/* I/O lcore:批量发到物理端口 */
pending_tx = rte_ring_sc_dequeue_burst(
g_inout_ring->out, (void **)tx, BURST_SIZE, NULL);
uint16_t sent = rte_eth_tx_burst(gport_id, 0, tx, pending_tx);
协议线程完成组包后,I/O lcore 从全局 out ring 批量取出 mbuf,再调用 rte_eth_tx_burst() 发往物理端口。应用提交、协议组包和网卡发送依然处在三种不同的执行节奏中,彼此只通过 socket out ring 和全局 out ring 两次交接。
KNI 是一条双向兼容通道。数据面决定交给 Linux 的报文,通过 rte_kni_tx_burst() 送入内核的 vEth0;Linux 从 vEth0 发出的包,则由 I/O lcore 调用 rte_kni_rx_burst() 取回,再直接经 rte_eth_tx_burst() 发往物理网卡。这里的 tx 和 rx 都是从 KNI 一侧命名的,理解方向时需要跟着报文走,不能只凭函数名判断。
路由模式把另一类 IPv4 报文接入同一个出口。route_sync_start() 通过 NETLINK_ROUTE 读取 Linux 主路由表,先获取现有路由,再订阅后续变化。只有目标接口上的 IPv4 单播、非 multipath 路由会写入 rte_lpm,默认接口名为 vEth0。FIB 最多保存 4096 条规则,此外还有独立的默认路由槽。
非本机 IPv4 报文完成最长前缀匹配后,直连路由以目的 IP 为下一跳,带 gateway 的路由则使用 gateway。ARP 表提供下一跳 MAC;TTL 小于等于 1 的报文直接丢弃,其余报文递减 TTL、更新 IPv4 checksum、改写 Ethernet MAC,然后进入全局 out ring。
这样一来,KNI 为本机流量和用户态未处理的报文保留内核去向,Netlink 把 Linux 已有的路由控制面结果同步给 DPDK FIB,协议线程负责实际的转发查表。KNI 没有成为主数据快路径,Linux 也没有退出系统;两者分别留在兼容面和控制面。
设计取舍、收益与边界
沿着整条链路回看,几个关键选择的作用与代价都很具体。I/O 和协议处理分属两个 worker lcore,网卡轮询无须等待 TCP 状态分支、ARP 查询和定时处理,代价是 mbuf 要跨全局 ring 交接。全局 ring 只搬运 RX/TX mbuf,per-socket ring 和 TCP 缓冲保留端点语义,应用线程与协议轮询因而可以独立推进。与此同时,socket_block_t 较重,TCP 还有固定 64 KiB 收发缓冲;连接规模上升时,内存与管理成本需要重新评估。
真实 eventfd 加 hook,让 Redis、Nginx 这类现有应用可以继续使用常见的 socket 与 epoll 调用,普通 fd 和 Unix socket 仍交给 libc。这种兼容依赖 hook 对 API 语义的覆盖。当前实现接住了常用 socket、epoll、fcntl 与 ioctl(FIONBIO) 等路径,但不能等同于 Linux socket 的全部行为。KNI 与 Netlink 复用 Linux 的兼容面和路由控制面,也意味着运行时仍要关注 KNI 生命周期、vEth0 配置与 Linux 路由表。
README 还记录了一次 Redis benchmark:用户态栈为 20833.33 requests/s,内核栈为 15723.27 requests/s。这只是 README 中特定环境的一次结果,可以作为该环境下的运行记录,不能外推为用户态栈普遍快于 Linux 内核协议栈。
测试覆盖也限定了结论的边界。仓库现有单元测试主要涉及 epoll close、hook ioctl 和 Netlink 解码,尚不足以证明完整数据面的正确性;完整 TCP 状态机、丢包重传、并发连接与端到端收发,仍需要更系统的回归与压力测试。因此,这套实现适合用来观察网卡轮询、协议状态和应用接口如何闭合,但不能据此宣称生产可用、RFC 完整兼容、端到端零拷贝、全链路无锁或在所有场景胜过内核栈。
从收包方向看,RX mbuf 由 NIC 经过 I/O lcore 和全局 in ring 到达协议 lcore,TCP/UDP 状态再借助 per-socket 队列、缓冲与 eventfd,转化为应用可见的事件。发送方向沿 socket out ring 和全局 out ring 回到 NIC。KNI 为需要 Linux 的报文保留去向,Netlink 为转发路径提供路由结果。
638



被折叠的 条评论
为什么被折叠?



