linux 旁路掉协议栈的处理点
对于协议栈的发展,目前有三种处理趋势,一种是类似于使用dpdk的方式,然后将协议栈放到用户态来做,做得比较好的一般都是以bsd的协议栈为底子,可以参考的是腾讯开源的的方案,另外一种是,继续放在内核,但进行一些旁路,比如netpoll的架构,或者pass某一段路径。最后一种是像google一下推出新的协议。本文主要描述协议栈旁路问题。
为什么需要旁路协议栈,当我们有把握直接操作网卡队列的时候,没必要操作协议栈,可以自己填三层和二层的报头,然后发送出去,减少协议栈的消耗。当一个流的第一个包发送的时候,可以经历完整的协议栈,触发路由学习和二层的arp,之后后面的报文就可以旁路掉协议栈。这个一般对于无状态的udp适用,tcp的话,由于需要考虑重传,则不考虑旁路,而选择在ip层旁路。
当我们需要发包的时候,通过sendmsg,send,sendto,sendfile之类的接口来调用协议栈发包,协议栈帮我们做了什么事情呢?
1.以udp为例,当需要缓存报文的时候,也就是开启了cork之类的时候,sk_write_queue 中取一个最后一个skb,将数据挂在这个skb中缓存,并不立刻发送。
2.否则构造skb,把我们用户态拷贝进去的报文(或者sendfile调用的sendpage,是内核态的报文)管理起来,其主要管理的结构就是sock的sk_write_queue 成员中,新的skb挂在这个sk_write_queue 这个queue的尾,这个地方,对于udp来说,就可以作为旁路的点了,因为skb无非是用来管理发送数据的,我可以直接根据sock查找到的flow,来判断走哪个网卡,然后直接构造udp和ip头,然后用自己的skb池子中的skb结构来发包,发包的话,也不经历ip层,也不经历qdisc层,也不经历虚拟设备层(bond,vlan等),直接给网卡的tx加锁发包。
3.假设cork已经解除,则会开始尝试发包,如果报文cork超过了我们能够发送的最大报文,则会调用ip_flush_pending_frames丢弃,不仅仅是丢弃当前skb,而是丢弃该sock下的所有pending data。(Throw away all pending data on the socket)
4.根据sk->dst是否为NULL,如果不为NULL,则check这个路由,否则,调用ip_route_output_flow查找路由,这个一般由__ip_route_output_key_hash 来实现,是主要的路由解析函数
5.这个就进入了ip层,udp的话进入ip_send_skb-->ip_local_out,其实就是调用 skb_dst(skb)->output(sk, skb); 这个一般就是ip_output-->ip_finish_output,不考虑分片和gso的话,则调用ip_finish_output2,tcp的话,主要是回调ip_queue_xmit,对于tcp来说,可以通过修改queue_xmit这个指针来旁路掉ip层,为什么不在tcp之前就进行旁路,因为tcp还有状态机,旁路的话,
比较麻烦。
const struct inet_connection_sock_af_ops ipv4_specific = { .queue_xmit = ip_queue_xmit,
6.ip_finish_output2会在确定邻居ok的情况下,进入dst_neigh_output,邻居协议解析一般是单独的,ipv4的话,通过arp和rap协议来维护邻居,如果邻居状态ok,则·neigh_hh_output-->dev_queue_xmit,
7.根据 skb->dev,以及skb的其他信息,调用netdev_pick_tx 来获取txq,txq中的qdisc就获取到了qdisc的队列,一个网卡的tx队列,就有一个qdisc与之相对应,进入qdisc队列,除了一些不需要qdisc队列的,如lo设备,其他会经过qdisc队列
8.调用sch_direct_xmit 发包,qdisc会对网卡的tx队列上锁,注意这个是一把spinlock,上不了锁会忙等待,也会设置
上锁成功之后,最终调用我们最为熟悉的dev_hard_start_xmit->xmit_one->netdev_start_xmit-->__netdev_start_xmit,这个函数,也是一个函数指针的回调,也是一个绝佳的旁路的点,比如我们可以通过cork发送大包,然后到此进行拆分成小包,直接调用网卡的发送函数来发包,就避免的skb的大量申请,我们甚至可以维护一个skb的池子,避免skb的缓存缩放,我们可以做一个page的池子,使用sendpage方式的时候,从池子里面取page,然后再释放之前skb中对应的page,反正有很多可以作为的地方。
9.__netdev_start_xmit 里面就完全是使用driver 的ops去发包了,ops->ndo_start_xmit.其实到此为止,一个skb已经从netdevice层送到driver层了.当然,但凡是回调的地方,如果不想直接改内核的话,使用内核模块,可以替换相应的ndo_start_xmit 指针,然后做自己的发包,比如bond设备对应的 ndo_start_xmit 赋值为了bond_start_xmit,你可以替换为自己的xmit。
10.如果步骤9对应的就是实际网卡,那么就调用的是网卡的发包函数,如果是类似于bond之类的虚拟网卡,则在替换skb相应的数据之后,比如skb->dev,skb->queue_mapping等之后,最终还是会重新走一遍9,直到真正的物理设备。下面附一个经历bond处理,再到实际网卡处理发包的堆栈,两次dev_queue_xmit一目了然:
#24 [ffff884dab0f3b48] dev_hard_start_xmit at ffffffff81592bc0 #25 [ffff884dab0f3ba0] sch_direct_xmit at ffffffff815bbd5a #26 [ffff884dab0f3bf0] __dev_queue_xmit at ffffffff815958e6 #27 [ffff884dab0f3c48] dev_queue_xmit at ffffffff81595c20-----------实际网卡 #28 [ffff884dab0f3c58] bond_dev_queue_xmit at ffffffffc026fbe2 [bonding] #29 [ffff884dab0f3c78] bond_start_xmit at ffffffffc02717be [bonding] #30 [ffff884dab0f3cc0] dev_hard_start_xmit at ffffffff81592bc0 #31 [ffff884dab0f3d18] __dev_queue_xmit at ffffffff81595b08 #32 [ffff884dab0f3d70] dev_queue_xmit at ffffffff81595c20-------------bond开始 #33 [ffff884dab0f3d80] ip_finish_output at ffffffff815dc8f6 #34 [ffff884dab0f3dd0] ip_output at ffffffff815dce53 #35 [ffff884dab0f3e30] ip_local_out_sk at ffffffff815daa87 #36 [ffff884dab0f3e50] ip_send_skb at ffffffff815dd8a6 #37 [ffff884dab0f3e68] udp_send_skb at ffffffff81605a9c #38 [ffff884dab0f3ea8] udp_push_pending_frames at ffffffff81605cde #39 [ffff884dab0f3ec8] udp_lib_setsockopt at ffffffff81606344 #40 [ffff884dab0f3ee8] udp_setsockopt at ffffffff816073b4 #41 [ffff884dab0f3ef8] sock_common_setsockopt at ffffffff8157831a #42 [ffff884dab0f3f08] sys_setsockopt at ffffffff81577476 #43 [ffff884dab0f3f50] system_call_fastpath at ffffffff816c4715
穿越了这么多层,其实如果直接对驱动层熟悉的话,可以直接调用ndo_start_xmit 发包,但是一般来说,前文所述的都是sys调用,如果qdisc中的配额用完了,那么会触发一个软中断,也就是著名的NET_TX_SOFTIRQ,也就是软中断中会调用 net_tx_action,这个函数会获取percpu变量softnet_data,然后先处理其completion_queue释放内存,再处理output_queue,其实也就是调用
总结一下:前面描述的是发包的过程,可以看到这个过程其实蛮重的,旁路掉协议栈,意味着需要和协议栈抢网卡tx的锁,我们拿到锁之后,自己根据第一个报文学习到的flow的二层和三层信息,填好的二层和三层数据的skb报文就能发送出去了,udp协议,建议可以在udp协议层就旁路,tcp协议,建议在进入ip层的指针时旁路,再往下,可以再经历过qdisc之后,替换ndo_start_xmit ,虽然替换ndo_start_xmit 已经经历了完整的协议栈,不算是旁路,但就是从减少一点skb的申请和释放来说,也是有收益的。旁路协议栈心中要谨记tx的锁,不然很容易导致死锁。比如,在软中断中,可能会获取tx的锁而发包,在sys中,也会获取tx的锁来发包,那么你旁路的时候,要保证别人没有拿到对应tx的锁。
最后,所有的旁路都要保证安全,即本身有些流程可能会拦截一些异常,而这些异常都被跳过,直接到终点了,从实现的角度看,都是在A--》B--》C--》D这种流程中,在D处进行学习,保证那些可以旁路的数据建立特征数据库,这样在A处,检查是否满足特征数据库,如果满足,则直接A--》D,旁路掉中间的流程,当然如果B和C除了检查之外可能也会改造A的数据流,可以将这部分流程抽出来,放在D处来完成,这个就是旁路的常见设计模式了。
对于协议栈的上行旁路,一般什么策略呢?我们以gro来作为例子:
5.0的内核中,gro的dev层的函数为:
static enum gro_result dev_gro_receive(struct napi_struct *napi, struct sk_buff *skb)
可以看到,当前的gro,操作的对象依然现定于napi_strcut,对于一个多队列的网卡,每个队列其实有对应的一个napi_strct,但是
有个问题需要看到,我们一个napi_struct过来的报文合并的概率是多大呢?从驱动的流表hash的结果来看,大概率能够保证一个流进入一个网卡的队列,
当使用类似bond之类的虚拟设备的时候,基本也能够通过l4的均衡来保证一个flow的包进入同一个网卡队列。所以看起来好像问题不大,
但是从目前gro的实现来看,它存在的问题有:
1.从时间的角度说,在软中断的收包函数中net_rx_action,如果当前的收包中断使用掉了配额,那么就会调用
napi_gro_flush(n, HZ >= 1000);
这种情况下,gro聚合的时间显然很有限制,
2.从空间的角度说,
/* Instead of increasing this, you should create a hash table. */
#define MAX_GRO_SKBS 8
这个明显太小了,而且作者说了,这个值不适合增大,你如果想增大,干脆创建一个hashtable。
所以,对于flow比较少,发包比较urst的系统来说,linux自带的gro是够用的,但现在,大家发包都强调pacing,自然收包也会出现pacing现象,那么gro的修改就显得很重要了。
从offload的设计来看,gro和gso是放在一起,而通过dev_gro_receive到inet_gro_receive到udp_gro_receive或者tcp_gro_receive是常见的路径了,要想定制gro的话,可以
只判断tcp的流程来做优化,因为udp的gro在普通的服务器使用场景很小,将两个udp的报文合成一个,对于用户态收包程序来说,显然需要一个边界的定义来协助,效果一般。