『 Linux 』网络传输层 - TCP(三)

文章目录

TCP三次握手的验证

TCP协议确保数据传输的可靠性是基于连接的,而TCP协议的连接需要进行三次握手;

以客户端与服务端双端为例,通常情况下客户端需要创建一个SYN报文并发送给服务端,服务端接收到对应的SYN报文后将无偿返回一个SYN+ACK报文以捎带应答的方式,既保证了服务端接收到了来自客户端的SYN报文(应答),也同时向客户端发送对应的SYN报文以同样进行连接请求;

下面有一个服务端和客户端的代码示例:

  • server.hpp

    cpp 复制代码
    // 引入自定义的日志库
    #include "log.hpp"
    
    // 定义默认端口号
    const int defaultport = 8080; // 默认端口号 将绑定该端口号
    
    // 定义服务器类
    class Server
    {
    public:
        // 构造函数,初始化服务器端口(默认为 defaultport)
        Server(uint16_t port = defaultport) : port_(port) {}
    
        // 初始化服务器,设置监听套接字
        void Init()
        {
            // 创建一个监听套接字
            listen_socket_ = socket(AF_INET, SOCK_STREAM, 0);
            if (listen_socket_ < 0)
            {
                // 如果创建失败,记录致命错误并退出
                lg(FATAL, "create listen socket fail");
                exit(1);
            }
    
            // 初始化本地地址结构体
            struct sockaddr_in local;
            memset(&local, 0, sizeof(local));
            local.sin_family = AF_INET;             // 设置地址族为 IPv4
            local.sin_port = htons(port_);          // 设置端口号,使用网络字节序
            local.sin_addr.s_addr = INADDR_ANY;     // 绑定到所有本地接口
    
            socklen_t len = sizeof(local);
            // 绑定套接字到指定的地址和端口
            if (bind(listen_socket_, (sockaddr *)&local, len) < 0)
            {
                // 如果绑定失败,记录致命错误并退出
                lg(FATAL, "bind error");
                exit(2);
            }
    
            // 开始监听传入的连接
            if (listen(listen_socket_, 1) < 0)
            {
                // 如果监听失败,记录致命错误并退出
                lg(FATAL, "listen error");
                exit(3);
            }
        }
    
        // 启动服务器
        void Start()
        {
            // 服务器主循环,暂时只是在此处休眠
            while (true)
            {
                sleep(1);
            }
        }
    
    private:
        int listen_socket_; // 监听套接字文件描述符
        uint16_t port_;     // 服务器端口号
    };

    在这段代码中封装了一个服务器类,服务器类只设置了套接字并把套接字设置为监听状态监听连接,并不调用accept()函数获取连接;

  • server.cc

    cpp 复制代码
    // 引入服务器类的头文件
    #include "server.hpp"
    
    // 用于显示使用方法的函数
    void Usage() 
    { 
        // 打印如何使用程序的信息
        printf("\n\tUsage : ./server port[port>1024]\n\n"); 
    }
    
    // 主函数,程序的入口点
    int main(int argc, char *argv[])
    {
        // 检查命令行参数的个数,如果不是 2 个参数(包括程序名),则显示使用方法并退出
        if (argc != 2)
        {
            Usage(); // 调用 Usage 函数打印用法信息
            exit(-1); // 以错误码 -1 退出程序
        }
    
        // 将命令行参数(端口号)转换为整数
        uint16_t port = std::stoi(argv[1]);
    
        // 创建 Server 对象,并传递端口号
        Server ts(port);
    
        // 初始化服务器(设置监听套接字)
        ts.Init();
    
        // 启动服务器(进入主循环,处理客户端连接)
        ts.Start();
    
        // 程序正常退出
        return 0;
    }

    在这段代码中调用服务器类,用于实例化服务器并启动服务器;

  • client.hpp

    cpp 复制代码
    // 定义客户端类
    class Client
    {
    public:
        // 构造函数,初始化客户端的IP和端口
        Client(std::string clientip, uint16_t clientport) : socket_(-1), ip_(clientip), port_(clientport) {}
    
        // 初始化客户端,创建套接字
        void Init()
        {
            // 创建一个 TCP 套接字
            socket_ = socket(AF_INET, SOCK_STREAM, 0);
            if (socket_ < 0)
            {
                // 如果创建失败,记录致命错误并退出
                lg(FATAL, "socket creat error");
                exit(-1);
            }
        }
    
        // 处理请求(目前此函数为空循环)
        void Request()
        {
            while (true)
                ;
        }
    
        // 启动客户端,连接到服务器
        void Start()
        {
            // 初始化服务器地址结构体
            struct sockaddr_in server;
            memset(&server, 0, sizeof(server));
            server.sin_family = AF_INET; // 设置地址族为 IPv4
    
            // 将IP地址从字符串转换为网络字节序格式
            inet_pton(AF_INET, ip_.c_str(), &(server.sin_addr));
            server.sin_port = htons(port_); // 设置端口号,使用网络字节序
            socklen_t len = sizeof(server);
    
            // 尝试连接到服务器
            if (connect(socket_, (sockaddr *)&server, len))
            {
                // 如果连接失败,记录致命错误并退出
                lg(FATAL, "connect error");
                exit(2);
            }
    
            // 请求处理
            Request();
        }
    
    private:
        int socket_;           // 套接字文件描述符
        std::string ip_;       // 服务器的IP地址
        uint16_t port_;        // 服务器的端口号
    };

    这段代码中定义了一个客户端类,客户端类用于向服务端发起连接请求,不对服务端进行发起请求等行为;

  • client.cc

    cpp 复制代码
    // 引入客户端类的头文件
    #include "client.hpp"
    
    // 用于显示使用方法的函数
    void Usage() 
    { 
        // 打印如何使用程序的信息
        printf("\n\tUsage : ./client ip port[port>1024]\n\n"); 
    }
    
    // 主函数,程序的入口点
    int main(int argc, char *argv[])
    {
        // 检查命令行参数的个数,如果不是 3 个参数(包括程序名),则显示使用方法并退出
        if (argc != 3)
        {
            Usage(); // 调用 Usage 函数打印用法信息
            exit(-1); // 以错误码 -1 退出程序
        }
    
        // 将命令行参数中的端口号转换为整数
        int16_t port = std::stoi(argv[2]);
    
        // 获取命令行参数中的IP地址
        std::string ip(argv[1]);
    
        // 创建 Client 对象,并传递IP和端口号
        Client tc(ip, port);
    
        // 初始化客户端(创建套接字)
        tc.Init();
    
        // 启动客户端,连接到服务器并进入请求处理
        tc.Start();
    
        // 程序正常退出
        return 0;
    }

    该端代码用于实例化客户端类并启动客户端;

在这个服务端客户端用例中对于服务端并没有使用accept()函数来获取连接,所以以应用层而言并不能直接与对端进行通信,而对于客户端而言,只是调用了connect()函数向服务端发送了SYN的TCP报文;

在博客『 Linux 』网络传输层 - TCP(二)提到,对connect()函数而言,其本身是让系统构建一个SYN的TCP报文并发送给对端,实际不参与TCP的三次握手,同样的accept()函数也只是将已经建立好的连接以文件描述符(套接字描述符)的形式交付给上层以实现双方通信,同样不参与TCP的三次握手细节;

将上文代码进行编译并分别运行服务端与客户端(此处在不同的机器上进行测试);

运行服务端后在运行服务端对应的机器上调用netstat -nltp命令观察,服务端已经处于监听状态;

在另一台机器中运行客户端并在两台机器上分别调用netstat -ntp命令查看服务端与客户端的状态;

当客户端与服务端连接建立成功后将会进入ESTABLISHED状态,表示连接建立成功,但是在这个例子中运行的两个客户端一个状态处于ESTABLISHED状态,而另一个处于SYN_SENT状态,这表明处于SYN_SENT状态的客户端并未与服务端成功建立连接,也意味着三次握手并没有成功;

同时该实验也验证了connect()accept()两个系统调用接口并不直接参与三次握手的细节,三次握手是双方操作系统自行完成的;

同样在服务端对应机器上使用netstat -ntp命令查看,当前只有一个连接,说明充当服务器的一端并不会为未成功三次握手的机器维持不必要的不完全连接,或者是说其不会为未完成三次握手的机器长时间维持不必要的不完全连接;

通常当双端三次握手未建立成功时存在两种情况,一种即为服务器直接将新来的SYN报文丢弃,或者是服务器为该SYN报文维持一段时间的半连接,即将其放置对应的半连接队列中进行管理,此时服务器将会新增一个半连接的状态,即SYN_RECV(具体不同操作系统下对应的TCP策略不同);

TCP中存在两个队列,分别为半连接队列和全连接队列:

  • 半连接队列

    半连接队列通常为服务端用于管理与维护三次握手未完全成功的连接,当服务端的全连接队列已满,新的客户端所发来的SYN报文同样会被服务端接收,并且返回一个SYN+ACK报文,当客户端再次向服务端发起最后的ACK报文时,由于全连接队列已满,服务端无法接收这个ACK报文,所以会将客户端最后的ACK报文丢弃或是重新向客户端发送SYN+ACK报文,直至之前的连接被断开(不同机器上对于TCP的实现可能有细微差异);

    但是客户端由于已经发出了最后的ACK报文,会认为自己的连接已经建立成功从而处于ESTABLISHED状态,而服务端由于未接收或者无法接收最后的ACK报文将会把这次连接的状态维持在SYN_RECV状态,此时的连接并未完全建立故称为半连接状态;

    虽然半连接并未完全建立,但服务器还是为该半连接维持状态以方便后续在有机会(有已经断开的连接,即全连接队列不满的状态)时可以将该连接完全连接,但半连接仍占用服务端的资源,为了防止SYN洪水等攻击,通常服务端将会把半连接队列的最大长度进行限制,同时也不会将半连接状态维持的过久;

  • 全连接队列

    全连接队列中通常为TCP将已经经历三次握手的连接进行管理,放置在该队列当中;

    通常完全连接队列长度与listen系统调用接口中的第二个参数,即backlog有关;

上文的例子服务器在队列满了后并没有接收对应的SYN报文而是直接丢弃,所以导致服务端并未对该连接维护半连接,对应的由于第一步握手并没有完成,所以客户端将会一直处于SYN_SENT状态;

无论是任何TCP连接,都要进入这个过程,即 未连接->半连接->全连接 ;

  • 未连接状态

    这是初始状态,表示客户端和服务端之间尚未建立任何连接;

    客户端发送SYN报文以请求建立连接,此时客户端进入SYN_SENT状态;

    如果服务端没有收到该SYN报文,或者接收后丢弃了它,客户端将仍处于SYN_SENT状态;

  • 半连接状态

    服务端接收到客户端的SYN报文后,会回复一个SYN+ACK报文,并进入SYN_RECV状态;

    此时,客户端收到SYN+ACK后,表示服务端已经接受了连接请求,但连接尚未完全建立;

  • 全连接状态

    客户端向服务端发送一个ACK报文,完成最后一步握手;

    当服务端接收到这个ACK报文后,双方的连接就成功建立,进入全连接状态;


listen 系统调用接口

为什么两个客户端都对服务端发起了连接,但最终只有一个客户端成功与服务端建立连接,这与服务端调用的listen()系统调用接口的参数有关;

  • listen()

    cpp 复制代码
    NAME
           listen - listen for connections on a socket
    
    SYNOPSIS
           #include <sys/types.h>          /* See NOTES */
           #include <sys/socket.h>
    
           int listen(int sockfd, int backlog);
    DESCRIPTION
           listen()  marks  the  socket  referred to by sockfd as a passive socket, that is, as a
           socket that will be used to accept incoming connection requests using accept(2).
    
           The sockfd argument is a file descriptor that refers to a socket of  type  SOCK_STREAM
           or SOCK_SEQPACKET.
    
           The  backlog argument defines the maximum length to which the queue of pending connec‐
           tions for sockfd may grow.  If a connection request arrives when the  queue  is  full,
           the client may receive an error with an indication of ECONNREFUSED or, if the underly‐
           ing protocol supports retransmission, the request may be ignored so that a later reat‐
           tempt at connection succeeds.
    
    RETURN VALUE
           On  success,  zero  is returned.  On error, -1 is returned, and errno is set appropri‐
           ately.

    其中对于int backlog参数,该参数定义了等待连接队列的最大长度,假设该参数为n,则n+1即为可建立连接的最大长度;

    在上文的代码中服务端在调用listen()时对应的backlog参数设置为了0,这表示不设置等待队列,即可成功建立的连接为0+1个;

对于不同机器而言,其TCP所采用的策略略有差异,尤其是对于半连接队列的管理与维护;

通常半连接队列的最大长度与listen系统调用接口没有直接关系;

通常情况下listen()系统调用接口的第二个参数backlog参数不能过大也不能没有;

通常backlog参数直接与全连接队列的长度相关,虽然全连接队列设置为最常可以让服务器进行满载工作状态,但太长同样有一定弊端;

操作系统中一切有规律或者相同的结构都是通过以 "先描述再组织" 的方式以特定的数据结构组织在一起的,但是无论是以哪种数据结构将这些数据组织在一起都将增大管理和维护的成本;

同时如果全连接队列太长可能导致在全连接队列中较尾部的连接需要等待较长的时间才可以打得到服务端,此时客户端的请求可能也会迟迟得不到响应;

如果将全连接队列设置为差不多的大小可以降低对全连接队列的维护成本,将硬件资源分配给需要服务的连接,使服务完成后系统可以处理下一个连接从而提高整体效率,即系统设计中的 "适度原则" ;

同时也不能没有全连接队列,服务器通常需要同时处理多个客户端连接,如果没有全连接队列,这就表示服务端一次只能维护一个连接,当服务端处理一个新的连接后会将这个连接进行直接丢弃,通常全连接队列可以缓存这些已经三次握手了的连接;

同时服务器不可能立即处理所有达到的连接,CPU的处理速度优先,需要队列来暂存待处理的连接以避免因处理不当导致的连接丢失;

对于accept()调用与连接建立是不同步的,应用程序可能因为各种原因无法及时accept()来获取连接,如果没有全连接队列,这些已经进行了三次握手的连接可能会因为还没被上层accept()获取就被丢弃,全连接队列也提供了一个缓冲机制;


四次挥手的验证

在进行四次挥手时,主动申请断开连接一方将发送一个FIN报文,接收到FIN报文的一端将无条件返回一个ACK报文表示同意对端与该端断开连接,但此时连接并未完全断开,双端能保证最先主动申请断开连接的一端除了对对端返回对应的ACK等应答报文以外不主动再发送数据;

对应的接收到FIN报文的一端将进入CLOSE_WAIT状态;

对上文中的服务端代码进行修改,增加accept()系统调用接口获取套接字描述符,并在一段时间对套接字描述符调用close()系统调用接口表示主动向客户端断开连接(调用close()系统调用接口表示让系统构建一个FIN报文并发送给对端);

cpp 复制代码
class Server
{
public:
    // 启动服务器
    void Start()
    {
        // 服务器主循环,暂时只是在此处休眠
        while (true)
        {
            struct sockaddr_in client;
            socklen_t size = sizeof(client);
            int sockfd = accept(listen_socket_, (struct sockaddr *)&client, &size);
            if (sockfd < 0)
            {
                lg(WARNING, "Accept fail...");
                exit(4);
            }

            sleep(10);
            lg(INFO, "sockfd :%d closed...", sockfd);
            close(sockfd);
        }
    }

private:
    int listen_socket_; // 监听套接字文件描述符
    uint16_t port_;     // 服务器端口号
};

在服务端获取套接字的十秒后调用close()系统调用接口断开连接;

重新运行服务端和客户端,并调用netstat -ntp命令查看对应端口的连接状态;

结果为当服务端调用了close()系统调用接口后客户端进入了CLOSE_WAIT状态,但由于客户端并没有主动调用close()来向服务端发起FIN报文关闭连接请求,无法进行第三次挥手,客户端将一直处于CLOSE_WAIT状态;

通常情况下主动调用close()调用接口向对端发送FIN报文时将进入一个瞬时状态FIN_WAIT1状态;

该处状态为瞬时状态,并不好直接观察,这个状态表示主动申请断开连接方向对端发送一个FIN报文并等待对端返回ACK报文进行应答,不好观察的原因为通常对端接收到FIN报文后将无条件进行应答,可以使用while:;do脚本使用netstat命令并且设置较短时间间隔观察,例:

shell 复制代码
while:;
do netstat -natp | grep [PORT] ; 
sleep [SHOT_TIME] ;
echo "[分隔符,如------]" ;
done

当对端返回ACK报文进行应答后并被主动断开连接端收到时,主动断开连接端将进入FIN_WAIT2状态,该端将不再主动向对端发送数据,但依旧可以对对端所发的数据段进行处理并返回对应ACK等应答报文;

当被动断开连接方向主动断开连接方发送FIN报文时将会进入一个LAST_ACK瞬时状态;

这些持续时间较短的状态同样可以使用while:;do脚本来尝试获取,此处实验时间均在shell脚本中使用sleep 0.3的间隔;

这个瞬时状态通常由被动断开连接方接收到主动断开连接方对该FIN报文进行应答的时间决定,大多数情况这个时间十分短暂,但也可能会出现网络拥塞或者丢包等情况,当被动断开连接方接收到了主动断开连接方对该FIN报文的ACK应答报文时,对应的LAST_ACK状态将会解除,对应的被动断开连接放也会进入CLOSED的状态,即连接关闭;

主动断开连接方发送完对被动断开连接方FIN报文的ACK报文后将会进入TIME_WAIT状态;


CLOSE_WAIT状态与TIME_WAIT状态

CLOSE_WAIT状态通常发生在被动关闭连接的一端,在收到主动关闭端所发送的FIN报文后,这种状态的主要作用是允许被动关闭端完成其数据传输,并允许它在完全断开连接之前清理资源;

通常情况下CLOSE_WAIT状态与FIN_WAIT2状态是相互协作的,即在正常四次挥手的过程中,被动关闭端处于CLOSE_WAIT状态时主动关闭端必定处于FIN_WAIT2状态,在此过程中通信必须依旧保持可靠,被动关闭端完成对剩余数据的处理,而主动关闭端则等待被动关闭端所发的最后的FIN报文;

当四次挥手过程结束后,主动关闭端将进入TIME_WAIT状态,这个状态会持续一段时间,通常为30-60s;

TIME_WAIT状态的作用为如下:

  • 确保最后一个ACK报文被对方收到

    为了保证四次挥手同样具有可靠性,被动关闭端在向主动关闭端发出FIN报文后主动关闭端需要对该FIN报文进行ACK应答,只有当ACK报文被对端接收到后被动关闭端才会进入CLOSED状态;

    由于被动关闭端在进入CLOSED状态后表示彻底与对端断开连接,不会对ACK再次进行应答,所以为了保证ACK报文大概率被被动关闭端接收到,主动关闭端必须处于TIME_WAIT状态一段时间,这样可以更安全的关闭连接;

  • 允许老的重复分段消失

    TIME_WAIT状态通常持续一段时间,理论上是发送最后一个ACK的两倍的最大数据段生命周期,即2MSL,这段时间可以让网络中所有本连接的延迟或者重复的数据包消失,放置这些老的数据包在新的连接中被错误的接收;

  • 避免"端口重用"时的冲突

    TIME_WAIT状态期间,相同端口号是不可用的,防止新的连接使用相同的端口号,但受到旧的连接残留在无网络中的数据段干扰,尤其是在连接频繁建立和关闭的系统中,这个特性避免了新旧连接混淆导致数据错误;

通常在连接的任意一方处于TIME_WAIT状态时表明该端该连接还没有彻底被断开,即IP与端口号仍在被使用;

通常情况下当服务端因为某种原因崩溃时服务端就为主动断开连接方,而服务器通常需要为多个客户端提供服务,如果服务端崩溃导致服务端成为主动断开连接的一端,那么服务端必定会进入TIME_WAIT状态,为了避免服务器进入TIME_WAIT状态而无法使用同一个端口号立即重新启动可以调用setsockopt()接口来设置套接字属性;

  • setsockopt()

    cpp 复制代码
    NAME
           getsockopt, setsockopt - get and set options on sockets
    
    SYNOPSIS
           #include <sys/types.h>          /* See NOTES */
           #include <sys/socket.h>
    
           int setsockopt(int sockfd, int level, int optname,
                          const void *optval, socklen_t optlen);
    
    RETURN VALUE
           On success, zero is returned for the standard options.  On error, -1 is returned, and errno is set appropriately.
    
           Netfilter  allows  the  programmer  to  define custom socket options with associated handlers; for such options, the return
           value on success is the value returned by the handler.
    • sockfd

      该参数是指向一个打开的套接字描述符,表示需要设置属性的套接字;

    • level

      指定操作的层级,套接字选项有不同层级,如:

      SOL_SOCKET表示通用套接字选项,适用于所有的套接字;

      IPPORT_TCP表示TCP层的特定选项,如TCP的TCP_NODELAY;

      IPPORT_IP表示适用于IP层的选项;

    • optname

      标识了需要设置的选项名称,这些选项控制套接字的行为,如:

      • SO_REUSEADDR

        表示允许在同一个端口启动服务器的不同实例;

      • SO_KEEPALIVE

        表示保持连接活跃,即使没有数据交换;

      • TCP_NODELAY

        表示禁用Nagle算法;

    • optval

      表示指向包含新选项值的缓冲区指针,根据不同的选项,这个缓冲区可能是一个布尔值,整数或者是一个结构体;

      例如为了前期用SO_REUSEADDR时会指定一个整形的变量并设计其为非零,通常是1,表示启用选项;

    • optlen

      表示optval的缓冲区大小,为了安全性,放置缓冲区溢出,确保程序正确读取选项的值;

修改服务端代码,调用setsockopt()函数设置套接字属性;

cpp 复制代码
class Server
{
public:
    void Init()
    {
        listen_socket_ = socket(AF_INET, SOCK_STREAM, 0);

        int opt = 1;
        setsockopt(listen_socket_, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt)); // 设置套接字属性

        if (listen_socket_ < 0)
        {
            lg(FATAL, "create listen socket fail");
            exit(1);
        }

        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));
        local.sin_family = AF_INET;         
        local.sin_port = htons(port_);     
        local.sin_addr.s_addr = INADDR_ANY; 

        socklen_t len = sizeof(local);
        if (bind(listen_socket_, (sockaddr *)&local, len) < 0)
        {
            lg(FATAL, "bind error");
            exit(2);
        }

        if (listen(listen_socket_, 1) < 0)
        {
            lg(FATAL, "listen error");
            exit(3);
        }
    }

private:
    int listen_socket_; 
    uint16_t port_;     
};

重新编译代码并运行服务端与客户端;

结果为服务端仍处于TIME_WAIT状态时可以立即重新启动而不需要等待TIME_WAIT状态结束;

当然使用setsockopt()设置地址复用时仍需注意,因为当设置地址复用后无论是进入TIME_WAIT状态的连接还是服务端进行正常的服务时都可以使得该端不同进程绑定同个端口产生多个服务器实例;

通常也可以修改系统配置文件来修改TIME_WAIT状态时长,通常在/proc/sys/net/ipv4/tcp_fin_timeout文件中保存着TIME_WAIT状态时长;

bash 复制代码
$ cat /proc/sys/net/ipv4/tcp_fin_timeout 
60

可以通过修改该配置文件来修改对应的TIME_WAIT状态时长;


最大报文段生命周期 - MSL

MSL用于定义一个TCP报文段在网络中能够存在的最长时间;

MSL是一个时间限制,用来确保TCP连接被清理并且相关的资源在一定时间后可以被重用(如端口);

常见的MSL值为两分钟,但具体值可根据不同的系统和实现而有所不同,其作用如下:

  • 确保数据传输完整性

    MSL保证报文段在网络中的存活时间足够长,使其能够到达目的地;如果一个报文段在网络中存在的时间超过了MSL,就会被丢弃;这样可以避免过时的数据报文被错误地交付;

  • 防止旧连接的干扰

    在TCP连接完全关闭后,端口等资源会因为进入TIME_WAIT状态而被保留一段时间(通常是MSL的两倍,即4分钟);

    这个机制确保了来自之前连接的迟到报文(即超过MSL的报文)不会被新的连接错误地接收;这对于防止延迟的报文错误影响新连接是非常有用的;

  • 支持可靠的连接释放

    TCP使用四次挥手(四次握手的过程结束)来终止一个连接;

    MSL确保了所有在关闭过程中的报文能够被适时处理,例如确认报文丢失时需要重新发送的情况;


流量控制

TCP协议为了确保数据传输更加可靠,需要对数据传输进行流量控制,以避免双方因接收缓冲区溢满导致大面积的丢包从而出现的不可靠现象;

那么有一个问题:

  • TCP双方如何确认第一条数据段的长度合适?

TCP双方在建立连接前需要进行三次握手,三次握手并不只是互相发送SYNACK报文,在报文中双方还必须互相协商,如双方告知对方本端的序列号以及确认序列号,同时双端还会交换本端当前窗口的大小,即双方的接受能力;

通常情况下三次握手完成之后才能进行数据的传输,但实际上主动连接方在第三次握手时即可以以捎带应答的方式将需要传输的数据附着在该ACK报文中,但在发送数据之前必须进行窗口信息的交换,这样说明了双方交换窗口大小的时机在第一次握手和第二次握手的阶段;

  • 窗口探测报文

    窗口探测报文通常在TCP中国用来探测接收方的窗口大小;

    这种探测机制通常为当发送方已经接收到接收方窗口大小为0的通知(零窗口),但是发送方需要继续发送数据时;

    • 零窗口

      在TCP中,每个方向的数据传输都有一个窗口大小,定义了在需要接收方确认之前,发送方可以发送多少数据,如果接收方的缓冲区被填满(无法再接收新的数据),接收方会通知发送方一个窗口大小为0;

      当发送方接收到这个零窗口的报文时将会停止发送数据,知道接收方的缓冲区有足够的空间;

    当发送方接收到零窗口通知后会停止发送数据,为了确定何时可以恢复数据传输,发送方将周期性的发送窗口探测报文,这个探测报文仅仅是一个TCP段,即只有TCP报头,其中包含1byte的数据;

    当接收方清空了足够的缓冲区后,它将会发送一个更新的窗口大小给发送方,这个新的窗口大小不再是零,那么发送方可以恢复数据传输,如果更新仍然是零,发送方将继续等待并周期性的发送窗口探测报文;

在TCP报文结构中存在一个16位窗口大小字段,存放窗口大小信息,16位通常表示最大为65535byte表示窗口大小,而实际上TCP首部中的40字节选项中还包含了一个窗口扩大因子M,实际窗口大小是窗口字段的值左移M位,实际上的窗口大小将根据不同操作系统的设置而定的,并没有一个实际的数值;

  • 流量控制既表现可靠性也表现效率

    流量控制的可靠性表现在通过流量控制避免接收方在接收缓冲区溢满时发送方仍向接收方发送数据从而导致大面积丢包;

    流量控制的效率表现在通过流量控制减少丢包从而减少重传次数间接提高效率;

    实际上可靠性和效率两点并不冲突;

    TCP在实现过程中以可靠性为主,效率为辅;


滑动窗口

通常情况下,已经发出去但是还没有收到应答的报文,需要被TCP暂时保存起来,而实际上这些报文的保存位置通常为发送方的发送缓冲区中,通常情况下无论是由应用层缓冲区发送到TCP中还是TCP缓冲区发送到网络中所用的方式都是以拷贝的方式,即拷贝到对应的位置但仍在原来的位置保留原件,这样可以更加灵活的进行不同策略的实现,如是否进行数据的重发等等;

通常情况下如果TCP进行了超时重传,快速重传等重传策略则可以直接把发送缓冲区中的数据重新发送给对端,等待对端,直到等待接收到该数据的ACK应答报文;

以发送缓冲区为例,发送缓冲区通常分为几个部分:

  • 已发送已确认

    该区域中的数据已经发送并且收到对端的ACK对该报文的应答,可以被上层拷贝下来的数据进行覆盖;

    已发送已确认区域中的数据可以视为无效数据,本质上计算机中不存在实际意义的清空,只是将该区域中的数据视为无效数据,可被覆盖;

  • 已发送未确认

    这段区域中所缓存的数据为已经发送或者可以进行发送,但由于之前的报文并没有接收到应答所以并不会将该区域中的数据进行覆盖或者继续发送;

  • 待发送

    这部分包含有应用程序写入但是尚未发送的数据;

    这些数据将根据网络条件,接收方的窗口大小和本地的拥塞控制策略等因素进行发送;

而上部分中的 已发送未确认 部分则被称为滑动窗口;

存在滑动窗口区域才可以向对端发送大量的TCP报文;

滑动窗口的大小范围通常是对方的接收窗口大小,即对方的接收窗口能力;

通常情况下一端发送给另一端数据时报文中通常会附带该端的接收窗口大小,而对端将根据所接受到的接收窗口大小来动态调整自己的滑动窗口,从而可以确保所发送的数据量对端能有相应的接收能力来接收这些数据;

滑动窗口的窗口滑动本质上是以双指针的形式向右动态调整当前窗口的大小,从而拓展滑动窗口大小,与算法中的滑动窗口原理相当,对应的缓冲区的区域划分也是通过这样的原理实现的;

相关推荐
GGBondlctrl11 分钟前
丹摩征文活动 |【网络原理】关于HTTP的进化之HTTPS的加密原理的那些事
网络·https·非对称加密·对称加密·中间人攻击
溟洵14 分钟前
MySQl基础----Linux下数据库的密码和数据库的存储引擎(内附 实操图和手绘图 简单易懂)
linux·数据库·mysql
yfs102417 分钟前
Linux通过端口号找到程序启动路径(Ubuntu20)
linux·运维·服务器
有梦想的咕噜21 分钟前
在 Ubuntu 上安装 `.deb` 软件包有几种方法
linux·运维·ubuntu
苏格拉真没有底23 分钟前
docker配置代理解决不能拉镜像问题
运维·docker·容器
zjj58725 分钟前
tartanvo ubuntu 20.04部署
linux·运维·ubuntu
牛奔33 分钟前
解决Mac M芯片 Wireshark 运行rvictl -s 后,出现Starting device failed
网络·测试工具·macos·wireshark
Xam_d_LM34 分钟前
【Linux】获得同一子网下当前在线设备IP/Latency/MAC 通过nmap指定CIDR扫描当前在线设备
linux·运维·服务器
一颗星星辰1 小时前
数据结构 | 题目练习第三章 | 有效的括号 | 用队列实现栈
java·linux·数据结构
黄小耶@1 小时前
Hadoop(环境搭建篇)
linux·运维·服务器