TCP ACK 延迟
今天开始学习Socket编程,但是上网查询的一些资料之后发现与之相关的知识太多了,所以我从基础看起,慢慢来,首先来看一下Delayed Ack 和 Nagle Algorithm的内容。
1.Delayed Ack
tcp协议规定在接受到数据段时需要向对方发送一个确认,但如果只是单纯的发送一个确认,代价会比较高(20字节的ip首部,20字节的tcp首部),最好能附带响应数据一起发送给对方.所以tcp在何时发送ack给对方有以下规定:
1) 当有响应数据要发送时,ack会随响数据立即发送给对方.
2) 如果没有响应数据,ack的发送将会有一个延迟,以等待看是否有响应数据可以一起发送,这称是"Delayed Ack".但这个延迟最多不会超过500ms,一般为200ms.如果在200ms内有数据要发送,那么ack会随数据一起立即发送给对方.注意这里的延迟200ms,不是指的从接受到对方数据到发送ack的最长等待时间差.而是指的内核启动的一个定时器,它每隔200ms就查看下是否有ack要发送.例如:假设定时器在0ms时启动,对方的数据段在
185ms时到达,那么ack最迟会在200ms时发送,而不是385ms时发送.
3) 如果在等待发送ack期间,对方的第二个数据段又到达了,这时要立即发送ack.但是如果对方的三个数据段相继到达,那么第二个数据段到达时ack立即发送,但第三个数据段到达时是否立即发送,则取决于上面两条.
2.Nagle Algorithm
当tcp协议用来传输小的数据段时代码是很高的,并且如果传输是在广域网上,那可能就会引起网络拥塞.Nagle算法就是用来解决这个问题.该算法要求一个TCP连接上最多只能有一个未被确认(未收到Ack确认)的未完成的小分组,在该分组的确认到达之前不能发送其他的小分组。相反TCP收集这些少量的分组,并在确认到来时以一个分组的方式发出去.Host Requirements RFC声明TCP必须实现Nagle算法,但必须为应用提供一种方法来关闭该算法在某个连接上执行。
纳格算法是合并(coalescing)一定数量的输出资料后一次送出。特别的是,只要有已送出的封包尚未确认,传送者会持续缓冲封包,直到累积一定数量的资料才送出。
算法如下如下:
if 有新资料要传送 if 讯窗大小 >= MSS and 可传送的资料 >= MSS 立刻传送完整MSS大小的segment else if 管线中有尚未确认的资料 在下一个确认(ACK)封包收到前,将资料排进缓冲区伫列 else 立即传送资料 (MSS=最大segment大小)
为什么要同时介绍这两个知识呢?
因为这两个技术同时使用的话会出现问题,下面来看一下问题的出现场景:
A 和B进行数据传输 : A运行Nagle算法,B运行delayed ACK算法
1. A->B 发一个packet(数据包), B不回应,delay ACK
2. A-> 再发一个packet(数据包)
3. B收到第二个packet(数据包),这时候会回应第一个packet(数据包),即第一个ACK
4. 假设这时候A里的数据已经<MSS,则A将停止发送数据,等待第二个packet(数据包)的ACK
此时问题就来了,因为A没有收到第二个packet的ACK确认,同时数据<MSS,由Nagle算法可以得知,这段数据将被被存到缓冲区等待发送,同时这时候B也在等A再发一个packet然后再回应一个ACK,所以这样A和B就发生了死锁了,但是Delayed Ack是有等待机制的,就是会等待500ms,一般是200ms,如果在这200ms内有数据数据要发送(ACK),就回应一个packet(数据包)的ACK,这样就会打破这种死锁的问题。即只有当200ms(或小于200ms)的延迟过后双方才会继续传输。
当然我们从上面可以看到这种等待机制还是有副作用的,那就是需要等待:一项数据表明:
在以太网上,传输100000字节仅需1ms,但由于delayed ack和nagle的作用却要花费201ms,这显然对程序的效率产生了很大影响.
对于这个问题的解决方案如下:
Nagle算法的立意是良好的,避免网络中充塞小封包,提高网络的利用率。但是当Nagle算法遇到delayed ACK悲剧就发生了。Delayed ACK的本意也是为了提高TCP性能,跟应答数据捎带上ACK,同时避免糊涂窗口综合症,也可以一个ack确认多个段来节省开销。悲剧发生在这种情况,假设一端发送数据并等待另一端应答,协议上分为头部和数据,发送的时候不幸地选择了write-write,然后再read,也就是先发送头部,再发送数据,最后等待应答。发送端的伪代码是这样:
write(head); write(body); read(response);
接收端的处理代码类似这样:
read(request); process(request); write(response);
这里假设head和body都比较小,当默认启用nagle算法,并且是第一次发送的时候,根据nagle算法,第一段head可以立即发送,因为没有等待确认的段;接收端收到head,但是包不完整(不足MMS),继续等待body达到并延迟ACK;发送端继续写入body,这时候nagle算法起作用了,因为head还没有被ACK,所以body要延迟发送。这就造成了发送端和接收端都在等待对方发送数据的现象,发送端等待接收端ACK head以便继续发送body,而接收端在等待发送方发送body并延迟ACK,悲剧的无以言语。这种时候只有等待一端超时并发送数据才能继续往下走。
正因为nagle算法和delayed ack的影响,再加上这种write-write-read的编程方式造成了很多网贴在讨论为什么自己写的网络程序性能那么差。然后很多人会在帖子里建议 禁用Nagle算法吧,设置TCP_NODELAY为true即可禁用nagle算法。但是这真的是解决问题的唯一办法和最好办法吗?
其实问题不是出在nagle算法身上的,问题是出在write-write-read这种应用编程上。禁用nagle算法可以暂时解决问题,但是禁用 nagle算法也带来很大坏处,网络中充塞着小封包,网络的利用率上不去,在极端情况下,大量小封包导致网络拥塞甚至崩溃。因此,能不禁止还是不禁止的 好,后面我们会说下什么情况下才需要禁用nagle算法。对大多数应用来说,一般都是连续的请求——应答模型,有请求同时有应答,那么请求包的ACK其实可以延迟到跟响应一起发送,在这种情况下,其实你只要避免write-write-read形式的调用就可以避免延迟现象,利用writev做聚集写或者 将head和body一起写,然后再read,变成write-read-write-read的形式来调用,就无需禁用nagle算法也可以做到不延迟。
writev是系统调用,在Java里是用到GatheringByteChannel.write(ByteBuffer[] srcs, int offset, int length)方法来做聚集写。这里可能还有一点值的提下,很多同学看java nio框架几乎都不用这个writev调用,这是有原因的。主要是因为Java的write本身对ByteBuffer有做临时缓存,而writev没有做缓存,导致测试来看write反而比writev更高效,因此通常会更推荐用户将head和body放到同一个Buffer里来避免调用writev。
下面我们将做个实际的代码测试来结束讨论。这个例子很简单,客户端发送一行数据到服务器,服务器简单地将这行数据返回。客户端发送的时候可以选择分两次发,还是一次发送。分两次发就是write-write-read,一次发就是write-read-write-read,可以看看两种形式下延迟的差 异。注意,在windows上测试下面的代码,客户端和服务器必须分在两台机器上,似乎winsock对loopback连接的处理不一样。
服务器源码:
package net.fnil.nagle; import java.io.BufferedReader; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.net.InetSocketAddress; import java.net.ServerSocket; import java.net.Socket; public class Server { public static void main(String[] args) throws Exception { ServerSocket serverSocket = new ServerSocket(); serverSocket.bind(new InetSocketAddress(8000)); System.out.println("Server startup at 8000"); for (;;) { Socket socket = serverSocket.accept(); InputStream in = socket.getInputStream(); OutputStream out = socket.getOutputStream(); while (true) { try { BufferedReader reader = new BufferedReader(new InputStreamReader(in)); String line = reader.readLine(); out.write((line + "\r\n").getBytes()); } catch (Exception e) { break; } } } } }
服务端绑定到本地8000端口,并监听连接,连上来的时候就阻塞读取一行数据,并将数据返回给客户端。
客户端代码:
package net.fnil.nagle; import java.io.BufferedReader; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.net.InetSocketAddress; import java.net.Socket; public class Client { public static void main(String[] args) throws Exception { // 是否分开写head和body boolean writeSplit = false; String host = "localhost"; if (args.length >= 1) { host = args[0]; } if (args.length >= 2) { writeSplit = Boolean.valueOf(args[1]); } System.out.println("WriteSplit:" + writeSplit); Socket socket = new Socket(); socket.connect(new InetSocketAddress(host, 8000)); InputStream in = socket.getInputStream(); OutputStream out = socket.getOutputStream(); BufferedReader reader = new BufferedReader(new InputStreamReader(in)); String head = "hello "; String body = "world\r\n"; for (int i = 0; i < 10; i++) { long label = System.currentTimeMillis(); if (writeSplit) { out.write(head.getBytes()); out.write(body.getBytes()); } else { out.write((head + body).getBytes()); } String line = reader.readLine(); System.out.println("RTT:" + (System.currentTimeMillis() - label) + " ,receive:" + line); } in.close(); out.close(); socket.close(); } }
客户端通过一个writeSplit变量来控制是否分开写head和body,如果为true,则先写head再写body,否则将head加上body一次写入。客户端的逻辑也很简单,连上服务器,发送一行,等待应答并打印RTT,循环10次最后关闭连接。
首先,我们将writeSplit设置为true,也就是分两次写入一行,在我本机测试的结果,我的机器是ubuntu 11.10:
WriteSplit:true
RTT:8 ,receive:hello world
RTT:40 ,receive:hello world
RTT:40 ,receive:hello world
RTT:40 ,receive:hello world
RTT:39 ,receive:hello world
RTT:40 ,receive:hello world
RTT:40 ,receive:hello world
RTT:40 ,receive:hello world
RTT:40 ,receive:hello world
RTT:40 ,receive:hello world
可以看到,每次请求到应答的时间间隔都在40ms,除了第一次。linux的delayed ack是40ms,而不是原来以为的200ms。第一次立即ACK,似乎跟linux的quickack mode有关,这里我不是特别清楚,有比较清楚的同学请指教。
接下来,我们还是将writeSplit设置为true,但是客户端禁用nagle算法,也就是客户端代码在connect之前加上一行:
Socket socket = new Socket(); socket.setTcpNoDelay(true); socket.connect(new InetSocketAddress(host, 8000));
再跑下测试:
WriteSplit:true
RTT:0 ,receive:hello world
RTT:0 ,receive:hello world
RTT:0 ,receive:hello world
RTT:0 ,receive:hello world
RTT:1 ,receive:hello world
RTT:0 ,receive:hello world
RTT:0 ,receive:hello world
RTT:0 ,receive:hello world
RTT:0 ,receive:hello world
RTT:0 ,receive:hello world
这时候就正常多了,大部分RTT时间都在1毫秒以下。果然禁用Nagle算法可以解决延迟问题。如果我们不禁用nagle算法,而将writeSplit设置为false,也就是将head和body一次写入,再次运行测试(记的将setTcpNoDelay这行删除):
WriteSplit:false
RTT:7 ,receive:hello world
RTT:1 ,receive:hello world
RTT:0 ,receive:hello world
RTT:0 ,receive:hello world
RTT:0 ,receive:hello world
RTT:0 ,receive:hello world
RTT:0 ,receive:hello world
RTT:0 ,receive:hello world
RTT:0 ,receive:hello world
RTT:0 ,receive:hello world
结果跟禁用nagle算法的效果类似。既然这样,我们还有什么理由一定要禁用nagle算法呢?通过我在xmemcached的 压测中的测试,启用nagle算法在小数据的存取上甚至有一定的效率优势,memcached协议本身就是个连续的请求应答的模型。上面的测试如果在 windows上跑,会发现RTT最大会在200ms以上,可见winsock的delayed ack超时是200ms。
最后一个问题,什么情况下才应该禁用nagle算法?
当你的应用不是这种连续的请求——应答模型,而是需要实时地单向发送很多小数据的时候或者请求是有间隔的,则应该禁用nagle算法来提高响应性。一个最明显是例子是telnet应用,你总是希望敲入一行数据后能立即发送给服务器,然后马上看到应答,而不是说我要连续敲入很多命令或者等待200ms才能看到应答。
上面是我对nagle算法和delayed ack的理解和测试,有错误的地方请不吝赐教。