TCP协议细节系列(1):Nagle和Cork算法
| 一:概念 |
1984年John Nagle为了解决福特航空公司小包导致的网络拥塞问题,提出了Nagle算法,RFC1122 4.2.3.4章节有记载,算法也比较简单,见下面伪码:
if there is new data to sendif the window size >= MSS and available data is >= MSSsend complete MSS segment nowelseif there is unconfirmed data still in the pipeenqueue data in the buffer until an acknowledge is receivedelsesend data immediatelyend ifend if
算法也比较直接,简单来说就是大包直接发送,如果没有待确认的报文的话,小包也可以发,但是如果有待确认的报文,小包就要等一等,这就是Nagle算法的全部,Linux做了一点优化,就是待确认的包变成了待确认的小包,在Linux中等到什么时候呢?实际上是起了一个probe定时器(值通常是一个rtt大小),超时了就发送这个被延迟的小包,我们要注意的一点是Nagle算法只是减少小包发送,而不是禁止,因为如果网络中没有未被确认的报文(或者小包),Nagle还是允许发送小包的,Nagle算法只是想解决小包造成的网络拥塞问题,所以网络不拥塞的话,还是要发送小包,明白了这点,我们就能很好的理解Cork算法了,Cork算法也很简单,见下面伪码:
if there is new data to sendif the window size >= MSS and available data is >= MSSsend complete MSS segment nowelseenqueue data in the buffer,start a timer and when the timer is expired, send the packet.end if
Cork算法也很简单吧,它干脆就不允许小包发送,如果是小包,就只能等,为什么要这样做?是因为Cork算法的目的跟Nagle不一样,Cork是解决网络利用率低的问题,小包当然利用率低,所以只发送大包,简单粗暴,但是Nagle和Cork算法的代价是引入了时延,Cork在Linux是默认关闭的,而Nagle却是默认打开的,我们可以使用下面的方法来打开或关闭Nagle和Cork:
setsockopt(client_fd, SOL_TCP, TCP_NODELAY,(int[]){1}, sizeof(int));//关闭Naglesetsockopt(client_fd, SOL_TCP, TCP_NODELAY,(int[]){1}, sizeof(int));//打开Cork
| 二、测试工具:大名鼎鼎的packetdrill |
为了测试Nagle和Cork的行为,不得不引入一个测试工具packetdrill,这是Google公司的一款工具,用来测试TCP是非常的好用,为了方便理解下面的测试过程我先简单介绍一下packetdrill工具的原理,看下面的图示(!!!注意这里只介绍收发包都在一台机器上的行为,在两台机器上也是类似的):
packetdrill在执行时,自动创建了一个Tun设备,这个设备很有意思,它一头像是网卡,连接协议栈,另外一头像是个字符设备,用户态的应用程序可以使用这个字符设备读写报文。
左边发包时,srcip:192.0.2.1 dstip:192.168.71.61,报文送到Tun网卡时,被送到协议栈,协议栈看到dstip是自己,所以就把报文收上来处理,
右边发包时,srcip:192.168.71.61 dstip:192.0.2.1,查看路由表,发现192.0.2.1要走Tun网卡设备,就把报文送到Tun网卡,然后就被Tun网卡送到Tun字符设备,就到了右边,这样用户态的packetdrill就能从右边收报文了。
所以packetdrill的原理还是挺容易理解的,理解了上面的介绍,我们就知道了,要想抓packetdrill的交互报文,我们就得在Tun设备抓包,但是有个问题就是Tun设备是packetdrill动态创建的,那我们怎么提前启动抓包呢?只能用下面这个命令:接口只能指定为any
sudo tcpdump -S -i any -n tcp port 8080 //8080是上图右边使用的端口
| 三、测试场景1:打开Cork选项,小包被延时发送 |
3.1场景描述:这个场景是server端尝试发一个小包,然后等一小会,再发送一个大包,这个大包会被拆成2个大包+1个小包,我们主要观察小包被延迟发送了,以及时延是多少。
3.2测试脚本:为了测试这个场景我构造了下面的packetdrill脚本,我在脚本中给与一些详细解释。
// Test TCP_CORK and TCP_NODELAY sockopt behavior`./defaults.sh`//创建socket,被测试端0 socket(..., SOCK_STREAM, IPPROTO_TCP) = 3+0 setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0+0 bind(3, ..., ...) = 0+0 listen(3, 1) = 0//关闭tso,gro,gso,为了方便观察,这样就能抓把发的每一个报文+0 `ethtool -K tun0 gro off`+0 `ethtool -K tun0 gso off`+0 `ethtool -K tun0 tso off`//client发送握手,mss是62,并要TimeStamps选项+0 < S 0:0(0) win 32792 <mss 62,sackOK,TS val 100 ecr 0,nop,wscale 7>//server侧回复握手报文+0 > S. 0:0(0) ack 1 <...>//client侧确认+.02 < . 1:1(0) ack 1 win 257+0 accept(3, ..., ...) = 4// Set TCP_CORK sockopt to hold small packets+0 setsockopt(4, SOL_TCP, TCP_CORK, [1], 4) = 0+0 %{ print tcpi_snd_mss }%//server发送一个8字节小包+0 write(4, ..., 8) = 8* > P. 1:9(8) ack 1 <...>+0 < . 1:1(0) ack 9 win 300+0 %{ print tcpi_rto }%//server发送105报文,由于client在syn报文给的mss是62,且要求timestamp选项//(10字节),不过TCP头要4字节对齐,所以选项占12字节,所以留给app是每个包最多//50字节,105字节需要分3个包发出去,我们预期第三个报文会被延期发送+5.0 write(4, ..., 105) = 105* > . 9:59(50) ack 1 <...>* > P. 59:109(50) ack 1 <...>* > P. 109:114(5) ack 1 <...>//client侧发送确认报文,把105字节全部确认掉。+0 < . 1:1(0) ack 114 win 400+15.0 close(4) = 0
3.3 执行测试:
我们首先打开一个terminal,输入以下命令启动抓包:
sudo tcpdump -S -i any -n tcp port 8080
打开另外一个terminal,输入下面的命令,执行packetdrill脚本:
sudo packetdrill ./sockopt_cork_nodelay.pkt
我们在抓包terminal中看到下面的输出:
//握手报文08:19:37.530544 IP 192.0.2.1.45293 > 192.168.148.108.8080: Flags [S], seq 0, win 32792, options [mss 62,sackOK,TS val 100 ecr 0,nop,wscale 7], length 008:19:37.530685 IP 192.168.148.108.8080 > 192.0.2.1.45293: Flags [S.], seq 470731719, ack 1, win 65535, options [mss 1460,sackOK,TS val 775582184 ecr 100,nop,wscale 8], length 008:19:37.552022 IP 192.0.2.1.45293 > 192.168.148.108.8080: Flags [.], ack 1, win 257, length 0//server端第一次发8字节报文08:19:37.778600 IP 192.168.148.108.8080 > 192.0.2.1.45293: Flags [P.], seq 1:9, ack 1, win 256, options [nop,nop,TS val 775582432 ecr 100], length 8: HTTP08:19:37.778954 IP 192.0.2.1.45293 > 192.168.148.108.8080: Flags [.], ack 9, win 300, length 0//server端第二次发105字节报文,被分成了3个报文50+50+508:19:42.780396 IP 192.168.148.108.8080 > 192.0.2.1.45293: Flags [.], seq 9:59, ack 1, win 256, options [nop,nop,TS val 775587433 ecr 100], length 50: HTTP08:19:42.780423 IP 192.168.148.108.8080 > 192.0.2.1.45293: Flags [P.], seq 59:109, ack 1, win 256, options [nop,nop,TS val 775587433 ecr 100], length 50: HTTP08:19:42.829877 IP 192.168.148.108.8080 > 192.0.2.1.45293: Flags [P.], seq 109:114, ack 1, win 256, options [nop,nop,TS val 775587483 ecr 100], length 5: HTTP//client侧的确认报文,确认全部105字节08:19:42.830157 IP 192.0.2.1.45293 > 192.168.148.108.8080: Flags [.], ack 114, win 400, length 0//关闭socket08:19:57.830910 IP 192.168.148.108.8080 > 192.0.2.1.45293: Flags [F.], seq 114, ack 1, win 256, options [nop,nop,TS val 775602484 ecr 100], length 008:19:57.910017 IP 192.0.2.1.45293 > 192.168.148.108.8080: Flags [R.], seq 1, ack 114, win 400, length 0
从时间戳上我们就可以看出来,字节为5的小包跟全面两个50字节报文时间上时间上差了大约(829877 - 780423)=49454,大约49ms,在执行packetdrill脚本的terminal输出当前的tcpi_rto是220000,大约是上面值的4倍,看起来小包是被延时了。
| 四、Linux的实现 |
我们来看看发包的过程,
4.1 第一个函数如下:
int tcp_sendmsg_locked(struct sock *sk, struct msghdr *msg, size_t size){while (msg_data_left(msg)) {//申请skb//拷贝发送内容到skb//如果skb的长度小于size_goal,就continue,对于我们的测试105字节,实际上就退出了whileif (skb->len < size_goal || (flags & MSG_OOB) || unlikely(tp->repair))continue;}....}out:if (copied) {tcp_tx_timestamp(sk, sockc.tsflags);//调用tcp_push进行发包tcp_push(sk, flags, mss_now, tp->nonagle, size_goal);}}
4.2 接下来调用tcp_push, 这个函数做了一些其他处理,我们忽略掉,然后调用__tcp_push_pending_frames
void tcp_push(struct sock *sk, int flags, int mss_now,int nonagle, int size_goal){...__tcp_push_pending_frames(sk, mss_now, nonagle);}
4.3 __tcp_push_pending_frames比较简单的函数,但是有个地方要注意,见下面注释。
void __tcp_push_pending_frames(struct sock *sk, unsigned int cur_mss,int nonagle){/* If we are closed, the bytes will have to remain here.* In time closedown will finish, we empty the write queue and* all will be happy.*/if (unlikely(sk->sk_state == TCP_CLOSE))return;if (tcp_write_xmit(sk, cur_mss, nonagle, 0,sk_gfp_mask(sk, GFP_ATOMIC)))//什么时候会走到这里呢?在我们的测试中第一次发送的8字节包会走到//这里,也就是说第一次发的8字节也是被延时了。tcp_check_probe_timer(sk);}
4.4 我们接着看看tcp_write_xmit,对于小于mss的报文,也就是小包,直接tcp_nagle_test来看看能不能发送,对于大于mss的报文,要切分,切分后的剩余小包需要tcp_nagle_test来判断是否可以发送。
static bool tcp_write_xmit(struct sock *sk, unsigned int mss_now, int nonagle,int push_one, gfp_t gfp){while ((skb = tcp_send_head(sk))) {//第一次发8字节时,这里为真,第二次发送105字节时,tso_segs为3if (tso_segs == 1) {if (unlikely(!tcp_nagle_test(tp, skb, mss_now,(tcp_skb_is_last(sk, skb) ?nonagle : TCP_NAGLE_PUSH))))break;} else {...}limit = mss_now;//第二次发送105字节时,tso_segs为3if (tso_segs > 1 && !tcp_urg_mode(tp))//tcp_mss_split_point和下面的tso_fragment一起完成切割skb,//105字节切分为两个skb,一个是100字节,一个是5字节,且返回的//limit就是100字节。limit = tcp_mss_split_point(sk, skb, mss_now,min_t(unsigned int,cwnd_quota,max_segs),nonagle);if (skb->len > limit &&unlikely(tso_fragment(sk, skb, limit, mss_now, gfp)))break;}//end of whileif (likely(sent_pkts)) {/* Send one loss probe per tail loss episode. *///对于105字节,我们来到了这里,通过启动tcp_schedule_loss_probe,启动//定时器。if (push_one != 2)tcp_schedule_loss_probe(sk, false);return false;}return !tp->packets_out && !tcp_write_queue_empty(sk);
4.5 我们最终来到了tcp_nagle_test函数,返回true是允许发送
static inline bool tcp_nagle_test(const struct tcp_sock *tp, const struct sk_buff *skb,unsigned int cur_mss, int nonagle){//如果有PUSH,允许if (nonagle & TCP_NAGLE_PUSH)return true;/* Don't use the nagle rule for urgent data (or for the final FIN). */if (tcp_urg_mode(tp) || (TCP_SKB_CB(skb)->tcp_flags & TCPHDR_FIN))return true;//我们主要关注这个函数if (!tcp_nagle_check(skb->len < cur_mss, tp, nonagle))return true;return false;}
static bool tcp_nagle_check(bool partial, const struct tcp_sock *tp,int nonagle){//只针对小包,如果cork打开,则返回true,//如果没有关闭NAGLE,如果有小包没有确认,则返回true//返回true则不允许发送,false允许。return partial &&((nonagle & TCP_NAGLE_CORK) ||(!nonagle && tp->packets_out && tcp_minshall_check(tp)));}
看到了吧,以上就是Nagle和Cork的实现,包括两方面,一方面是阻止小包发送,另外一部分是启动定时器,待定时器超时,发送该被延迟的小包。
下面是定时的超时函数
void tcp_write_timer_handler(struct sock *sk){struct inet_connection_sock *icsk = inet_csk(sk);int event;tcp_mstamp_refresh(tcp_sk(sk));event = icsk->icsk_pending;switch (event) {//对应第二次发送剩余的5字节的小包,//tcp_send_loss_probe会把这个小包发出去case ICSK_TIME_LOSS_PROBE:tcp_send_loss_probe(sk);break;//对应第一个8字节的小包,tcp_probe_timer//会把这个小包发出去case ICSK_TIME_PROBE0:icsk->icsk_pending = 0;tcp_probe_timer(sk);break;}out:sk_mem_reclaim(sk);}
| 五、代码流程的确认 |
上面分析的代码流程,实际上也不是我一下子就找到的,中间也有一些困惑,但好在我们有工具或手段来验证自己的猜测,这就是另外一个大名鼎鼎的工具bcc,它是Brendan Gregg创建的最近比较流行的工具,当然其它工具比如bpftrace和systemtap都可以,不过我的选择是bcc,bcc支持kprobe和kretprobe,来看看我写的脚本
#!/usr/bin/python## Copyright (c) 2022 xin.guo.# Licensed under the Apache License, Version 2.0 (the "License")## 15-May-2022 xin guo Created this.from __future__ import print_functionfrom bcc import BPFfrom bcc.utils import printb# define BPF programbpf_text = """#include <uapi/linux/ptrace.h>#include <net/sock.h>#include <net/tcp.h>#include <net/inet_connection_sock.h>#include <bcc/proto.h>#include <linux/socket.h>u32, struct sock *);int kprobe__tcp_rate_skb_sent(struct pt_regs *ctx, struct sock *sk,const struct sk_buff *skb){u32 pid = bpf_get_current_pid_tgid();struct tcp_skb_cb *tcb;if (skb) {macro TCP_SKB_CB from net/tcp.h */tcb = ((struct tcp_skb_cb *)&((skb)->cb[0]));u16 dport = sk->__sk_common.skc_dport;u16 sport = sk->__sk_common.skc_num;struct tcp_sock *tp = tcp_sk(sk);== 8080){call seq:%u dport:%u len:%d\\n", tcb->seq, ntohs(dport) , skb->len);call packets_out:%u skb data_len:%u\\n", tp->packets_out, skb->data_len);segs:%u gso_max_size:%u\\n", tp->gso_segs, sk->sk_gso_max_size);}}return 0;};int kprobe____ip_queue_xmit(struct pt_regs *ctx, struct sock *sk,const struct sk_buff *skb){u32 pid = bpf_get_current_pid_tgid();struct tcp_skb_cb *tcb;if (skb) {macro TCP_SKB_CB from net/tcp.h */tcb = ((struct tcp_skb_cb *)&((skb)->cb[0]));u16 dport = sk->__sk_common.skc_dport;u16 sport = sk->__sk_common.skc_num;struct tcp_sock *tp = tcp_sk(sk);== 8080){call%u %u len:%d\\n", tcb->seq, ntohs(dport) , skb->len);packets_out:%u skb data_len:%u sport:%u\\n", tp->packets_out, skb->data_len, sport);segs:%u gso_max_size:%u\\n", tp->gso_segs, sk->sk_gso_max_size);}}return 0;};int kprobe__tcp_write_timer_handler(struct pt_regs *ctx, struct sock *sk){u32 pid = bpf_get_current_pid_tgid();u16 dport = sk->__sk_common.skc_dport;u16 sport = sk->__sk_common.skc_num;struct tcp_sock *tp = tcp_sk(sk);struct inet_connection_sock *icsk = inet_csk(sk);== 8080){call:dst port %u sport:%u event:%u\\n",ntohs(dport) , sport, icsk->icsk_pending );}return 0;};int kprobe__tcp_send_loss_probe(struct pt_regs *ctx, struct sock *sk){u32 pid = bpf_get_current_pid_tgid();u16 dport = sk->__sk_common.skc_dport;u16 sport = sk->__sk_common.skc_num;struct tcp_sock *tp = tcp_sk(sk);== 8080){call:dst port %u sport:%u\\n",ntohs(dport) , sport );}return 0;};int kprobe__tcp_push(struct pt_regs *ctx, struct sock *sk, int flags, int mss_now,int nonagle, int size_goal){u32 pid = bpf_get_current_pid_tgid();u16 dport = sk->__sk_common.skc_dport;u16 sport = sk->__sk_common.skc_num;struct tcp_sock *tp = tcp_sk(sk);== 8080){call:dst port %u sport:%u\\n",ntohs(dport) , sport );mss_now %u nonagle:%u size_goal:%u\\n",mss_now , nonagle, size_goal );}return 0;};int kprobe__tcp_sendmsg_locked(struct pt_regs *ctx, struct sock *sk, struct msghdr *msg, size_t size){u32 pid = bpf_get_current_pid_tgid();struct tcp_sock *tp = tcp_sk(sk);u16 sport = sk->__sk_common.skc_num;== 8080){call%u %u\\n", size, tp->packets_out);}return 0;};int kretprobe__tcp_sendmsg_locked(struct pt_regs *ctx){u32 pid = bpf_get_current_pid_tgid();int ret = PT_REGS_RC(ctx);ret %u %u\\n", pid, ret);return 0;}# initialize BPFb = BPF(text=bpf_text)# header%-12s %-16s" % ("PID", "COMM", "Msg"))# filter and format outputwhile 1:# Read messages from kernel pipetry:pid, cpu, flags, ts, msg) = b.trace_fields()#(_tag, seq, mss, nagle) = msg.split(b" ")except ValueError:# Ignore messages from other tracerscontinueexcept KeyboardInterrupt:exit()# Ignore messages from other tracers#if task.decode() != "packetdrill":# continue%-12.12s %-16s" % (pid, task,msg))
我们得到的输出是:
PID COMM Msg//第一次发包8字节65098 packetdrill tcp_sendmsg_locked call8 065098 packetdrill tcp_push call:dst port 58909 sport:808065098 packetdrill tcp_push mss_now 50 nonagle:2 size_goal:1640065098 packetdrill tcp_sendmsg_locked ret 65098 8//第一次真正发包,event:3 代表ICSK_TIME_PROBE00 <idle> tcp_write_timer_handler call:dst port 58909 sport:8080 event:30 <idle> ip_queue_xmit call0 58909 len:400 <idle> ip_queue_xmit packets_out:0 skb data_len:8 sport:80800 <idle> ip_queue_xmit segs:328 gso_max_size:655360 <idle> tcp_rate_skb_sent call seq:2780301583 dport:58909 len:80 <idle> tcp_rate_skb_sent call packets_out:0 skb data_len:80 <idle> tcp_rate_skb_sent segs:328 gso_max_size:655360 <idle> tcp_write_timer_handler call:dst port 58909 sport:8080 event:0//第二次发包105字节65098 packetdrill tcp_sendmsg_locked call105 065098 packetdrill tcp_push call:dst port 58909 sport:808065098 packetdrill tcp_push mss_now 50 nonagle:2 size_goal:19200//发送100字节,最终是两个包出去,下面我们解释一些原因65098 packetdrill ip_queue_xmit call0 58909 len:13265098 packetdrill ip_queue_xmit packets_out:0 skb data_len:100 sport:808065098 packetdrill ip_queue_xmit segs:384 gso_max_size:6553665098 packetdrill tcp_rate_skb_sent call seq:2780301591 dport:58909 len:10065098 packetdrill tcp_rate_skb_sent call packets_out:0 skb data_len:10065098 packetdrill tcp_rate_skb_sent segs:384 gso_max_size:6553665098 packetdrill tcp_sendmsg_locked ret 65098 105//5字节的小包发送,event:5代表ICSK_TIME_LOSS_PROBE24618 Xorg tcp_write_timer_handler call:dst port 58909 sport:8080 event:524618 Xorg tcp_send_loss_probe call:dst port 58909 sport:808024618 Xorg ip_queue_xmit call0 58909 len:3724618 Xorg ip_queue_xmit packets_out:2 skb data_len:5 sport:808024618 Xorg ip_queue_xmit segs:384 gso_max_size:6553624618 Xorg tcp_rate_skb_sent call seq:2780301691 dport:58909 len:524618 Xorg tcp_rate_skb_sent call packets_out:2 skb data_len:524618 Xorg tcp_rate_skb_sent segs:384 gso_max_size:65536//下面是其他的一些交互,我们先不关注。0 <idle> tcp_write_timer_handler call:dst port 58909 sport:8080 event:065098 packetdrill ip_queue_xmit call0 58909 len:3265098 packetdrill ip_queue_xmit packets_out:0 skb data_len:0 sport:808065098 packetdrill ip_queue_xmit segs:384 gso_max_size:6553665098 packetdrill tcp_rate_skb_sent call seq:2780301696 dport:58909 len:065098 packetdrill tcp_rate_skb_sent call packets_out:0 skb data_len:065098 packetdrill tcp_rate_skb_sent segs:384 gso_max_size:65536
如果你仔细阅读了上面的内容,我想你一定有个疑惑,明明我们在packetdrill的脚本中关闭了GSO,但是为啥我们在bcc的输出中还是看到tcp协议栈还是发送100(两个mss大小)的Ip层呢?这是因为GSO是关不掉的,强制打开,见这个函数:
void sk_setup_caps(struct sock *sk, struct dst_entry *dst){u32 max_segs = 1;//我们关闭了dev的GSOfeature,可是还是看到了GSO打开了,//我们要看sk->sk_route_forced_caps 这个是在tcp_init_sock设置的sk->sk_route_caps = dst->dev->features | sk->sk_route_forced_caps;sk->sk_route_caps &= ~sk->sk_route_nocaps;if (sk_can_gso(sk)) {if (dst->header_len && !xfrm_dst_offload_ok(dst)) {sk->sk_route_caps &= ~NETIF_F_GSO_MASK;} else {sk->sk_route_caps |= NETIF_F_SG | NETIF_F_HW_CSUM;sk->sk_gso_max_size = dst->dev->gso_max_size;max_segs = max_t(u32, dst->dev->gso_max_segs, 1);}}sk->sk_gso_max_segs = max_segs;}
我们看到sk_route_forced_caps在下面这个函数中被强制设置了NETIF_F_GSO
void tcp_init_sock(struct sock *sk){sk->sk_route_forced_caps = NETIF_F_GSO;}
另外一个疑惑或许是,我们抓包确实是抓到两个50字节的报文,而不一个100字节的报文,这是因为抓包点在GSO分包之后,代码就先不展示了,后面再说吧,本文已经很长了,也该结束了。
| 六、总结 |
看起来很简单的Nagle和Cork,要想弄清楚它也是要花一些功夫的,当然我们也要依赖一些工具,不然有可能会得不到正确的答案,我这里只展示了一个基本的测试场景,不过我们利用本文提供的脚本,可以很容易理解和测试其他场景,比如关闭Nagle+关闭Cork、打开Nagle+关闭Cork等。
