详谈 TCP 和 UDP 协议

TCP & UDP

  • 1 关于传输层的两个协议
  • 2 UDP
  • 3 TCP
    • 3.1 TCP 协议的首部格式
    • 3.2 TCP 为了保证可靠性传输所做的工作
      • 3.2.1 确认应答机制
      • 3.2.2 超时重传机制
      • 3.2.3 连接管理机制(重点) 3次握手4次挥手
      • 3.2.4 其他控制手段

1 关于传输层的两个协议

TCP/IP中有两个具有代表性的传输层协议,分别是 TCP 和 UDP。TCP 提供可靠的通信传输,UDP 常被用于让广播和细节控制交给应用的通信传输。
TCP

  • TCP 是面向连接的、可靠的流协议。流是指不间断的数据结构,TCP 为保证可靠性传输,实行顺序控制重发控制机制,此外还具有流量控制拥塞控制等功能。

UDP

  • UDP 是不具有可靠性的数据报协议。细微的处理它会交给上层的应用去完成。UDP主要用于对高速传输实时性有较高要求的的通信或广播通信。比如打电话。

2 UDP

UDP首部的格式如下图
详谈 TCP 和 UDP 协议_第1张图片
UDP 的特点

  • 无连接:知道对端的 IP 地址和端口号就可以直接进行传输,不需要建立连接
  • 不可靠:没有确认和重传机制,因为网络原因导致无法发送到对端,UDP 协议层也不会给应用层返回任何报错消息
  • 面向数据报:不能灵活的控制读写数据的次数和数量

对于面向数据报的理解

  • 应用层交给 UDP 多长的报文,UDP原样发送,既不会拆分,也不会合并;
  • 用UDP传输100个字节的数据:如果发送端调用一次sendto, 发送100个字节, 那么接收端也必须调用对应的一次recvfrom接收100个字节; 而不能循环调用10次recvfrom,每次接受10个字节;

UDP 的缓冲区

  • UDP没有真正意义上的 发送缓冲区. 调用sendto会直接交给内核, 由内核将数据传给网络层协议进行后续的传输动作;
  • UDP具有接收缓冲区. 但是这个接收缓冲区不能保证收到的UDP报的顺序和发送UDP报的顺序一致; 如果缓冲区满了,再次到达的数据就会被丢失。

UDP是全双工的:UDP 的socket既能读也能写
UDP 的使用注意事项

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

基于 UDP 的应用层协议

  • NFS: 网络文件系统
  • TFTP: 简单文件传输协议
  • DHCP: 动态主机配置协议
  • BOOTP: 启动协议(用于无盘设备启动)
  • DNS: 域名解析协议
  • 当然, 也包括你自己写UDP程序时自定义的应用层协议

3 TCP

TCP全称为 "传输控制协议(Transmission Control Protocol").顾名思义,要对通信的细节和过程进行详细的控制

3.1 TCP 协议的首部格式

详谈 TCP 和 UDP 协议_第2张图片

  • 源端口号与目的端口号:表示数据从哪个进程来到哪个进程去
  • 32位序号与32位确认序号(面试重点):见 3.2
  • 4 位首部长度:表示该TCP头部有多少个32位bit(有多少个4字节);所以TCP头部最大长度是15 * 4 = 60 个字节
  • 6 位标志位(新的资料中位更多):URG:紧急指针是否有效 ACK:确认号是否有效 PSH:提示接受端应用程序立刻从TCP缓冲区把数据读走 RST:对方要求重新建立连接,我们把携带 RST 标识的称为复位报文段 SYN:请求建立连接,把携带SYN 标识的称为同步报文段 FIN:通知对方,本段要关闭了,我们称 FIN 标识的为结束报文段
  • 16位窗口大小:(见3.2)其实是接受缓冲区中剩余空间的大小,涉及 3.2 节的流量控制
  • 16位校验和:发送端填充, CRC校验. 接收端校验不通过, 则认为数据有问题. 此处的检验和不光包含TCP首部, 也包含TCP数据部分.
  • 16 位紧急指针:标识哪部分数据是紧急数据;
  • 40字节头部选项:提高TCP的传输性能。具体可参考《图解TCP/IP》

3.2 TCP 为了保证可靠性传输所做的工作

3.2.1 确认应答机制

详谈 TCP 和 UDP 协议_第3张图片

  • TCP将每个字节的数据都进行了编号. 即为序列号
  • 每一个ACK都带有对应的确认序列号, 意思是告诉发送者, 我已经收到了哪些数据; 下一次你从哪里开始发.
    那么TCP报文为什么要有序列号和确认应答号呢?(上面的面试题)=========> 序列号解决的按序到达的问题,确认序号保证的确认应答的问题,可以保证两端同时通信的全双工。解决了丢包和乱序的问题,当然这些细节像 HTTP 协议是不关心的,是内核级协议 TCP 要做的事情。

3.2.2 超时重传机制

详谈 TCP 和 UDP 协议_第4张图片

  • 主机A发送数据给B之后, 可能因为网络拥堵等原因, 数据无法到达主机B;
  • 如果主机A在一个特定时间间隔内没有收到B发来的确认应答,就会进行重发
  • 但是, 主机A未收到B发来的确认应答, 也可能是因为ACK丢失了,因此主机B会收到很多重复数据. 那么TCP协议需要能够识别出那些包是重复的包, 并且把重复的丢弃掉,这时候我们可以利用前面提到的序列号, 就可以很容易做到去重的效果.

那么超时的时间如何确定呢?

  • 最理想的情况下, 找到一个最小的时间, 保证 "确认应答一定能在这个时间内返回",但是这个时间的长短, 随着网络环境的不同, 是有差异的,如果超时时间设的太长, 会影响整体的重传效率,如果超时时间设的太短, 有可能会频繁发送重复的包;

TCP为了保证无论在任何环境下都能比较高性能的通信, 因此会动态计算最大超时时间

  • Linux中(BSD Unix和Windows也是如此), 超时以500ms为一个单位进行控制, 每次判定超时重发的超时时间都是500ms的整数倍,如果重发一次之后, 仍然得不到应答, 等待 2*500ms 后再进行重传,如果仍然得不到应答, 等待 4*500ms 进行重传. 依次类推, 以指数形式递增,累计到一定的重传次数, TCP认为网络或者对端主机出现异常, 强制关闭连接。

3.2.3 连接管理机制(重点) 3次握手4次挥手

在正常情况下, TCP要经过三次握手建立连接, 四次挥手断开连接,具体过程见下图
详谈 TCP 和 UDP 协议_第5张图片

  • 为何要3次握手?1次或者2次可以嘛?

1、1次或者2次,容易让服务端遭到洪水攻击,大量的SYN进来建立连接,而无论是 client 还是 server 维护连接都是需要成本的,很容易server的资源耗尽就挂掉了,所以不可以。
2、用3次握手 client 和 server 都验证了一次收和发,用最小的成本验证了全双工
3、避免服务器出现建立连接的错误判断,减少服务器资源浪费:3次握手最后的ACK是客户端发的,一经发送,客户端马上认为连接建立成功,但是ACK在网络中传输是需要时间的,那么此时服务器有可能在一段时间后收到ACK,也有可能ACK丢失,所以就造成了客户端维护这个失败的连接,浪费了资源,但是客户端的资源相比于服务器的资源重要性明显不如后者,若是4次握手或者偶数次握手,那么势必会造成服务端同样的问题,这次问题就很严重了,所以无疑3次握手是最佳选择

  • 为何要4次挥手才能断开连接?

因为 client 和 server 的连接是双向的,全双工的,要断开就要断开从客户端到服务端的通信连接,和 服务端到 客户端的通信连接

  • 服务端状态转换

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

  • 客户端状态转换

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

重点理解CLOSE_WAIT 和 TIME_WAIT 状态

  • CLOSE_WAIT 状态

如果服务器上挂满了大量的CLOSE_WAIT状态的连接,那么是服务端没有关闭和客户端通信的的文件描述符,导致4次挥手没有完成,而且还会导致文件描述符泄露(资源泄露)。解决办法就是 close(connfd)

详谈 TCP 和 UDP 协议_第6张图片

  • TIME_WAIT状态(面试超级常考)
    这个状态存在的意义:看下图
    详谈 TCP 和 UDP 协议_第7张图片
    客户端接受到服务器端的 FIN 报文后进入此状态,此时并不是直接进入 closed 状态,还需要等待一个时间计时器设置的时间 2MSL。这么做有两个理由:
  • 确保最后一个确认报文能够到达。如果服务端没有收到客户端发来的确认报文,那么服务端就会重新发送 FIN(第三次挥手),这个TIME_WAIT 状态就是为了处理这种情况的
  • 等待一段时间是为了本连接持续时间内所产生的所有报文都从网络上消失,使得下一个新的连接不会出现旧的连接请求报文

做一个小实验,这是一个简易的服务端程序,首先启动server,然后启动client,然后用Ctrl-C使server终止,这是马上再运行服务端程序

#pragma once

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

#define BACKLOG 5

using namespace std;

class HttpServer{
    private:
        int port;
        int lsock;
    public:
        HttpServer(int _p):port(_p), lsock(-1)
        {}

        void InitServer()
        {
            signal(SIGCHLD, SIG_IGN);
            lsock = socket(AF_INET, SOCK_STREAM, 0);
            if(lsock < 0){
                cerr << "socket error" << endl;
                exit(2);
            }
            struct sockaddr_in local;
            bzero(&local, sizeof(local));
            local.sin_family = AF_INET;
            local.sin_port = htons(port);
            local.sin_addr.s_addr = INADDR_ANY;

            // 使用setsockopt 设置 socket 描述符的选项 SO_REUSEADDR 为 1,表示允许创建 端口号相同但IP地址不同的多个socket描述符
            // 解决 服务器TIME_WAIT 导致的重启服务器进而发生绑定失败的问题
           // int opt = 1;
           // setsockopt(lsock,SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
            if(bind(lsock, (struct sockaddr*)&local, sizeof(local)) < 0){
                cerr << "socket bind error" << endl;
                exit(3);
            }

            if(listen(lsock, BACKLOG) < 0){
                cerr << " listen error" << endl;
                exit(4);
            }
                    }

        void EchoHttp(int sock)
        {
            char request[2048];
            size_t s = recv(sock, request, sizeof(request), 0); //bug!
            if(s > 0){
                request[s] = 0;
                cout << request << endl;


                string response = "HTTP/1.0 200 OK\r\n";
                response += "Content-type: text/html\r\n";
               // response += "location: https://www.baidu.com";
                response += "\r\n";
                send(sock, response.c_str(), response.size(), 0);
            }
            close(sock);
        }
        void Start()
        {
            struct sockaddr_in peer;
            for(;;){
                socklen_t len = sizeof(peer);
                int sock = accept(lsock, (struct sockaddr*)&peer, &len);
                if(sock < 0){
                    cerr << "accept error "<<endl;
                    continue;
                }

                cout << "get a new connect ... done" << endl;

                if(fork() == 0){
                    //child
                    close(lsock);
                    EchoHttp(sock);
                    exit(0);
                }
                close(sock);
            }
        }
        ~HttpServer()
        {
            if(lsock != -1){
                close(lsock);
            }
        }
};

则发生了如下的情况
详谈 TCP 和 UDP 协议_第8张图片
详谈 TCP 和 UDP 协议_第9张图片
在这里插入图片描述

  • 这是因为,虽然server的应用程序终止了,但TCP协议层的连接并没有完全断开,因此不能再次监 听同样的server端口.我们用netstat命令查看一下:
    在这里插入图片描述
  • TCP协议规定,主动关闭连接的一方要处于TIME_ WAIT状态,等待两个MSL(maximum segment lifetime)的时间后才能回到CLOSED状态.
  • 我们使用Ctrl-C终止了server, 所以server是主动关闭连接的一方, 在TIME_WAIT期间仍然不能再次监听同样的server端口
  • MSL在RFC1122中规定为两分钟,但是各操作系统的实现不同, 在Centos7上默认配置的值是60s
  • 可以通过 cat /proc/sys/net/ipv4/tcp_fin_timeout 查看msl的值
    在这里插入图片描述
    为什么是TIME_WAIT的时间是2MSL呢?
  • MSL是TCP报文的最大生存时间, 因此TIME_WAIT持续存在2MSL的话
  • 就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失(否则服务器立刻重启, 可能会收到来自上一个进程的迟到的数据, 但是这种数据很可能是错误的)
  • 同时也是在理论上保证最后一个报文可靠到达(假设最后一个ACK丢失, 那么服务器会再重发一个FIN. 这时虽然客户端的进程不在了, 但是TCP连接还在, 仍然可以重发最后的ACK)

如何解决TIME_WAIT状态引起的bind失败呢?在server的TCP连接没有完全断开之前不允许重新监听, 某些情况下可能是不合理的

  • 服务器需要处理非常大量的客户端的连接(每个连接的生存时间可能很短, 但是每秒都有很大数量的客户端来请求
  • 这个时候如果由服务器端主动关闭连接(比如某些客户端不活跃, 就需要被服务器端主动清理掉), 就会产生大量TIME_WAIT连接
  • 由于我们的请求量很大, 就可能导致TIME_WAIT的连接数很多, 每个连接都会占用一个通信五元组(源ip, 源端口, 目的ip, 目的端口, 协议). 其中服务器的ip和端口和协议是固定的. 如果新来的客户端连接的ip和端口号和TIME_WAIT占用的链接重复了, 就会出现问题

使用setsockopt()设置socket描述符的 选项SO_REUSEADDR为1, 表示允许创建端口号相同但IP地址不同的多个socket描述符

详谈 TCP 和 UDP 协议_第10张图片

3.2.4 其他控制手段

TCP 还有 利用滑动窗口原理的快速重传,流量控制,拥塞控制,捎带应答,这里给大家推荐一本书《图解TCP/IP》写的很详细。

你可能感兴趣的