TCP协议——三次握手和四次挥手

1. 示意图

image-20240319202159125

Tips:

不管是握手还是挥手,发送的都是完整的TCP报头,这不过这些标记位被设置了

2. 三次握手

TCP可靠性保证里面有一个在建立连接之前需要进行三次握手

打个比方:

小潘(客户端):小王,我喜欢你,我们在一起吧!(发起请求,第一次握手)

小王(服务端):好呀好呀(做出应答),我也喜欢你,那我们在一起吧!(第二次握手)

小潘(客户端):吼吼吼,我们现在是情侣啦~(做出应答,第三次握手)

  1. 客户端向服务端发送SYN请求建立连接,发送完毕之后状态变为SYN_SENT同步发送
  2. 服务端收到请求之后,状态变为SYN_RECV同步收到,并做出ACK确认应答,并捎带SYN请求建立连接
  3. 客户端收到应答之后,发送ACK,状态变为ESTABLISHED,表示客户端连接建立完毕;服务端收到ACK之后,状态变为ESTABLISHED,服务端连接建立完毕

三次握手时的三个函数:

  • 客户端向服务端发起连接调用connect函数,建立的本质是connect要求客户端构建SYN请求报文发送给服务端

    connect函数是负责发起三次握手,握手的过程由双方操作系统自己完成

    发送SYN之后就进入阻塞状态,三次握手完毕才会返回

  • accept本身并不参与三次握手,只会把建立好的连接拿过来,如果底层没有建立好的连接,会一直阻塞住

三次握手成功之后,调用writeread这些系统调用进行通信

本质也不是发送接收数据,将数据写到TCP发送缓冲区(将数据从TCP接收缓冲区读上来)

3. 四次挥手

正常数据通信完毕之后,四次挥手断开连接

打个比方,小潘和小王打视频,嘴巴讲话输出数据流,眼睛看、耳朵听着接收信息

… … 巴拉巴拉巴拉巴拉讲了很久之后 … …

四次挥手断开连接:

  • 小潘:我说完啦(嘴巴不输出了(写端关闭),眼睛可以看、耳朵还在听着(读端未关闭),第一次挥手,发送FIN包)

  • 小王:好的,但我还没讲完,你听我再说会吧!(确认应答,第二次挥手)

    // ... ...
    //小王继续巴拉巴拉说
    // ... ...
    
  • 小王:现在我也说完啦!(嘴巴不输出了(关闭写端),眼睛可以看、耳朵可以听(读端未关闭),第三次挥手。发送FIN包)

  • 小潘:点点头,知道双方确认结束视频,准备挂断视频(发送的不是数据流,而是控制信息,第四次挥手)

  • 客户端向服务端发送FIN,表明没有要发送的数据,要断开连接,进入`
  • 因为要保证可靠性,服务端向客户端发送ACK确认应答表明收到
  • 当服务端也没有数据发送之后,向客户端发送FIN
  • 客户端收到之后,发送ACK确认应答表明收到

4. 三次和四次问题

事实上TCP建立连接的时候也是四次握手,只不过将第二次报文被捎带应答了:

image-20240320140936623

对于四次挥手,也可也合并成三次挥手,客户端说我要断开连接啦FIN(第一次挥手),服务端说好的,我也要和你断开连接ACK+FIN(第二次挥手),最后客户端说好的,那我们断开连接吧ACK(第三次挥手)

从最朴素的角度看,三次握手和四次挥手本质是一来一回的一种可靠性,既双方至少可靠的给对方发送了一次消息。

那为什么各种教材或者书籍都写的是三次握手和四次挥手呢?

  • 客户端向服务端发送SYN建立连接请求,服务端一定会给客户端发送ACK应答,然后服务端也要和客户端建立连接,因为不存在协商上时间差的问题,所以ACK+SYN压缩在一起是必然的。

    因为服务端就是为客户端做服务的,就是等着客户端来连接,所以当客户端发起连接请求的时候,服务端必须无偿同意!这个可不敢比作是男女朋友关系

  • 至于四次挥手,这里面ACKFIN要分开,这是因为有协商的成分在,当客户端要与服务端断开连接说“服务端,我没什么和你要和你说的了,我要断开连接”,服务端收到之后说“可我还有话要给你说啊”,然后服务端将消息发送完毕之后发送FIN,客户端答复ACK,此时才真正断开连接

    所以在一方想断开连接,另一方并不想断开连接,所以想让第二次和第三次挥手压缩成一个,是具有巧合性的!

4.1 为什么三次握手

对于三次握手它能够保证无论是客户端还是服务端在通信之前,双方至少做过一次可靠收发,这叫验证全双工通路是否通畅!

这里至少一次收发,表示的是可靠的收发,如果是2次握手:

image-20240320143552300

这里虽然双方都有一次收和发,但是对于服务端来讲,只表明自己有接收能力,并不知道是否具有发送能力,因为发出去的报文,没有应答!

假设进行一次握手:

客服端发送一个SYN请求,服务端就建立一个连接;如果客户端恶意向服务端发送大量SYN请求,服务端就要建立大量的连接

可是服务端维护这些连接是有成本的,服务端需要将这些连接管理起来,这样就十分容易将服务端连接资源打满

image-20240320144626539

假设进行两次握手:

当客户端发送SYN请求,服务端收到之后向客户端发送ACK确认,在发送之后,服务端先将连接建立好,当客户端收到ACK之后再建立连接。可是如果客户端之间丢弃这个ACK报文,那这其实是是和一次握手的问题一样。

就算这个客户端不是恶意行为,当客户端出现异常时,并没有建立连接,可是服务端还是要将连接维护一段时间。如果有一千万的客户端连接,其中10%的客户端异常了,这就表明服务端需要长时间维护这10%无用的连接。

所以让服务器作异常兜底,是行不通的!

三次握手:

三次握手,第一次握手和第二次握手都是有对于的应答,所以并不担心是否丢失,就算丢失了三次握手未成功,连接还未建立

最担心的就是第三次的应答的丢失,但是对于第三次握手,是客户端发出的,发出之后客户端以为连接建立完毕,当服务端收到之后再建立连接,如果没收到则不建立连接,认为三次握手没有成功。这样就将建立连接失败的成本嫁接到了客户端!

这就能在一定程度上保证服务端的稳定性,既奇数次握手,确保一般情况下握手失败连接成本在客户端!

为什么不是5、7、9次呢?

因为三次是验证全双工的最小次数!

4.2 为什么四次挥手

断开连接的本质是双方没有数据给对方发送,所以必须可靠告诉对方。

四次挥手即双方都能得知对方不想发送消息的意愿,需要协商断开连接

客户端没有消息给服务端发送,可是服务端还是有消息给客服端发送,客户端收到ACK之后,还是能收到服务端的消息的(关闭写端不关闭读端

客户端:发送FIN之后,状态变为FIN_WAIT_1

服务端:收到之后状态变为CLOSE_WAIT,发送ACK

客户端:收到之后状态变为FIN_WAIT_2

FIN_WAIT_2表示不会再给对方发送数据,最后发送的ACK并不是数据,而是管理报文

服务端:发送FIN之后,状态变为LAST_ACK

客户端:收到之后状态变为TIME_WAIT,发送ACK

服务端:状态变为CLOSE

5. 状态变化实验

Tips:

以下实验是基于此篇文章写的tcp套接字代码:Linux网络编程——tcp套接字

研究的是三次握手和四次挥手,之间的IO过程省略

采用2台服务器进行测试

#pragma once

#include"Log.hpp"

#include<iostream>
#include<cstring>

#include<sys/wait.h>
#include<unistd.h>
#include<signal.h>
#include<pthread.h>

#include<sys/types.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<netinet/in.h>

#include"threadPool.hpp"
#include"Task.hpp"

const int defaultfd = -1;
const std::string defaultip = "0.0.0.0";
const int backlog = 1;  //不要设置太大
Log log;


enum{
    USAGE_ERR = 1,
    SOCKET_ERR,
    BIND_ERR,
    LITSEN_ERR
};
class TcpServer;

class ThreadData
{
public:
    ThreadData(int fd, const std::string &ip, const uint16_t &port, TcpServer *t)
    :t_sockfd_(fd), t_clientip_(ip), t_clientport_(port), t_tsvr_(t)
    {}
public:
    int t_sockfd_;
    std::string t_clientip_;
    uint16_t t_clientport_;
    TcpServer *t_tsvr_; //需要this指针
};

class TcpServer
{

public:
    TcpServer(const uint16_t &port, const std::string &ip = defaultip)
    :listensockfd_(defaultfd)
    ,port_(port)
    ,ip_(ip)
    {}

    //初始化服务器
    void Init()
    {
        //创建套接字
        listensockfd_ = socket(AF_INET, SOCK_STREAM, 0); //sock_stream提供字节流服务--tcp
        if(listensockfd_ < 0)
        {
            log(Fatal, "create socket, errno: %d, errstring: %s",errno, strerror(errno));
            exit(SOCKET_ERR);
        }

        log(Info, "create socket success, sockfd: %d",listensockfd_);

        // int opt = 1;
        // setsockopt(listensockfd_, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));  //防止偶发性服务器无法进行立即重启

        //本地套接字信息
        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));
        //填充网络信息
        local.sin_family = AF_INET;
        local.sin_port = htons(port_);
        inet_aton(ip_.c_str(), &(local.sin_addr));

        //bind
        int bd = bind(listensockfd_, (struct sockaddr*)&local, sizeof(local));
        if(bd < 0)
        {
            log(Fatal, "bind error, errno: %d, errstring: %s",errno, strerror(errno));
            exit(BIND_ERR);
        }
        log(Info, "bind success");


        //tcp面向连接, 通信之前要建立连接
        //监听
        if(listen(listensockfd_, backlog) < 0)
        {
            log(Fatal, "listen error, errno: %d, errstring: %s",errno, strerror(errno));
            exit(LITSEN_ERR);
        }
        log(Info, "listen success");

    }
    void Start()
    {
        log(Info, "server is running...");
        while(true)
        {
            sleep(1);
            //获取新链接
            // struct sockaddr_in client;
            // socklen_t len = sizeof(client);
            // int sockfd = accept(listensockfd_, (struct sockaddr*)&client, &len);
            // if(sockfd < 0)
            // {
            //     log(Warning, "accpet error, errno: %d, errstring: %s",errno, strerror(errno));
            //     continue;
            // }
            // uint16_t clientport = ntohs(client.sin_port);
            // char clientip[32];
            // inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip));
            // log(Info, "get a new link..., sockfd: %d, clientip: %s, clientport: %d", sockfd, clientip, clientport);
            //根据新链接进行通信
		   //... ...

            //sleep(1); 
        }
    }
    void Service(int sockfd, const std::string &clientip, const uint16_t &clientport)
    {
        char buffer[4096];
        while(true)
        {
            ssize_t n = read(sockfd, buffer, sizeof(buffer));
            if(n > 0)
            {
                buffer[n] = 0;
                std::cout << "client say# " << buffer << std::endl;
                std::string echo_str = "tcpserver echo# ";
                echo_str += buffer;

                write(sockfd, echo_str.c_str(), echo_str.size());
            }
            else if(n == 0)
            {
                log(Info, "%s:%d quit, server close sockfd: %d", clientip.c_str(), clientport, sockfd);
                break;
            }
            else
            {
                log(Warning, "read error, sockfd: %d, clientip: %s, clientport: %d", sockfd, clientip.c_str(), clientport);
                break;
            }
            memset(buffer, 0, sizeof(buffer));
        }
    }

    ~TcpServer(){}
private:
    int listensockfd_;
    uint16_t port_;
    std::string ip_;
};

5.1 三次握手实验

运行服务端:

image-20240320165043797

服务端状态:

image-20240320165106917

本主机客户端和其他客户端连接:

image-20240320165209839

查看连接情况:

image-20240320165801455

这里发现连接建立成功和上层是否accpet无关,是由双方操作系统自主完成

listen第二个参数

listen第二个参数设置为1:

image-20240320171623985

这里发现客户端以为连接成功,可是服务端出现了一个SYN_RECV,并没有三次握手成功。

这是因为listen第二个参数表明连接的最大个数+1

三次握手成功之后,服务端会在底层建立好连接,不过这个连接可以不拿上去(accpet),对于这些建立好的连接没有被拿上去,所以操作系统需要将这里连接维护起来——先描述再组织,采取队列的形式管理这些建立好的连接,叫做全连接队列

这队列的最大长度,就是由listen第二个参数决定的

我们这里设置的是backlog = 1,所以连接队列最长为backlog + 1,没有accept拿走连接,所以连接2个客户端之后就满了(生产消费者模型)

SYN_RECV状态变为ESTABLISHED状态,必须要收到ACK,可是这里的ACK已经发送了(因为客户端的状态已经变为ESTABLISHED状态),但由于listen第二个参数的设置,服务端连接队列满了,所以将收到的ACK直接丢弃了

服务端并不会长时间维持SYN_RECV状态,这叫半连接队列,半连接的节点会隔一段时间被释放掉

这个半连接队列也有长度,由内核自己定

真正意义SYN洪水:

要进入全连接队列,首先要先进入半连接队列,虽然说握手失败之后半连接会过一段时间被释放,但是也耐不住恶意请求一直发SYN将半连接占满,这就导致正常的连接请求进不来,这才是真正意义上的 SYN洪水

为什么listen第二个参数不能太长,为什么不能没有?

  • 如果全连接队列太长,这就会导致有些连接来不及被上层处理,但还是要被系统长时间维护

来不及处理说明服务器已经很忙了,之后还有新连接到来,然后系统还要分资源出来维护这个队列,所以不能设置太长,这样就能再匀出资源给上层使用

  • 如果直接将这个全连接队列删掉,全力支持上层处理,当它空闲的时候,这一部分就又浪费了。
    就好比商场的餐饮店,不仅店子里有吃饭完的位置,门口也有椅子,想吃饭的人里面有位置就进去吃,没位置就可以坐在椅子上等一会,当有客人离席的时候,可以立马补上。如果门口没有椅子,里面满了,客人只能站着等,这样用户体验不好,走了,这样就会损失一批客户。

以上都是为了服务器资源充分利用

5.2 四次挥手实验

设置服务端获取连接5秒之后断开:

        while(true)
        {
            sleep(1);
            //获取新链接
            struct sockaddr_in client;
            socklen_t len = sizeof(client);
            int sockfd = accept(listensockfd_, (struct sockaddr*)&client, &len);
            if(sockfd < 0)
            {
                log(Warning, "accpet error, errno: %d, errstring: %s",errno, strerror(errno));
                continue;
            }
            uint16_t clientport = ntohs(client.sin_port);
            char clientip[32];
            inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip));
            log(Info, "get a new link..., sockfd: %d, clientip: %s, clientport: %d", sockfd, clientip, clientport);
            sleep(5);
            //关闭
            close(sockfd);
            log(Info, "close sockfd..., sockfd: %d, clientip: %s, clientport: %d", sockfd, clientip, clientport);
            //....
        }

image-20240321102930664

现象:主动要求断开连接的一方,在四次挥手完毕之后,会进入TIME_WAIT状态,等待若干时长之后,自动释放

如果在TIME_WAIT状态下关闭服务端,然后再重新启动,发现服务起不来:

image-20240321103737591

报错信息:绑定失败

这是因为TIME_WAIT状态表示连接并没有彻底断开,ipport是正在被使用的,所以服务器挂掉之后无法立即再启动

比如说在节假日高峰期,出行人数非常非常非常多,售票软件没抗住,服务器挂掉了,此时服务器上是存在这大量的TIME_WAIT状态的,此时服务器就无法立即重启,这就出事故了。

如果因为TIME_WAIT问题导致服务器无法立即重启,可以设置setsockopt,允许地址复用:

int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);

sockfd:文件名描述符

level:设置层级,一般是SOL_SOCKET套接字层

optname:设置选项

optval:指向一个缓冲区

optlen:缓冲区大小

    //初始化服务器
    void Init()
    {
        //创建套接字
        listensockfd_ = socket(AF_INET, SOCK_STREAM, 0); //sock_stream提供字节流服务--tcp
        if(listensockfd_ < 0)
        {
            log(Fatal, "create socket, errno: %d, errstring: %s",errno, strerror(errno));
            exit(SOCKET_ERR);
        }

        log(Info, "create socket success, sockfd: %d",listensockfd_);

        int opt = 1;
        setsockopt(listensockfd_, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));  //防止偶发性服务器无法进行立即重启

        //本地套接字信息
        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));
        //填充网络信息
        local.sin_family = AF_INET;
        local.sin_port = htons(port_);
        inet_aton(ip_.c_str(), &(local.sin_addr));

        //bind
        int bd = bind(listensockfd_, (struct sockaddr*)&local, sizeof(local));
        if(bd < 0)
        {
            log(Fatal, "bind error, errno: %d, errstring: %s",errno, strerror(errno));
            exit(BIND_ERR);
        }
        log(Info, "bind success");


        //tcp面向连接, 通信之前要建立连接
        //监听
        if(listen(listensockfd_, backlog) < 0)
        {
            log(Fatal, "listen error, errno: %d, errstring: %s",errno, strerror(errno));
            exit(LITSEN_ERR);
        }
        log(Info, "listen success");

    }

为什么客户端退出之后再连接可以直接连上?

因为客户端的端口是系统随机指定的,每次都会换端口

而服务器的上的某个服务,端口号是固定的

一个报文从客户端发到服务端,这从客户端发出去到服务端收到之前,这个报文都是在网络当中,但是在网络中是有存活时间的,存活最长时间称为MSL

TIME_WAIT的持续时间是2MSL

  • 在断开连接的时候,可能历史上还有残留的数据,所以要等这些历史的数据在网络通信当中消散,这一来一回的最大时间就是2MSL

    在准备断开连接的这个时间点,并不是要让对方收到这个数据(因为tcp有超时重传、按序到达机制,改补发的早就补发了),而是让对方丢弃这些数据。如果历史残留数据没有消散,在某些情况下,例如又重新连接,采用了同样的ip和端口(极端情况),这样就会影响下一次通信!

  • 四次挥手的时候,也是要保证挥手成功,前两次挥手双方的连接都还未彻底释放,如果失败还有机会补发;但如果最后一次ACK挥手报文丢失,而主动断开连接的一方又立即释放了连接,那对方就会一直处于LAST_ACK状态,这时候对方就算重新补发FIN,但是人家已经退了,所以在TIME_WAIT等待期间,如果ACK丢失,还能收到对方补发的FIN,这就能确保四次挥手正常退出

数据在网络当中是毫米级别,但为什么TIME_WAIT一般是30s~60s呢?

这里分为最大传送时长最大存在时长

传输是毫秒级别,但是存在时长(例如在网络中阻塞了)是由系统决定的

cat /proc/sys/net/ipv4/tcp_fin_timeout
# 可以自己改
  • 17
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

加法器+

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值