第27章 SMP网络协议栈:并行协议处理、Socket锁优化、零拷贝技术

网络协议栈,在单核时代是个温顺的绵羊。到了多核SMP环境下,它立马变成了一头猛兽。为什么?因为网络数据流的处理路径太长——从网卡中断到协议解析,从Socket层到用户态缓冲区,每一步都可能成为锁竞争的战场。

我早年在一个网络设备项目上吃过亏。四核处理器,跑着标准Linux协议栈,结果吞吐量还不如双核。排查下来,发现三个核在抢同一个Socket锁,剩下一个核在干瞪眼。嗯,这就是典型的SMP网络瓶颈。

27.1 并行协议处理:从串行到流水线

传统协议栈是串行处理的。一个数据包进来,中断处理→IP层→TCP层→Socket层→用户态,全在一个CPU上跑完。SMP环境下,这种模式浪费了多核能力。

我个人习惯把协议栈处理分成三个阶段:

  • 中断分发阶段:网卡中断通过RSS(Receive Side Scaling)分发到不同CPU
  • 协议处理阶段:每个CPU独立处理自己收到的数据包
  • Socket交付阶段:将处理完的数据放入对应Socket的接收队列

RT-Thread的SMP协议栈实现中,我建议采用per-CPU的协议处理上下文。每个CPU维护自己的内存池、协议控制块缓存,减少跨核访问。

核心思路:让数据包尽量在同一个CPU上完成全生命周期。从中断到用户态,不要跨核迁移。

代码层面,可以这样设计:

/* 每个CPU独立的协议处理结构 */
struct net_proc_context {
    struct pbuf_pool   *pool;       /* 本地内存池 */
    struct sock_defrag *defrag;     /* IP分片重组表 */
    struct tcp_control *tcp_ctrl;   /* TCP控制块缓存 */
    uint32_t           packet_count;/* 统计信息 */
};

/* 初始化时,为每个CPU创建独立上下文 */
void net_proc_init(void)
{
    int cpu_id;
    for (cpu_id = 0; cpu_id < RT_CPUS_NR; cpu_id++) {
        struct net_proc_context *ctx = &per_cpu(net_proc_ctx, cpu_id);
        ctx->pool = pbuf_pool_create(cpu_id);
        ctx->defrag = defrag_table_create();
        ctx->tcp_ctrl = tcp_ctrl_cache_create();
    }
}

这里有个坑——TCP的TIME_WAIT状态处理。如果连接在CPU0上建立,关闭时却在CPU1上处理,会导致状态不一致。我曾经在这个问题上折腾了两天,最后解决方案是:在TCP控制块中记录创建时的CPU ID,所有状态变更操作都路由到该CPU执行。

27.2 Socket锁优化:从大锁到细粒度

Socket层是锁竞争的重灾区。多个线程同时读写同一个Socket,或者多个CPU同时处理不同Socket但共享全局表,都会引发锁冲突。

RT-Thread的Socket实现,我建议采用三级锁策略

锁级别 保护对象 锁类型 竞争频率
L1 Socket私有数据 spinlock
L2 Socket表(fdtable) rwlock
L3 全局协议状态 mutex

你想想看,如果每个Socket都有自己的锁,两个线程操作不同Socket就不会互相干扰。这才是真正的并行。

具体实现上,我习惯用RCU(Read-Copy-Update)来保护Socket表。因为查找Socket的操作远多于增删操作,RCU可以让读操作完全无锁:

/* Socket表使用RCU保护 */
struct socket *sock_get(int fd)
{
    struct socket *sock;
    
    rcu_read_lock();
    sock = rcu_dereference(sock_table[fd]);
    if (sock) {
        /* 增加引用计数,防止被释放 */
        atomic_inc(&sock->refcnt);
    }
    rcu_read_unlock();
    
    return sock;
}

void sock_put(struct socket *sock)
{
    if (atomic_dec_and_test(&sock->refcnt)) {
        /* 延迟释放,等待所有RCU读端完成 */
        call_rcu(&sock->rcu_head, sock_free_rcu);
    }
}

注意:RCU虽然读端无锁,但写端开销较大(需要等待宽限期)。适合读多写少的场景。如果Socket频繁创建销毁,建议改用读写锁。

还有一个优化点——Socket选项的读取。像SO_ERROR、SO_TYPE这些只读选项,完全不需要加锁。我见过一些实现,每次getsockopt都拿锁,其实很多选项的值在创建后就固定了。

27.3 零拷贝技术:减少数据搬运

网络协议栈最大的性能杀手是什么?数据拷贝。从网卡DMA到内核缓冲区,从内核缓冲区到Socket缓冲区,从Socket缓冲区到用户态——一次收包至少三次拷贝。

零拷贝,说白了就是让数据少搬家。RT-Thread的SMP实现中,我主要用了三种技术:

27.3.1 页对齐的DMA缓冲区

网卡DMA直接写到页对齐的缓冲区,这样在协议栈处理时,只需要传递页指针,不需要拷贝数据。IP分片重组时,也是通过页链表来管理,避免数据搬移。

/* 零拷贝的pbuf结构 */
struct pbuf {
    struct pbuf *next;
    void        *payload;    /* 指向DMA缓冲区内的数据 */
    uint16_t    tot_len;
    uint16_t    len;
    uint8_t     type;        /* PBUF_RAM, PBUF_ROM等 */
    
    /* 页管理信息 */
    struct page *page;
    uint32_t    offset;
};

27.3.2 Socket直接I/O

用户态通过mmap映射Socket的接收缓冲区,数据直接从内核态映射到用户态,不需要copy_to_user。我记得在某个视频流项目中,用这个技术把吞吐量提升了30%。

小技巧:零拷贝不是万能的。对于小包(小于256字节),拷贝的开销反而比映射小。因为映射需要TLB刷新,这个开销不小。我一般会设置一个阈值,大包走零拷贝,小包走传统拷贝。

27.3.3 跨核的cache一致性优化

SMP环境下,零拷贝有个隐藏问题——cache一致性。数据在CPU0上被DMA写入,CPU1要读取时,需要先invalid cache line。这个操作本身就有开销。

我的做法是:让数据生产者和消费者在同一个CPU上。网卡中断绑定到某个CPU,该CPU上的协议栈处理线程也绑定到同一个核。这样数据从DMA到协议处理,都在同一个CPU的cache域内,不需要跨核同步。

/* 中断和协议处理绑定到同一CPU */
void net_if_config(struct netif *iface, int cpu_id)
{
    /* 设置网卡中断亲和性 */
    irq_set_affinity(iface->irq_num, cpu_id);
    
    /* 创建绑定到该CPU的处理线程 */
    rt_thread_t thread = rt_thread_create("net_rx",
        net_rx_thread_entry, iface,
        4096, RT_THREAD_PRIORITY_MAX - 2, 10);
    rt_thread_control(thread, RT_THREAD_CTRL_BIND_CPU, 
                     (void *)cpu_id);
    rt_thread_startup(thread);
}

嗯,这里要注意——如果网卡不支持RSS,所有中断都到一个CPU,其他CPU就闲着了。这时候需要软件层面的负载均衡,比如根据源IP哈希分发数据包到不同CPU的处理队列。

27.4 实战经验:一个性能调优案例

最后分享一个我实际遇到的案例。某路由器产品,8核处理器,跑RT-Thread SMP,网络吞吐量始终上不去。用perf一看,发现spin_lock_irqsave占了40%的CPU时间

排查过程是这样的:

  1. 先看中断分布——所有网卡中断都在CPU0上
  2. 再看协议栈锁——TCP的accept队列用了全局锁
  3. 最后看Socket操作——每个recv都要拿锁

解决方案:

  • 开启网卡RSS,中断分散到4个CPU
  • accept队列改成per-CPU的lifo结构,减少锁竞争
  • recv操作使用RCU保护,读路径无锁

优化后,吞吐量从2.1Gbps提升到6.8Gbps。你看,很多时候性能瓶颈不在算法复杂度,而在锁的设计上。

总结一下:SMP网络协议栈优化的核心就三句话——数据尽量不跨核,锁尽量不共享,数据尽量不拷贝。做到这三点,多核的威力才能真正发挥出来。