连续发送多份小数据时40ms延迟问题

连续发送多份小数据时40ms延迟问题

以及TCP_NODELAY、TCP_CORK失效问题的定位与解决

提到TCP_NODELAY和TCP_CORK,相信很多人都很熟悉。然而由于Linux实现上的问题,这两个参数在实际使用中,并不像书里介绍的那么简单。最近DTS在解决一个TCP超时问题时,对这两个参数和它们背后所隐藏的问题有了比较深刻的认识,在此与同学们分享一下我们的经验和教训。

问题描述

和许多经典的分布式程序类似,DTS使用TCP长连接用于client和server的数据交互:client发送请求给server,然后等待server回应。有时候出于数据结构上的考虑,client需要先连续发送多份数据,再等待server的回应。测试发现这种情况下,server端有时会出现接收数据延迟。比如说某个case里,client会先发送275个字节,接着发送24个字节,然后再发送292字节数据等等;此时如果该TCP连接被复用过,则server端在收取24字节这批数据时会很容易出现40ms延迟。

由于client每次发送的数据都很小,很自然想到是nagle算法延迟了client端的数据发送,于是在client端和server端都设置了TCP_NODELAY。然而测试发现,此时server虽然顺利接受了24字节数据,却在接受随后292字节数据时依然出现了40ms延迟。难道是数据太多导致TCP_NODELAY失效?因此又在client端添加了TCP_CORK选项:即如果client需要连续发送多次数据,则先关闭TCP_NODELAY,打开TCP_CORK;所有数据write完后,再关闭TCP_CORK,打开TCP_NODELAY。按照设想,client应该会把所有数据打包在一起发送,但测试结果依然和以前一样,server还是在收取第三份数据时出现了40ms的延迟。

不得已使用tcpdump进行分析,结果如下:

1
2
3
4
18:18:01.640134 IP jx-dp-wk11.jx.baidu.com.36989 > tc-dpf-rd-in.tc.baidu.com.licensedaemon: P 551:826(275) ack 141 win 1460 <nop,nop,timestamp 2551499424 1712127318>
18:18:01.640151 IP jx-dp-wk11.jx.baidu.com.36989 > tc-dpf-rd-in.tc.baidu.com.licensedaemon: P 826:850(24) ack 141 win 1460 <nop,nop,timestamp 2551499424 1712127318>
18:18:01.680812 IP tc-dpf-rd-in.tc.baidu.com.licensedaemon > jx-dp-wk11.jx.baidu.com.36989: . ack 850 win 2252 <nop,nop,timestamp 1712127359 2551499424>
18:18:01.680818 IP jx-dp-wk11.jx.baidu.com.36989 > tc-dpf-rd-in.tc.baidu.com.licensedaemon: P 850:1142(292) ack 141 win 1460 <nop,nop,timestamp 2551499465 1712127359>

注意红色的部分,可见client并没有将所有数据打成一个包,每次write的数据还是作为单独的包发送;此外,client在发送完24字节的数据后,一直等到server告知ack才接着发送剩下的292字节。由于server延迟了40ms才告知ack,因此导致了其接收292字节数据时也出现了40ms延迟。

既然查出了延迟是server端delayed ack的原因,通过设置server端TCP_QUICKACK,40ms延迟的问题得到了解决。

原因定位

虽然DTS的延时问题暂时得到了解决,但其内在原因却使人百思不得其解:为什么TCP_NODELAY会失效?为什么TCP_CORK无作为?…… 在STL同学的帮助下,我们逐渐对这些困惑有了答案。

首先介绍下delayed ack算法:当协议栈接受到TCP数据时,并不一定会立刻发送ACK响应,而是倾向于等待一个超时或者满足特殊条件时再发送。对于Linux实现,这些特殊条件如下:

1)收到的数据已经超过了full frame size

2)或者处于快速回复模式

3)或者出现了乱序的包

4)或者接收窗口的数据足够多

如果接收方有数据回写,则ACK也会搭车一起发送。当以上条件都不满足时,接收方会延迟40ms再回应ACK。

1.为什么TCP_NODELAY失效

UNIX网络编程这本书介绍说,TCP_NODELAY同时禁止了nagle算法和delayed ACK算法,因此小块数据可以直接发送。然而Linux实现中,TCP_NODELAY只禁止了nagle算法。另一方面,协议栈在发送包的时候,不仅受到TCP_NODELAY的影响,还受到协议栈里面拥塞窗口的影响。由于server端delayed ack,client迟迟无法收到ack应答,拥塞窗口堵满,从而无法继续发送更多数据;一直到40ms后ack达到,才能继续发送(题外话: TCP_NODELAY在FREEBSD上性能优于Linux上,因为FREEBSD并不像Linux一样需要第一个包到达后就响应ACK)。

这也解释了为什么延时现象在重用过的TCP连接上特别容易出现:目前使用的52bs内核中,连接刚建立时拥塞窗口默认是3,因此可以发送3个数据包,而后拥塞窗口变为2,就会导致第3个292字节的包发不出去。

2.为什么TCP_CORK失效

TCP_CORK会将发送端多份数据打成一个包,待到TCP_CORK关闭后一起发送。Linux Man手册上也描述了TCP_CORK选项和TCP_NODELAY一起使用的情形。然而根据之前tcpdump的结果,client端设置TCP_CORK后并没有发挥效果。继续测试发现,只要设置过TCP_NODELAY选项,即使随后关闭也会导致TCP_CORK无效;如果从未设置过TCP_NODELAY,则TCP_CORK可以产生效果。

根据STL同学对协议栈代码的调研,发现这个是Linux实现上的问题。在内核中,设置启动TCP_NODELAY选项后,内核会为socket增加两个标志位TCP_NAGLE_OFF和TCP_NAGLE_PUSH,关闭TCP_NODELAY的时候,内核只去掉了TCP_NAGLE_OFF标志位。而在发包的时候判断的却恰恰是TCP_NAGLE_PUSH标志位,如果该位置位设置,就直接把包发出去,从而导致TCP_CORK发挥不了作用。这很可能是这一版本Linux内核实现上的bug。

3.TCP_QUICKACK的作用和限制

前面介绍delayed ack算法时,讲到协议栈迅速回复ack的情形之一就是进入到快速回复模式。而TCP_QUICKACK选项就是向内核建议进入快速回复模式。快速回复ack模式的判断条件如下:(tp->ack.quick && tp->ack.pingpong),其中设置QUICKACK选项会置pingpong=0。

然而,随着TCP连接的重用和数据的不断收发,快速回复模式有可能失效。例如在后续的交互过程当中,pingpong变为1的条件就有:1.收到fin后;2. 发送方发送数据时,发现当前时间与上次接收数据的时间小于40ms。此外,发送方发现数据包带有ack标志位时,也会减小ack.quick值。这些都会导致快速回复模式的退出。因此,即使每次接受数据前都设置TCP_QUICKACK选项,也不能完全解决delayed ack问题。

解决方案

经过上述的测试与分析,可以认识到当连续发送多个小数据时,TCP_NODELAY作用并不明显,TCP_CORK无法像宣传的那样和TCP_NODELAY混合使用,而TCP_QUICKACK也不能完全解决问题。因此,我们最终的解决方案如下:

(1)在client端多次发送数据时,先打开TCP_CORK选项,发送完后再关闭TCP_CORK,将多份小数据打成一个包发送;此外,client端不能设置TCP_NODELAY选项,以避免TCP_CORK失效。

(2)server端开启TCP_QUICKACK选项,尽量快速回复ack。

通过这个延时问题的解决,可以看到由于Linux实现策略上的问题,TCP_NODELAY和TCP_CORK还是暗藏了不少陷阱。实际应用中,其实也可以绕过这些参数,在应用层将多份数据序列化到一个buffer中,或者使用writev系列函数。然而,这些方法需要额外的内存拷贝,或者让传输对象对外暴露过多的数据结构信息,并不一定容易实现,也会添加代码重构的代价。

另一方面,考虑到那些使用TCP进行异步请求的应用,由于多个请求需要同时复用一个TCP连接,也很容易出现延时问题;而无论是通过TCP_CORK还是writev哪种方法,都不太适合这种异步场景。最近STL推出的新内核添加了一个禁止delayed ack的系统参数,使用该参数理论上讲可以彻底根除40ms的延迟问题。

以上内容转自这里