vlambda博客
学习文章列表

TCP协议细节系列(1):Nagle和Cork算法

一:概念

1984年John Nagle为了解决福特航空公司小包导致的网络拥塞问题,提出了Nagle算法,RFC1122 4.2.3.4章节有记载,算法也比较简单,见下面伪码:

if there is new data to send if the window size >= MSS and available data is >= MSS send complete MSS segment now else if there is unconfirmed data still in the pipe enqueue data in the buffer until an acknowledge is received else send data immediately end if  end if

算法也比较直接,简单来说就是大包直接发送,如果没有待确认的报文的话,小包也可以发,但是如果有待确认的报文,小包就要等一等,这就是Nagle算法的全部,Linux做了一点优化,就是待确认的包变成了待确认的小包,在Linux中等到什么时候呢?实际上是起了一个probe定时器(值通常是一个rtt大小),超时了就发送这个被延迟的小包,我们要注意的一点是Nagle算法只是减少小包发送,而不是禁止,因为如果网络中没有未被确认的报文(或者小包),Nagle还是允许发送小包的,Nagle算法只是想解决小包造成的网络拥塞问题,所以网络不拥塞的话,还是要发送小包,明白了这点,我们就能很好的理解Cork算法了,Cork算法也很简单,见下面伪码:

if there is new data to send if the window size >= MSS and available data is >= MSS send complete MSS segment now  else      enqueue 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.8080Flags [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字节,实际上就退出了while if (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为3 if (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为3 if (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 while           if (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>
BPF_HASH(currsock, 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); if(sport == 8080) { bpf_trace_printk("tcp_rate_skb_sent call seq:%u dport:%u len:%d\\n", tcb->seq, ntohs(dport) , skb->len); bpf_trace_printk("tcp_rate_skb_sent call packets_out:%u skb data_len:%u\\n", tp->packets_out, skb->data_len); bpf_trace_printk("tcp_rate_skb_sent 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); if(sport == 8080) { bpf_trace_printk("ip_queue_xmit call%u %u len:%d\\n", tcb->seq, ntohs(dport) , skb->len); bpf_trace_printk("ip_queue_xmit packets_out:%u skb data_len:%u sport:%u\\n", tp->packets_out, skb->data_len, sport); bpf_trace_printk("ip_queue_xmit 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); if(sport == 8080) { bpf_trace_printk("tcp_write_timer_handler 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); if(sport == 8080) { bpf_trace_printk("tcp_send_loss_probe 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); if(sport == 8080) { bpf_trace_printk("tcp_push call:dst port %u sport:%u\\n",ntohs(dport) , sport ); bpf_trace_printk("tcp_push 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;
if(sport == 8080) { bpf_trace_printk("tcp_sendmsg_locked 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);
bpf_trace_printk("tcp_sendmsg_locked ret %u %u\\n", pid, ret);

return 0;}
"""
# initialize BPFb = BPF(text=bpf_text)
# headerprint("%-6s %-12s %-16s" % ("PID", "COMM", "Msg"))

# filter and format outputwhile 1: # Read messages from kernel pipe try: (task, pid, cpu, flags, ts, msg) = b.trace_fields() #(_tag, seq, mss, nagle) = msg.split(b" ") except ValueError: # Ignore messages from other tracers continue except KeyboardInterrupt: exit() # Ignore messages from other tracers #if task.decode() != "packetdrill": # continue
printb(b"%-6d %-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等。