DPDK从入门到精通(二)

0 阅读17分钟

"不积跬步,无以至千里;不积小流,无以成江海。" -荀子

Kernel FD的理解

在 Linux 网络编程中,内核通过 listenacceptsend/recv 等一系列接口构建了通信的基石。然而,对于初学者而言,最令人费解的莫过于那个频繁出现、被称为 FD(文件描述符)int 类型参数。

教学视频中常将 ‘一个 FD 对应一个 Socket’ 挂在嘴边,这种抽象的表述往往让人陷入迷雾:FD 究竟是某种神奇的编号,还是 Socket 本身?

在深入 FD 之前,我们必须先理清 Socket 的本质。当客户端与服务器交互时,双方会建立起一个连接。这个连接不仅是一条逻辑上的通信隧道,更是一个承载了双向数据流的会话(Session)。

Socket 便是这个会话在内核中的物理化身。 一旦连接建立,服务端便能通过 Socket 记录的五元组信息(源IP、源端口、目的IP、目的端口、协议),精准地识别出每一个数据包所属的归属。它就像是一个会话管理器,让服务端在面对海量并发时,依然能清晰地分辨出每一条对话的来龙去脉,所以socket可以我觉得可以称为connection或者session

因此Kernel需要为每一个客户端都维护一个socket,那么Kernel需要在内部维护一个socket表,表中拥有每个socket实例,每个socket都是一个客户端与服务端的连接。

而我们只需要根据socket表的索引就可以O(1)O(1)复杂度快速找到对应的socket,而socket表的索引就是FD

socket表的数据架构模型

如果socket表是个顺序表模型,我们来考虑下必然碰到的几个问题:

1.容量不够,频繁扩容

假设 Socket 表初始容量为 1024,当第 1025 个请求到来时,全量拷贝的噩梦便开始了。随着请求量攀升,‘创建、复制、删除’的动作会像多米诺骨牌一样频繁触发。这种 O(n)O(n) 复杂度的搬运工式操作,让服务器在面对高并发时显得步履蹒跚。

2.删除socket时,顺序表震荡

当服务端关闭一个连接时,必须从 Socket 表中移除对应的条目。若底层采用顺序表模型,删除操作将演变成一场内存搬运的灾难:为了填补删除后的空位并保持表的连续性,该位置之后的所有 Socket 实例都必须依次向前挪移。

在高并发场景下,连接的开启与关闭如潮汐般频繁。每掉线一个用户,都会引发成千上万个 Socket 实例在内存中‘集体位移’。这种 O(n)O(n) 复杂度的数据震荡,不仅极大地消耗了 CPU 周期,更让系统的吞吐量在频繁的内存重组中消耗殆尽。”

3.内存溢出

在顺序表模型下,开发者陷入了一个两难的境地:为了避免频繁扩容,你可能会预先申请一块巨大的连续内存(例如直接开辟 100 万个槽位)。

然而,这种**‘豪赌式’的预分配极其低效:如果实际连接只有几千个,剩下的 99% 空间都变成了不可用的‘死内存’ ,导致极大的资源浪费。反之,如果预留不足,瞬时涌入的流量会直接撑爆顺序表,引发内存溢出(Out of Memory)**。这种‘非黑即白’的刚性结构,根本无法适应互联网流量那种波峰波谷的剧烈变化。”

鉴于顺序表在扩展性与稳定性上的先天缺陷,内核在设计 Socket 表时转向了更灵活的链表模型

链表通过指针映射完美化解了顺序表的‘三大硬伤’:

  1. 无需扩容:新连接到来时,只需动态申请一块内存并挂载到链表末尾,彻底告别了 O(n)O(n) 的全量拷贝。
  2. 拒绝震荡:删除某个 Socket 仅需修改前后节点的指针指向,原地释放内存,不再引发后续数据的‘集体搬迁’。
  3. 按需分配:Socket 实体可以散落在内存的任何角落,不再苛求大块连续空间,极大地提升了内存利用率并规避了溢出风险。”

FD的管理

当一个原始数据包从网卡涌入时,它只是一串包含 IP 和端口的二进制流,并不会自我标榜:‘我是属于 FD 5 的’。如果我们只能依靠遍历链表来匹配会话,那么在高并发下,CPU 将陷入无穷无尽的对比循环中。

因此,为了实现收到数据包 -> 瞬时定位 Socket的极致需求,我们必须在 Socket 实体之上,构建一套专门管理索引映射(Mapping) 的机制。这套机制必须像导航雷达一样,在报文落地的瞬间,就指出它在 Socket 表中对应的精准坐标(FD)。”

为了实现从数据包到 Socket 实体的秒级定位,我们可以引入 哈希(Hash) 作为索引核心。

其核心逻辑是对数据包的 ‘五元组’(源IP、目的IP、源端口、目的端口、协议)执行哈希运算。通过将哈希值对 Socket 表容量取模,我们能瞬间计算出该数据包对应的 FD(文件描述符)

拿到FD后再去socket表中查找对应的socket,如果没有查到说明是新连接,那么我们可以在对应的索引上创建新的socket,如果查到了则直接返回对应的socket指针。

但是,当我们需要处理数十万或者或以上的连接时,哈希计算往往会存在哈希冲突问题,也就是说不同数据包的五元组可能经过哈希计算后得出相同的值,那么此时再进行取模计算就会找到相同的socket,从而导致数据处理异常。

为了避免上述问题的发生,我们不能使用简单的哈希计算加链表索引去CRUD socket表,我们需要引入哈希桶或者叫做哈希表的方式,五元组作为哈希表的key,哈希表的value是存放哈希值相同的链表的链表

Gemini_Generated_Image_btijlqbtijlqbtij.png

上述解决方案就是广为使用的拉链法,现在当收到数据包,我们进行哈希取模计算后去找到对应链表的索引,然后再根据拿到数据包的五元组去遍历该链表中的socket中的五元组字段,如果匹配相同则拿到对应的socket返回指针。

哈希链表的二级指针与一级指针区别

根据上述描述我们可以自己定义一个哈希链表,我们通过链表头作为每个哈希的bucket管理每个哈希冲突的链表,代码如下:

#define NUM_SOCKET 1024
struct bucket_head{
    struct bucket_node *first;
}

struct bucket_node{
    struct bucket_node *next;
    struct bucket_node *prev
}

struct socket {
    uint_32 ipaddr;
    ......五元组等字段
    
    struct bucket_node node;
}

struct bucket_head* hash_bucket[NUM_SOCKET]

上述代码定义了可容纳1024个socket的socket哈希表,当接收数据包后根据五元组hash去模找到对应的bucket_head,通过bucket_head->next遍历每个socket的五元组匹配,如果没有匹配的则说明是新的连接,那么我们需要创建一个新的socket并将其插入到首部,代码如下:

struct socket* new_sk = (struct socket*)malloc(sizeof(struct socket)); 
new_sk->src_ip = ip;

struct node_head *head = node_list[index];

// --- 1. 处理新节点的后继 ---
new_sk->n.next = head->first;

// --- 2. 处理原老大的前驱  ---
if (head->first != NULL) {
    head->first->prev = &(new_sk->n);
}

// --- 3. 处理桶的指向 ---
head->first = &(new_sk->n);

// --- 4. 处理新节点的前驱 ---
new_sk->n.prev = NULL; // 一级指针下,第一个节点的 prev 只能是 NULL

当我们需要关闭一个会话时,拿到FD根据五元组匹配到对应socket后,我们需要指向如下步骤:

struct socket_head *head = hash_bucket[计算后的fd];
.......通过socket_head->first->next找到socket后
//判断头部是否为NULL,如果为NULL则是头部第一个socket,如果是第一个socket则不改变prev指针,如果不是null则代表是中间元素
if(socket->node->prev){
    //将上一个元素的next改成自己的下一个socket
    socket->node->prev->next = &(socket->node->next);
}else{
    //如果是null,则说明他是第一个元素,我们需要修改head的first让其指向新的socket
    head->first = socket->node;
}
//将自己下一个元素的prev指向自己上一个元素,这里依旧判断是否为尾部
if(socket->node->next){
    socket->node-next->prev = &(socket->node-prev);
}
//最后再删除自身
free(socket)

根据上述代码,我们通过bucket_node的prev一级指针完成针对socket的删除和新增,但是在高并发的网络环境下,哈希链表需要频繁的删除、新增socket,如果使用的是一级指针的情况下,删除时CPU每次都要判断socket是否处于头部还是尾部,并且新增和删除时都要必须额外传入 head 指针,增加了函数了耦合度和计算开销。

因此我们可以引入二级指针方式减少代码的不必要计算开销,通过二级指针直接找到存放上一个node的变量地址,直接通过修改该地址的成员来完成新增和删除的功能,我们还是以代码来讲解

#define NUM_SOCKET 1024
struct bucket_head{
    struct bucket_node *first;
}

struct bucket_node{
    struct bucket_node *next;
    struct bucket_node **pprev /指向上一个存储上一个node的变量本身的地址
}

struct socket {
    uint_32 ipaddr;
    ......五元组等字段
    
    struct bucket_node node;
}

struct bucket_head* hash_bucket[NUM_SOCKET]

当我们创建一个新连接时,代码如下

struct socket* new_sk = (struct socket*)malloc(sizeof(struct socket)); 
new_sk->src_ip = ip;
struct socket_head *head = hash_bucket[计算后的fd];
//1.socket的next指向head的first
new_sk->node->next = head->first;
//2.socket的ppre直接指向head
new_sk->node->prev = &head->first
// 3. 维护链表完整性:如果原来桶里有人,要改老大的回指
if (head->first != NULL) { 
    // 让原老大的 pprev 指向新节点的 next 变量地址
    head->first->pprev = &new_sk->n.next; 
}
// 4. 让桶正式指向新节点
head->first = &new_sk->n;

新增socket时一级指针和二级指针还没有明显的性能差异,最主要的是删除会话时,代码如下:

struct socket_head *head = hash_bucket[计算后的fd];
.......通过socket_head->first->next找到socket后
// 1. 获取“指向我的那个格子”的地址 (可能是桶的 first,也可能是前任的 next) 
struct node **pprev_slot = sk->node.pprev; 
// 2. 获取“我的后任”的地址 
struct node *next_node = sk->node.next;
// 3.直接把两个关联起来
pprev_slot = next_node;
// 3. 维护反向指针,如果下一个成员不为null,那么需要把其pprev指向现在的slot
if (next_node) {
    next_node->pprev = pprev_slot; 
}
//3删除自身
free(socket)

根据上述代码,我们可以直接通过二级指针找到对应head下的first变量地址,解引用后把first所存储的值直接改成当前socket的后驱,然后再把当前socket的前驱指向head的first地址即可完成删除,无需通过索引去找对应head也没有多余的分支条件,大大的提高了cpu的性能。

DPDK中rte_hash和FD资源池

上述代码我们了解了如何使用哈希链表去维护多个哈希冲突的链表,但是在真正的高并发网络服务中,我们需要知道每个连接是否超时、上下行流量是多少,如果我们采用上述解决方案那么我们首先要遍历哈希表然后再去遍历每个索引下的链表,嵌套循环极大的影响了CPU的性能,没法在满足高性能处理业务的同时维护好每一个连接。

DPDK框架提供了rte_hash 解决方案,和GO语言的map一样,只需要传递key和value即可,无需担心哈希冲突问题,因此我们在使用DPDK时无需使用哈希链表来维护socket增加和删除。

之前的方案我们是把五元组哈希取模后的值作为FD去找对应的索引,然后再遍历五元组比对找到真正的socket,现在既然rte_hash已经拿五元组作为Key直接可以拿到socket,那么FD还有什么用呢?

如果没有FD那么我们在recv和send的时候都要通过五元组使用rte_hash去找socket,这种方法很笨且无法维护;因此我们只需要定义一个socket数组,数组的索引就是FD,后续recv或者send时我们只需要根据索引拿到socket就行;

现在还有一个关键问题,当rte_hash计算后发现这是一个新的连接,我们怎么去拿FD到对应的位置上创建socket?是随机数吗,如果是获取随机数当FD那么肯定会覆盖已有的socket,在代码里维护一个int类型的FD_number吗?那关闭连接或新建连接时为了并发安全还要加锁处理,CPU性能大打折扣。

因此定义一个FD资源池是一个最好的解决方案,当有一个连接新建时,我们从资源池里拿出一个FD然后在socket数组的对应索引上创建socket,当连接关闭时只需要把对应的FD归还资源池再释放socket即可。

socket链表和尾插法

我们用DPDK的rte_hash表解决了查询socket需求socket数组和FD资源池解决了创建会话和删除会话的需求,但是在高可用网络服务下我们如何快速查找出已超时的连接呢?难道是遍历socket数组吗这肯定不可能;

因此我们还需要维护一个socket链表,链表头维护第一个socket和最后一个socket的指针,使用尾插法把活跃的socket始终放在链表的尾部,当我们需要删除超时的连接时只需要遍历链表的前几个socket即可删除超时的会话,如果遍历多个socket发现都不超时那么后面的所有socket都不会存在超时现象。

Gemini_Generated_Image_myaje5myaje5myaj.png

首先定义数据结构,代码如下:

#define NUM_FD 1024
struct node_head {
    struct node *first;
    struct node **last;
}

struct node{
    struct node *next;
    struct node **pprev;
}

struct socket {
    int fd;
    ......五元组等字段
    struct rte_ring *tx_ring;
    struct rte_ring *rx_ring;
    struct bucket_node node;
    uint64_t last_active; //上次活跃时间
}

struct socket_manager {
    struct socket socket_table[NUM_FD];
    struct node_head *head;
    struct rte_ring *free_fd_ring;
    struct rte_ring *accept_fd_ring;
}

socket的两个队列是用于接受和发送自己的数据包的,socket_manager的free_fd_ring是用来初始化一个FD资源池,我们把1024个FD数字全部调用rte_ring_enqueue这个函数压到一个队列里,每次创建新连接先从这个队列里取一个FD,直接调用rte_ring_dequeue,创建完socket后把FD压入accept_fd_ring然后我们可以封装一层accept函数从这里源源不断拿到新建立的socket fd,当关闭连接时在调用enqueue把对应FD放回队列中,而head则是管理socket链表的存在;

当收到数据包后通过rte_hash发现是一个新的连接,此时我们就可以使用如下流程:

//1.从free_fd_ring中拿一个FD
void *fd_ptr;
if (rte_ring_dequeue(socket_manager->free_fd_ring, &fd_ptr) == 0) {
    int fd = (int)(uintptr_t)fd_ptr;
//2.新建socket
    struct socket *new_sk = rte_zmalloc("socket", sizeof(struct socket), 64);
    new_sk.fd = fd;
    ....填充socket的其他字段,比如五元组,创建ring等
//3.在socket table中填充socket
    socket_manager.socket_table[fd] = socket;
//4.socket的node的next为null表示自己在尾部
    socket->node->next = null;
//5.socket_manager的head的last指向的node的值改成socket->node
    *(socket_manager->head->last) = &(socket->node);
//6.socket的pprev指向head的last
    socket->node->pprev = socket_manager->head->last;
//7.head的last指向当前socket的node的next
    socket_manager->head->last = &(socket->node->next)
//最后可以返回FD给用户,
    rte_ring_enqueue(socket_manager->accept_fd_ring,(void*)fd);
}

当收到的数据包属于某一个socket时,我们需要将该socket移动到链表的末尾,并刷新socket中的活跃时间,具体代码如下:

//1.将该socket的前驱指向socket的后驱
*(socket->node->pprev) = socket->node->next;
//2.如果socket的后驱不为NULL,需要把socket后驱的前驱指向socket前驱的后驱
if(socket->next){
    socket->node->next->pprev = socket->node->pprev->next;
}
//3.当前head的last的值的next得指向当前socket
*(socket_manager->head->last) = socket->node;
//4.当前socket的next指向null表示是最后一个元素
socket->node->next = null;
//5.当前socket的pprev得指向原来head的last的值
socket->node->pprev = socket_manager->head->last
//6.让head的last指向当前socket的next
socket_manager->head->last = &(socket->node->next);
//最后把数据投递到socket自己的私有队列中
rte_ring_enqueue(socket->rx_ring,&mbuf)

BitMap监听可发送数据的连接

在高性能网络协议中,BitMap(位图) 是管理海量连接状态的“快准狠”神器。当你需要监听哪些连接有数据待发送时,BitMap 能极大地减少 CPU 的无效轮询。

1. 核心动机:为什么用 BitMap?
  • 消除 O(N)O(N) 轮询:如果你有 100 万个 Socket,逐个检查 need_send 标志位太慢。
  • 内存极简:一个 bit 代表一个 FD(文件描述符),100 万个连接仅需约 122KB 内存。
  • CPU 友好:利用 __builtin_ctzll(计算末尾 0 的个数)等硬件指令,可以一次性跳过 64 个无数据的连接。
2. 数据结构设计

socket_manager 中增加一个位图数组,通常以 uint64_t 为单位:

#define MAX_FD 1048576
#define BITS_PER_UNIT 64
#define BITMAP_SIZE (MAX_FD / BITS_PER_UNIT)

struct socket_manager {
    // ... 其他成员 (head, socket_table)
    uint64_t send_bitmap[BITMAP_SIZE]; // 发送就绪位图
};
3. 核心操作逻辑(位运算)
A. 标记就绪(当应用层往 Ring 写入数据时)

当某个 Socket 的发送 Ring 从空变为“有数据”时,挂起该位:

void mark_send_ready(struct socket_manager *mgr, int fd) {
    // 找到在第几个 uint64_t 单元
    int unit_idx = fd / 64;
    // 找到在该单元的第几位
    int bit_idx = fd % 64;
    // 置 1 操作
    mgr->send_bitmap[unit_idx] |= (1ULL << bit_idx);
}
B.清除标记(当发送驱动处理完数据时)
void clear_send_ready(struct socket_manager *mgr, int fd) {
    mgr->send_bitmap[fd / 64] &= ~(1ULL << (fd % 64));
}
4. 高效扫描:如何快速找到“谁要发包”?

这是 BitMap 最强大的地方,使用 ffs (Find First Set) 指令:

void scan_and_send(struct socket_manager *mgr) {
    for (int i = 0; i < BITMAP_SIZE; i++) {
        uint64_t val = mgr->send_bitmap[i];
        while (val > 0) {
            // 使用 CPU 指令快速找到从右起第一个 1 的位置
            int bit_pos = __builtin_ctzll(val); 
            int fd = i * 64 + bit_pos;

            // 1. 获取对应的 Socket
            struct socket *sk = mgr->socket_table[fd];
            // 2. 执行真正的发送函数
            do_actual_send(sk);

            // 3. 将处理完的位从临时变量中去掉,继续找下一个 1
            val &= ~(1ULL << bit_pos);
        }
    }
}
5. 结合 Socket 链表的进阶架构

在实际生产中,BitMap 通常和你的 TAILQ 链表配合使用:

  • BitMap 负责“检索” :快速告诉你哪个 FD 活跃。
  • 链表负责“排序/超时” :记录谁是最久没发送的(LRU)。
  • Socket Table 负责“定位” :通过 FD 瞬间拿到 struct socket 指针。

上述就是socket_manager的完整功能介绍,当完成了socket_manager后,只需要在pipeline架构中合适的位置调用socket_manager的方法即可实现一个高可用的架构。