Search K
Appearance
Appearance
这篇文章我们来介绍延迟确认。
首先必须明确两个观点:
如果收到一个数据包以后暂时没有数据要分给对端,它可以等一段时间(Linux 上是 40ms)再确认。如果这段时间刚好有数据要传给对端,ACK 就可以随着数据一起发出去了。如果超过时间还没有数据要发送,也发送 ACK,以免对端以为丢包了。这种方式成为「延迟确认」。
这个原因跟 Nagle 算法其实一样,回复一个空的 ACK 太浪费了。
这种机制被称为延迟确认(delayed ack),思破哥的文章把延迟确认(delayed-ack)称为「磨叽姐」,挺形象的。TCP 要求 ACK 延迟的时延必须小于 500ms,一般操作系统实现都不会超过 200ms。
延迟确认在很多 linux 机器上是没有办法关闭的,
那么这里涉及的就是一个非常根本的问题:「收到数据包以后什么时候该回复 ACK」
static void __tcp_ack_snd_check(struct sock *sk, int ofo_possible)
{
struct tcp_sock *tp = tcp_sk(sk);
/* More than one full frame received... */
if (((tp->rcv_nxt - tp->rcv_wup) > tp->ack.rcv_mss
/* ... and right edge of window advances far enough.
* (tcp_recvmsg() will send ACK otherwise). Or...
*/
&& __tcp_select_window(sk) >= tp->rcv_wnd) ||
/* We ACK each frame or... */
tcp_in_quickack_mode(tp) ||
/* We have out of order data. */
(ofo_possible &&
skb_peek(&tp->out_of_order_queue))) {
/* Then ack it now */
tcp_send_ack(sk);
} else {
/* Else, send delayed ack. */
tcp_send_delayed_ack(sk);
}
}可以看到需要立马回复 ACK 的场景有:
其它情况一律使用延迟确认的方式
需要重点关注的是:tcp_in_quickack_mode()
/* Send ACKs quickly, if "quick" count is not exhausted
* and the session is not interactive.
*/
static __inline__ int tcp_in_quickack_mode(struct tcp_sock *tp)
{
return (tp->ack.quick && !tp->ack.pingpong);
}
/* Delayed ACK control data */
struct {
__u8 pending; /* ACK is pending */
__u8 quick; /* Scheduled number of quick acks */
__u8 pingpong; /* The session is interactive */
__u8 blocked; /* Delayed ACK was blocked by socket lock*/
__u32 ato; /* Predicted tick of soft clock */
unsigned long timeout; /* Currently scheduled timeout */
__u32 lrcvtime; /* timestamp of last received data packet*/
__u16 last_seg_size; /* Size of last incoming segment */
__u16 rcv_mss; /* MSS used for delayed ACK decisions */
} ack;内核 tcp_sock 结构体中有一个 ack 子结构体,内部有一个 quick 和 pingpong 两个字段,其中 pingpong 就是判断交互连接的,只有处于非交互 TCP 连接才有可能即进入 quickack 模式。
什么是交互式和 pingpong 呢?
顾名思义,其实有来有回的双向数据传输就叫 pingpong,对于通信的某一端来说,R-W-R-W-R-W…(R 表示读,W 表示写)
延迟确认出现的最多的场景是 W-W-R(写写读),我们来分析一下这种场景。
可以用一段 java 代码演示延迟确认。
服务端代码如下,当从服务端 readLine 有返回非空字符串(读到 \n 或 \r)就把字符串原样返回给客户端
public class DelayAckServer {
private static final int PORT = 8888;
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket();
serverSocket.bind(new InetSocketAddress(PORT));
System.out.println("Server startup at " + PORT);
while (true) {
Socket socket = serverSocket.accept();
InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream();
int i = 1;
while (true) {
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
String line = reader.readLine();
if (line == null) break;
System.out.println((i++) + " : " + line);
outputStream.write((line + "\n").getBytes());
}
}
}
}下面是客户端代码,客户端分两次调用 write 方法,模拟 http 请求的 header 和 body。第二次 write 包含了换行符(\n),然后测量 write、write、read 所花费的时间。
public class DelayAckClient {
public static void main(String[] args) throws IOException {
Socket socket = new Socket();
socket.connect(new InetSocketAddress("server_ip", 8888));
InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
String head = "hello, ";
String body = "world\n";
for (int i = 0; i < 10; i++) {
long start = System.currentTimeMillis();
outputStream.write(("#" + i + " " + head).getBytes());
outputStream.write((body).getBytes());
String line = reader.readLine();
System.out.println("RTT: " + (System.currentTimeMillis() - start) + ": " + line);
}
inputStream.close();
outputStream.close();
socket.close();
}
}运行结果如下
javac DelayAckClient.java; java -cp . DelayAckClient
RTT: 1:
RTT: 44:
RTT: 46:
RTT: 44:
RTT: 42:
RTT: 41:
RTT: 41:
RTT: 44:
RTT: 44:
RTT: 44:除了第一次,剩下的 RTT 全为 40 多毫秒。这刚好是 Linux 延迟确认定时器的时间 40ms 抓包结果如下:

对包逐个分析一下 1 ~ 3:三次握手 4 ~ 9:第一次 for 循环的请求,也就是 W-W-R 的过程
10 ~ 14:第二次 for 循环
从第二次 for 开始,后面的数据包都一样了。整个过程包交互图如下:

--tolerance_usecs=100000
0.000 socket(..., SOCK_STREAM, IPPROTO_TCP) = 3
0.000 setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
0.000 bind(3, ..., ...) = 0
0.000 listen(3, 1) = 0
0.000 < S 0:0(0) win 32792 <mss 1000, sackOK, nop, nop, nop, wscale 7>
0.000 > S. 0:0(0) ack 1 <...>
0.000 < . 1:1(0) ack 1 win 257
0.000 accept(3, ..., ...) = 4
+ 0 setsockopt(4, SOL_TCP, TCP_NODELAY, [1], 4) = 0
// 模拟往服务端写入 HTTP 头部: POST / HTTP/1.1
+0 < P. 1:11(10) ack 1 win 257
// 模拟往服务端写入 HTTP 请求 body: {"id": 1314}
+0 < P. 11:26(15) ack 1 win 257
// 往 fd 为4 的 模拟服务器返回 HTTP response {}
+ 0 write(4, ..., 100) = 100
// 第二次模拟往服务端写入 HTTP 头部: POST / HTTP/1.1
+0 < P. 26:36(10) ack 101 win 257
// 抓包看服务器返回
+0 `sleep 1000000`这个构造包的过程跟前面的思路是一模一样的,抓包同样复现了 40ms 延迟的现象。

这个是我刚开始学习 TCP 的一个疑惑,既然是 TCP 的一个特性,那有没有一个开关可以开启或者关闭延迟确认呢?答案是否定的,大部分 Linux 实现上并没有开关可以关闭延迟确认。我曾经以为它是一个 sysctl 项,可是后来找了很久都没有找到,没有办法通过一个配置彻底关掉或者开启 Linux 的延迟确认。
Nagle 算法和延迟确认本身并没有什么问题,但一起使用就会出现很严重的性能问题了。Nagle 攒着包一次发一个,延迟确认收到包不马上回。
如果我们把上面的 Java 代码稍作调整,禁用 Nagle 算法可以试一下。
Socket socket = new Socket();
socket.setTcpNoDelay(true); // 禁用 Nagle 算法
socket.connect(new InetSocketAddress("server ip", 8888));运行 Client 端,可以看到 RTT 几乎为 0
RTT: 1:
RTT: 0:
RTT: 1:
RTT: 1:
RTT: 0:
RTT: 1:
RTT: 1:
RTT: 0:
RTT: 1:
RTT: 0:抓包结果如下

黑色背景部分的是客户端发送给服务端的请求包,可以看到在禁用 Nagle 的情况下,不用等一个包发完再发下一个,而是几乎同时把两次写请求发送出来了。服务端收到带换行符的包以后,立马可以返回结果,ACK 可以捎带过去,就不会出现延迟 40ms 的情况。
这篇文章主要介绍了延迟确认出现的背景和原因,然后用一个实际的代码演示了延迟确认的具体的细节。到这里 Nagle 算法和延迟确认这两个主题就介绍完毕了。