Linux网络-传输层UDP/TCP详解

目录

计算机网络的层状结构

UDP协议

UDP报文格式

理解UDP/TCP报文的本质

UDP的特点

UDP的缓冲区

sendto/recvfrom/send/recv/write/read IO类接口

UDP是全双工的

UDP注意事项

UDP协议,实现简单聊天室(服务端+客户端)

TCP协议

TCP协议段格式

确认应答(ACK)机制

TCP全双工,具有发送缓冲区和接收缓冲区。

流量控制

连接管理机制

如何理解连接

理解 - TCP建立连接的三次握手

理解 - TCP断开连接的四次挥手

TCP四次挥手的状态变化

四次挥手的CLOSE_WAIT状态

四次挥手的TIME_WAIT状态

多进程,多线程,线程池版的基于TCP/IP协议的client-server

tcp_server.hpp

tcp_server.cc

tcp_client.cc

超时重传机制

滑动窗口

快重传

拥塞控制

延迟应答

捎带应答

TCP面向字节流

粘包问题

针对TCP面向字节流-粘包问题的自定义应用层协议的网络版本计算器server&&client

TCP异常情况

TCP/UDP对比

真·TCP/UDP对比

如何让UDP实现可靠传输

listen的第二个参数


计算机网络的层状结构

Linux网络-传输层UDP/TCP详解_第1张图片

计算机网络是层状的,UDP/TCP协议是传输层协议。

我们使用的网络编程系统调用就是传输层提供的接口。例如accept, connect, listen, socket...

网络传输时,向下要封装报头,向上要解包,也就是去掉报头。发送方需要将应用层数据包逐层向下,添加每层的报头进行封装。接收方通过网络接收到对方传来的报文时,需要逐层向上去掉报头,进行解包,提取出最终的有效载荷。也就是网络发送方真正要传输的数据。

几乎任何协议,都要首先解决两个问题:1. 如何分离(将报头和有效载荷拆分开,接收方需要做的)和如何封装(发送方做的,添加报头)2. 如何向上交付(有效载荷拆分出来之后,交付给上一层)

套接字 = IP + 端口号。IP是网络层协议报头包含的字段,标识着网络传输时应该将数据传输给哪个主机。端口号是传输层协议报头包含的字段,对应着传输层报文中的有效载荷应该交付给该主机上的哪个进程。这样对应进程收到传输层的有效载荷之后,就可以根据应用层协议,将应用层报文中的有效载荷提取出来。

这块其实是网络基础1的内容,之前没写博客... 这里简单记录下。

UDP协议

UDP报文格式

Linux网络-传输层UDP/TCP详解_第2张图片

1. 源端口号,目的端口号标明了此UDP报文是哪个进程发出的,发送给哪个进程。
2. 如何解包(分离):UDP采用固定长度报头,接收方将报文前8字节提取出,剩下的就是有效载荷。
3. 如何向上交付:接收方的OS的传输层收到UDP报文之后,16位目的端口号标明了对应进程。(该进程bind了端口号,在内核中,存储诸如port : PCB指针这样的KV类型,就可以通过端口号找到对应的进程)
4. 承接第三点,这也是为什么在应用层编写UDP代码时,定义端口号时,喜欢定义为uint16_t,正是因为传输层协议使用的端口号为16位的。
5. UDP如何提取到整个完整报文:16位UDP长度字段(???????这块好像涉及到UDP报文的存储方式了... 真的需要提取吗....(大概率是需要的...) TCP仅包含报头长度,有什么影响吗???????)

理解UDP/TCP报文的本质

Linux网络-传输层UDP/TCP详解_第3张图片

1. UDP/TCP报头在操作系统中本质就是一个位段类型。
2. OS中会有很多UDP报文,TCP报文,那么,OS需要管理这些报文,即先描述,再组织。所以报文在内核中并非仅位段 + 有效载荷。还会有其他字段。

UDP的特点

UDP传输过程类似于寄信。

无连接: 知道对端的IP和端口号就可以直接进行传输, 不需要建立连接;(sendto)

不可靠: 没有确认机制, 没有重传机制; 如果因为网络故障该数据段无法发到对方, UDP协议层也不会给应用层返回任何错误信息;

面向数据报: 不能够灵活的控制读写数据的次数和数量;  :  应用层交给UDP多长的报文, UDP原样发送, 既不会拆分, 也不会合并;   用UDP传输100个字节的数据: 如果发送端调用一次sendto, 发送100个字节, 那么接收端也必须调用对应的一次recvfrom, 接收100个字节; 而不能循环调用10次recvfrom, 每次接收10个字节;  发送端的sendto和接收端的recvfrom次数是一样的。

UDP的缓冲区

UDP没有真正意义上的发送缓冲区,调用sendto会直接交给内核, 由内核将数据传给网络层协议进行后续的传输动作;

UDP具有接收缓冲区,但是因为UDP不可靠,没有任何传输控制行为。故这个接收缓冲区无法保证接收到的UDP报的顺序和发送UDP报的顺序一致。如果缓冲区满了,再到达的UDP数据就会被丢弃。(提醒一下,接收缓冲区中存储的是UDP报文中去掉报头之后的有效载荷)

sendto/recvfrom/send/recv/write/read IO类接口

Linux网络-传输层UDP/TCP详解_第4张图片

我们调用UDP的sendto/recvfrom和TCP的recv/send时,表面上是网络发送和网络接收函数,实质上,它们只是拷贝函数,将应用层缓冲区的数据拷贝到发送缓冲区,将接收缓冲区中的数据拷贝到应用层缓冲区中。(特别是对于TCP而言)(注意,UDP没有发送缓冲区,所以为虚线,若TCP则为实线。)

将数据拷贝到发送缓冲区之后,什么时候进行网络发送,发多少,出错了怎么办,这些都是由传输层协议决定的。缓冲区也是传输层提供的。

UDP是全双工的

UDP没有发送缓冲区,有接收缓冲区,数据在网络中的发送和接收互不影响,可以同时进行,因此为全双工的。UDP的socket既能读,也能写。

UDP注意事项

我们注意到, UDP协议首部中有一个16位的最大长度. 也就是说一个UDP能传输的数据最大长度是64K(包含UDP首部8字节). 然而64K在当今的互联网环境下, 是一个非常小的数字. 如果我们需要传输的数据超过64K, 就需要在应用层手动的分包, 多次发送, 并在接收端手动拼装;

UDP协议,实现简单聊天室(服务端+客户端)

udpserver.hpp

#ifndef _UDP_SERVER_HPP_
#define _UDP_SERVER_HPP_

#include "log.hpp"
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

// 基于UDP协议的服务端
class UdpServer
{
public:
    UdpServer(uint16_t port, std::string ip = "")
        : _port(port), _ip(ip), _sock(-1)
    {
    }
    void initServer()
    {
        // 1.创建套接字
        _sock = socket(AF_INET, SOCK_DGRAM, 0); // 1.套接字类型:网络套接字(不同主机间通信) 2.面向数据报还是面向字节流:UDP面向数据报
        // SOCK_DGRAM支持数据报(固定最大长度的无连接、不可靠消息)。
        if (_sock < 0)
        {
            // 创建套接字失败?
            logMessage(FATAL, "%d:%s", errno, strerror(errno));
            exit(2);
        }
        // 2.bind : 将用户设置的ip和port在内核中和我们当前的进程强关联
        struct sockaddr_in local; // 传给bind的第二个参数,存储ip和port的信息。
        local.sin_family = AF_INET;
        // 服务器的IP和端口未来也是要发送给对方主机的 -> 先要将数据发送到网络!-> 故需要转换为网络字节序
        local.sin_port = htons(_port); // host->network l 16
        // "192.168.110.132" -> 点分十进制字符串风格的IP地址,每一个区域取值范围是[0-255]: 1字节 -> 4个区域,4字节
        // INADDR_ANY:让服务器在工作过程中,可以从本机的任意IP中获取数据(一个服务器可能不止一个ip,(这块有些模糊)
        local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str());
        // 点分十进制字符串风格的IP地址 <-> 4字节整数   4字节主机序列 <-> 网络序列   inet_addr可完成上述工作
        if (bind(_sock, (struct sockaddr *)&local, sizeof local) < 0) // !!!
        {
            logMessage(FATAL, "bind : %d:%s", errno, strerror(errno));
            exit(2);
        }
        logMessage(NORMAL, "init udp server %s...", strerror(errno));
    }
    void start()
    {
        // 作为一款网络服务器,永远不退出的!-> 服务器启动-> 进程 -> 常驻进程 -> 永远在内存中存在,除非挂了!
        char buff_message[1024]; // 存储从client发来的数据
        for (;;)
        {
            struct sockaddr_in peer;     // 输出型参数
            socklen_t len = sizeof peer; // 输出+输入型参数
            memset(&peer, 0, len);

            // 读取client发来的数据
            ssize_t sz = recvfrom(_sock, buff_message, sizeof(buff_message) - 1, 0, (struct sockaddr *)&peer, &len); // 哪个ip/port给你发的
            // receive a message from a socket,从一个套接字(或许对应网卡)中接收信息
            buff_message[sz] = 0;
            uint16_t cli_port = ntohs(peer.sin_port);      // 从网络中来,转换为主机序列!哈哈哈
            std::string cli_ip = inet_ntoa(peer.sin_addr); // 4字节网络序列ip->字符串风格的IP
            char key[128];
            snprintf(key, sizeof key, "%s-%d", cli_ip.c_str(), cli_port);
            logMessage(NORMAL, "[%s:%d] clinet worked", cli_ip.c_str(), cli_port);
            // printf("[%s:%d]# %s\n", cli_ip.c_str(), cli_port, buff_message);    // 可有可无...服务端显示一下客户端发来了什么
            if (_map.find(key) == _map.end())
            {
                _map.insert({key, peer});
                logMessage(NORMAL, "[%s:%d] client joined", cli_ip.c_str(), cli_port);
            }
            // if (sz > 0)
            // {
            //     // 从client获取到了非空数据,client端的ip/port信息存储在peer中。
            //     buff_message[sz] = 0;
            //     uint16_t cli_port = ntohs(peer.sin_port); // 从网络中来,转换为主机序列!哈哈哈
            //     std::string cli_ip = inet_ntoa(peer.sin_addr); // 4字节网络序列ip->字符串风格的IP
            //     snprintf(key, sizeof key, "%s-%d", cli_ip.c_str(), cli_port);
            //     logMessage(NORMAL, "[%s:%d] clinet worked", cli_ip.c_str(), cli_port);
            //     // printf("[%s:%d]# %s\n", cli_ip.c_str(), cli_port, buff_message);    // 可有可无...服务端显示一下客户端发来了什么
            //     if(_map.find(key) == _map.end())
            //     {
            //         _map.insert({key, peer});
            //         logMessage(NORMAL, "[%s:%d] client joined", cli_ip.c_str(), cli_port);
            //     }
            // }
            // else
            // {
            //     // 这里的逻辑是:如果client端最初发送空数据,则不加入群聊。
            //     buff_message[0] = 0;
            // }
            // 群聊服务端,此时需要给所有群聊中的client,发送某client发来的数据
            for (auto &iter : _map)
            {
                // if(iter.second.sin_port != peer.sin_port)
                std::string sendMessage(key);
                sendMessage += "# ";
                sendMessage += buff_message;
                sendto(_sock, sendMessage.c_str(), sendMessage.size(), 0, (struct sockaddr *)&iter.second, sizeof iter.second);
            }
        }
    }
    ~UdpServer()
    {
        close(_sock);
    }

private:
    // 一个服务器,一般必须需要ip地址和port(16位的整数)
    uint16_t _port;  // 端口号
    std::string _ip; // ip
    int _sock;
    std::unordered_map _map; // [ip:port] | struct
};

#endif

udp_server.cc

#include "udp_server.hpp"
#include 
#include 
#include 

static void Usage(const char *proc)
{
    std::cout << "\nUsage: " << proc << " port\n"
              << std::endl;
}

// 格式:./udp_server 8080
// 疑问: 为什么不需要传ip?
int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        Usage(argv[0]);
        exit(1);
    }
    uint16_t port = atoi(argv[1]);
    std::unique_ptr svr(new UdpServer(port));
    svr->initServer();
    svr->start();
    return 0;
}

udp_client.cc

#include "log.hpp"
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
static void Usage(char *proc)
{
    std::cout << "\nUsage : " << proc << " server_ip server_port\n"
              << std::endl;
}

void *in(void *args)
{
    // 接收server端发送的数据
    logMessage(NORMAL, "接收线程已启动");
    int sock = *(int *)args; // 套接字
    char message_svr[1024];  // 缓冲区
    while (true)
    {
        struct sockaddr_in server;
        socklen_t len = sizeof server;
        // 一直都在等待UDP报文,进行接收
        ssize_t sz = recvfrom(sock, message_svr, sizeof message_svr - 1, 0, (struct sockaddr *)&server, &len);
        if (sz > 0)
        {
            message_svr[sz] = 0;
            std::cout << message_svr << std::endl;
        }
    }
}

struct ThreadData
{
    ThreadData(int sock, const std::string &ser_ip, uint16_t ser_port)
        : _sock(sock), _server_ip(ser_ip), _server_port(ser_port)
    {
    }
    int _sock;
    std::string _server_ip;
    uint16_t _server_port;
};

void *out(void *args)
{
    // 发送client端发送的数据
    logMessage(NORMAL, "发送线程已启动");
    ThreadData *td = (ThreadData *)args;
    struct sockaddr_in server;
    memset(&server, 0, sizeof server);
    server.sin_port = htons(td->_server_port);
    server.sin_family = AF_INET;
    server.sin_addr.s_addr = inet_addr(td->_server_ip.c_str());
    std::string message;
    while (true)
    {
        // std::cout << "client# ";
        std::cerr << "client# ";  // 为了不让这个输出信息也输出到管道中!和服务端发来的进行区分!否则会出现乱码现象。
        // fflush(stdout);
        std::getline(std::cin, message);

        sendto(td->_sock, message.c_str(), message.size(), 0, (struct sockaddr *)&server, sizeof server);
    }
}

// ./udp_client 127.0.0.1 8080
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(1);
    }
    int sock = socket(AF_INET, SOCK_DGRAM, 0);
    if (sock < 0)
    {
        logMessage(FATAL, "socket : %d:%s", errno, strerror(errno));
        exit(2);
    }
    pthread_t itid, otid;
    pthread_create(&itid, nullptr, in, (void *)&sock);
    struct ThreadData td(sock, argv[1], atoi(argv[2]));
    pthread_create(&otid, nullptr, out, (void*)&td);

    pthread_join(itid, nullptr);
    pthread_join(otid, nullptr);
    return 0;
}

上方是使用UDP协议实现的一个简单的聊天室的server和client。

服务端:socket创建套接字,bind绑定ip和端口,然后开始recvfrom,业务处理,sendto(因为UDP无连接,不可靠) 这里的业务处理很简单,存在一个std::unordered_map _map; // [ip:port] | struct,记录当前聊天室的所有客户端,value是这个客户端对应的sockaddr_in,将来用于sendto。将每个客户端发来的数据和它的ip:port字符串进行拼接,发送给这个unordered_map中的所有客户端。在客户端第一次发信息过来时,会自动加入聊天室,也就是进入unordered_map中

客户端:socket创建套接字,不需要显式bind,创建两个线程,一个收取网络数据,另一个获取用户输入,发送网络数据。接收线程只需要套接字,发送线程需要套接字,server_ip,server_port(用于sendto)

Linux网络-传输层UDP/TCP详解_第5张图片

Linux网络-传输层UDP/TCP详解_第6张图片 因为接收线程和发送线程的打印信息存在混乱的情况。所以将接收用户输入的线程的打印信息设为cerr,也就是往标准错误中输出。而接收服务端网络数据的线程的输出信息定为cout。再通过mkfifo创建管道文件,将客户端的标准输出重定向到管道文件中。解决输出信息混乱的问题。

TCP协议

TCP全称为 "传输控制协议(Transmission Control Protocol")

TCP面向连接,具有可靠性,面向字节流。

TCP协议段格式

Linux网络-传输层UDP/TCP详解_第7张图片

16位源/目的端口号:表示数据从哪个进程来,到哪个进程去。

TCP报文如何进行解包(分离):报头中,有一个4位首部长度字段,表征着该TCP报头有多少个32bit(4字节),TCP报头不是定长的,而是变长的,因为选项内容不定。所以,TCP头部最大为15*4=60字节。即0~60字节。

所以,解包的大致过程为:提取20字节,获取4位首部长度(在20字节中的位置固定),x*4-20为选项的长度。提取出x*4-20字节后,剩下的就是有效载荷(也就是应该交给应用层的数据,需存入TCP的接收缓冲区中)

16位校验和:不考虑...   选项:不考虑.... 数据:就是传输层协议携带的有效载荷,在TCP/IP网络通信过程中,一般为应用层报文!需写入TCP接收缓冲区中,由用户读取。保留位(6bit位):该字段的位设置为零,这些位保留,供以后扩用。

6位标志位:TCP报文有多种类型(属性),这6个标志位是用于标记报文类型的,比如ACK标志位若为1,则代表这个报文有ACK属性,即表示确认序号有效(见下方确认应答(ACK)机制)(多个标志位可叠加,一个报文可有多种属性类型)

URG: 紧急指针是否有效(TCP因为有序号,故数据是按序到达的,URG配合16位紧急指针可以实现将有效载荷中的某紧急数据提前向上交付)
ACK: 确认序号是否有效(凡是该报文具有应答特征,该标志位都会被设置为1。大部分网络报文ACK都是被设置为1的,因为TCP有捎带应答机制。但是第一个TCP连接请求报文的ACK标志位不为1)
PSH: 提示接收端应用程序立刻从TCP缓冲区把数据读走(联系流量控制和滑动窗口机制)
RST: 对方要求重新建立连接; 我们把携带RST标识的称为复位报文段(比如一端网线断了,则两端对于连接建立认知不一致)
SYN: 请求建立连接; 我们把携带SYN标识的称为同步报文段
FIN: 通知对方, 本端要关闭了, 我们称携带FIN标识的为结束报文段

确认应答(ACK)机制

TCP和UDP一个最大的不同就是,TCP具有可靠性,而可靠性就需要TCP采取一些机制和策略措施来实现,其中最重要的就是确认应答(ACK)机制。
为什么网络传输存在不可靠性呢?(这里的不可靠,比如数据在传输过程中丢失了,丢包了。)其实,单纯只是因为传输距离变长了,比如在操作系统内部,也就是一个机器中,也需要近距离通过电路传输,因为这里距离近,所以不存在协议。而一旦进行网络传输,也就是主机与主机之间,距离变长了,就需要一些协议,比如TCP/IP协议。
不存在100%可靠的协议,比如A主机向B主机发送一个数据,没有哪个协议能够保证这个数据一定送达。但是TCP的确认应答(ACK)机制,能够保证在局部上数据传输的100%可靠性。

TCP协议的确认应答机制:当A向B发送一条消息,我们无法保证,最新传输的这条消息的可靠性,但是如果B就这条消息,给A一个ACK(一个确认应答),则A一定可以保证B收到了这条消息。

TCP将发送缓冲区中的每个字节的数据都进行了编号,发送TCP报文时,报头中的32位序号字段应该填写这个报文的数据部分(有效载荷)的第一个字节的序号。比如这个报文的32序号为1,包含1000字节的数据,则接收方接收到之后,返回ACK时,需要将32位确认序号字段写为1001,意思是告诉发送者, 我已经收到了哪些数据(1~1000字节的数据); 下一次你从哪里开始发(1001字节开始发),同时接收方返回的报文的ACK标志位应置为1(代表着32位确认序号有效)

Linux网络-传输层UDP/TCP详解_第8张图片

TCP报文发送方,可以一次发送三个报文(多个)(见下方滑动窗口机制),这三个报文可能不是按序到达的,此时,接收方就可以根据这些报文的序号,将接收到的报文进行排序,确保可靠性!

确认序号的含义是:表示确认序号对应的数字,之前的所有数据报文已经全部都收到了。告知发送方下次从确认序号指明的序号开始发送。
比如,接收方发送ACK为1001,2001,3001三个ACK报文,此时若1001和2001ACK丢失了,3001ACK接收方接收到了,这种情况并不影响,因为根据确认序号的含义,表示之前的包含1-3000的数据的三个报文,接收方都收到了。

序号和确认序号的作用:

1. 将请求和应答一一对应。(接受方知道哪个ACK对应哪个之前发送过的数据报文)
2. 允许部分ACK丢失或者不给应答。因为确认序号的含义是确认序号之前的所有数据都收到了。
3. 接收方可以根据序号,将接收到的报文进行排序,解决报文乱序问题。
4. 一个报文中设置序号和确认序号两个字段是因为,TCP是全双工的,任何一方在发送ACK确认的时候,也可能同时想向对方发送数据消息。既可以收,也可以发。(只需要填写确认序号字段,且将ACK标志位置为1即可)     见下文    (这个... 和全双工有关系?...)

TCP全双工,具有发送缓冲区和接收缓冲区。

Linux网络-传输层UDP/TCP详解_第9张图片
TCP全双工,client和server都有发送缓冲区与接收缓冲区。

我们编写TCP代码时,使用的send,recv方法,其实只是拷贝函数,而不是网络收发函数。send的作用是把应用层的缓冲区数据拷贝到发送缓冲区,recv的作用是把接收缓冲区中的数据拷贝到应用层缓冲区。

而拷贝到发送缓冲区之后,什么时候发,发多少,丢包出错了怎么办,这都是TCP协议需要管的,所以称之为传输控制协议!(好兄弟,TCP是面向字节流的)

这两对缓冲区之间的数据流动互不影响,所以TCP是全双工的!

流量控制

接收端处理数据的速度是有限的. 如果发送端发的太快, 导致接收端的缓冲区被打满, 这个时候如果发送端继续发送, 就会造成丢包, 继而引起丢包重传等等一系列连锁反应.

因此TCP支持根据接收端的处理能力(其实就是接收端的接收缓冲区的剩余空间大小), 来决定发送端的发送速度. 这个机制就叫做流量控制(Flow Control);

1. 接收端将自己可以接收的剩余接收缓冲区空间大小放入TCP首部中的 "窗口大小" 字段, 通过ACK报文通知发送端;(因为接收端需要给发送端发送的报文进行ACK)
2. 窗口大小字段越大, 说明网络的吞吐量越高;
3. 接收端一旦发现自己的缓冲区快满了, 就会将窗口大小设置成一个更小的值通知给发送端; 发送端接受到这个更小的窗口大小值之后, 就会减慢自己的发送速度;
4. 如果接收端缓冲区满了, 就会将窗口置为0; 这时发送方不再发送数据, 但是需要定期发送一个窗口探测数据段(可以理解为不携带有效载荷的报文), 使接收端把窗口大小告诉发送端。
5. TCP首部中的16位窗口大小字段,就是存放了窗口大小信息。16位最大表示65535字节。实际上,TCP首部40字节选项(最大40字节)中还包含了一个窗口扩大因子M, 实际窗口大小是窗口字段的值左移 M 位;
6. 当接收端的ACK报文窗口大小总是很小或总是为0时,发送端发送的报文中可以将PSH标志位置为1,表示督促对方尽快将接收缓冲区中的数据向上交付。

7. 流量控制是TCP连接双方的,因为TCP协议的每一方都有一个发送缓冲区和一个接收缓冲区。
8. 在TCP连接双方第一次进行携带有有效载荷的报文进行通信时,如何得知对方的窗口带下?其实,第一次数据通信 不等于 第一次交换报文。在TCP三次握手时,需要传输报文,这时就可以填写窗口大小字段,交换双方的接收缓冲区剩余空间大小。

连接管理机制

正常情况下,TCP要经过三次握手建立连接,四次挥手断开连接。(TCP面向连接!)

如何理解连接

对于一个server来说,可能会有很多client连接server,所以server端一定会存在大量的连接。OS需要管理这些连接!则需要先描述,再组织。所以所谓的连接,本质就是操作系统内核中的一种数据结构类型。当建立连接成功时,就是在内存中创建对应的连接对象。再对多个连接对象进行某种数据结构的组织,方便管理。

理解 - TCP建立连接的三次握手

Linux网络-传输层UDP/TCP详解_第10张图片

1. TCP三次握手的过程中,并非是传输SYN,ACK...而是传输TCP报文,这个报文的SYN,ACK标志位为1

2. 可以将上图的纵轴理解为时间线,TCP三次握手的过程需要时间消耗,每次报文的传输也需要时间消耗。

3. 三次握手并不是一定成功,只是较大概率成功,TCP的可靠性是在三次握手建立连接完成之后才能保证的。主要原因是第三次握手时,客户端发来的ACK报文可能丢包。

4. 客户端和服务端在三次握手的进行过程中,会有状态变化。比较值得关注的是,客户端在发送完第三次握手的ACK之后,进入ESTABLISED状态,而服务端接收到ACK才会认为连接建立完成。具有滞后性。

为什么TCP连接过程设置为三次握手,而不是一次,两次,四次呢?

TCP三次握手的作用,意义,好处:

1. 三次握手,推广至奇数次握手,则最后一次握手一定是由客户端发起的(大多),则在最后一次ACK发起后,客户端认为连接建立成功,而服务端收到ACK后,才会认为连接建立成功,则服务端建立连接在客户端之后,这样,可以嫁接同等的成本给客户端,防止单机客户端耗费低成本就消耗服务端的大量资源。(一般服务端配置都比客户端高)同时,若第三次握手ACK丢失,则此次连接失败的后果由客户端承担。

2. 三次握手,对于客户端和服务端来说,每一方都收到了对方的一次ACK,证明此方到彼方的TCP网络传输信道是畅通的。也就是在TCP建立连接的过程中,验证了全双工!而一次握手和两次握手都无法验证全双工。

TCP一次握手建立连接:一次握手会使得服务端很难确定连接的请求是否是合法的,因为一次握手无法提供足够的信息来判断请求的来源和目的。攻击者可以通过伪造请求来欺骗服务端,从而实现未授权访问或恶意攻击。同时,恶意客户端可能采用同一时间发送大量SYN请求报文的方式来攻击服务端。OS管理这些连接需要成本(内核数据结构),故,对于服务端来说很危险。且无法验证全双工。

TCP两次握手建立连接:两次握手,对于服务端来说依旧有安全问题,比如客户端发送大量的SYN,服务端向客户端发送SYN+ACK,这样服务端就认为TCP连接建立成功的话,客户端如果把服务端的第二次握手报文丢弃,则服务端建立大量恶意连接,消耗系统资源,则客户端承担的成本比服务端小的多,这样单机恶意攻击服务端的情况就难以防御。类似于一次握手。同时无法验证全双工,只能验证客户端到服务端的传输信道。

TCP四次握手建立连接:依旧有安全问题,客户端可能丢弃第四次报文,此时server比client先建立连接。同时,偶数次握手,TCP建立连接失败的后果由服务端承担,不安全。

总之,TCP的三次握手,其实本身就无法保证连接一定建立成功。是一种较小成本,较为安全的一种握手方式。1. 若客户端恶意攻击,则可以嫁接同等成本给客户端,防止单机以低成本恶意攻击服务端。2. 验证全双工。而对于5678更多次的握手,就是浪费网络资源,同时也无法提高安全性。

(好了,到此为止吧,第一次学,浅了解一下(doge))最有说服力的还是验证全双工!!!

理解 - TCP断开连接的四次挥手

Linux网络-传输层UDP/TCP详解_第11张图片

1. 主动断开连接的一方先发送FIN报文:FIN,ACK,FIN,ACK

2.  被动断开连接的一方可能将第二次挥手的ACK和第三次挥手的FIN合并为一个报文。四次挥手可能为三次挥手。

3. 四次挥手不一定顺利完成,比如第二次ACK丢失,最后一次ACK丢失。但是因为TCP有超时重传机制,所以整体来说不影响。

4. 为什么是四次挥手?因为断开连接是建立TCP连接双方的事情,需要双方都关闭此方到彼方的传输信道,故每一方都需要发送一次FIN报文。(因为TCP全双工,两个通信信道呀,所以需要两次FIN报文发送,同时需要两次ACK确认应答。

TCP四次挥手的状态变化

服务端状态转化:

[CLOSED -> LISTEN] 服务器端调用listen后进入LISTEN状态, 等待客户端连接;
[LISTEN -> SYN_RCVD] 一旦监听到连接请求(同步报文段), 就将该连接放入内核等待队列中, 并向客户端 发送SYN确认报文.
[SYN_RCVD -> ESTABLISHED] 服务端一旦收到客户端的确认报文, 就进入ESTABLISHED状态, 可以进行 读写数据了.
[ESTABLISHED -> CLOSE_WAIT] 当客户端主动关闭连接(调用close), 服务器会收到结束报文段, 服务器返回确认报文段并进入CLOSE_WAIT;
[CLOSE_WAIT -> LAST_ACK] 进入CLOSE_WAIT后说明服务器准备关闭连接(需要处理完之前的数据); 当服务器真正调用close关闭连接时, 会向客户端发送FIN, 此时服务器进入LAST_ACK状态, 等待最后一个 ACK到来(这个ACK是客户端确认收到了FIN)
[LAST_ACK -> CLOSED] 服务器收到了对FIN的ACK, 彻底关闭连接.

客户端状态转化:

[CLOSED -> SYN_SENT] 客户端调用connect, 发送同步报文段;
[SYN_SENT -> ESTABLISHED] connect调用成功, 则进入ESTABLISHED状态, 开始读写数据; [ESTABLISHED -> FIN_WAIT_1] 客户端主动调用close时, 向服务器发送结束报文段, 同时进入 FIN_WAIT_1;
[FIN_WAIT_1 -> FIN_WAIT_2] 客户端收到服务器对结束报文段的确认, 则进入FIN_WAIT_2, 开始等待服务器的结束报文段;
[FIN_WAIT_2 -> TIME_WAIT] 客户端收到服务器发来的结束报文段, 进入TIME_WAIT, 并发出LAST_ACK;
[TIME_WAIT -> CLOSED] 客户端要等待一个2MSL(Max Segment Life, 报文最大生存时间)的时间, 才会进入CLOSED状态.

其中比较关键的两个状态是:

1. 被动关闭方收到对方的FIN,并发出ACK之后,在发出第三次挥手的FIN之前,会进入CLOSE_WAIT状态。

2. 主动关闭方在收到对方第三次挥手的FIN,并发出ACK之后,会进入一段TIME_WAIT状态,而不是直接进入CLOSED状态。

四次挥手的CLOSE_WAIT状态

大多情况下,TCP连接的断开,都是由客户端发起的。则若服务端在收到客户端的FIN并发出ACK之后,不发出FIN,则会一直进入CLOSE_WAIT状态。而发出第三次挥手的FIN,就需要服务端主动close关闭文件描述符。

故,服务端需要注意,当TCP通信结束后,需要close对应的文件描述符。否则,这个TCP连接将不会释放,时间长了,越来越多的TCP连接占用内存资源,会引发问题。同时,服务进程的文件描述符也会越来越少。

所以,若发现服务器有大量的close_wait状态的连接存在时,原因是什么呢?即应用层服务器写的有bug,忘记关闭对应的连接sockfd,导致四次挥手没有正确完成。(注:下方多版本TCPserver+client,服务端都进行了close文件描述符,避免了CLOSE_WAIT连接大量存在。)

四次挥手的TIME_WAIT状态

主动断开连接的一方,收到对方第三次挥手的FIN,且发出ACK之后,会进入TIME_WAIT状态,而不是直接进入CLOSED状态。经验证,确实是!

在此状态下,虽然应用程序终止了,但TCP协议层的TCP连接并没有完全断开,地址信息ip,port依旧是被占用的。
故,有一个现象:当服务端直接ctrl+c之后,因为文件描述符表的生命周期随进程!故,进程终止,相当于所有文件描述符被关闭,相当于服务端主动进行四次挥手断开连接。故TCP协议层的连接此时会进入TIME_WAIT状态,故此时ip,port是被占用的,故此时若立刻再次启动服务进程,会显示失败。

1. TCP协议规定,主动关闭连接的一方要处于TIME_ WAIT状态,等待两个MSL(maximum segment lifetime) 的时间后才能进入到CLOSED状态;
2. 我们使用Ctrl-C终止了server, 所以server是主动关闭连接的一方, 在TIME_WAIT期间仍然不能再次监听同样的server端口;
3. MSL在RFC1122中规定为两分钟,但是各操作系统的实现不同, 在Centos7上默认配置的值是60s;

为什么要有TIME_WAIT状态?(且还是2MSL)
1. MSL是TCP报文的最大生存时间, 因此TIME_WAIT持续存在2MSL的话, 就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失(否则服务器立刻重启, 可能会收到来自上一个进程的迟到的数据, 但是这种数据很可能是错误的);
2. 保证最后一个ACK成功传输给对端。(若传输失败,则对端可能会进行超时重传,重新传来一个FIN,这时虽然客户端不存在了,但是TCP连接还在,仍然可以重发LAST_ACK;

解决TIME_WAIT状态引起的bind失败的方法

在server的TCP连接没有完全断开之前不允许重新监听, 某些情况下可能是不合理的。

int opt = 1; setsockopt(listensock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));  进行setsockopt即可让端口号和ip在TIME_WAIT期间,依旧被服务器再次绑定。

多进程,多线程,线程池版的基于TCP/IP协议的client-server

tcp_server.hpp

#ifndef _TCP_SERVER_HPP_
#define _TCP_SERVER_HPP_

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include "./threadpool/log.hpp"
#include "./threadpool/threadPool.hpp"

static void service(int sock, const std::string &client_ip, const uint16_t &client_port)
{
    // echo server
    // 注意,tcp是面向字节流的,可以直接使用文件IO接口: read write
    char buffer[1024];
    while (true)
    {
        ssize_t sz = read(sock, buffer, sizeof buffer);
        if (sz > 0)
        {
            buffer[sz] = 0;
            std::cout << client_ip << " : " << client_port << "# " << buffer << std::endl;
            write(sock, buffer, strlen(buffer)); // echo buffer,将发过来的数据当做字符串
        }
        else if (sz == 0)
        {
            // 有所不同,sz == 0(read返回0),表示客户端关闭连接(如果对方发送空字符串呢?... 事实证明,发送空串这里sz不会返回0,只针对于客户端关闭会返回0)
            logMessage(NORMAL, "%s:%d shutdown, service done", client_ip.c_str(), client_port);
            break;
        }
        else
        {
            logMessage(ERROR, "read socket error, %d:%s", errno, strerror(errno));
            break; // 读取失败,退出即可
        }
    }
    close(sock); // 注意,此处其实就是server从CLOSE_WAIT到LAST_ACK的状态转变过程,其实就是发送第三次FIN挥手
}

// 线程池版,就是多了一个线程名,标识哪个线程正在提供service
static void service_for_threadpool(int sock, const std::string &client_ip\
                    , const uint16_t &client_port, const std::string& name)
{
    // echo server
    // 注意,tcp是面向字节流的,可以直接使用文件IO接口: read write
    // 我们一般服务器进程业务处理,如果是从连上,到断开,要一直保持这个链接, 长连接。
    // 显然,长连接并不适合于线程池,因为线程池内线程数量有限   // 后面有其他方案!
    char buffer[1024];
    while(true)
    {
        ssize_t sz = read(sock, buffer, sizeof buffer);
        if(sz > 0)
        {
            buffer[sz] = 0;
            std::cout << name << " | " << client_ip << ":" << client_port << "# " << buffer << std::endl;
            write(sock, buffer, strlen(buffer));  // echo buffer,将发过来的数据当做字符串
        }
        else if(sz == 0)
        {
            // 有所不同,sz == 0,表示客户端关闭连接(如果对方发送空字符串呢?... 事实证明,发送空串这里sz不会返回0,只针对于客户端关闭会返回0)
            logMessage(NORMAL, "%s:%d shutdown, service done", client_ip.c_str(), client_port);
            break;
        }
        else
        {
            logMessage(ERROR, "read socket error, %d:%s", errno, strerror(errno));
            break;   // 读取失败,退出即可
        }
    }
    close(sock);
}

static void transform(int sock, const std::string &client_ip, const uint16_t &client_port, const std::string &name)
{
    char message[1024];
    ssize_t sz = recv(sock, message, sizeof message - 1, 0);
    if (sz > 0)
    {
        std::string result;
        message[sz] = '\0';
        char *ch = message;
        while (*ch != '\0')
        {
            if (islower(*ch) != 0)
                result += toupper(*ch);
            else
                result += *ch;
            ch++;
        }
        send(sock, result.c_str(), result.size(), 0);
    }
    else if (sz == 0)
    {
        logMessage(NORMAL, "%s:%d shutdown, service done", client_ip.c_str(), client_port);
    }
    else
    {
        logMessage(ERROR, "service recv error");
    }
    close(sock);  // 服务端进行第三次挥手!不会出现大量CLOSE_WAIT连接
}

// class ThreadData
// {
// public:
//     ThreadData() = default;
//     ThreadData(int sock, const std::string &ip, uint16_t port)
//         : _sock(sock), _ip(ip), _port(port)
//     {
//     }
//     int _sock;
//     std::string _ip;
//     uint16_t _port;
// };

class TcpServer
{
private:
    const static int gback_log = 20;
    // static void *threadRoutine(void *args)
    // {
    //     pthread_detach(pthread_self()); // 线程分离
    //     ThreadData *td = (ThreadData *)args;
    //     service(td->_sock, td->_ip, td->_port); // 让子线程给客户端提供服务
    //     delete td;                              // ThreadData动态开辟..
    //     // close(td->_sock);                       // 第三次FIN挥手...需要,但是其实service里面已经close过了。
    //     return nullptr;
    // }

public:
    TcpServer(uint16_t port, const std::string &ip = "")
    : _port(port), _ip(ip), _listen_sock(-1), _threadpool_ptr(ThreadPool::getThreadPool(5))  // 单例模式..
    {}
    // TcpServer(uint16_t port, const std::string &ip = "")
    //     : _port(port), _ip(ip), _listen_sock(-1)
    // {
    // }
    void initServer()
    {
        // 1. 创建套接字
        _listen_sock = socket(AF_INET, SOCK_STREAM, 0); // 网络套接字, 面向字节流(tcp)
        if (_listen_sock < 0)
        {
            logMessage(FATAL, "create listen socket error, %d:%s", errno, strerror(errno));
            exit(2);
        }
        logMessage(NORMAL, "create listen socket success: %d", _listen_sock);

        // 2. bind,注意云服务器不能绑定公网IP,不允许。
        struct sockaddr_in local;
        memset(&local, 0, sizeof local);
        local.sin_family = AF_INET;
        local.sin_port = htons(_port);
        local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str());
        if (bind(_listen_sock, (struct sockaddr *)&local, sizeof local) < 0)
        {
            logMessage(FATAL, "bind error, %d:%s", errno, strerror(errno));
            exit(3);
        }

        // 3. listen监听: 因为TCP是面向连接的,在我们正式通信之前,需要先建立TCP连接
        // listen: 将套接字状态设置为监听状态。服务器要一直处于监听状态,这样客户端才能随时发起连接。
        if (listen(_listen_sock, gback_log) < 0) // gback_log后面讲,全连接队列的长度。我猜测就是这个服务器同一时刻允许连接的客户端的数量最大值?也不太对呀,这么少么?
        {
            logMessage(FATAL, "listen error, %d:%s", errno, strerror(errno));
            exit(4);
        }
        logMessage(NORMAL, "init server success");
    }
    void start()
    {
        // signal(SIGCHLD, SIG_IGN); // 让子进程执行完service之后自动回收自己的资源,避免僵尸进程。多进程版1需要
        _threadpool_ptr->run(); // 线程池内所有线程开始执行routine,等待任务投放
        while (true)
        {
            // 4. 获取与客户端建立好的连接(注意,accept不进行三次握手)
            struct sockaddr_in client;
            socklen_t len = sizeof client;
            // 注意,这里要放在循环内部,因为要获取与多个客户端建立好的连接(要获取与多个客户端建立好的连接)
            int service_sock = accept(_listen_sock, (struct sockaddr *)&client, &len); // 返回一个用于与客户端进行网络IO的套接字,不同于listen_sock
            // On success, these system calls return a nonnegative integer that is a descriptor for the accepted socket.  On error, -1 is returned, and errno is set appropriately.
            if (service_sock < 0)
            {
                logMessage(ERROR, "accept error, %d:%s", errno, strerror(errno));
                continue; // 继续获取新连接,并非FATAL的
            }
            // 5. 获取连接成功,开始进行网络通信

            uint16_t client_port = ntohs(client.sin_port);      // 连接的客户端的port
            std::string client_ip = inet_ntoa(client.sin_addr); // 连接的客户端的ip
            logMessage(NORMAL, "get link(accept) success, service socket: %d | %s : %d", service_sock,
                       client_ip.c_str(), client_port);

            // 真·获取连接成功,开始进行网络通信
            {
                // // version 1: 单进程循环版,server端进程直接提供service版
                // // 同一时刻只能给一个client提供服务,处理完一个,service退出,才能再进行accept,显然不行。
                // 注意,这里其他客户端可以连接,因为listen了,只是主进程没有进行accept!
                // service(service_sock, client_ip, client_port);
            }
            {
                // // version 2: 多进程:父进程创建子进程,让子进程给客户端提供服务。父进程去再次accept获取新的客户端连接。
                // // 这样做的一个条件原理是:子进程fork出来之后自动继承父进程的文件描述符表
                // pid_t pid = fork();
                // assert(pid != -1);
                // if(pid == 0)
                // {
                //     // 子进程
                //     close(_listen_sock);  // _listen_sock是父进程用于accept与客户端获取连接的,子进程不需要。
                //     service(service_sock, client_ip, client_port);
                //     exit(0);  // 子进程给客户端提供完服务,退出。
                // }
                // close(service_sock);  // 父进程关闭service_sock(通常为4)否则随着客户端数量增多,文件描述符资源将耗尽
                //                       // 文件描述符泄漏。文件描述符本质是数组下标,是数组,则就有空间上限。
                //                       // 注意,此处为server从CLOSE_WAIT到LAST_ACK的状态转变过程,其实就是发送第三次FIN挥手。
            }
            {
                // // version 2.1 多进程2.0
                // // 创建子进程,让子进程再创建子进程去给客户端提供service,父进程继续while循环accept
                // // 其实只是回收子进程,避免僵尸进程的另一种方法罢了。
                // pid_t pid = fork();
                // if(pid == 0)
                // {
                //     // 子进程
                //     if(fork() > 0) exit(0); // 子进程直接退出
                //     // 孙子进程,它的父进程上面已经exit了,变为孤儿进程,由OS自动回收孤儿进程(让1号进程领养)
                //     close(_listen_sock);
                //     service(service_sock, client_ip, client_port);
                //     exit(0);
                // }
                // // 父进程
                // // close(service_sock);  // 依旧,这里为server进行第三次FIN挥手。避免大量CLOSE_WAIT连接
                // waitpid(pid, nullptr, 0);  // 虽然是0(阻塞式等待),但是不会阻塞。
            }
            {
                // // 多线程版本
                // pthread_t tid;
                // ThreadData *td = new ThreadData(service_sock, client_ip, client_port); // 这里不能在栈区开辟ThreadData
                // // ThreadData td(service_sock, client_ip, client_port);
                // pthread_create(&tid, nullptr, threadRoutine, (void *)td);
                // // 在多线程这里不用进程关闭特定的文件描述符 service_sock,线程会关闭的,也就是threadRoutine内部
            }
            {
                // 线程池版本
                // Task task(service_sock, client_ip, client_port, service_for_threadpool); // 长链接,不适合线程池版本

                // 客户端socket,connect,服务端accept。服务端的主线程往线程池的任务队列内放入一个Task,主线程继续去accept
                // 某个线程被pthread_cond_siganl,执行transform,客户端从键盘输入,send,服务端recv,处理数据,send,客户端recv。这里的recv是会阻塞对方的。
                // 客户端最后recv之后,服务端这个线程的Task执行结束,继续去检测任务队列,pthread_cond_wait
                // 客户端也close当前这个sock,客户端再次socket,connect时,服务端的主线程早就在accept等待连接了。
                Task task(service_sock, client_ip, client_port, transform);  // 指派线程池内某个线程执行transform
                _threadpool_ptr->pushTask(task);
            }
        }
    }
    ~TcpServer()
    {
        close(_listen_sock);
    }
private:
    uint16_t _port;   // 端口号
    std::string _ip;  // ip
    int _listen_sock; // 监听套接字,listen创建,用于accept
    ThreadPool *_threadpool_ptr;
};

#endif

tcp_server.cc

#include "tcp_server.hpp"
#include 
#include 

static void Usage(char *proc)
{
    std::cout << "\nUsage: " << proc << " port\n" << std::endl;
}

// ./tcp_server 8080
int main(int argc, char **argv)
{
    if(argc != 2)
    {
        Usage(argv[0]);
        exit(1);
    }
    std::unique_ptr svr(new TcpServer(atoi(argv[1])));
    svr->initServer();
    svr->start();
    return 0;
}

tcp_client.cc

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

// 长连接版本
// ./tcp/client serverIp serverport
// int main(int argc, char **argv)
// {
//     if(argc != 3)
//     {
//         std::cout << "\nUsage: "<< argv[0] << " serverIp serverPort\n" << std::endl;
//         exit(1);
//     }
//     // 1. socket
//     int sock = socket(AF_INET, SOCK_STREAM, 0);
//     if(sock < 0)
//     {
//         std::cerr << "socket error" << std::endl;
//         exit(2);
//     }
//     // client 不需要显式地bind,但是一定需要port,让OS自动进行port选择
//     // 2. connect : 由客户端发起三次握手请求,对方处于listen状态!
//     struct sockaddr_in server;
//     memset(&server, 0, sizeof server);
//     server.sin_family = AF_INET;
//     server.sin_addr.s_addr = inet_addr(argv[1]);
//     server.sin_port = htons(atoi(argv[2]));
//     if(connect(sock, (struct sockaddr*)&server, sizeof server) < 0)
//     {
//         std::cerr << "connect error" << std::endl;
//         exit(3);
//     }
//     std::cout << "connect success" << std::endl;
//     // 3. 网络通信:read/write recv/send
//     char buff[1024];
//     while(true)
//     {
//         std::string line;
//         std::cout << "请输入# ";
//         std::getline(std::cin, line);
//         send(sock, line.c_str(), line.size(), 0);
//         ssize_t sz = recv(sock, buff, sizeof buff - 1, 0);
//         if(sz > 0)
//         {
//             buff[sz] = 0;
//             std::cout << "server# " << buff << std::endl;
//         }
//         else if(sz == 0)
//         {
//             // 对端关闭连接,我方需要close文件描述符,相当于发起第三次FIN挥手
//             std::cout << "server quit, clinet quit too" << std::endl;
//             break;
//         }
//         else
//         {
//             std::cerr << "recv error" << std::endl;
//             break;
//         }
//     }
//     close(sock);
//     return 0;
// }

// 短连接版

// ./tcp/client serverIp serverport
int main(int argc, char **argv)
{
    if (argc != 3)
    {
        std::cout << "\nUsage: " << argv[0] << " serverIp serverPort\n"
                  << std::endl;
        exit(1);
    }

    struct sockaddr_in server;
    memset(&server, 0, sizeof server);
    server.sin_family = AF_INET;
    server.sin_addr.s_addr = inet_addr(argv[1]);
    server.sin_port = htons(atoi(argv[2]));
    int sock = 0;
    char buff[1024];   // 获取server发来的处理结果
    std::string line;  // 获取用户输入
    while (true)
    {
        sock = socket(AF_INET, SOCK_STREAM, 0);
        // std::cout << "sock : " << sock << std::endl;
        if (sock < 0)
        {
            std::cerr << "socket error" << std::endl;
            exit(2);
        }
        if (connect(sock, (struct sockaddr *)&server, sizeof server) < 0)
        {
            std::cerr << "connect error" << std::endl;
        }
        // std::cout << "connect success" << std::endl;
        // 3. 网络通信:read/write recv/send

        std::cout << "请输入# ";
        std::getline(std::cin, line);
        send(sock, line.c_str(), line.size(), 0);
        ssize_t sz = recv(sock, buff, sizeof buff - 1, 0);
        if (sz > 0)
        {
            buff[sz] = 0;
            std::cout << "server# " << buff << std::endl;
        }
        else if (sz == 0)
        {
            std::cout << "server quit, clinet quit too" << std::endl;
            break;
        }
        else
        {
            std::cerr << "recv error" << std::endl;
            break;
        }
        close(sock);
    }
    return 0;
}

client的短连接版对应server的线程池版。client的长连接版对应server的多进程,多线程版。 

Linux网络-传输层UDP/TCP详解_第12张图片

上图为多进程2.0版本中,若server创建子进程之后,不进行close文件描述符,则会有大量CLOSE_WAIT状态连接存在。

Linux网络-传输层UDP/TCP详解_第13张图片

上图为线程池版本中,每一次客户端都是以短连接的方式与服务端进行TCP连接。大概就是,先建立TCP连接,客户端send,服务端recv,进行业务处理,send结果,close文件描述符,客户端recv,打印输出,close文件描述符。但是因为往往是服务端先进行close,所以会有大量TIME_WAIT连接存在。而因为客户端为短连接,且端口号是由OS随机分配的,所以图中Foreign Address中的端口号都不一样。 

Linux网络-传输层UDP/TCP详解_第14张图片

线程池版本中,若服务端每次不关闭短连接的文件描述符,则会发现,存在很多CLOSE_WAIT状态连接,而因为server和client都是在一个主机上运行的,所以同时会发现还有很多FIN_WAIT2状态的TCP连接,就是因为主动断开TCP连接的一方没有收到对方的第三次FIN挥手。


RST,URG标志位没写,网络版本计算器没写(现在倒也不用写,后面再说吧)


超时重传机制

超时重传机制是TCP协议保证可靠性的一个关键因素。个人认为,TCP协议可靠性最关键的就是两个,一个是确认应答ACK机制,另一个就是超时重传机制。有了这两个机制,几乎就可以保证数据可靠地传输给对方。

超时重传机制:在确认应答机制的前提下,当A向B发送报文,收到对应的ACK后,可以确保报文传达给了B。而当A在一定时间内没有收到B的ACK时,则判定为出问题了,则A重新给B发送报文。

可能的情况:
1. 主机A发送数据报给B之后, 可能因为网络拥堵等原因, 数据报无法到达主机B,丢包了。2. 报文没有丢包,B发送的ACK丢失了。

则不论情况一还是情况二,A都需要在一定时间没有收到ACK之后,重新发送报文。但如果是情况二,则主机B会收到很多重复数据(报文),则TCP协议就需要能够识别出哪些包是重复的包,并将重复的丢弃掉。接收方可以根据TCP协议报头中的序列号,很容易进行去重。

超时的时间如何确定?
1. 这个时间长短,随着网络环境的不同,是有差异的。网络环境好,则时间应相对短一些,网络环境差,则时间应相对长一些。
2. 如果时间设得太长,会影响整体的重传效率。如果时间设的太短,有可能会频繁发送重复的包。也会影响整体传输效率。
3. TCP为了保证无论在任何环境下都能比较高性能地通信, 因此会动态计算这个最大超时时间. Linux中(BSD Unix和Windows也是如此), 超时以500ms为一个单位进行控制, 每次判定超时重发的超时时间都是500ms的整数倍. 如果重发一次之后, 仍然得不到应答, 等待 2*500ms 后再进行重传. 如果仍然得不到应答, 等待 4*500ms 进行重传. 依次类推, 以指数形式递增. 累计到一定的重传次数, TCP认为网络或者对端主机出现异常, 强制关闭连接.

滑动窗口

因为有了确认应答机制,于是对于每一个发出的数据报文,都需要一个ACK确认应答。收到ACK之后再发送下一个数据段。但这样做会导致TCP网络通信性能较差,尤其是数据往返的时间较长时。
滑动窗口策略就是用来提高TCP传输效率的。
一收一发的策略下,互换一次数据报的时间是X(捎带应答机制下),而滑动窗口使得可以一次发送多条报文(其实本质不是一次发送多条,而是发送滑动窗口内数据报文后,不需要等待ACK就可以发送窗口内的下一条),这样整体效率就提高了。(多个数据段的等待时间重叠了)

Linux网络-传输层UDP/TCP详解_第15张图片Linux网络-传输层UDP/TCP详解_第16张图片

结合上面的两个图(并不对应)

1. 滑动窗口的窗口大小指的是无需等待确认应答就可以继续发送数据的最大值.上图中的窗口大小即为4000字节。(TCP面向字节流,这4000字节被分为几个报文进行发送不确定,上图中为4个)
2. 发送前四个段时,不需要等待任何ACK,可以直接发送。
3. 收到第一个ACK后,滑动窗口向右滑动。继续发送第五个段的数据;以此类推。
4. 窗口越大,表示网络的吞吐率越高(传输效率越高)

5. 如图一所示,发送缓冲区中的数据可以大致分为三个部分,已发送并收到ACK确认的数据,已发送但未收到ACK确认的数据,未发送的数据。其中,窗口包括第二部分,可能包括未发送数据的某一部分,也就是,已发送但未收到ACK的数据,收到ACK之后,窗口会右移(后移),此时窗口可能包括未发送的数据的一部分,也就是不需要等待前方报文的ACK就可以立即发送了,但尚未发送。
6. 滑动窗口的发送策略,其实并不是完全按照一批一批发送的,也就是,图二中,若收到了1001的ACK,则窗口会右移,此时,4001-5000的数据报文就可以立即发送了。
7. 滑动窗口在发送缓冲区内,属于发送方的发送缓冲区的一部分(如第五点所示),滑动窗口的本质:发送方,可以一次性向对方发送数据的最大值(滑动窗口,提高效率的一个策略)。滑动窗口的大小 = 接收方窗口大小(接收缓冲区剩余空间) 和 拥塞窗口(见下文)的较小值。
8. 滑动窗口模型理解:可以将发送缓冲区理解为一个字节数组,每一个字节都有下标。滑动窗口有两个下标:win_start, win_end。win_end = win_start + min(窗口大小(不是滑动窗口大小),拥塞窗口大小),若收到ACK,则win_start = ACK的确认序号,win_end = win_start + min(窗口大小,拥塞窗口大小)。故,滑动窗口的本质就是指针或者下标。
9. 滑动窗口不一定必须向右移动,比如收到ACK且min(x, y)减小,则可能不会右移。滑动窗口可能为0,比如对方窗口大小 == 0 或者 拥塞窗口大小 == 0。中间发送的某些报文的ACK丢失,可能,但是不影响。因为ACK的确认序号的含义是该序号前的数据都收到了。滑动窗口一直右移,可能越界吗?不可能,因为发送缓冲区的物理上是线性的,逻辑上是环形的。

快重传

Linux网络-传输层UDP/TCP详解_第17张图片

之前说了丢包问题的一种可能,即中间或者开头的报文的ACK丢失,但是根据确认序号的含义,所以不影响。另一种可能为开头的报文丢失,则后面收到的所有ACK的确认序号都会为1001。
如果发送端主机连续三次收到了同样一个 "1001" 这样的应答, 就会将对应的数据 1001 - 2000 重新发送;
这个时候接收端收到了 1001 之后, 再次返回的ACK就是7001了(因为2001 - 7000)接收端其实之前就已 经收到了, 被放到了接收端操作系统内核的接收缓冲区中;
这种机制被称为 "高速重发控制"(也叫 "快重传").    (快重传与超时重传是协作关系)

拥塞控制

我们之前学的TCP的策略,机制等等都是为了解决TCP可靠性或者传输效率的,比如滑动窗口,可以提高TCP传输效率。但是丢包问题可能分为两种,1. 少量丢包,则超时重传 / 快重传 2. 大量丢包,则可能是因为此时网络拥塞,则此时不该采取重传策略,而是先给网络缓冲的机会。(注意,网络中的TCP/IP连接是很多的!!!)

对于这种避免网络拥塞,以及网络拥塞之后的应对策略等,称为拥塞控制。

1. 拥塞窗口:单机主机一次向网络中发送大量的数据时,可能会引发网络拥塞的上限值。
2. 滑动窗口大小 = min(拥塞窗口大小,对端的窗口大小(接收能力))(其实就是考虑对方接收能力,还要考虑网络拥塞情况)
3. 在TCP连接刚建立好时,如果在刚开始阶段就发送大量的数据, 仍然可能引发问题,因为此时网络状况并不清楚,因此TCP引入 慢启动 机制,先发少量数据,探探路,摸清当先的网络拥堵状况,再逐渐增大数据传输速度。慢启动机制除了在TCP刚建立好时使用,还有每一次发生网络拥塞之后。
4. 承接第四点,慢启动:拥塞窗口为1(刚建立好 && 网络拥塞之后),后面若正常收到ACK应答,则拥塞窗口先以指数方式增长。到了一定阈值之后,再线性增长。

Linux网络-传输层UDP/TCP详解_第18张图片

1. "慢启动" 只是指初始时慢, 但是增长速度非常快(因为是指数增长).
2. 不能一直指数增长, 故引入一个拥塞窗口的阈值,超过阈值之后线性增长....
3. 为什么网络拥塞之后,前期是指数增长?指数:前期较慢,后期增长较快。a. 前期给网络一个缓冲的机会 - 慢 b. 中后期,网络恢复之后,需要尽快恢复通信的效率 - 快。  (而因为不能一味的指数增长,所以有了后面的阈值)(阈值细节看kj)

拥塞控制, 归根结底是TCP协议想尽可能快的把数据传输给对方, 但是又要避免给网络造成太大压力的折中方案.

延迟应答

有了滑动窗口之后,往往接收方接收缓冲区剩余空间大小,也就是接收能力会较大地影响TCP传输效率。那么当接收方接收到数据后,若立刻进行ACK,则此时窗口大小是比较小的。若接收方可以延迟一些时间再应答,给应用层一定时间,将接收缓冲区中的数据读取走,则会给发送方一个较大的窗口大小。窗口越大,网络吞吐量越大,传输效率越高。我们的目标是在网络不拥塞的情况下尽量提高传输效率。

也不是所有的包都可以延迟应答,比如,数量限制:每隔N个包就应答一次。时间限制:超过最大延迟时间就应答一次。具体的数量和超时时间, 依操作系统不同也有差异; 一般N取2, 超时时间取200ms;(肯定也不能太长,否则会触发超时重传)

捎带应答

一种提高TCP传输效率的机制

在延迟应答的基础下,还有确认应答ACK机制。往往并不是只有一方向另一方发送数据。而是双方互发数据。所以有时候,ACK可以和"接收方"想向"发送方"发送的数据报文合并,一起回给发送方。

TCP面向字节流

TCP的一大特点之一,面向字节流...   (没写kj)

1. 之前说过,write,send本质是拷贝函数,将应用层数据拷贝到发送缓冲区中,然后由TCP决定什么时候发,发多少。
2. 如果发送的字节数太长, 会被拆分成多个TCP的数据包发出;
3. 如果发送的字节数太短, 就会先在缓冲区里等待, 等到缓冲区长度差不多了, 或者其他合适的时机发送出去;(TCP要考虑网络状况,对方的接收能力等等)

所以,比如你send一个hello,一个world,第一个hello可能到内核发送缓冲区后1次发出,可能2次,3次。也可能一次发出hellowo(因为这些数据会先存入发送缓冲区中,由TCP协议进行传输控制)。故,对方read时,读取的是一个完整的应用层报文,还是半个,一个半。都是有可能的。TCP的读和写不一定一一对应。所以说TCP面向字节流。

而对于UDP,他不会像TCP一样考虑接收方的接收能力,不会看网络拥塞情况等等来进行传输控制。发送方sendto几次接收方就recvfrom几次。读取和发送次数是对应的。即使数据报出现丢包,UDP也不会管。所以说UDP面向数据报。

粘包问题

1. 首先要明确, 粘包问题中的 "包" , 是指的应用层的数据包.
2. 在TCP的协议头中, 没有如同UDP一样的 "报文长度" 这样的字段, 但是有一个序号这样的字段.
3. 站在传输层的角度, TCP是一个一个报文过来的. 按照序号排好序放在接收缓冲区中.
4. 站在应用层的角度, 看到的只是一串连续的字节数据.
5. 那么应用层看到了这么一连串的字节数据, 就不知道从哪个部分开始到哪个部分, 是一个完整的应用层数据包.(因此,需要制定应用层协议)(粘包其实就是应用层读取时,读到一个半,或两个应用层数据包。其实半个也一样,也需要解决)

那么如何避免粘包问题呢? 归根结底就是一句话, 明确两个包之间的边界!其实就是制定应用层协议,使得应用层读取到一段字节数据之后,可以判断到哪里是一个完整的应用层数据报。应用层读取到一个完整的报文之后,再根据应用层协议,将其中的有效载荷提取出来,这一部分就是对方主机真正想向己方发送的数据。

而对于UDP协议来说,发送方sendto发送一次时,肯定是发送了一个完整的应用层报文的(或者直接发数据也可以)。站在应用层的角度,使用UDP的时候, 要么收到完整的UDP报文, 要么不收. 不会出现"半个"的情况.收到完整UDP报文后,解包之后就是一个完整的应用层报文,不会出现粘包现象。所以UDP不粘包的本质原因还是因为UDP面向数据报。
而因为UDP面向数据报,故UDP不粘包。所以,如果单从解决粘包问题这个角度考虑,使用UDP协议传输网络数据时,可以不制定应用层协议。当然,应用层协议的作用并非只用于解决粘包问题。其实就是,接收端传输层接收到UDP报文进行解包,向上交付后,直接就是数据,不需要使用应用层协议解包,因为不会粘包。

针对TCP面向字节流-粘包问题的自定义应用层协议的网络版本计算器server&&client

http://t.csdn.cn/uMEuP

TCP异常情况

TCP/UDP对比

真·TCP/UDP对比

之前说过(好像说过),TCP的可靠和UDP的不可靠是一个中性词,没有优劣之分,这只是它们的特点。因为可靠,一定意味着复杂,不可靠就意味着简单。

而也不是说TCP的传输效率就一定低于UDP,因为TCP有很多提高传输效率的策略和机制,比如,滑动窗口,快重传,延迟应答,捎带应答等。

总的来说,TCP用于可靠传输的情况, 应用于文件传输, 重要状态更新等场景。UDP用于对高速传输和实时性要求较高的通信领域, 例如, 早期的QQ, 视频传输等. 另外UDP可以用于广播。具体TCP和UDP什么时机用,怎么用,还要根据具体的需求场景去判定。

如何让UDP实现可靠传输

参考TCP的可靠性机制, 在应用层实现类似的逻辑。如,引入序列号,保证数据顺序。引入确认应答,确保对端收到了数据。引入超时重传,如果一段时间内没有收到应答,就重发数据。

listen的第二个参数

#include 
#include 
#include 
#include 
#include 
#include "Sock.hpp"

int main()
{
    Sock sock;
    int listen_sock = sock.Socket();
    sock.Bind(listen_sock, 8080);
    sock.Listen(listen_sock, 3);

    sleep(5);
    std::string ip;
    uint16_t port;
    sock.Connect(listen_sock, ip, port);
    sleep(1000000000);
    return 0;
}

TCP三次握手时,Linux内核会维护两个队列:

  • 半连接队列,被称为SYN队列
  • 全连接队列,被称为accept队列

Linux网络-传输层UDP/TCP详解_第19张图片

在服务端进行listen调用后,此时这个服务端就已经可以和客户端建立TCP连接了,即三次握手的过程。三次握手并非在accept时进行。
而如果同一时刻或者较短时间内大量客户端发起连接,此时服务端可能来不及提供服务(如创建新线程提供服务),此时就需要先进行连接,然后将此连接保存起来,之后服务端再提供对应服务。这就是TCP全连接队列的意义(联想海底捞门口的等待队列)。
全连接队列的长度 = listen第二个参数+1

1、客户端发送SYN包,并进入SYN_SENT状态
2、服务端接收到数据包将相关信息放入半连接队列(SYN 队列),并返回SYC+ACK包给客户端。
3、服务端接收客户端ACK数据包,这时如果全连接队列(accept 队列)没满,就会从半连接队列里面将数据取出来放入全连接队列,等待应用使用,当队列已满就会跟据tcp_abort_on_overflow配置执行策略。Linux网络-传输层UDP/TCP详解_第20张图片

上图为,当listen的backlog(第二个参数)为3时,建立了四个TCP连接(三次握手完成,ESTABLISHED状态),此时再发起一个连接,会处于SYN_RECV状态,不会完成三次握手连接。 (这个连接一段时间后就会被自动终止)

经过实验证实,即使server进行accept获取建立好的TCP连接。该连接依旧会存储在全连接队列中。

你可能感兴趣的:(网络,tcp/ip,udp)