案例一:同事随手写个压力测试程序,其实现逻辑为:每秒钟先连续发N个132字节的包,然后连续收N个由后台服务回显回来的132字节包。其代码简化如下:
char sndBuf[132]; char rcvBuf[132]; while (1) { for (int i = 0; i < N; i++){ send(fd, sndBuf, sizeof(sndBuf), 0); ... } for (int i = 0; i < N; i++) { recv(fd, rcvBuf, sizeof(rcvBuf), 0); ... } sleep(1); }
在实际测试中发现,当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
。最终代码如下:
char sndBuf[132]; char rcvBuf[132]; while (1) { for (int i = 0; i < N; i++) { send(fd, sndBuf, 132, 0); ... } for (int i = 0; i < N; i++) { recv(fd, rcvBuf, 132, 0); setsockopt(fd, IPPROTO_TCP, TCP_QUICKACK, (int[]){1}, sizeof(int)); } sleep(1); }
案例二:在营销平台内存化CDKEY版本做性能测试时,发现请求时耗分布异常:90%的请求均在2ms以内,而10%左右时耗始终在38-42ms之间,这是一个很有规律的数字:40ms。因为之前经历过案例一,所以猜测同样是因为延迟确认机制引起的时耗问题,经过简单的抓包验证后,通过设置TCP_QUICKACK
选项,得以解决时延问题。
延迟确认机制
在《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 171.24.38.136.44792 > 175.24.11.18.9877: S 3512052379:3512052379(0) win 5840 <mss 1448,wscale 7> 2 00:44:37.878045 IP 175.24.11.18.9877 > 171.24.38.136.44792: S 3581620571:3581620571(0) ack 3512052380 win 5792 <mss 1460,wscale 2> 3 00:44:37.879080 IP 171.24.38.136.44792 > 175.24.11.18.9877: . ack 1 win 46 ...... 4 00:44:38.885325 IP 171.24.38.136.44792 > 175.24.11.18.9877: P 1321:1453(132) ack 1321 win 86 5 00:44:38.886037 IP 175.24.11.18.9877 > 171.24.38.136.44792: P 1321:1453(132) ack 1453 win 2310 6 00:44:38.887174 IP 171.24.38.136.44792 > 175.24.11.18.9877: P 1453:2641(1188) ack 1453 win 102 7 00:44:38.887888 IP 175.24.11.18.9877 > 171.24.38.136.44792: P 1453:2476(1023) ack 2641 win 2904 8 00:44:38.925270 IP 171.24.38.136.44792 > 175.24.11.18.9877: . ack 2476 win 118 9 00:44:38.925276 IP 175.24.11.18.9877 > 171.24.38.136.44792: P 2476:2641(165) ack 2641 win 2904 10 00:44:38.926328 IP 171.24.38.136.44792 > 175.24.11.18.9877: . ack 2641 win 134
从上面的tcpdump抓包分析看,第8个包是延迟确认的,而第9个包的数据,在Server端(175.24.11.18)虽然早就已放到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 178.14.5.3.1913 > 178.14.5.4.20001: S 1324697951:1324697951(0) win 5840 <mss 1460,wscale 2> 2 16:46:14.289549 IP 178.14.5.4.20001 > 178.14.5.3.1913: S 2866427156:2866427156(0) ack 1324697952 win 5792 <mss 1460,wscale 2> 3 16:46:14.288690 IP 178.14.5.3.1913 > 178.14.5.4.20001: . ack 1 win 1460 ...... 4 16:46:15.327493 IP 178.14.5.3.1913 > 178.14.5.4.20001: P 1321:1453(132) ack 1321 win 4140 5 16:46:15.329749 IP 178.14.5.4.20001 > 178.14.5.3.1913: P 1321:1453(132) ack 1453 win 2904 6 16:46:15.330001 IP 178.14.5.3.1913 > 178.14.5.4.20001: P 1453:2641(1188) ack 1453 win 4140 7 16:46:15.333629 IP 178.14.5.4.20001 > 178.14.5.3.1913: P 1453:1585(132) ack 2641 win 3498 8 16:46:15.337629 IP 178.14.5.4.20001 > 178.14.5.3.1913: P 1585:1717(132) ack 2641 win 3498 9 16:46:15.340035 IP 178.14.5.4.20001 > 178.14.5.3.1913: P 1717:1849(132) ack 2641 win 3498 10 16:46:15.371416 IP 178.14.5.3.1913 > 178.14.5.4.20001: . ack 1849 win 4140 11 16:46:15.371461 IP 178.14.5.4.20001 > 178.14.5.3.1913: P 1849:2641(792) ack 2641 win 3498 12 16:46:15.371581 IP 178.14.5.3.1913 > 178.14.5.4.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/tcpdelackmin
即是期望设置最小的延迟确认超时时间为1ms。
不过在slackware和suse系统下,均未找到这个选项,也就是说40ms这个最小值,在这两个系统下,是无法通过配置调整的。
linux-2.6.39.1/net/tcp.h
下有如下一个宏定义:
#define TCP_DELACK_MIN ((unsigned)(HZ/25)) /* minimal time to delay before sending an ACK */
注: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
`Enable quickack mode if set or disable quickack mode if cleared. In quickack mode, acks are sent immediately, rather than delayed if needed in accordance to normal TCP operation. This flag is not permanent, it only enables a switch to or from quickack mode. Subsequent operation of the TCP protocol will once again enter/leave quickack mode depending on internal protocol processing and factors such as delayed ack timeouts occurring and data transfer. This option should not be used in code intended to be portable.`
手册中明确描述TCP_QUICKACK
不是永久的。那么其具体实现是如何的呢?参考setsockopt
函数关于TCP_QUICKACK
选项的实现:
case TCP_QUICKACK: if (!val) { icsk->icsk_ack.pingpong = 1; } else { icsk->icsk_ack.pingpong = 0; if ((1 << sk->sk_state) & (TCPF_ESTABLISHED | TCPF_CLOSE_WAIT) && inet_csk_ack_scheduled(sk)) { icsk->icsk_ack.pending |= ICSK_ACK_PUSHED; tcp_cleanup_rbuf(sk, 1); if (!(val & 1)) icsk->icsk_ack.pingpong = 1; } } break;
其实linux下socket有一个pingpong属性来表明当前链接是否为交互数据流,如其值为1,则表明为交互数据流,会使用延迟确认机制。但是pingpong这个值是会动态变化的。例如TCP链接在要发送一个数据包时,会执行如下函数(linux-2.6.39.1/net/ipv4/tcp_output.c
, Line 156):
/* Congestion state accounting after a packet has been sent. */ static void tcp_event_data_sent(struct tcp_sock *tp,struct sk_buff *skb, struct sock *sk) { ...... tp->lsndtime = now; /* If it is a reply for ato after last received * packet, enter pingpong mode. */ if ((u32)(now - icsk->icsk_ack.lrcvtime) < icsk->icsk_ack.ato) icsk->icsk_ack.pingpong = 1; }
最后两行代码说明:如果当前时间与最近一次接受数据包的时间间隔小于计算的延迟确认超时时间,则重新进入交互数据流模式。也可以这么理解:延迟确认机制被确认有效时,会自动进入交互式。
通过以上分析可知,TCP_QUICKACK选项是需要在每次调用recv后重新设置的。
4.为什么不是所有包都延迟确认?
TCP实现里,用tcp_in_quickack_mode(linux-2.6.39.1/net/ipv4/tcp_input.c, Line 197)
这个函数来判断是否需要立即发送ACK。其函数实现如下:
/* Send ACKs quickly, if "quick" count is not exhausted * and the session is not interactive. */ static inline int tcp_in_quickack_mode(const struct sock *sk) { const struct inet_connection_sock *icsk = inet_csk(sk); return icsk->icsk_ack.quick && !icsk->icsk_ack.pingpong; }
要求满足两个条件才能算是quickack模式:
- pingpong被设置为0。
- 快速确认数(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化的。
- 打开
TCP_NODELAY
选项,则意味着无论数据包是多么的小,都立即发送(不考虑拥塞窗口)。 - 如果将TCP连接比喻为一个管道,那
TCP_CORK
选项的作用就像一个塞子。设置TCP_CORK
选项,就是用塞子塞住管道,而取消TCP_CORK
选项,就是将塞子拔掉。例如下面这段代码:
int on = 1; setsockopt(sockfd, SOL_TCP, TCP_CORK, &on, sizeof(on)); //set TCP_CORK write(sockfd, ...); //e.g., http header sendfile(sockfd, ...); //e.g., http body on = 0; setsockopt(sockfd, SOL_TCP, TCP_CORK, &on, sizeof(on)); //unset TCP_CORK
当TCP_CORK
选项被设置时,TCP链接不会发送任何的小包,即只有当数据量达到MSS时,才会被发送。当数据传输完成时,通常需要取消该选项,以便被塞住,但是又不够MSS大小的包能及时发出去。如果应用程序确定能一起发送多个数据集合(例如HTTP响应的头和正文),建议设置TCP_CORK
选项,这样在这些数据之间不存在延迟。为提升性能及吞吐量,Web Server、文件服务器这一类一般会使用该选项。
著名的高性能Web服务器Nginx,在使用sendfile模式的情况下,可以设置打开TCP_CORK选项:将nginx.conf配置文件里的tcp_nopush
配置为on。(TCP_NOPUSH
与TCP_CORK
两个选项实现功能类似,只不过NOPUSH是BSD下的实现,而CORK是Linux下的实现)。另外Nginx为了减少系统调用,追求性能极致,针对短连接(一般传送完数据后,立即主动关闭连接,对于Keep-Alive的HTTP持久连接除外),程序并不通过setsockopt
调用取消TCP_CORK选项,因为关闭连接会自动取消TCP_CORK选项,将剩余数据发出。
转载请注明出处:https://stgod.com/3970/