3. sk_buff 队列核心结构:理解 sk_buff_head、锁机制与入队/出队操作

在 Linux 内核网络协议栈中,sk_buff 队列是连接协议层与驱动层的核心数据结构。无论是 TCP 分段后的数据包、Qdisc 排队规则中的待发送帧,还是 NAPI 收包时的 backlog,本质上都是通过 sk_buff_head 组织起来的双向链表。理解队列的锁语义、入队/出队原语以及内存屏障,是后续分析调度算法与驱动协同的基础。

3.1 sk_buff_head 结构体解剖

sk_buff_head 是一个轻量级的链表头,不包含数据负载,仅维护链表指针、队列长度和自旋锁。其定义位于 include/linux/skbuff.h:

struct sk_buff_head {
    /* 双向链表指针:next 指向第一个 sk_buff,prev 指向最后一个 */
    struct sk_buff    *next;
    struct sk_buff    *prev;

    /* 队列长度(sk_buff 个数) */
    __u32             qlen;

    /* 保护队列的自旋锁 */
    spinlock_t        lock;
};
字段类型说明
nextstruct sk_buff *指向队列中第一个 sk_buff 节点;空队列时指向自身
prevstruct sk_buff *指向队列中最后一个 sk_buff 节点;空队列时指向自身
qlen__u32当前队列中的 sk_buff 总数,用于快速判断队列空/满
lockspinlock_t自旋锁,保护队列的并发修改,禁止在中断上下文睡眠
设计要点:sk_buff_head 本身不包含数据,只作为“锚点”。空队列时,next 和 prev 均指向自身(自环),这是 Linux 内核链表的标准实现方式,简化了边界判断。

3.2 队列锁机制与并发模型

sk_buff 队列可能被多个执行上下文同时访问:

  • 上半部(硬中断):网卡收包完成中断,调用 netif_receive_skb() 将 skb 送入 backlog 队列。
  • 下半部(软中断):NET_RX_SOFTIRQ 或 NET_TX_SOFTIRQ 处理队列中的 skb。
  • 进程上下文:用户态通过 socket 发送数据,最终调用 dev_queue_xmit() 将 skb 加入 Qdisc 队列。

锁的使用规则:

  1. 自旋锁(spinlock):所有队列操作(入队/出队/遍历)必须持有 lock。
  2. 中断安全:在可能被中断上下文调用的路径上,必须使用 spin_lock_irqsave() 保存中断状态。
  3. 禁止睡眠:自旋锁持有期间不能调用可能睡眠的函数(如 kmalloc(GFP_KERNEL))。
常见错误:在持有 sk_buff_head 锁时调用 consume_skb() 释放 skb,如果 skb 属于其他队列或触发 socket 内存回收,可能导致锁顺序反转(ABBA 死锁)。正确的做法是先出队,释放锁,再释放 skb。

3.3 入队与出队操作原语

内核提供了一系列内联函数用于队列操作,这些函数均假设调用者已持有锁(或处于不会并发的初始化阶段)。

3.3.1 入队操作

函数语义时间复杂度
__skb_queue_head()将 skb 插入队列头部(LIFO 行为)O(1)
__skb_queue_tail()将 skb 插入队列尾部(FIFO 行为)O(1)
skb_queue_head()加锁版 __skb_queue_head()O(1)
skb_queue_tail()加锁版 __skb_queue_tail()O(1)

以 __skb_queue_tail() 为例,其实现核心:

static inline void __skb_queue_tail(struct sk_buff_head *list,
                                    struct sk_buff *newsk)
{
    struct sk_buff *prev, *next;

    prev = (struct sk_buff *) list;
    next = list->prev;  // 原尾节点

    newsk->next = prev;   // newsk->next 指向 head(自环)
    newsk->prev = next;   // newsk->prev 指向原尾
    next->next = newsk;   // 原尾的 next 指向 newsk
    prev->prev = newsk;   // head->prev 指向 newsk(新尾)

    list->qlen++;         // 队列长度递增
}

3.3.2 出队操作

函数语义时间复杂度
__skb_dequeue()从队列头部取出一个 skbO(1)
__skb_dequeue_tail()从队列尾部取出一个 skbO(1)
skb_dequeue()加锁版 __skb_dequeue()O(1)
skb_dequeue_tail()加锁版 __skb_dequeue_tail()O(1)

出队操作必须检查队列是否为空:

static inline struct sk_buff *__skb_dequeue(struct sk_buff_head *list)
{
    struct sk_buff *skb = skb_peek(list);  // 取 head->next
    if (skb)
        __skb_unlink(skb, list);           // 从链表中移除
    return skb;
}
性能关键点:所有入队/出队操作均为 O(1),不涉及内存分配或拷贝。但 qlen 的递增/递减必须与锁操作原子一致,否则并发场景下长度计数会漂移。

3.4 队列操作流程与内存屏障

下图展示了 sk_buff 队列在 NAPI 收包路径中的典型入队/出队流程:

sk_buff 队列入队/出队流程 sk_buff 队列入队/出队流程(NAPI 收包路径) 硬件中断到达 NAPI poll 回调 skb_queue_tail() 入队 sk_buff_head 队列 qlen++ / spinlock skb_dequeue() 出队 协议栈处理 说明:入队时持有 spin_lock_irqsave(),出队后释放锁再提交协议栈,避免锁持有时间过长

3.5 队列遍历与安全删除

遍历 sk_buff 队列时,必须使用内核提供的安全遍历宏,防止在遍历过程中删除节点导致指针失效:

struct sk_buff *skb, *tmp;

spin_lock_irqsave(&queue->lock, flags);
skb_queue_walk_safe(queue, skb, tmp) {
    if (条件满足) {
        __skb_unlink(skb, queue);
        // 此时 skb 已脱离队列,可以安全释放
        spin_unlock_irqrestore(&queue->lock, flags);
        kfree_skb(skb);
        spin_lock_irqsave(&queue->lock, flags);
    }
}
spin_unlock_irqrestore(&queue->lock, flags);
为什么需要 skb_queue_walk_safe?
标准 skb_queue_walk() 使用 skb->next 作为循环步进变量。如果在遍历中调用 __skb_unlink() 删除当前 skb,其 next 指针可能被修改,导致步进丢失或访问已释放内存。_safe 版本预先保存 skb->next 到 tmp,避免此问题。

3.6 队列状态检查与调试

内核提供了几个内联函数用于快速检查队列状态:

函数返回值用途
skb_queue_empty()队列为空返回非零判断是否还有待处理 skb
skb_queue_len()返回 qlen 值获取队列当前长度
skb_peek()返回第一个 skb 指针(不移除)预览队首元素
skb_peek_tail()返回最后一个 skb 指针(不移除)预览队尾元素
调试技巧:在 /proc/net/softnet_stat 中可以看到每个 CPU 的 backlog 队列长度。如果某个 CPU 的 qlen 持续偏高,说明该 CPU 的 NAPI 处理能力不足,可能需要调整 net.core.netdev_budget 或启用 RPS(Receive Packet Steering)。

3.7 本章小结

  • sk_buff_head 是内核中最基础的 skb 队列结构,通过双向链表 + 自旋锁实现 O(1) 入队/出队。
  • 锁机制 必须考虑中断上下文,使用 spin_lock_irqsave() 保证安全。
  • 入队/出队原语 分为加锁版(skb_queue_xxx)和非加锁版(__skb_queue_xxx),驱动和协议栈根据调用上下文选择。
  • 遍历安全 是常见陷阱,必须使用 skb_queue_walk_safe() 在删除节点时保护链表完整性。