文章目录
用户态协议栈:基于dpdk的自定义网络协议栈
简介: 操作系统系统POSIX API所提供的网络接口,数据收发是基于用户态与内核态的频繁切换实现。而dpdk实现了绕过内核监管,直接在用户态访问网络硬件,避免频繁状态切换。
引言
Linux操作系统内核提供的网络POSIX API依赖内核协议栈处理数据(具体的细节见POSIX API网络通信TCP协议接口详解 - +_+0526 - 博客园),数据的读写中频繁在内核和用户态间切换。简单来说POSIX API就好像公立学校,里面有成熟的管理体系与安全机制保证家长对孩子的所有培养要求,但由于庞大的管理体系任何决策都需要层层转达,最后落实到孩子身上效率极低。而DPDK直接对接网卡,对于网卡所接受到的数据实行零拷贝,即网卡中的数据缓存直接映射到计算机内存中,省去了内核作为中间桥梁。向上DPDK直接向用户态负责,所有的数据直接用户操作。相对于POSIX API这种方案就类似于家庭教师,直接将教育资源对接给孩子,所有的反馈和调整都能立即沟通完成,效率大大提高;但简单的一对一缺乏监管往往存在风险,此时需要家长自定义相关条例确保工作能够正常运行。本文从DPDK的安装与配置出发,介绍其基本原理,通过简单的UDP/TCP通信理解其基本用法,最后实现并发TCP协议栈。
DPDK安装与配置
DPDK基于多队列网卡,该网卡在物理上将数据拆分为多个独立队列每个队列都可以分配CPU并行执行任务。在Vmware虚拟机设置中添加网络适配器2:
此处网卡选择NAT模式,虚拟机配置中有三类常用网卡配置方式供选择。
-
桥接:虚拟机通过虚拟交换机直接连接到物理网络,在此方案下与宿主机器属于同一个网段。即虚拟交换机作为桥与宿主机处于统一网段。
-
NAT:宿主机直接作为网关和路由器,虚拟机直接与网关通信。所有的收发数据统一经过物理机,同样与宿主机处于同一网段。
-
仅主机:虚拟机与宿主机网络完全隔离,仅宿主机可访问虚拟机。
为便于后续与宿主机通信,此处选择NAT模式。配置完成重启虚拟机可以通过命令cat /proc/interrupts | grep eth1
查看当前网卡是否支持多队列。不同的网卡类型不一定支持多队列网卡,通过修改.vmx虚拟机文件中对应网卡的配置信息:
此时再次执行cat /proc/interrupts | grep eth1
若出现以下信息说明多队列网卡配置成功。
在高并发海量大数据包的场景下,常规的4KB内存页面无法有效承载。DPDK采用巨页(hugepage)方案将内存页面提升至2MB甚至1GB,大大降低同等内存条件下的页表项,进而减少页表查询和缺页中断。在系统文件/etc/default/grub
下修改默认巨页配置:
至此虚拟机环境配置完毕,在https://core.dpdk.org/下载DPDK源码。此处选择19.08.2版本,下载在本地进行后续的编译与安装操作。
下载完成后进入dpdk目录,执行安装脚本./usertools/dpdk-setup.sh
。
-
首先选择编译工具,对应不同平台:
同时还需要配置对应的环境变量,以便于dpdk后续编译能够找到对应的编译工具:
export RTE_SDK=/path/to/dpdk
export RTE_TARGET=x86_64-native-linux-gcc
在这之上需要安装用于实现dpdk功能的各个模块:
- UIO(Userspace I/O):通过设备文件直接将网卡寄存器映射到用户空间,实现用户态直接访问硬件内存。
- VFIO(Virtual Function I/O):安全硬件虚拟化,确保设备只能访问限定内存区域避免程序错误导致系统崩溃。
- KNI(Kernel NIC Interface):内核协议栈桥接,做到DPDK仅从硬件收发数据,将数据交由内核无需重构整个网络栈。
基于上述模块,还需要进一步配置巨页信息
针对两种不同系统(非NUMA/NUMA),配置巨页页数,将指定大小的巨页都分为512页。
最后也是最重要的一步,将DPDK与网卡绑定:
由上图可以看到,本机有两个VMXNET3的网卡,前面的数字代表其NIC号。需要注意的是,每条信息的末尾***Active***字样,这表示该网卡正在被内核接管。就好比事业单位的员工被借调到其他部门,若需要该员工在本机构工作应该停止当前工作将其管理权拿回。通过命令sudo ifconfig eth1 down
,关闭eth1网卡。此时内核交出该网卡管理权:
可以看到eth1网卡不再出现***Active***标识符。选择绑定网卡,输入对应的NIC号可得绑定成功:
至此DPDK安装及环境搭建完成,在./build/hellworld
目录中编译执行helloworld若出现以下内容说明安装及配置成功:
读到这里大家可能对DPDK有了一定的认识,知道它可以直接与硬件交互,同时因为巨页的存在可以高效处理大数据包。但是大家有没有想过Linux原生架构比DPDK慢在哪里,换句话说:DPDK究竟在系统架构中扮演了一个什么角色使得仅它一个工具就能让整个网络性能大大提高?
Linux操作系统在网络通信中的架构,在ISO体系中网络从低到高依次:物理层、数据链路层、网络层、传输层、应用层。对应在硬件上面有示意图如下右图所示:
可以看到,数据包的接收、TCP/IP协议栈解析数据完全由内核进行管理。当数据解析完成通知用户程序处理数据,此时就会发生频繁的内核/用户态的切换。
DPDK与传统内核架构不同,数据通过内存直接访问映射到内存中零拷贝放在用户态内存池mbuf中(此处mbuf就扮演了sk_buff的角色)。由KNI控制数据与内核协议栈交互的流量。KNI仍然需要与内核交互,为了追求极致的性能,我们可以使用自定义协议栈,完全把数据放在用户态处理。
简单UDP/TCP的echo应用
对DPDK有了一定程度的理解之后,通过几个简单的demo来明确它的用法。
DPDK的核心在于往预分配内存池中读写数据。网卡收到的数据直接映射在内存中通过内存池进行读写;将数据组织后仍旧将其放在内存池中进行发送。在这中间可以将数据交给内核中协议栈,同时也可以按照业务需求定制化协议栈。
- DPDK环境初始化
环境初始化最简单来说就是将DPDK各组件进行原始配置,包括绑定的网卡、收发队列的大小、收发队列的具体配置等。一般情况下都是一套完整的流程。
创建用于收发数据的内存池,该内存池作为数据收发的载体。
struct rte_mempool *mbuf_pool = rte_pktmbuf_pool_create("mbuf pool", NUM_MBUFS, 0, 0, RTE_MBUF_DEFAULT_BUF_SIZE, rte_socket_id());
首先初始化DPDK,首先设置环境抽象层(Environment Abstraction Layer, EAL),为后续DPDK组件提供支持。该函数的参数是一些DPDK的专属命令行参数。
if (rte_eal_init(argc, argv) < 0) {
rte_exit(EXIT_FAILURE, "Error with EAL init\n");
}
初始化EAL之后,就是拿到具体的网卡进行配置。rte_eth_dev_count_avail()函数用于获取当前可用的网卡数量,返回一个nint16_t
类型数据。
uint16_t nb_sys_ports = rte_eth_dev_count_avail();
if (nb_sys_ports == 0) {
rte_exit(EXIT_FAILURE, "No Supported eth found\n");
}
拿到具体网卡,接下来就是配置接收和发送队列。对于接收队列来说它的任务仅仅是从内存池中拿取数据,所以只需要配置接收数据的大小以及关联到内存池。值得注意的是,rte_eth_dev_socket_id(global_portid)
获取的是网卡所归属的NUMA节点,与网络通信中的socket节点有所不同。
static const struct rte_eth_conf port_conf_default = {
.rxmode = { .max_rx_pkt_len = RTE_ETHER_MAX_LEN }
};
rte_eth_dev_configure(global_portid, num_rx_queues, num_tx_queues, &port_conf_default);
rte_eth_rx_queue_setup(global_portid, 0, 128, rte_eth_dev_socket_id(global_portid), NULL, mbuf_pool)
而对发送队列来说配置就有些复杂,首先需要获取到网卡的硬件信息,配置发送队列的参数:
//获取具体的网卡信息
struct rte_eth_dev_info dev_info;
rte_eth_dev_info_get(global_portid,&dev_info);
struct rte_eth_txconf txq_conf = dev_info.default_txconf;
txq_conf.offloads = port_conf_default.rxmode.offloads;
//配置接收队列信息
rte_eth_tx_queue_setup(
global_portid,//网卡端口ID
0,//队列ID
512,//环形缓冲区大小
rte_eth_dev_socket_id(global_portid),//NUMA节点
&txq_conf)//队列配置
至此收发队列初始化配置完成rte_eth_dev_start(global_portid)
启动指定设备开始工作。
- 解析接收数据
DPDK开始工作,当本机接收到数据调用rte_eth_rx_burst(global_portid, 0, mbufs, BURST_SIZE)
从指定网卡中获取到数据放在内存池中。该函数返回实际收到的数据包数量。
uint16_t rte_eth_rx_burst(
uint16_t port_id, // 网卡端口ID (0 ~ rte_eth_dev_count_avail()-1)
uint16_t queue_id, // 接收队列ID (0 ~ nb_rx_queues-1)
struct rte_mbuf **rx_pkts, // 接收数据包数组(输出参数)
uint16_t nb_pkts // 最大接收数量(单次批量大小)
);
此时在mbufs数组中依次存储了所接收到的数据包,这种数据包完全是从网卡上拿到的原始数据。在五层网络体系中数据在每一层都要按照其对应的协议加载协议头尾,所以在解析时不仅要将这部分头尾去掉,还要根据协议头中获取到所需要的信息。
对于udp来说其数据包由以下协议头组织而成:
相对应的,我们需要层层将协议头剥离获取到我们所需要的数据:
struct rte_ether_hdr *ethhdr = rte_pktmbuf_mtod(mbufs[i], struct rte_ether_hdr *);
struct rte_ipv4_hdr *iphdr = rte_pktmbuf_mtod_offset(mbufs[i], struct rte_ipv4_hdr *, sizeof(struct rte_ether_hdr));
if (iphdr->next_proto_id == IPPROTO_UDP){
struct rte_udp_hdr *udphdr = (struct rte_udp_hdr *)(iphdr + 1);
...
}
可以看到我们依次按照个协议头的长度进行偏移,最后取到用户数据的开头地址。
我们的程序需要完成的是数据回送,也就意味着我们要知道对方的ip地址和端口这些数据在协议头中进行解析提取,需要注意的是,好比两个人互相打电话,他们的目的号码是对方的号码,而来电号码是自己。同理,对端发送的源地址与目的地址应转换为本段的目的地址和源地址,这样才能完成数据的发送。
rte_memcpy(global_smac,ethhdr->d_addr.addr_bytes,RTE_ETHER_ADDR_LEN);
rte_memcpy(global_dmac,ethhdr->s_addr.addr_bytes,RTE_ETHER_ADDR_LEN);
rte_memcpy(&global_sip,&iphdr->dst_addr,sizeof(uint32_t));
rte_memcpy(&global_dip,&iphdr->src_addr,sizeof(uint32_t));
rte_memcpy(&global_sport, &udphdr->dst_port, sizeof(uint16_t));
rte_memcpy(&global_dport, &udphdr->src_port, sizeof(uint16_t));
处理完地址,就是按照udp指针取到用户数据,进行下一步的组织。
struct rte_mbuf *mbuf = rte_pktmbuf_alloc(mbuf_pool);
if(!mbuf){
rte_exit(EXIT_FAILURE, "Error rte_pktmbuf_alloch\n");
}
uint16_t length = ntohs(udphdr->dgram_len);//获取到udp报的长度
uint16_t total_len = length + sizeof(struct rte_ipv4_hdr) + sizeof(struct rte_ether_hdr);
mbuf->pkt_len = total_len;
mbuf->data_len = total_len;
uint8_t *msg = rte_pktmbuf_mtod(mbuf,uint8_t *);
- 组织数据包发送
相较于TCP,UDP没有复杂的重传机制,组织发送数据时只需要按照协议格式依次填充。
//组织以太网头
struct rte_ether_hdr *eth = (struct rte_ether_hdr *)msg;
rte_memcpy(eth->d_addr.addr_bytes,global_dmac,RTE_ETHER_ADDR_LEN);
rte_memcpy(eth->s_addr.addr_bytes,global_smac,RTE_ETHER_ADDR_LEN);
eth->ether_type = htons(RTE_ETHER_TYPE_IPV4);
//组织ip头
struct rte_ipv4_hdr *ip = (struct rte_ipv4_hdr *)(eth+1); //msg + sizeof(struct rte_ether_hdr *)
ip->version_ihl = 0x45;
ip->type_of_service = 0;
ip->total_length = htons(total_len - sizeof(struct rte_ether_hdr));//字节序问题
ip->packet_id = 0;
ip->fragment_offset = 0;
ip->time_to_live = 64;
ip->next_proto_id = IPPROTO_UDP;//指定上一层需要发送的数据类型
ip->src_addr = global_sip;
ip->dst_addr = global_dip;
//校验和
ip->hdr_checksum = 0;//在计算时会将这部分值加入 避免脏数据影响结果 要先置为0
ip->hdr_checksum = rte_ipv4_cksum(ip);
//组织udp
struct rte_udp_hdr *udp = (struct rte_udp_hdr *)(ip+1);
udp->src_port = global_sport;
udp->dst_port = global_dport;
uint16_t udplen = total_len - sizeof(struct rte_ether_hdr) - sizeof(struct rte_ipv4_hdr);
udp->dgram_len = htons(udplen);
rte_memcpy((uint8_t*)(udp+1),data,udplen);
udp->dgram_cksum = 0;
udp->dgram_cksum = rte_ipv4_udptcp_cksum(ip,udp);
最后将组织完成的udp包进行回送:
rte_eth_tx_burst(global_portid,0,&mbuf,1);
TCP的连接建立需要进行三次握手。服务器作为被访问端,它收到连接请求,发送回应。其中对应的是三次握手中的第二次数据发送(详细内容见POSIX API网络通信TCP协议接口详解 - +_+0526 - 博客园):
此时协议头中SYN与ACK标识置为1,同时初始化自己的发送队列序号,对对端发送来的序号进行回应,即回应序号acknum值+1。
前面部分的源地址目的地址解析与UDP相同此处不再赘述,首先需要拿到对方的seqnum和acknum:
global_seqnum = ntohl(tcphdr->sent_seq);
global_acknum = ntohl(tcphdr->recv_ack);
在TCP连接中客户端与服务器端都有不同的状态,这里我们自然而然就想到了状态机,将TCP的不同状态对应不同事件。
在客户端发起请求时只有当服务器处理LISTEN状态才能建立连接,第一次握手报文的flag字段中仅SYN值置为1。依据这两个条件判断服务器接收到的报文是否是发起请求的第一次握手报文:
if(global_flags & RTE_TCP_SYN_FLAG){
if(tcp_status == USTACK_TCP_STATUS_LISTEN){
...
组织处理发送数据
...
}
}
判断成功说明对方请求发起连接,服务器要做的是将报文组织起来在flag字段中将SYN和ACK的值置为1,同时告诉对方自己的sendnum并将对方的acknum+1。
static int ustack_encode_tcp_pkt(uint8_t *msg,uint16_t total_len){
...//以太网帧以及ip帧组织
struct rte_tcp_hdr *tcp = (struct rte_tcp_hdr *)(ip+1);
tcp->src_port = global_sport;
tcp->dst_port = global_dport;
tcp->sent_seq = htonl(12345);//发送序号 随机生成
tcp->recv_ack = htonl(global_seqnum + 1);//网络字节序与本地字节序 在数据收发中需要注意的内容
tcp->data_off = 0x50;
tcp->tcp_flags = RTE_TCP_SYN_FLAG | RTE_TCP_ACK_FLAG; //0x1 << 1; 通过宏定义赋值
//tcp->rx_win = htons(4096);//通知对方 我还能接收4096个字节
tcp->rx_win = TCP_INIT_WINDOWS;
tcp->cksum = 0;
tcp->cksum = rte_ipv4_udptcp_cksum(ip,tcp);
return 0;
}
数据包中前面大部分操作与UDP相同,配置TCP数据报中的源地址目的地址,本段的发送序号随机生成,按照对方的序号将回应序号值+1,设置flag字段值以及接收窗口。接着将数据发送出去,将当前状态设为SYN_RCVD。
if(global_flags & RTE_TCP_SYN_FLAG){
if(tcp_status == USTACK_TCP_STATUS_LISTEN){
...
ustack_encode_tcp_pkt(msg,total_len);
rte_eth_tx_burst(global_portid,0,&mbuf,1);
tcp_status = USTACK_TCP_STATUS_SYN_RCVD;
}
}
客户端收到回应向服务器发送第三次握手,此时flag字段中仅ACK置为1。服务器收到数据报什么都不需要做将状态转为ESTABLISHED及连接建立成功。
当客户端向服务器发送数据,判断flag值中PSH字段是否为1。判断成功从数据部分提取数据:
if(global_flags & RTE_TCP_PSH_FLAG){
if(tcp_status == USTACK_TCP_STATUS_ESTABLISHED){
uint8_t hdrlen = (tcphdr->data_off >> 4) * sizeof(uint32_t);
uint8_t *data = ((uint8_t*)tcphdr + hdrlen);
printf("tcp data: %s\n",data);
}
}
可以看到,第一个报文从客户端向服务器发送请求,flag值为2说明为SYN报文。接着服务器会送客户端发送第三次报文连接建立,开始发送数据,因为没有后续数据回应客户端回不断超时重传。
并发TCP网络协议栈
TCP协议通过一套完整的滑动窗口流量控制来传输数据,在上文介绍的TCP解析中仅能支持一个TCP客户端请求,那么如何做到并发处理多个TCP客户端请求呢?
联系已经学习到的知识,在Linux上实现并发总共仅有这几种方法:一请求一线程、多路IO复用。
- 完整架构
由图所示,主程序循环负责向网卡中读写数据,将读取数据入队、待发送的数据从队列中取出交给网卡发送。
while (1) {
// rx 从网卡中获取数据
struct rte_mbuf *rx[BURST_SIZE];
unsigned num_recvd = rte_eth_rx_burst(gDpdkPortId, 0, rx, BURST_SIZE);
...
rte_ring_sp_enqueue_burst(ring->in, (void**)rx, num_recvd, NULL);
...
// tx 从待发送堆中取出数据
struct rte_mbuf *tx[BURST_SIZE];
unsigned nb_tx = rte_ring_sc_dequeue_burst(ring->out, (void**)tx, BURST_SIZE, NULL);
rte_eth_tx_burst(gDpdkPortId, 0, tx, nb_tx);
...
}
由示意图可知,主函数、处理数据的循环都是在不同的线程执行。但它们处理的数据都是网卡中读取到的数据,那怎样才能做到在两个不同线程之间传递数据呢?答案是环形无锁队列ring,主循环和数据处理循环在接收数据时前者作为生产者后者作为消费者。
其中tcp_process是数据包的核心处理数据,基于 TCP 状态机执行状态转换和数据处理。而tcp_server_entry是TCP服务器的主入口函数,负责初始化服务器设置服务器监听连接请求,并为每个客户端连接创建处理逻辑。
tcp_server_entry在主函数中以多线程方式开启:
#if ENABLE_TCP_APP
lcore_id = rte_get_next_lcore(lcore_id, 1, 0);
rte_eal_remote_launch(tcp_server_entry, mbuf_pool, lcore_id);
#endif
-
一请求一线程
在tcp_server_entry中使用一请求一线程方式为客户端分配线程,详情见(TCP服务器:从一请求一线程到百万并发 - +_+0526 - 博客园)。
while (1) { struct sockaddr_in client; socklen_t len = sizeof(client); int connfd = naccept(listenfd, (struct sockaddr*)&client, &len); pthread_t thid; pthread_create(&thid, NULL, client_thread, &connfd); }
通过回调函数处理客户端的请求,向客户端回发数据。
-
epoll并发处理
本文的重点不在于讲解如何并发处理客户端请求,关于IO多路复用处理高并发请求详情见(TCP服务器:从一请求一线程到百万并发 - +_+0526 - 博客园)。
- 自定义文件描述符系统
在本项目中网络栈自定义实现在底层无法使用操作系统所提供的文件描述符机制。自定义了一个位图用于标识文件描述符:
#define MAX_FD_COUNT 1024
static unsigned char fd_table[MAX_FD_COUNT] = {0};
static int get_fd_frombitmap(void) {
int fd = DEFAULT_FD_NUM;
for ( ;fd < MAX_FD_COUNT;fd ++) {
if ((fd_table[fd/8] & (0x1 << (fd % 8))) == 0) {
fd_table[fd/8] |= (0x1 << (fd % 8));
return fd;
}
}
return -1;
}
static int set_fd_frombitmap(int fd) {
if (fd >= MAX_FD_COUNT) return -1;
fd_table[fd/8] &= ~(0x1 << (fd % 8));
return 0;
}
当使用自定义网络接口nsocket在函数中通过get_fd_frombitmap分配自定义fd。
- 自定义epoll
通过将文件描述符交给epoll统一管理实现IO多路复用。问题在于系统提供的struct epoll_event所接管的fd是操作系统自己维护的一套文件描述符,那么如何让epoll可以管理自定义的fd呢?
所以我们需要自定义epoll改写接口将其适配到我们的项目中。epoll的核心在于四个API和一个数据结构。四个API用于初始化和管理事件。
分别定义epoll、epoll数据、epoll事件结构体:
struct eventpoll {
int fd;
ep_rb_tree rbr;
int rbcnt;
LIST_HEAD( ,epitem) rdlist;
int rdnum;
int waiting;
pthread_mutex_t mtx; //rbtree update
pthread_spinlock_t lock; //rdlist update
pthread_cond_t cond; //block for event
pthread_mutex_t cdmtx; //mutex for cond
};
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events;
epoll_data_t data;
};
tcp_process中如果有可读的数据就绪,通过epoll_event_callback通知epoll该连接有数据可读:
//函数定义
int epoll_event_callback(struct eventpoll *ep, int sockid, uint32_t event) {
struct epitem tmp;
tmp.sockfd = sockid;
struct epitem *epi = RB_FIND(_epoll_rb_socket, &ep->rbr, &tmp);
if (!epi) {
printf("rbtree not exist\n");
return -1;
}
if (epi->rdy) {
epi->event.events |= event;
return 1;
}
printf("epoll_event_callback --> %d\n", epi->sockfd);
pthread_spin_lock(&ep->lock);
epi->rdy = 1;
LIST_INSERT_HEAD(&ep->rdlist, epi, rdlink);
ep->rdnum ++;
pthread_spin_unlock(&ep->lock);
pthread_mutex_lock(&ep->cdmtx);
pthread_cond_signal(&ep->cond);
pthread_mutex_unlock(&ep->cdmtx);
}
在tcp成功建立连接时,该fd就变为可读事件需要通过回调函数通知epoll:
static int ng_tcp_handle_syn_rcvd(struct ng_tcp_stream *stream, struct rte_tcp_hdr *tcphdr) {
...
struct ng_tcp_table *table = tcpInstance();
epoll_event_callback(table->ep, listener->fd, EPOLLIN);
...
}
epoll的使用场景往往需要频繁的查找,并且无法事先明确该函数需要占用的内存,综合考虑采用红黑树作为epoll的数据结构。epoll中所有的事件都由红黑树统一组织,就绪队列只属于红黑树的一部分。如图,epoll目前管理五个事件,只有红色为就绪事件被就绪链表统一连接。
定义每个结点结构体,将数据交给红黑树管理:
struct epitem {
RB_ENTRY(epitem) rbn;
LIST_ENTRY(epitem) rdlink;
int rdy; //exist in list
int sockfd;
struct epoll_event event;
};
epoll_create初始化epoll实例,其主要功能就是给必要的参数初始化和分配空间:
int nepoll_create(int size) {
int epfd = get_fd_frombitmap(); //tcp, udp
struct eventpoll *ep = (struct eventpoll*)rte_malloc("eventpoll", sizeof(struct eventpoll), 0);
...
ep->fd = epfd;
ep->rbcnt = 0;
RB_INIT(&ep->rbr);//插入红黑树
LIST_INIT(&ep->rdlist);//初始化就绪链表
...
return epfd;
}
nepoll_ctl根据提供的操作符设置事件。对于epoll事件来说常用三类操作:添加、修改、删除。这三个操作本质来说就是对底层红黑树的CURD。
根据红黑树查找的情况,添加一个新分配的epitem。同时还需要注意epoll上下文作为一个互斥资源需要通过加锁来进行互斥访问。
if (op == EPOLL_CTL_ADD) {
pthread_mutex_lock(&ep->mtx);
...
struct epitem *epi = RB_FIND(_epoll_rb_socket, &ep->rbr, &tmp);
...
epi = (struct epitem*)rte_malloc("epitem", sizeof(struct epitem), 0);
...
epi = RB_INSERT(_epoll_rb_socket, &ep->rbr, epi);
ep->rbcnt ++;
...
pthread_mutex_unlock(&ep->mtx);
}
同理增加、修改事件就是在互斥条件下对红黑树的结点进行调整此处不再赘述。
epoll_wait核心功能是等待 epoll 实例中就绪的事件,并将这些事件返回给用户。这个函数的实现基础就是对互斥锁和自旋锁的使用(多线程互斥资源管理方案 - +_+0526 - 博客园)。使用条件变量+互斥锁处理事件的等待,通过条件变量将未就绪的线程CPU让出。而使用自旋锁保证对就绪链表的高效操作,减少了互斥锁频繁切换上下文所带来的开销。
当rdnum为0,即无就绪事件时,计算判断超时时间将线程使用条件等待挂起。
while (ep->rdnum == 0 && timeout != 0) {
ep->waiting = 1;
if (timeout > 0) {
// 计算绝对超时时间
struct timespec deadline = ...
int ret = pthread_cond_timedwait(&ep->cond, &ep->cdmtx, &deadline);
timeout = 0;
} else if (timeout < 0) {
// 无限等待,直到有事件就绪
int ret = pthread_cond_wait(&ep->cond, &ep->cdmtx);
}
ep->waiting = 0;
}
使用自旋锁保护就绪链表rdlist,当就绪链表不为空,从就绪链表头部取出epitem结点作为接续为事件返回给用户。
pthread_spin_lock(&ep->lock);
int cnt = 0;
int num = (ep->rdnum > maxevents ? maxevents : ep->rdnum);
while (num != 0 && !LIST_EMPTY(&ep->rdlist)) {
struct epitem *epi = LIST_FIRST(&ep->rdlist);
LIST_REMOVE(epi, rdlink); // 从就绪链表移除
memcpy(&events[cnt++], &epi->event, sizeof(struct epoll_event));
ep->rdnum--;
}
pthread_spin_unlock(&ep->lock);
至此自定义epoll的实现告一段落,总结一下。之所以要自己实现epoll,是因为自定义的网络协议栈并未采用系统提供的文件描述符系统,从而需要定制化一个可适配的epoll组件。epoll是由一个数据结构和四个API组织而成,其高并发性能就来自于底层的红黑树与就绪链表;四个API分别往下处理数据结构的CURD,往上用于通知协议处理模块信息。
最后在tcp_server_entry中按照epoll使用方法开启监听处理事件:
while (1) {
struct epoll_event events[1024] = {0};
int nready = epoll_wait(epfd, events, 1024, -1);
int i = 0;
for (i = 0;i < nready;i ++) {
int connfd = events[i].data.fd;
if (connfd == listenfd) {
int clientfd = naccept(listenfd, (struct sockaddr*)&clientaddr, &len);
printf("accept finshed: %d\n", clientfd);
ev.events = EPOLLIN;
ev.data.fd = clientfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, &ev);
} else if (events[i].events & EPOLLIN) {
char buffer[1024] = {0};
int count = nrecv(connfd, buffer, 1024, 0);
if (count == 0) { // disconnect
printf("client disconnect: %d\n", connfd);
nclose(connfd);
epoll_ctl(epfd, EPOLL_CTL_DEL, connfd, NULL);
continue;
}
printf("RECV: %s\n", buffer);
count = nsend(connfd, buffer, count, 0);
printf("SEND: %d\n", count);
}
}
多客户端测试可以正常收发数据:
问题与解决方案
问题一:RX队列中如何做到无CPU参与的零拷贝?
- 零拷贝的基础:直接内存访问(DMA),网卡通过 DMA 控制器将接收到的数据包直接写入内存缓冲区。且在RX队列中其存储的并非是实际的数据,而是具体的描述符,其包含了数据的内存地址、长度及状态。
- 环形缓冲区ring的驱动引擎:Ring 是固定大小的循环队列(通常由数组实现),包含头尾指针。当写入位置到达数组末尾时,自动回绕到起始位置,形成逻辑上的环形。网卡将数据写入ring中未使用的位置,而用户数据从ring头部取出已经被填充的描述符,处理完毕后将描述符置为就绪以待网卡再次写入。
- 生产者消费者解耦:基于ring,网卡作为生产者、用户程序作为消费者在数据操作上做到解耦,完美匹配高并发场景下两端的处理速度差异。
问题二:简单利用DPDK实现一个发包工具。
详情见:TCP简易发包工具 - +_+0526 - 博客园
问题三:网络协议中的主机字节序与网络字节序。
- 主机字节序(Host Byte Order):计算机主机在存储多字节数据时采用的字节排列顺序,取决于主机的 CPU 架构。往往被分为两种:大端序,高位字节存于低地址,低位字节存于高地址,例如网络协议标准、IBM/PC 架构;小端序,低位字节存于低地址,高位字节存于高地址,例如 x86、ARM 架构。
- 网络字节序(Network Byte Order):确保数据在网络传输中的一致性,统一使用大端序。
- 字节序的转换场景:
场景类型 | 具体场景描述 | 为何需要转换 | 常见协议 / 技术示例 |
---|---|---|---|
网络通信 | 应用程序通过 Socket 发送 / 接收数据时(如 TCP/UDP 数据包) | 网络协议规定使用网络字节序(大端),而不同主机可能使用小端字节序,需统一格式以确保跨平台兼容性。 | TCP/IP、HTTP、DNS、FTP、SMTP 等网络协议。 |
文件格式存储 | 存储跨平台兼容的二进制文件(如图片、音频、视频文件的头部信息,或日志文件中的数值数据) | 不同设备读取文件时需统一字节序,避免因字节序差异导致数据解析错误。 | JPEG/PNG 图片格式、WAV 音频文件、ELF 可执行文件格式。 |
进程间通信 | 不同主机或不同字节序架构的进程通过共享内存、消息队列等方式传递二进制数据时 | 确保接收方正确解析数据,避免因字节序不同导致数值错误(如整数、浮点数的解析)。 | 分布式系统中的进程通信、跨平台微服务数据交互。 |
硬件接口交互 | 与网络设备、嵌入式设备或外部硬件(如 FPGA、网卡)进行二进制数据交互时 | 硬件接口可能遵循网络字节序或特定字节序规范,需与主机字节序适配。 | 网卡驱动程序、网络控制器 API、PCIe 设备通信。 |
序列化与反序列化 | 对结构体、对象进行序列化(如 JSON、Protobuf、XML 的二进制编码)后在网络中传输或存储 | 序列化后的数据需符合网络或存储介质的字节序规范,确保跨平台反序列化正确。 | Protobuf 的二进制编码、Thrift 接口定义语言、自定义二进制序列化协议。 |
数据库存储 | 数据库中存储需要跨主机查询的二进制数据(如索引中的数值、二进制大对象的头部信息) | 不同数据库节点可能有不同字节序架构,需统一格式以保证查询结果一致性。 | MySQL 的二进制日志(Binlog)、MongoDB 的 BSON 格式存储。 |
网络编程 API 调用 | 使用 Socket API 中的特定函数(如htons 、htonl 、ntohs 、ntohl )处理端口号、IP 地址等数据 | 这些 API 强制要求将主机字节序转换为网络字节序(或反向转换),是网络编程的基础要求。 | 在 C/C++ 中调用bind() 、connect() 函数时设置端口和 IP。 |
统一采用以下函数进行转换:
-
主机→网络:htons(短整型)、htonl(长整型);
-
网络→主机:ntohs(短整型)、ntohl(长整型)。
总结
本文系统介绍了基于DPDK的用户态协议栈设计与实现,通过对比传统内核协议栈的局限性,阐述了DPDK如何通过零拷贝、巨页内存和轮询模式驱动等技术实现高性能网络数据处理。从DPDK的环境配置、编译安装到核心架构解析,逐步深入其实现原理,并通过UDP/TCP通信实例演示了协议栈开发的关键技术点。重点探讨了并发TCP协议栈的实现方案,创新性地设计了基于红黑树和就绪链表的自定义epoll系统,解决了用户态协议栈的IO多路复用问题。最后针对实际开发中的字节序转换、零拷贝机制等核心问题提供了系统化的解决方案。这套用户态协议栈不仅突破了内核协议栈的性能瓶颈,其模块化设计更为定制化网络功能开发提供了灵活框架,为高性能网络应用开发提供了新的技术路径。
相关链接
https://github.com/0voice