再探Linux下的TCP延迟确认机制--TCP_QUICKACK
前面有一文简单写了一下Linux下的延迟确认机制,但当时不求甚解,后来陆陆续续做又了一些分析,于是就有了这篇文章。
案例一:某同事随手写个压力测试程序,其实现逻辑为:每秒钟先连续发N个132字节的包,然后连续收N个由后台服务回显回来的132字节包。其代码大致如下:
1: char sndBuf[132];
2: char rcvBuf[132];
3: while (1) {
4: for (int i = 0; i < N; i++){
5: send(fd, sndBuf, sizeof(sndBuf), 0);
6: ...
7: }
8: for (int i = 0; i < N; i++) {
9: recv(fd, rcvBuf, sizeof(rcvBuf), 0);
10: ...
11: }
12: sleep(1);
13: }
在实际测试中发现,当N大于等于3的情况,第2秒之后,每次第三个recv调用,总会阻塞40毫秒左右,但在分析Server端日志时,发现所有请求在Server端处理时耗均在2ms以下。
当时的具体定位过程如下:先试图用strace跟踪客户端进程,但奇怪的是:一旦strace attach上进程,所有收发又都正常,不会有阻塞现象,一旦退出strace,问题重现。经同事提醒,很可能是strace改变了程序或系统的某些东西(这个问题现在也还没搞清楚),于是再用tcpdump抓包分析,发现Server后端在回现应答包后,Client端并没有立即对该数据进行ACK确认,而是等待了近40毫秒后才确认。经过Google,并查阅《TCP/IP详解卷一:协议》得知,此即TCP的延迟确认(Delayed Ack)机制。
其解决办法如下:在recv系统调用后,调用一次setsockopt函数,设置TCP_QUICKACK。最终代码如下:
1: char sndBuf[132];
2: char rcvBuf[132];
3: while (1) {
4: for (int i = 0; i < N; i++) {
5: send(fd, sndBuf, 132, 0);
6: ...
7: }
8: for (int i = 0; i < N; i++) {
9: recv(fd, rcvBuf, 132, 0);
10: setsockopt(fd, IPPROTO_TCP, TCP_QUICKACK, (int[]){1}, sizeof(int));
11: }
12: sleep(1);
13: }
延迟确认机制
在《TCP/IP详解卷一:协议》第19章对其进行原理进行了详细描述:TCP在处理交互数据流(即Interactive Data Flow,区别于Bulk Data Flow,即成块数据流,典型的交互数据流如telnet、rlogin等)时,采用了Delayed Ack机制以及Nagle算法来减少小分组数目。
书上已经对这两种机制的原理讲的很清晰,这里不再做复述。本文后续部分将通过分析TCP/IP在Linux下的实现,来解释一下TCP的延迟确认机制。
1、为什么TCP延迟确认会导致延迟?
其实仅有延迟确认机制,是不会导致请求延迟的(初以为是必须等到ACK包发出去,recv系统调用才会返回)。一般来说,只有当该机制与Nagle算法或拥塞控制(慢启动或拥塞避免)混合作用时,才可能会导致时耗增长。我们下面来详细看看是如何相互作用的:
延迟确认与Nagle算法
我们先看看Nagle算法的规则(可参考tcp_output.c文件里tcp_nagle_check函数注释):
1)如果包长度达到MSS,则允许发送;
2)如果该包含有FIN,则允许发送;
3)设置了TCP_NODELAY选项,则允许发送;
4)未设置TCP_CORK选项时,若所有发出去的包均被确认,或所有发出去的小数据包(包长度小于MSS)均被确认,则允许发送。
对于规则4),就是说要求一个TCP连接上最多只能有一个未被确认的小数据包,在该分组的确认到达之前,不能发送其他的小数据包。如果某个小分组的确认被延迟了(案例中的40ms),那么后续小分组的发送就会相应的延迟。也就是说延迟确认影响的并不是被延迟确认的那个数据包,而是后续的应答包。
1 00:44:37.878027 IP 172.25.38.135.44792 > 172.25.81.16.9877: S 3512052379:3512052379(0) win 5840 <mss 1448,wscale 7>
2 00:44:37.878045 IP 172.25.81.16.9877 > 172.25.38.135.44792: S 3581620571:3581620571(0) ack 3512052380 win 5792 <mss 1460,wscale 2>
3 00:44:37.879080 IP 172.25.38.135.44792 > 172.25.81.16.9877: . ack 1 win 46
......
4 00:44:38.885325 IP 172.25.38.135.44792 > 172.25.81.16.9877: P 1321:1453(132) ack 1321 win 86
5 00:44:38.886037 IP 172.25.81.16.9877 > 172.25.38.135.44792: P 1321:1453(132) ack 1453 win 2310
6 00:44:38.887174 IP 172.25.38.135.44792 > 172.25.81.16.9877: P 1453:2641(1188) ack 1453 win 102
7 00:44:38.887888 IP 172.25.81.16.9877 > 172.25.38.135.44792: P 1453:2476(1023) ack 2641 win 2904
8 00:44:38.925270 IP 172.25.38.135.44792 > 172.25.81.16.9877: . ack 2476 win 118
9 00:44:38.925276 IP 172.25.81.16.9877 > 172.25.38.135.44792: P 2476:2641(165) ack 2641 win 2904
10 00:44:38.926328 IP 172.25.38.135.44792 > 172.25.81.16.9877: . ack 2641 win 134
从上面的tcpdump抓包分析看,第8个包是延迟确认的,而第9个包的数据,在Server端(172.25.81.16)虽然早就已放到TCP发送缓冲区里面(应用层调用的send已经返回)了,但按照Nagle算法,第9个包需要等到第个7包(小于MSS)的ACK到达后才能发出。
延迟确认与拥塞控制
我们先利用TCP_NODELAY选项关闭Nagle算法,再来分析延迟确认与TCP拥塞控制是如何互相作用的。
慢启动:TCP的发送方维护一个拥塞窗口,记为cwnd。TCP连接建立是,该值初始化为1个报文段,每收到一个ACK,该值就增加1个报文段。发送方取拥塞窗口与通告窗口(与滑动窗口机制对应)中的最小值作为发送上限(拥塞窗口是发送方使用的流控,而通告窗口则是接收方使用的流控)。发送方开始发送1个报文段,收到ACK后,cwnd从1增加到2,即可以发送2个报文段,当收到这两个报文段的ACK后,cwnd就增加为4,即指数增长:例如第一个RTT内,发送一个包,并收到其ACK,cwnd增加1,而第二个RTT内,可以发送两个包,并收到对应的两个ACK,则cwnd每收到一个ACK就增加1,最终变为4,实现了指数增长。
在Linux实现里,并不是每收到一个ACK包,cwnd就增加1,如果在收到ACK时,并没有其他数据包在等待被ACK,则不增加。
本人使用案例1的测试代码,在实际测试中,cwnd从初始值2开始,最终保持3个报文段的值,tcpdump结果如下:
1 16:46:14.288604 IP 172.16.1.3.1913 > 172.16.1.2.20001: S 1324697951:1324697951(0) win 5840 <mss 1460,wscale 2>
2 16:46:14.289549 IP 172.16.1.2.20001 > 172.16.1.3.1913: S 2866427156:2866427156(0) ack 1324697952 win 5792 <mss 1460,wscale 2>
3 16:46:14.288690 IP 172.16.1.3.1913 > 172.16.1.2.20001: . ack 1 win 1460
......
4 16:46:15.327493 IP 172.16.1.3.1913 > 172.16.1.2.20001: P 1321:1453(132) ack 1321 win 4140
5 16:46:15.329749 IP 172.16.1.2.20001 > 172.16.1.3.1913: P 1321:1453(132) ack 1453 win 2904
6 16:46:15.330001 IP 172.16.1.3.1913 > 172.16.1.2.20001: P 1453:2641(1188) ack 1453 win 4140
7 16:46:15.333629 IP 172.16.1.2.20001 > 172.16.1.3.1913: P 1453:1585(132) ack 2641 win 3498
8 16:46:15.337629 IP 172.16.1.2.20001 > 172.16.1.3.1913: P 1585:1717(132) ack 2641 win 3498
9 16:46:15.340035 IP 172.16.1.2.20001 > 172.16.1.3.1913: P 1717:1849(132) ack 2641 win 3498
10 16:46:15.371416 IP 172.16.1.3.1913 > 172.16.1.2.20001: . ack 1849 win 4140
11 16:46:15.371461 IP 172.16.1.2.20001 > 172.16.1.3.1913: P 1849:2641(792) ack 2641 win 3498
12 16:46:15.371581 IP 172.16.1.3.1913 > 172.16.1.2.20001: . ack 2641 win 4536
上表中的包,是在设置TCP_NODELAY,且cwnd已经增长到3的情况,第7、8、9发出后,受限于拥塞窗口大小,即使此时TCP缓冲区有数据可以发送亦不能继续发送,即第11个包必须等到第10个包到达后,才能发出,而第10个包明显有一个40ms的延迟。
注:通过getsockopt的TCP_INFO选项(man 7 tcp)可以查看TCP连接的详细信息,例如当前拥塞窗口大小,MSS等。
2、为什么是40ms?这个时间能不能调整呢?
首先在redhat的官方文档中,有如下说明:
一些应用在发送小的报文时,可能会因为TCP的Delayed Ack机制,导致一定的延迟。其值默认为40ms。可以通过修改tcp_delack_min,调整系统级别的最小延迟确认时间。例如:
# echo 1 > /proc/sys/net/ipv4/tcp_delack_min
即是期望设置最小的延迟确认超时时间为1ms。
不过在slackware和suse系统下,均未找到这个选项,也就是说40ms这个最小值,在这两个系统下,是无法通过配置调整的。
linux-2.6.39.1/net/tcp.h下有如下一个宏定义:
1: /* minimal time to delay before sending an ACK */
2: #define TCP_DELACK_MIN ((unsigned)(HZ/25))
注:Linux内核每隔固定周期会发出timer interrupt(IRQ 0),HZ是用来定义每秒有几次timer interrupts的。举例来说,HZ为1000,代表每秒有1000次timer interrupts。HZ可在编译内核时设置。在我们现有服务器上跑的系统,HZ值均为250。
以此可知,最小的延迟确认时间为40ms。
TCP连接的延迟确认时间一般初始化为最小值40ms,随后根据连接的重传超时时间(RTO)、上次收到数据包与本次接收数据包的时间间隔等参数进行不断调整。具体调整算法,可以参考linux-2.6.39.1/net/ipv4/tcp_input.c, Line 564的tcp_event_data_recv函数。
3、为什么TCP_QUICKACK需要在每次调用recv后重新设置?
在man 7 tcp中,有如下说明:
TCP_QUICKACK |
手册中明确描述TCP_QUICKACK不是永久的。那么其具体实现是如何的呢?参考setsockopt函数关于TCP_QUICKACK选项的实现:
1: case TCP_QUICKACK:
2: if (!val) {
3: icsk->icsk_ack.pingpong = 1;
4: } else {
5: icsk->icsk_ack.pingpong = 0;
6: if ((1 << sk->sk_state) &
7: (TCPF_ESTABLISHED | TCPF_CLOSE_WAIT) &&
8: inet_csk_ack_scheduled(sk)) {
9: icsk->icsk_ack.pending |= ICSK_ACK_PUSHED;
10: tcp_cleanup_rbuf(sk, 1);
11: if (!(val & 1))
12: icsk->icsk_ack.pingpong = 1;
13: }
14: }
15: break;
1: /* Congestion state accounting after a packet has been sent. */
2: static void tcp_event_data_sent(struct tcp_sock *tp,
3: struct sk_buff *skb, struct sock *sk)
4: {
5: ......
6: tp->lsndtime = now;
7: /* If it is a reply for ato after last received
8: * packet, enter pingpong mode.
9: */
10: if ((u32)(now - icsk->icsk_ack.lrcvtime) < icsk->icsk_ack.ato)
11: icsk->icsk_ack.pingpong = 1;
12: }
最后两行代码说明:如果当前时间与最近一次接受数据包的时间间隔小于计算的延迟确认超时时间,则重新进入交互数据流模式。也可以这么理解:延迟确认机制被确认有效时,会自动进入交互式。
通过以上分析可知,TCP_QUICKACK选项是需要在每次调用recv后重新设置的。
4、为什么不是所有包都延迟确认?
TCP实现里,用tcp_in_quickack_mode(linux-2.6.39.1/net/ipv4/tcp_input.c, Line 197)这个函数来判断是否需要立即发送ACK。其函数实现如下:
1: /* Send ACKs quickly, if "quick" count is not exhausted
2: * and the session is not interactive.
3: */
4: static inline int tcp_in_quickack_mode(const struct sock *sk)
5: {
6: const struct inet_connection_sock *icsk = inet_csk(sk);
7: return icsk->icsk_ack.quick && !icsk->icsk_ack.pingpong;
8: }
要求满足两个条件才能算是quickack模式:
1、pingpong被设置为0。
2、快速确认数(quick)必须为非0。
关于pingpong这个值,在前面有描述。而quick这个属性其代码中的注释为:scheduled number of quick acks,即快速确认的包数量,每次进入quickack模式,quick被初始化为接收窗口除以2倍MSS值(linux-2.6.39.1/net/ipv4/tcp_input.c, Line 174),每次发送一个ACK包,quick即被减1。
5、关于TCP_CORK选项
TCP_CORK选项与TCP_NODELAY一样,是控制Nagle化的。
1、打开TCP_NODELAY选项,则意味着无论数据包是多么的小,都立即发送(不考虑拥塞窗口)。
2、如果将TCP连接比喻为一个管道,那TCP_CORK选项的作用就像一个塞子。设置TCP_CORK选项,就是用塞子塞住管道,而取消TCP_CORK选项,就是将塞子拔掉。例如下面这段代码:
1: int on = 1;
2: setsockopt(sockfd, SOL_TCP, TCP_CORK, &on, sizeof(on)); //set TCP_CORK
3: write(sockfd, ...); //e.g., http header
4: sendfile(sockfd, ...); //e.g., http body
5: on = 0;
6: setsockopt(sockfd, SOL_TCP, TCP_CORK, &on, sizeof(on)); //unset TCP_CORK
当TCP_CORK选项被设置时,TCP链接不会发送任何的小包,即只有当数据量达到MSS时,才会被发送。当数据传输完成时,通常需要取消该选项,以便被塞住,但是又不够MSS大小的包能及时发出去。为提升性能及吞吐量,Web Server、文件服务器这一类一般会使用该选项。
著名的高性能Web服务器Nginx,在使用sendfile模式的情况下,可以设置打开TCP_CORK选项:将nginx.conf配置文件里的tcp_nopush配置为on。(TCP_NOPUSH与TCP_CORK两个选项实现功能类似,只不过NOPUSH是FreeBSD下的实现,而CORK是Linux下的选项)。另外Nginx为了减少系统调用,追求性能极致,针对短连接(一般传送完数据后,立即主动关闭连接,对于Keep-Alive的HTTP持久连接除外),程序并不通过setsockopt调用取消TCP_CORK选项,因为关闭连接会自动取消TCP_CORK选项,将剩余数据发出。
更多推荐
所有评论(0)