第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时间。
排查过程是这样的:
- 先看中断分布——所有网卡中断都在CPU0上
- 再看协议栈锁——TCP的accept队列用了全局锁
- 最后看Socket操作——每个recv都要拿锁
解决方案:
- 开启网卡RSS,中断分散到4个CPU
- accept队列改成per-CPU的lifo结构,减少锁竞争
- recv操作使用RCU保护,读路径无锁
优化后,吞吐量从2.1Gbps提升到6.8Gbps。你看,很多时候性能瓶颈不在算法复杂度,而在锁的设计上。
总结一下:SMP网络协议栈优化的核心就三句话——数据尽量不跨核,锁尽量不共享,数据尽量不拷贝。做到这三点,多核的威力才能真正发挥出来。