TCP之Delay ACK在Linux和Windows上实现的异同-Linux的自适应ACK

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了TCP之Delay ACK在Linux和Windows上实现的异同-Linux的自适应ACK相关的知识,希望对你有一定的参考价值。

关于TCP Delay ACK的概念我就不多说了,到处复制粘贴标准文献以及别人的文章只能让本文篇幅加长而降低被阅读完的欲望,再者这也不是什么论文,附录参考文献几乎很少有人去看,所以我把这些都略过了。

和风吹的干皮鞋,吹的断愁绪吗?

写完本文后的补充:

这段话是我写完本文后补上去的。本来我想把这篇文章控制在2000字以内,或者更少的,800-1000字以内,无奈还是说多了...今天心情非常好,因为我竟然在梦里把三亨利之战的细节搞清楚了,迄今,我觉得自己对于西洋史的认知更近了一步,可以说今天是一个里程碑!我本来是想写一篇关于欧洲王朝史的文章的,可是没有太多的时间,只能延期,然后觉得自己事实上是一个搞IT的,并不是什么历史系的科班,于是回归正途,写一篇TCP的吧。
        当一个人常常自己喝淡酒,并且不喝醉,那么这喝的便不再是酒了,而是情怀!

序言

很多人在提到Delay ACK的时候,都会认为它既然是一个TCP特性,那就必然存在一个开关,可以随意开启或者关闭,以下是一些显然的想法:
1.系统中有一个开关,比如sysctl或者Windows注册表项,可以开启本机的TCP Delay ACK特性;
2.系统的编程API中提供了socket选项,可以通过setsockopt来开启或者关闭某一条连接的TCP Delay ACK特性。
3.某些操作系统或者某些系统版本可能不支持TCP Delay ACK选项。

持这些想法当然没有错,但是如果从另一个角度去理解Delay ACK话,可能就会有另一种想法了,而Linux正是这另一种想法的体现,当然,这也是本文的主题。

Delay ACK的实质

Delay ACK是什么?这是一个伪问题!解释概念往往非常容易,但对理解问题却是毫无益处。我们应该问:为什么会让一个针对接收到数据的ACK延迟发送?有人认为是为了减少网络上的ACK流量,有人说是为了给发送端一点突发的机会所以要积累ACK再发送,在ACK延迟的这段时间,发送端可能已经积累了足够的数据,而这对于提高长肥管道的吞吐率是有益处的。
...
也许你会认为我马上要长篇大论一通关于Nagle算法以及Write-Write-Read算法的细节了,事实上不!我们只需要知道,Delay ACK以及Nagle算法是针对特定场景的,不光是Delay ACK,不光是Nagle算法,所有的TCP算法,包括那些拥塞控制算法,都是针对特定场景的,没有放之四海而皆准的TCP算法!
        在一个TCP连接启动的时候,没有人可以预知该TCP连接后续的交互模式以及数据发送序列(除非你是在做重放实验!!),因此如果你开启了Delay ACK,但是恰恰遇到了并不适合开启Delay ACK的场景,比如遇到了Write-Write-Read,那岂不是会吃亏?那么一个问题摆在了人们面前:
到底是开启Delay ACK好呢?还是关闭它好呢?

正文

请记住上一节序言的最后的那个问题,本文将围绕它展开。本文不会去分析Delay ACK会造成问题(比如Write-Write-Read这种)的各种场景,而仅仅从以下一个场景开始去展开。该场景是我自己构造的,旨在解剖两类经典的Delay ACK的实现机制。
        先看场景吧。But,场景前有个声明。

关于Delay ACK的触发声明

一般而言,如果TCP接收端收到了超过一个MSS大小的数据,无论怎样都会立即回复一个ACK,这个2个MSS大小阈值是为了平衡延迟和吞吐,我不知道为什么会选为2个MSS,可能是经验值,也可能是大牛大傻逼拍脑袋拍出来的值。所以,为了简单起见,我接下来的论述中,每次(对于理解Linux内核的而言,就是每个传输的skb)所传输的数据长度均不会大于1000,我所有实验的MTU均为1500,也就是说,每次传输的数据长度均不会大于1个MSS,这样就不用考虑Delay ACK与MSS的关系了。
因此,下文所有情况中,如果按照标准的Delay ACK的理解,所有的传输均会触发接收端Delay ACK!

一个场景

我部署了3份代码在三台机器:
1.Server_Windows.c部署在一台Win7机器上
#undef UNICODE

#define WIN32_LEAN_AND_MEAN

#include <windows.h>
#include <winsock2.h>
#include <ws2tcpip.h>
#include <stdlib.h>
#include <stdio.h>

// Need to link with Ws2_32.lib
#pragma comment (lib, "Ws2_32.lib")

#define DEFAULT_PORT "8080"

int __cdecl main(void) 
{
    WSADATA wsaData;
    int iResult;

    SOCKET ListenSocket = INVALID_SOCKET;
    SOCKET ClientSocket = INVALID_SOCKET;

    struct addrinfo *result = NULL;
    struct addrinfo hints;

    int iSendResult;

    // Initialize Winsock
    iResult = WSAStartup(MAKEWORD(2,2), &wsaData);
    if (iResult != 0) {
        printf("WSAStartup failed with error: %d\n", iResult);
        return 1;
    }

    ZeroMemory(&hints, sizeof(hints));
    hints.ai_family = AF_INET;
    hints.ai_socktype = SOCK_STREAM;
    hints.ai_protocol = IPPROTO_TCP;
    hints.ai_flags = AI_PASSIVE;

    // Resolve the server address and port
    iResult = getaddrinfo(NULL, DEFAULT_PORT, &hints, &result);
    if ( iResult != 0 ) {
        printf("getaddrinfo failed with error: %d\n", iResult);
        WSACleanup();
        return 1;
    }

    // Create a SOCKET for connecting to server
    ListenSocket = socket(result->ai_family, result->ai_socktype, result->ai_protocol);
    if (ListenSocket == INVALID_SOCKET) {
        printf("socket failed with error: %ld\n", WSAGetLastError());
        freeaddrinfo(result);
        WSACleanup();
        return 1;
    }

    // Setup the TCP listening socket
    iResult = bind( ListenSocket, result->ai_addr, (int)result->ai_addrlen);
    if (iResult == SOCKET_ERROR) {
        printf("bind failed with error: %d\n", WSAGetLastError());
        freeaddrinfo(result);
        closesocket(ListenSocket);
        WSACleanup();
        return 1;
    }

    freeaddrinfo(result);

    iResult = listen(ListenSocket, SOMAXCONN);
    if (iResult == SOCKET_ERROR) {
        printf("listen failed with error: %d\n", WSAGetLastError());
        closesocket(ListenSocket);
        WSACleanup();
        return 1;
    }

    // Accept a client socket
    ClientSocket = accept(ListenSocket, NULL, NULL);
    if (ClientSocket == INVALID_SOCKET) {
        printf("accept failed with error: %d\n", WSAGetLastError());
        closesocket(ListenSocket);
        WSACleanup();
        return 1;
    }
    int v = 1;
	//setsockopt(ClientSocket, IPPROTO_TCP, TCP_QUICKACK, &v, 4);
    // No longer need server socket
    closesocket(ListenSocket);

    // Receive until the peer shuts down the connection
    do {
		char buff1[1] = {0};
		char buff2[999] = {0};
		char buff3[1000] = {0};
		char buff4[2000] = {0};
		iSendResult = send( ClientSocket, buff1, sizeof(buff1), 0 );
        iResult = recv(ClientSocket, buff4, 700, 0);
		iResult = recv(ClientSocket, buff4, 300, 0);
		iResult = recv(ClientSocket, buff4, 1000, 0);
        iSendResult = send( ClientSocket, buff2, sizeof(buff2), 0 );
        iResult = recv(ClientSocket, buff4, 700, 0);
        iResult = recv(ClientSocket, buff4, 300, 0);
        iResult = recv(ClientSocket, buff4, 700, 0);
        iResult = recv(ClientSocket, buff4, 300, 0);
        iSendResult = send( ClientSocket, buff3, sizeof(buff3), 0 );
        iResult = recv(ClientSocket, buff4, 700, 0);
        iResult = recv(ClientSocket, buff4, 300, 0);
        iResult = recv(ClientSocket, buff4, 700, 0);
        iResult = recv(ClientSocket, buff4, 300, 0);
        iResult = recv(ClientSocket, buff4, 700, 0);
        iResult = recv(ClientSocket, buff4, 300, 0);
        iResult = recv(ClientSocket, buff4, 700, 0);
        iResult = recv(ClientSocket, buff4, 300, 0);
    } while (0);
    
	sleep(10);
    // shutdown the connection since we‘re done
    iResult = shutdown(ClientSocket, SD_SEND);
    if (iResult == SOCKET_ERROR) {
        printf("shutdown failed with error: %d\n", WSAGetLastError());
        closesocket(ClientSocket);
        WSACleanup();
        return 1;
    }

    // cleanup
    closesocket(ClientSocket);
    WSACleanup();

    return 0;
}

我通过Dev-C++将其编译为Server.exe。代码来自MSDN,我只是修改了了数据的收发序列。
2.Server_Linux.c部署在一台Linux CentOS机器上
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>
#include<arpa/inet.h>
#include<string.h>
#include <netinet/in.h>
#include <netinet/tcp.h>

#define DEFAULT_PORT "8080"

int main(void) 
{
    int iResult;

    int ListenSocket;
    int ClientSocket;

    struct sockaddr_in server;

    int iSendResult;
    char recvbuf[2000];
    int recvbuflen = 2000;
    

    memset(&server, 0, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_addr.s_addr = INADDR_ANY;
    server.sin_port = htons(8080);

    // Create a SOCKET for connecting to server
    ListenSocket = socket(AF_INET , SOCK_STREAM , 0);
    if (ListenSocket == -1) {
        return 1;
    }

    // Setup the TCP listening socket
    iResult = bind( ListenSocket, (struct sockaddr *)&server , sizeof(server));
    if (iResult < 0) {
        perror("bind");
        close(ListenSocket);
        return 1;
    }


    iResult = listen(ListenSocket, 3);
    if (iResult == -1) {
        close(ListenSocket);
        return 1;
    }

    // Accept a client socket
    ClientSocket = accept(ListenSocket, NULL, NULL);
    if (ClientSocket == -1) {
        close(ListenSocket);
        return 1;
    }
    int v = 0;
    setsockopt(ClientSocket, IPPROTO_TCP, TCP_QUICKACK, &v, 4);
    // No longer need server socket
    close(ListenSocket);

    // Receive until the peer shuts down the connection
    do {
        char buff1[1] = {0};
        char buff2[999] = {0};
        char buff3[1000] = {0};
        char buff4[2000] = {0};
        iSendResult = send( ClientSocket, buff1, sizeof(buff1), 0 );
        iResult = recv(ClientSocket, buff4, 700, 0);
        iResult = recv(ClientSocket, buff4, 300, 0);
        iResult = recv(ClientSocket, buff4, 1000, 0);
        iSendResult = send( ClientSocket, buff2, sizeof(buff2), 0 );
        iResult = recv(ClientSocket, buff4, 700, 0);
        iResult = recv(ClientSocket, buff4, 300, 0);
        iResult = recv(ClientSocket, buff4, 700, 0);
        iResult = recv(ClientSocket, buff4, 300, 0);
        iSendResult = send( ClientSocket, buff3, sizeof(buff3), 0 );
        iResult = recv(ClientSocket, buff4, 700, 0);
        iResult = recv(ClientSocket, buff4, 300, 0);
        iResult = recv(ClientSocket, buff4, 700, 0);
        iResult = recv(ClientSocket, buff4, 300, 0);
        iResult = recv(ClientSocket, buff4, 700, 0);
        iResult = recv(ClientSocket, buff4, 300, 0);
        iResult = recv(ClientSocket, buff4, 700, 0);
        iResult = recv(ClientSocket, buff4, 300, 0);
    } while (0);

    sleep(10);
    // cleanup
    close(ClientSocket);

    return 0;
}

Linux上通过gcc将其编译为Server。值得注意的是,此段代码由上述的Server_Windows.c,也就是说和改自MSDN的代码逻辑完全一致,只是适配了Linux的API而已,因此它的程序语义和部署在Windows上的代码编译后的Server.exe是完全一致的,如果哪里发现了不同,那一定是协议栈实现的不同导致的。
3.Clinet.c部署在一台Linux Debian机器上
#include <stdio.h>
#include <stdlib.h>
#include <string.h> 
#include <unistd.h> 
#include <sys/types.h> 
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define DEFAULT_PORT 8080

int main(int argc, char** argv)
{
    int cPort = DEFAULT_PORT;
    int cClient = 0;
    int cLen = 0;
    struct sockaddr_in cli;
    char cbuf[4096] = {0};
    char buff1[700] = {0};
    char buff2[300] = {0};
    char buff3[1000] = {0};
    
    if(argc < 2)
    {
        printf("Uasge: client[server IP address]\n");
        return -1;
    }
    
    memset(cbuf, 0, sizeof(cbuf));
    
    cli.sin_family = AF_INET;
    cli.sin_port = htons(cPort);
    cli.sin_addr.s_addr = inet_addr(argv[1]);
    
    cClient = socket(AF_INET, SOCK_STREAM, 0);
    if(cClient < 0) {
        printf("socket() failure!\n");
        return -1; 
    }
    
    if(connect(cClient, (struct sockaddr*)&cli, sizeof(cli)) < 0) {
        printf("connect() failure!\n");
        return -1;
    }
    
    cLen = recv(cClient, cbuf, 1,0);    
    cLen = send(cClient, buff1, sizeof(buff1), 0);
    sleep(1);
    cLen = send(cClient, buff2, sizeof(buff2), 0);
    cLen = send(cClient, buff3, sizeof(buff3), 0);

    cLen = recv(cClient, cbuf, 999, 0);    
    
    cLen = send(cClient, buff1, sizeof(buff1), 0);
    cLen = send(cClient, buff2, sizeof(buff2), 0);
    sleep(1);
    cLen = send(cClient, buff1, sizeof(buff1), 0);
    cLen = send(cClient, buff2, sizeof(buff2), 0);
    
    cLen = recv(cClient, cbuf, 1000, 0);    
    
    cLen = send(cClient, buff1, sizeof(buff1), 0);
    cLen = send(cClient, buff2, sizeof(buff2), 0);
    sleep(1);
    cLen = send(cClient, buff1, sizeof(buff1), 0);
    cLen = send(cClient, buff2, sizeof(buff2), 0);
    sleep(1);
    cLen = send(cClient, buff1, sizeof(buff1), 0);
    cLen = send(cClient, buff2, sizeof(buff2), 0);
    cLen = send(cClient, buff1, sizeof(buff1), 0);
    cLen = send(cClient, buff2, sizeof(buff2), 0);
    
    close(cClient);
    
    return 0;
}
编译为Client。代码非常简单,就是连接上面的两个几乎一模一样的服务器。


我给出该场景的连接拓扑:


技术分享


分别在Windows和CentOS上启动两个客户端,然后在Debian机器上执行:
[[email protected] linux]# time ./Client 1.1.1.1

real    0m4.014s
user    0m0.001s
sys     0m0.005s
[[email protected] linux]# time ./Client 192.168.44.1

real    0m4.427s
user    0m0.002s
sys     0m0.010s

多执行几次,几乎每次结果都是Windows机器完成时间比CentOS多大约0.4秒!Why??


        想要解释表象的背后原理首先要摸清表象本身。很多人比如温州皮鞋厂老板太关注实现细节,上去就去看代码,这是不对的!

        我来通过tcpdump来看个究竟,通过tcpdump输出的时间戳可以看出详细的传输时间分布。我们首先看连接Windows服务的tcpdump输出,因为这个可能更符合大家的预期,也就是本文序言中展示的那3种想法中的其中之一:
技术分享
通过这个tcpdump输出,我们可以很明显看出在数据段传输和被确认之间的200ms延时,这个简单的200ms延迟看似正好符合Delay ACK的预期,即数据段被接收到之后,等待200ms之后再发送ACK。既然Windows已经“完美”呈现了Delay ACK应有的表现,那么我们来看看Linux的行为,看看这相差的0.4秒到底差别在哪里!
        以下是连接Linux CentOS服务器的tcpdump输出:
技术分享
同样的代码怎么可能会有两种表现呢?看来我们要深挖!
        通过仔细看以上连接CentOS服务器的tcpdump输出,我们看以看出,针对收到的数据回复ACK的时间并不像Windows那样持续的持有200ms的延迟,而是断续地持有40ms延迟或者根本没有延迟,这种表现明显与Windows表现不同!!
        既然程序一样,表现却不同,我们不得不深挖一下Linux和Windows关于Delay ACK协议栈实现的区别了,如果应用程序和网卡都一样的话,那么不同肯定是两者之间的东西造成的,这就是协议栈。

场景表现的分析

不管你信不信,Delay ACK在Linux系统中是没有一个统一配置的,几年前我曾经以为它是一个sysctl项,可是后来无没有找到,现在我常跟人讲,你没有能力通过一个配置彻底关掉或者开启Linux的Delay ACK。但是我现在知道我可以通过一个socket选项通过setsockopt调用来实现这个,这个选项就是TCP_QUICKACK选项:
TCP_QUICKACK (since Linux 2.4.4)
        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.

仔细读这段话,重要的发现有两个:
1.该选项是不稳定的
也就是说,即便你对一个TCP连接设置了该选项,那么后续的TCP逻辑(特定于Linux实现的Linux协议栈)也会取消你的设置,这意味着你同样没有能力通过一个socket选项彻底关掉或者开启TCP连接的Delay ACK。
2.该选项的不可移植
也就是说,你不能在除了Linux版本(内核高于2.4.4)之外的系统上使用该参数!这句话的言外之意是,这个参数是Linux实现Delay ACK的个性!即Linux实现的Delay ACK与其它系统的实现并无一致。
        这也许也许有点令人费解,在解释这个之前,我们再看下Windows的对应解释。在此之前,请注意,不要将Delay ACK与TCP_NODELAY选项进行关联,后者控制的是发送端Nagle算法的行为,而本文说的是接收端行为。
        在Windows中,有一个针对网卡的注册表项:
HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\services\Tcpip\Parameters\Interfaces\{3A41D224-0292-449A-BA2F-96330637DBE8}\TcpAckFrequency
其中3A41D224-0292-449A-BA2F-96330637DBE8是相应网卡的ID值,这个TcpAckFrequency是针对这个网卡的全局配置,它指示了ACK的发送机制,它的值表示回复一个ACK所要收到的最多数据段数。比如TcpAckFrequency的值为2,则表示在一个超时时间段(默认200ms)内收到2个数据段才回复一个ACK,这个2也是一个默认值。如果我想取消掉Delay ACK,就把它设置成1,表示收到1个数据段就回复一个ACK!
        设置完之后,禁用再启用网卡,上述注册表项即可重新加载而生效,无需重启系统。注意,如果网卡配置下面没有TcpAckFrequency,那么你手工添加它即可。
        此时,如果再次执行测试,就会发现针对Windows服务器和CentOS服务器的测试结果基本一致了,不再有0.4秒的偏差了:
[[email protected] linux]# time ./Client 192.168.44.1

real    0m4.025s
user    0m0.000s
sys     0m0.008s

[[email protected] linux]# time ./Client 192.168.44.1

real    0m4.015s
user    0m0.000s
sys     0m0.003s

此时再看连接Windows服务器的tcpdump输出,便发现再没有Delay ACK现象了:
技术分享
由是可见,这个TcpAckFrequency注册表选项,就是一个控制网卡范围内的TCP Delay ACK选项,设置成1表示禁用了Delay ACK,而设置成N则表示200ms内收到N个数据段方才回复一个ACK!
        通过tcpdump抓包,我们看到,Windows的Delay ACK是网卡范围内全局生效的,即,如果配置TcpAckFrequency为2(默认情况),那么无论如何都是收到2个段才会回复一个ACK,即便这会严重影响响应性时延也毫不变通,一切都由该参数决定。由于我没有精力和必要对Windows协议栈进行进一步的Hack,所以对Windows的Delay ACK分析也就到此为止了。

        然而相同的代码在Linux中表现却是另一番景象。它好像并不受某一个参数或者某个socket选项的影响(正如TCP_QUICKACK的manual里所述,其实如果你在服务器端的accept调用后添加设置TCP_QUICKACK选项,情况也是一样的。)。也就是说,Linux中的Delay ACK不是配置参数和socket选项决定的,而是自适应的,在这个自适应机制的范围内,系统依然提供了一个临时的开启/关闭Delay ACK的开关,即TCP_QUICKACK选项!

以上场景的packetdrill代码

在解释这个自适应Delay ACK原理之前,我给出以上场景测试C代码的packetdrill模拟脚本:
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...0.280 accept(3, ..., ...) = 4

0.100 < S 0:0(0) win 32792 <mss 1460, sackOK, nop, nop, nop, wscale 7>
0.100 > S. 0:0(0) ack 1 <...>

0.280 < . 1:1(0) ack 1 win 100
//0.300 setsockopt(4, IPPROTO_TCP, TCP_QUICKACK, [0], 4) = 0

0.300 write(4, ..., 1) = 1
0.300 < P. 1:701(700) ack 2 win 16425
1.300 < P. 701:1001(300) ack 2 win 16425
1.300 < P. 1001:2001(1000) ack 2 win 16425

1.300 write(4, ..., 999) = 999

1.300 %{ print tcpi_rcv_mss }%
1.300 < P. 2001:2701(700) ack 1001 win 16421
1.300 < P. 2701:3001(300) ack 1001 win 16421
2.300 < P. 3001:3701(700) ack 1001 win 16421
2.300 < P. 3701:4001(300) ack 1001 win 16421

2.300 write(4, ..., 1000) = 1000
2.300 < P. 4001:4701(700) ack 2001 win 16425
2.300 < P. 4701:5001(300) ack 2001 win 16425
3.300 < P. 5001:5701(700) ack 2001 win 16425
3.300 < P. 5701:6001(300) ack 2001 win 16425
4.300 < P. 6001:6701(700) ack 2001 win 16425
4.300 < P. 6701:7001(300) ack 2001 win 16425
4.300 < P. 7001:7701(700) ack 2001 win 16425
4.300 < P. 7701:8001(300) ack 2001 win 16425

10.300 write(4, ..., 13) = 13

其实以上的C代码就是依着这个packetdrill脚本写的。为了在Windows系统下进行测试然则packetdrill不支持Windows,那么就只能通过简单的C代码开模拟了,这个C代码模拟要比packetdrill更加直观且更加具有说服力,它就是实实在在的代码,任何系统都能跑。

解析自适应Delay ACK    

再次声明,本文仅仅单独解释Delay ACK,无关Nagle算法。
        抛开与Nagle算法一起使用时带来问题的那些复杂场景不说,Delay ACK的期待是数据捎带ACK,如果TCP被设计成数据无法捎带ACK,那么Delay ACK的意义就只剩下与Nagle合力提高吞吐率了。幸运的是,TCP可以由数据捎带ACK,在这个意义上,Delay ACK的意义就在于(如其Wiki主页所述):
In essence, several ACK responses may be combined together into a single response, reducing protocol overhead.

这个意义如下图所示:


技术分享


我们可以看出,在TCP双向数据传输是一来一回的场景下,Delay ACK可以省去所有的纯ACK段的发送。典型的比如远程终端登录(需要回显的如telnet,ssh之类)。

        Linux为这种双向数据传输取了个名字,叫做pingpong。上图描述的就是一个完美的pingpong模式,对于一条TCP连接的任意一端来讲,pingpong模式指的就是“R-W-R-W-R-W-R-W...”(其中R代表Read,W代表Write,简称RW模式)模式,但是事实上,在现实中,这种完美适应Delay ACK的RW模式几乎不存在,如果出现RRW模式恰逢对端启用了Nagle的话,就会出现问题,因此,不管是开启还是关闭Delay ACK,都无法完美平衡ACK开销与传输延迟之间的矛盾,自适应机制势在必行。
...
Linux实现的其自适应Delay ACK换句话说就是Linux的协议栈可以自动识别当前是否是pingpong(即RW场景或者说完全交互场景)场景,从而依照这个判断来动态开启或者关闭Delay ACK。仍然以上图为模版,我加一些细节,大家也许就可以看到究竟了:


技术分享


道理很简单,如果TCP接收端的协议栈可以在收到数据的“一段时间”内探测到了自身发送了数据,那么pingpong就会设置成1,此时显然就可以走Delay ACK的逻辑了,反之,如果TCP接收端协议栈发现超过“一段时间”都没有数据发送,那么自然会将pingpong设置成0,上述的“一段时间”叫做ATO,至于说如何探测到没有数据发送,很简单,那就是定时器超时。
        本质上,TCP之所以使能Delay ACK,其实并不是真的想ACK被Delay,而是期望数据的发送可以把ACK捎带过去,如果没有数据可发送,在ATO时间过后,ACK还是要发送的,毕竟ACK就是TCP的时钟。因此反过来可以下结论,如果ATO之后没有数据发送,而是发送了Delay ACK,那就说明该TCP传输模式此刻并不是pingpong模式,所以说,在Delay ACK定时器超时后,需要禁用pingpong模式,即把pingpong设置成0,以表示后续的ACK不能再Delay了,直到发现该TCP连接重新进入pingpong模式。
        一条TCP连接的pingpong最简单状态图如下:


技术分享


以上就是Linux自适应Delay ACK的几乎全部了,说“几乎”是因为它还存在几个细节,但这些细节不是重点,所以说我们只能简要叙述一下。但是有一个细节必须在这里详述,这就是QUICK计数器的细节。

关于QUICK计数器

Linux虽然可以自适应地根据pingpong的取值在Delay ACK与否之间切换,但是pingpong并不是唯一的依据,有的时候,即便pingpong为0证明了此时是非交互模式,也要启用Delay ACK。什么情况呢?
        我们知道,即时的ACK发送可以诱发发送端发送更多的数据,然而有的时候,接收端却不希望发送端发送太多的数据,比如接收端接收缓存内存吃紧的时候,这可能是由于应用程序读取数据过慢导致的,并且此时很有可能接收端并没有在发送数据,因此不符合Delay ACK的条件,但是由于接收端希望减缓发送端的发送,这种情况下,依然要Delay ACK。
        Linux为TCP的Delay ACK维护了一个计数器QUICK,该计数器表示在非Delay ACK模式下,发送的即时ACK的数量,也就是即时ACK的配额,在pingpong为0的前提下,只有QUICK持有配额(不为0),该即时ACK才可以被发送出去,否则该ACK会被Delay!
        QUICK计数器值在什么情况下会增减呢?
1.在连接初始化后,当第一次收到数据的时候
此时会将QUICK配额增加到最多16个段。配备16个段的配额是为了照顾慢启动,保证发送端在慢启动阶段时,接收端有足够的QUIICK配额发送即时的ACK。
2.当自从上一次接收到数据到现在又一次收到数据的时间间隔太久的时候
此时会将QUICK配额增加到最多16个段。此时配备足量的QUICK配额是为了发送即时的ACK以促使发送端尽快发送数据。
3.当接收端窗口缩减的时候
此时会将QUIICK配额清零。这种情况下,内存可以已经吃紧,尽量延缓接收数据是有益的,所以要减缓TCP时钟,延迟ACK的发送。
4.当接收端窗口小于接收端缓存一半的时候
此时会将QUIICK配额清零。请参见上述第4点。
        所以说,在pingpong这种交互性判断之外,还有另外一个主动的因素,即接收端是否想主动进入Delay ACK的因素在里面,因此,我们的状态图需要改一下了:


技术分享


本图考虑到了大多数的情况。我想Linux能做到如此,也算可以了,更细节的东西,随着内核版本不断变化的东西,我也不想细讲了。这种自适应到底是好是坏,我想大家看了状态图以及做了实验,也基本有个感官上的判断了!然而网络是一个复杂系统,任何所谓的预测都是一厢情愿,所以众口难调!我一向特别讨厌去讨论那些不确定的东西,比如大家在讨论网络如何如何拥塞,RTT如何如何波动的时候,我总是保持沉默,together with 当前的房价走势,股票等等,我依然是三缄其口,并不是我不懂,实则因为我想尽快结束这种无意义的讨论,乌合之众,行为和数据说话的年代,请勿理论分析。
        到此为止,大家也许会认为,是否Delay ACK是由pingpong值以及QUICK值平等决定的,其各自权力就像古罗马保民官一样,各持一票,一票否决。然则非也!大多数情况下,Linux会将“禁用Delay ACK”作为主线!这就涉及到了Linux TCP Delay ACK的Oneshot特性。

自适应Delay ACK的Oneshot特性

如果Linux在pingpong为1时,启用了Delay ACK,那么待Delay ACK定时器到期时发送真正的Delay ACK的时候,说明pingpong为1是假的,此时就会取消掉pingpong的1值!也就是说,pingpong=1这件事碰到ATO过期,就会将pingpong重置为0!这就是Delay ACK的Oneshot特性,Delay ACK只会触发一次,当它真的被触发了,它也就被清除了!
在这个Oneshot的特性下,我们只需要关注“什么时候开启Delay ACK”即可:
1.pingpong为1的时候,说明在交互模式,启用Delay ACK;
2.pingpong为0,但QUICK配额不足的时候,说明接收端要主动延迟发送端的数据发送,启用Delay ACK。

好了,接下来可以描述关于Delay ACK其它的另一些无关紧要的细节了,不看也罢。

自适应Delay ACK的另一些细节


1.慢启动的问题

在连接刚开始的慢启动阶段,数据总是希望被尽可能快的发送,因此ACK的及时性特别重要,除此之外,在对端发现丢包到恢复后的慢启动阶段,依然需要尽快的ACK来诱导数据尽快的发送,这个如何被数据的接收端所感知呢?
        答案是无法感知!非常遗憾!(尽管QUICK计数器设置为MAX 16是多么的意义重大!)
        你可以去计数,你可以去数时间,都是徒劳!鸡屎!

2.分配纯ACK失败后Delay ACK对其的接管

当你无论如何想要发送一个纯ACK的时候,你就需要申请一个skb(当然,本节是针对Linux内核实现的,不要扯DPDK这种忽悠温州皮鞋厂老板的傻逼技术),如何申请失败会怎样呢?Linux的做法非常巧妙,即,如果申请内存失败,那么就把这个ACK作为一个Delay ACK在此后的某个时间(大约ATO)发出!我们从发送纯ACK的代码中可以看到:
buff = alloc_skb(MAX_TCP_HEADER, GFP_ATOMIC);
if (buff == NULL) {
    inet_csk_schedule_ack(sk);
    inet_csk(sk)->icsk_ack.ato = TCP_ATO_MIN;
    inet_csk_reset_xmit_timer(sk, ICSK_TIME_DACK,
                  TCP_DELACK_MAX, TCP_RTO_MAX);
    return;
}

代码细节

我非常讨厌那种源码分析的文章,因为只要学过编程的,并且肯花点时间去看源码,基本都可以去分析。特别是对于温州皮鞋厂老板这种,以为看了Linux内核就可以屌鞭一切的老板,我更是想多说几句。
        以IP路由为例,如果你都不懂路由的概念,在Linux上都不知道怎么配置路由,只看fib_loopup有什么意义呢?我建议温州皮鞋厂老板先研究iproute2,然后再去看内核源码,可是老板不听劝,总觉得看完内核源码就是多么高大上!温州皮鞋厂老板是我的朋友,所以我才会这么多话,换其他人,我会保持沉默。
        本文接近尾声,我列几个与Delay ACK相关的几个内核函数,请感兴趣者去研读:

1.tcp_enter_quickack_mode

该函数将pingpong设置为0,取消了交互模式,并且给予了QUICK配额

2.if (skb_queue_empty(&tp->out_of_order_queue))

当空洞被填补,那么:inet_csk(sk)->icsk_ack.pingpong = 0;

3.tcp_delack_timer

延迟定时器到期,说明没有数据主动发送,不是交互模式,设置pingpong为0。

4.tcp_event_data_sent

当本端主动发送数据时:
if ((u32)(now - icsk->icsk_ack.lrcvtime) < icsk->icsk_ack.ato)
    icsk->icsk_ack.pingpong = 1;

5.tcp_event_data_recv

当第一次收到数据时,会增加QUICK配额,至于pingpong,后续发数据时再判断。

6.tcp_event_ack_sent

ACK发送时,不管是立即的ACK还是延迟ACK,都会取消Delay ACK定时器,并减去QUICK的相应配额。

结语

...
和风吹的干皮鞋,吹的断愁绪吗?

以上是关于TCP之Delay ACK在Linux和Windows上实现的异同-Linux的自适应ACK的主要内容,如果未能解决你的问题,请参考以下文章

TCP/IP Delay ack 和 Nagle算法

解决TCP延迟应答(Delay ACK)问题的3个小Trick-(续:正规的做法)

数据中心的 TCP-Delay ACK 与 RTO, RACK

4-5:TCP协议之确认应答(ACK)机制和超时重传机制

TCP 三次握手四次挥手, ack 报文的大小.tcp和udp的不同之处tcp如何保证可靠的tcp滑动窗口解释

TCP主动打开 之 第二次握手-接收SYN+ACK