日期:2014-05-16  浏览次数:20793 次

LInux Tcp 延迟确认问题

案例一:同事随手写个压力测试程序,其实现逻辑为:每秒钟先连续发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 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,则不增加