当前位置: 首页 > Linux

一个TCP发送缓冲区问题分析

时间:2023-04-06 03:03:25 Linux

最近遇到一个问题,简化模型如下:Client创建一个TCPsocket,通过SO_SNDBUF选项设置其发送缓冲区大小为4096字节。连接到Server后,每1秒发送一条TCP数据段长度为1024的报文。服务器不调用recv()。预期结果分为以下几个阶段:Phase1Server端的socket接收缓冲区未满,因此Server虽然不会recv(),但仍然可以对Client发送的消息回复ACK;Phase2Server端的socket接收缓冲区被填满,通知客户端零窗口(ZeroWindow)。客户端要发送的数据开始在socket的发送缓冲区中堆积;阶段3客户端套接字的发送缓冲区已满,用户进程在send()上被阻塞。在实际执行中,表现出来的现象也“基本”符合预期。但是,当我们在客户端通过ss-nt时不时的监控TCP连接的发送队列长度,发现这个值居然从0增加到14480,很容易就超过了之前设置的SO_SNDBUF值(4096)#ss-ntStateRecv-QSend-Q本地地址:端口对端地址:PortESTAB00192.168.183.130:52454192.168.183.130:14465StateRecv-QSend-Q本地地址:端口对端地址:PortESTAB01024192.168.183.130:52454192.168.183.130:14465StateRecv-QSend-QLocalAddress:PortPeerAddress:PortESTAB02048192.168.183.130:52454192.168.183.130:14465......StateRecv-QSend-QLocalAddress:PortPeerAddress:PortESTAB013312192.168.183.130:52454192.168.183.130:14465StateRecv-QSend-QLocalAddress:PortPeerAddress:PortESTAB014336192.168.183.130:52454192.168.183.135Recv-QState:144nd-QLocalAddress:PortPeerAddress:PortESTAB014480192.168.183.130:52454192.168.183.130:14465这里有必要解释一下Send-Q的含义。我们知道TCP的发送过程是受滑动窗口限制的。这里的Send-Q是发送方滑动窗口左边缘到所有未发送数据包的总长度。那么为什么这个值会超过SO_SNDBUF呢?DoubleSO_SNDBUF当用户通过SO_SNDBUF选项设置socket发送缓冲区时,内核记录在sk->sk_sndbuf中。@sock.c:sock_setsockopt{caseSO_SNDBUF:.....sk->sk_sndbuf=mat_x(u32,val*2,SOCK_MIN_SNDBUF)}注意内核在这里玩了个小把戏,记录在sk->sk_sndbuf中value不是用户设置的val,而是val的两倍!即Client设置4096时,kernel记录8192!那么为什么内核需要这样做呢?我想是因为内核使用sk_buff来保存用户数据有额外的开销,比如sk_buff结构本身,skb_shared_info结构,以及L2、L3、L4层的headersize。这些额外的开销自然会占用发送方的内存缓冲区,但应该不是用户需要关心的,所以内核在这里将这个值加倍,以保证即使有一半的内存被用来存储额外的开销,它也可以保证用户的数据有足够的内存来存储。但是这个问题无法解释,因为即使8192字节的发送缓存全部用于存储用户数据(额外开销为0,当然这是不可能的),也无法到达Send-最后的14480字节问。.由于sk_wmem_queued设置为sk->sk_sndbuf,内核在发送数据包时会检查当前发送缓冲区使用的内存值是否超过这个限制。前者由sk->wmem_queued保存。需要注意的是sk->wmem_queued=待发送数据占用的内存+额外开销占用的内存,所以应该大于send-Q@sock.hboolsk_stream_memory_free(conststructsock*sk){if(sk->sk_wmem_queued>=sk->sk_sndbuf)//如果当前sk_wmem_queued超过sk_sndbuf,则返回false,说明内存不够returnfalse;.....}sk->wmem_queued是不断变化的,对于TCPsocket来说,当内核将skb塞进发送队列后,这个值增加skb->truesize(truesize,顾名思义,指的是总包括额外开销后的消息大小);当消息被确认时,这个值会减少skb->真实大小。上面的tcp_sendmsg是铺垫,让我们看看tcp_sendmsg是怎么做的。一般来说,内核会根据写队列中是否有要发送的消息来决定是新建一个sk_buff还是追加用户数据到写队列的最后一个sk_buffinttcp_sendmsg(structsock*sk,structmsghdr*msg,size_tsize){mss_now=tcp_send_mss(sk,&size_goal,flags);//代码提交while(msg_data_left(msg)){intcopy=0;intmax=size_goal;skb=tcp_write_queue_tail(sk);如果(tcp_send_head(sk)){......copy=max-skb->len;}if(copy<=0){/*case1:allocnewskb*/new_segment:if(!sk_stream_memory_free(sk))gotowait_for_sndbuf;//如果发送缓冲区已满,则阻塞进程并休眠}....../*case2:copymsgtolastskb*/......}Case1.createanewsk_buff在我们的问题中,Client在第一阶段不积累sk_buff。也就是说,此时用户发送的每条消息都会通过sk_stream_alloc_skb创建一个新的sk_buff。在此之前,内核会检查发送缓冲内存是否超过了限制,在Phase1中,内核也可以通过这个检查。staticinlineboolsk_stream_memory_free(conststructsock*sk){if(sk-?sk_wmem_queued>=sk->sk_sndbuf)返回假;......}Case2.将用户数据添加到最后一个sk_buff,进入Phase2后,client的sendbuffer已经积累了sk_buff。这时内核会尝试追加用户数据(msg中的内容)到写队列的最后一个sk_buff。需要注意的是,搭便车的数据也是有大小限制的,用copy@tcp_sendmsgintmax=size_goal;copy=max-skb->len;表示其中size_goal表示sk_buff可以容纳的最大用户数据,减去已经使用过的skb->len,剩下的就是可以添加的数据长度。那么size_goal是怎么计算出来的呢?tcp_sendmsg|--tcp_send_mss|--tcp_xmit_size_goalstaticunsignedinttcp_xmit_size_goal(structsock*sk,u32mss_now,intlarge_allowed){如果(!large_allowed||!sk_can_gso(sk))返回mss_now;.....size_goal=>gso_segs*mss_now;.....returnmax(size_goal,mss_now);}继续追溯,可以看到size_goal与使用的网卡是否开启GSO功能有关。GSOEnable:size_goal=tp->gso_segs*mss_nowGSODisable:size_goal=mss_now在我的实验环境中,TCP连接的有效mss_now是1448字节。用systemtap添加检测点后,发现size_goal是14480字节!那正好是mss_now的10倍。所以当Clinet进入Phase2时,tcp_sendmsg计算copy=14480-1024=13456bytes。但是最后一个sk_buff真的能装这么多吗?在实验环境中,Phase1创建的sk_buff有skb->len=1024,skb->truesize=4372(4096+256,这个值的详细来源见sk_stream_alloc_skb)。看来这个sk_buff不能容纳14480啊。继续看内核的实现,skb_copy_to_page_nocache()复制前,sk_wmem_schedule()tcp_sendmsg{/*case2:copymsgtolastskb*/......if(!sk_wmem_schedule(sk,copy))goto等待内存;err=skb_copy_to_page_nocache(sk,&msg->msg_iter,skb,pfrag->page,pfrag->offset,copy);}在sk_wmem_schedule内部,sk_buff会被扩充(增加可存储用户数据的长度)。tcp_sendmsg|--sk_wmem_schedule|--__sk_mem_schedule__sk_mem_schedule(structsock*sk,intsize,intkind){sk->sk_forward_alloc+=amt*SK_MEM_QUANTUM;allocated=sk_memory_allocated_add(sk,amt,&parent_status);…//后面有一堆检查,比如系统内存是否足够,不检查是否超过sk_sndbuf}这样内核就可以让sk->wmem_queued超过sk->sndbuf的限制。我不认为这是优雅和合理的行为,因为它使用户设置的SO_SNDBUF无用!那么我可以添加和修改什么?关闭网卡的GSO特性,修改内核代码,将发送缓冲区限制的检查移到while循环的开头。while(msg_data_left(msg)){intcopy=0;intmax=size_goal;+if(!sk_stream_memory_free(sk))+gotowait_for_sndbuf;skb=tcp_write_queue_tail(sk);如果(tcp_send_head(sk)){如果(skb->ip_summed==CHECKSUM_NONE)max=mss_now;复制=最大-skb->len;}if(copy<=0){new_segment:/*分配新段。如果接口是SG,*将skbfitting分配给单页。*/-if(!sk_stream_memory_free(sk))-gotowait_for_sndbuf;完全完成我的个人主页