[ 网络 ] 传输层协议——TCP/UDP

目录

再谈端口号

端口号范围划分

netstat

 pidof

UDP协议

UDP协议端格式

UDP的特点

面向数据报

UDP的缓冲区

UDP使用注意事项

基于UDP的应用层协议

TCP协议

TCP的特点及其目的

TCP协议段格式

可靠性问题

确认应答机制(ACK)

超时重传机制

重发超时如何确定

流量控制

连接管理——3次握手 4次挥手 

tcp三次握手

tcp四次挥手

验证CLOSE_WAIT状态

listen的第二个参数

验证TIME_WAIT状态

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

滑动窗口

拥塞控制

延迟应答

捎带应答 

面向字节流

粘包问题

UDP是否存在粘包问题?

TCP异常情况

用UDP实现可靠传输

TCP小结

基于TCP应用层的协议


传输层负责数据能够从发送端传输到接收端。TCP/IP中有两个具有代表性的传输层协议,分别是UDP和TCP。TCP提供可靠的通信传输,而UDP则常被用于让广播和细节控制交给应用的通信传输。总之,根据通信的具体特征,选择合适的传输层协议是非常重要的。

再谈端口号

端口号(Port)标识了一个主机上进行通信的不同的应用程序,一台计算机上同时可以运行多个程序。传输层协议正是利用这些端口号识别本机中正在进行通信的应用程序,并准确的将数据传输。

在TCP/IP协议中,"源IP","源端口号","目的IP","目的端口号", "协议号" 这样的五元组来表示一个通信(可以通过netstat -n查看)

端口号范围划分

  • 0-1023:知名端口号,HTTP,FTP,SSH等这些广为使用的应用层协议,他们的端口号都是固定的
  • 1024-65535:操作系统动态分配的端口号,客户端程序的端口号,就是由操作系统从这个范围分配的

有些服务器是非常常用的,为了使用方便,人们约定一些常用的服务器,都是用以下这些固定的端口号

  • ssh服务器,使用22端口号
  • ftp服务器,使用21端口号
  • telnet服务器,使用23端口号
  • http服务器,使用80端口号
  • https服务器,使用443

cat /etc/services可以查看知名端口号,我们自己写一个程序使用端口号时要避开这些知名端口号。

 一个进程可以bind多个端口号但是一个端口号只能被一个进程bind

netstat

netstat是一个用来查看网络状态的重要工具

常用选项:

  • n拒绝显示别名,能显示数字的全部转化成数字
  • l 仅列出有Listen(监听)的服务状态
  • p 显示建立相关链接的程序名
  • t 仅显示tcp相关选项
  • u 仅显示udp相关选项
  • a 显示所有选项,默认不显示Listen相关

 pidof

再查看服务器的进程id时非常方便,通过进程名,查看进程id

UDP协议

UDP ( User Datagram Protocol )不提供复杂的控制机制,利用IP提供面向无连接的通信服务。并且它是将应用程序发来数据在收到的那一刻,立即按照原样发送到网络上的一种机制。

UDP协议端格式

  • 16位UDP长度,表示整个数据报(UDP首部+UDP数据) 的最大长度
  • 如果校验和出错,就会直接丢弃

前8个字段称为UDP报头,数据叫做有效载荷,如果应用层发送Hello,那么Hello就在数据中。

我们可以看到UDP采用定长报头。那么UDP在封装时,直接在有效载荷前加上UDP报头即可,而在分用的时候,应用层直接提取前8个字节就可以拿到UDP的报头字段。

网络协议栈的tcp/ip协议是内核中实现的,内核使用C语言实现的

struct udp_hdr
{
    unsigned int src_port : 16;
    unsigned int dst_port : 16;
    unsigned int udp_len  : 16;
    unsigned int udp_check : 16;
};

这就是udp报头字段,在C语言中我们称作位段。位段在申请空间的时候会以前面的类型的申请的。因此报文的宽度是0-31 ,因此udp的报头就是8字节。

UDP的特点

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:域名解析协议

TCP协议

TCP 全称为 " 传输控制协议 (Transmission Control Protocol"). 人如其名 , 要对数据的传输进行一个详细的控制;TCP与UDP的区别相当大,他充分的实现了数据传输时各种控制功能,可以进行丢包时的重发控制,还可以对次序乱掉的分包进行顺序控制。而这些在UDP中都没有。此外,TCP作为一种面向连接的协议,只有在确认通信对端存在时才会发送数据,从而可以控制通信流量的浪费。
根据TCP的这些机制,在IP这种无连接的网络上也能够实现高可靠性的通信。

TCP的特点及其目的

为了通过IP数据报实现可靠性传输,需要考虑很多事情。TCP通过检验和,序列号,确认应答,重发控制,连接管理以及窗口控制等等机制实现可靠性传输。

TCP协议段格式

  • 源/目的端口号:表示数据是从哪个进程来,到哪个进程去
  • 32位序号/32位确认序号:
    • 序列号(32位):是指发送数据的位置。每发送一次数据,就累加一次该数据字节数的大小。序列号不会从0或1开始,而是建立连接时由计算机生成的随机数作为其初始值,通过SYN包传给接收端主机。
    • 确认序号(32位):确认应答序号长32位,是指下一次应该收到的数据的序列号。实际上,它是指已收到确认应答号前一位位置的数据。发送端收到这个确认应答以后可以认为在这个序号以前的数据都已经被正常接受
  • 4位首部长度(数据偏移):该字段长4位,单位字节,实际的大小是15*4 = 60字节,该字段表示TCP所传输的数据部分应该从TCP包的哪个位开始计算,当然也可以把它看做TCP首部长度。因此不包括选项字段的话,TCP的首部长度为20字节长,因此4位首部长度最小设置为5.反之,如果该字段的值为5,那说明从TCP包的最一开始到20字节为止都是TCP首部,余下的部分为TCP数据。
  • 6位保留:该字段主要是为了以后扩展时使用。

可靠性问题

TCP的可靠性有一部分是体现在报头字段的。

1.什么是不可靠?

丢包,乱序,数据包校验失败.....

2.怎么确认一个报文是丢了还是没丢呢?

我们如果收到了应答,我们确认是没丢;否则就是不确定!

比如:你给你朋友发送一条消息:吃了吗?你能确认他收到了吗?其实是不能的。但是如果他给你回复一条:吃了,吃的饺子! 通过他的回复我们可以确认我们刚刚发送出去的消息,对方收到了。因此我们只要得到应答就意味着我们刚刚发送的消息对方100%收到了。

而在长距离交互的时候,永远有一条最新的数据是没有应答的,但是我们只要发送的消息有对应的应答,我们就认为我们发送的消息,对方是收到的 ! 而这个思想就是TCP可靠性的根本思想。这个机制就是确认应答机制

确认应答机制(ACK)

TCP通过肯定的确认应答(ACK)实现可靠的数据传输。当发送端将数据发出之后会等待对端的确认应答。如果有确认应答,说明数据已经成功到达对端,反之,则数据可能丢失了。

报文中有个序列号字段,序列号是按顺序发送数据的每一个字节都标上好嘛的编号,接收端查询接受数据TCP首部中的序列号和数据的长度,将自己下一步应该接受的序号作为确认应答返回回去,就这样,通过序列号和确认应答好,TCP实习可靠传输

发送的数据

序列号与确认应答号 

当主机A发送数据(1-1000字节),如果主机B收到了这1000个字节,则确认序号为1001,发给主机A,当主机A收到发现确认应答是1001时,主机A就知道前1000字节主机B已经成功收到,就可以继续发送下面的数据了。每一个ACK都带有对应的确认序列号, 意思是告诉发送者, 我已经收到了哪些数据; 下一次你从哪里开始发.

那么为什么需要两组序号?为什么既有序号又有确认序号

因为TCP是全双工的,我在给你发消息的同时,我也可以收消息。

举例:客户端给服务端发送消息,序号10;服务器给客户端回消息是也想发送自己的消息,那么也要有自己的序号!因此如果服务器想给你应答的同时也想给客户端发送消息,要应答就要填充确认序号,要发消息就要保证可靠性,因此服务端也需要携带自己的序号!因此需要同时设置,就需要一个序号一个确认序号!

超时重传机制

  • 主机A发送数据给B之后,可能因为网络拥堵等原因,数据无法到达主机B
  • 如果主机A在一个特定时间间隔内没有收到B发来的确认应答,就会进行重发 

但是主机A未收到B发送来的确认应答,也可能是因为ACK丢失了

由主机B返回的确认应答,因网络拥堵等原因在传输的途中丢失,没有到达主机A,主机A会等待一段时间,若在特定的时间间隔内始终未能收到这个确认应答,主机A会对数据进行重新发送,此时,主机B将第二次发送已接收此数据的确认应答。由于主机B已经收到过1~1000的数据,当再有相同数据送达时它会放弃。而主机B是如何确认这段数据是重复的呢?是通过序号,如果这段数据的TCP报头中的序号我已经收到过,那么就可以确定这段数据重复了。因此序号还有一个作用是去重报文。

重发超时如何确定

重发超时是指在重发数据之前,等待确认应答到来的那个特定时间间隔。如果超过了这个时间仍未收到确认应答,发送端将进行数据重发。那么如果超时,时间如何确定呢?

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

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

在Linux中,超时都以0.5秒为单位进行控制,因此重发超时都是0.5秒的整数倍'。不过,由于最初的数据包还不知道往返时间,所以其重发超时一般设置为6秒左右。 在BSD的Unix以及Windows系统中,超时都以0.5秒为单位进行控制,因此重发超时都是0.5秒的整数倍‘。不过,由于最初的数据包还不知道往返时间,所以其重发超时一般设置为6秒左右.

数据被重发之后若还是收不到确认应答,则进行再次发送。此时,等待确认应答的时间将会以2倍、4倍的指数函数延长。

此外,数据也不会被无限、反复地重发。达到一定重发次数之后,如果仍没有任何确认应答返回,就会判断为网络或对端主机发生了异常,强制关闭连接。并且通知应用通信异常强行终止。

流量控制

数据什么时候发,发多少,出错了怎么办,要不要添加提高效率的策略 -- 都是由OS内TCP自主决定的。因此TCP协议叫做传输控制协议

如果客户端发送的太快了,导致Server来不及接受怎么办?由于服务端接受缓冲区满了,所以多余的报文只能丢弃。但是发送报文都会消耗网络资源,因此我们在发送报文的时候都要根据对端的接受能力发送对应大小的报文。因此就需要让Client知道Server的接受能力。因此Server的接受能力就是由接受缓冲区剩余空间的大小,那么如何让Client知道,因此当server应答时,在TCP报头中有一个保存Server接受能力的属性字段:正是16位窗口大小

  • 接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 "窗口大小" 字段, 通过ACK端通知发送端;
  • 窗口大小字段越大, 说明网络的吞吐量越高;
  • 接收端一旦发现自己的缓冲区快满了, 就会将窗口大小设置成一个更小的值通知给发送端;
  • 发送端接受到这个窗口之后, 就会减慢自己的发送速度;
  • 如果接收端缓冲区满了, 就会将窗口置为0; 这时发送方不再发送数据, 但是需要定期发送一个窗口探测数据段, 使接收端把窗口大小告诉发送端
  • 16 位数字最大表示 65535, 那么 TCP 窗口最大就是 65535 字节么 ?
    实际上 , TCP 首部 40 字节选项中还包含了一个窗口扩大因子 M, 实际窗口大小是 窗口字段的值左移 M ;

 

如果接收端接收缓冲区打满,发送端就不会发送消息,但是会不定期发送窗口探测报文来得知接收端目的主机的实时的接受能力。

连接管理——3次握手 4次挥手 

TCP提供面向连接的通信传输。面向有连接是指在数据通信开始之前做好通信两端的准备工作。

一个连接的建立与断开,正常过程至少需要来回发送7个包才能完成。只有完成了3次握手,才算建立连接成功,才能正式通信!通信完毕之后,需要四次挥手才能断开连接!

站在Server的角度,收到的报文有的是用来建立连接的,有的是断开连接的,有的是用来传输数据的,因此报文也是有类别的!因此作为Server来讲,必须要区别报文的类别!

  1. SYN :只要报文是建立连接的请求,SYN标志位需要设定为1
  2. FIN:该报文是一个申请断开连接请求的标志位,因此SYN和FIN不会同时置位1.
  3. ACK:表示该报文是对历史报文的确认。TCP规定除了最初建立的连接时的SYN包之外该位必须设置为1.

  4. PSH:该为1时,表示需要将受到的数据立刻传给上层的应用层协议。PSH为0时,则不需要立即传而是先进行缓存。

  5. URG:表示包中有需要紧急处理的数据。报文在发送的时候是可能乱序到达的,乱序到达是不可靠的一种,因此需要让报文按序到达就需要序号。所以如果数据是必须在TCP中进行按序到达的话,也就说如果有一些数据优先级更高,但是序号较晚,无法做到数据被紧急处理吗,这样的报文如果想被优先处理,那么把URG标志位设置为1。与之配合的是16位紧急指针,是标定的紧急数据在数据中的标记位的。因此一个报文中的数据并不都是紧急数据,而是16位紧急指针指向的一个字节的数据是紧急数据。

    1. 一般在暂时中断通信,或中断通信的情况下使用。例如在Web浏览器中点击停止按钮,或者使用telent输出Ctrl+C时都会有URG为1的包。此外,紧急指针也用作表示数据流分段的标志

  6. RST:重新连接标志位,下面会解释。

tcp三次握手

为什么要三次握手?

因为tcp是面向连接的,因此在通信之前就必须先建立连接。

三次握手中客户端(主动断开连接的一方)状态转换:

  1. [CLOSED -> SYN_SENT] 客户端调用connect, 发送同步报文段;
  2. [SYN_SENT -> ESTABLISHED] connect调用成功, 则进入ESTABLISHED状态, 开始读写数据

三次握手中服务器端状态转换:

  1. [CLOSED -> LISTEN] 服务器端调用listen后进入LISTEN状态, 等待客户端连接;
  2. [LISTEN -> SYN_RCVD] 一旦监听到连接请求(同步报文段), 就将该连接放入内核等待队列中, 并向客户端发送SYN确认报文.
  3. [SYN_RCVD -> ESTABLISHED] 服务端一旦收到客户端的确认报文, 就进入ESTABLISHED状态, 可以进行 读写数据了.

为什么是3次?不是1次?2次?4次?

一次握手 -- 不行:

答案是不行,因为只有一次握手,也就是客户端只给服务器发送一个SYN,那么客户端如果循环给服务端发送SYN,那么服务端要维护此链接,就要消费很多有效资源,那么只需要一套机器,就可以让服务器浪费许多资源--SYN洪水。因此一次连接是肯定不行的。

二次握手 -- 不行:

两次握手时客户端给服务端发送SYN,服务器端回复ACK。其实两次握手和一次握手是一样的效果。当客户端给服务器继续发送SYN洪水,当服务器端接收到请求之后回复一个ACK,也会维护此链接,而服务端给客户端回复的ACK客户端直接丢弃,那么效果就和一次握手很类似了。

三次握手 -- 可以:

一次和两次之所以不行,是因为都是让服务器端先认为链接已经建立好了,而三次握手可以把最后一次确认的机会交给服务器端。也就是说,客户端如果给服务器发送SYN洪水,服务器端要维护这些链接,消耗资源,而回复ACK的时候,客户端也要建立维护链接,再返回ACK给服务器端,因此多一次握手,客户端也要和服务器端一样消耗资源维护链接,服务器端也会把客户端拉下水。因此三次握手即使失败或者非法,可以把最后一次报文丢失的成本嫁接给客户端。

6.RST:如果最后一次ACK丢失,客户端认为链接建立成功,服务器端等待对端回复ACK,服务器端过段时间会超时重传SYN+ACK。那么如果在这段时间,由于客户端认为链接建立成功,客户端已经开始向服务器端发送报文,当服务端收到消息的时候就疑惑?不是连接还没建立完成,怎么消息就来了呢?因此此时服务器端就立马给客户端发来的数据进行ACK响应,并且把RST标志位置为1,告诉客户端让其关闭连接,进行重新连接。

tcp四次挥手

四次挥手中客户端(主动断开连接的一方) 状态转换:
  • [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状态.

四次挥手中服务端状态转换:

  • [ESTABLISHED -> CLOSE_WAIT] 当客户端主动关闭连接(调用close), 服务器会收到结束报文段, 服务器 返回确认报文段并进入CLOSE_WAIT;
  • [CLOSE_WAIT -> LAST_ACK] 进入CLOSE_WAIT后说明服务器准备关闭连接(需要处理完之前的数据); 当 服务器真正调用close关闭连接时, 会向客户端发送FIN, 此时服务器进入LAST_ACK状态, 等待最后一个ACK到来(这个ACK是客户端确认收到了FIN)
  • [LAST_ACK -> CLOSED] 服务器收到了对FINACK, 彻底关闭连接。

为什么是四次挥手

客户端发送的FIN要保证服务器端收到,因此FIN必须要有ACK,因此两个FIN都必须要有ACK,这也就是4次挥手。当客户端和服务器端同时想要断开连接,在服务器端回复ACK的时候同时发送FIN。那么整个挥手过程变成了3次挥手。

验证CLOSE_WAIT状态

CLOSE_WAIT状态是一端想要断开连接,另一方不断开连接,那么另一方就会一直维持在CLOSE_WAIT状态。注意先不能accept.

#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <sys/socket.h>
#include <sys/stat.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <cassert>
#include <cerrno>
#include <pthread.h>
#include <unistd.h>
#include <signal.h>
class ServerTcp
{
public:
    ServerTcp(uint16_t port,const std::string& ip = "")
        :port_(port),ip_(ip),listenSock_(-1)
        {
            quit_ = false;
        }
    ~ServerTcp()
    {
        if(listenSock_>= 0)
        {
            close(listenSock_);
        }
    }
public:
    void init()
    {
        //1.创建socket
        listenSock_ = socket(PF_INET,SOCK_STREAM,0);
        if(listenSock_ < 0) exit(1);
        //2.bind 绑定
        //填充服务器信息
        struct sockaddr_in local;
        memset(&local,0,sizeof(local));
        local.sin_family = PF_INET;
        local.sin_port = htons(port_);
        ip_.empty()?(local.sin_addr.s_addr = INADDR_ANY):(inet_aton(ip_.c_str(),&local.sin_addr));
        if(bind(listenSock_,(const struct sockaddr*)&local,sizeof(local)) < 0) exit(2);
        //3.监听
        if(listen(listenSock_,2) < 0) exit(3);
        //让别人来链接你
    }
    void loop()
    {
        signal(SIGCHLD,SIG_IGN);//only linux
        while(!quit_)
        {
            sleep(1);
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);

            int serviceSock = accept(listenSock_,(struct sockaddr *)&peer,&len);
            if(quit_) break;
            if(serviceSock<0)
            {
                //获取链接失败
                std::cerr<<"accept error........." <<std::endl;
                continue;
            }
            std::cout<<"获取新链接成功: "<<std::endl;
        }
    }

    bool quitServer()
    {
        quit_ = true;
        return true;
    }

private:
    int listenSock_;
    uint16_t port_;
    std::string ip_;
    bool quit_;//安全退出
};
#include "server.hpp"

static void Usage()
{
    std::cout << "Usgae:\n\t ./server port" <<std::endl;
    exit(4);
}
int main(int argc,char* argv[])
{
    if(argc != 2) Usage();
    ServerTcp svr(atoi(argv[1]));
    svr.init();
    svr.loop();
    return 0;
}

当运行起来之后,我们可以使用另外一台及其进行对服务器连接(这里不推荐使用一台机器,因此如果是一台机器,服务器和客户端是一台机器,那么我们在查询的时候就会出现两个字段,不方便查看)

启动服务之后,在另外一台机器上链接服务器

此时

我们发现了8082的外部地址是81,正式我们另外一台机器。现在他的状态是ESTABLISHED -- 因此我们得到一个结论,我们现在不accpet,三次握手也会成功。

我们现在让81的机器同时链接4个会怎么样的?

 我们来查看一下状态,发现其中有一个状态是SYN_RECV,我们快快来看看这个状态是什么?

 我们发现,SYN_RECV意味着服务器收到了你连接的请求,但是我先不给你ACK。那么为什么到第四个客户端再连接时就不能完成三次握手呢?这是listen的第二个参数有关。

listen的第二个参数

基于刚才封装的 TcpServer  实现以下测试代码
对于服务器 , listen 的第二个参数设置为 2, 并且不调用 accept
此时启动 3 个客户端同时连接服务器 , netstat 查看服务器状态 , 一切正常 . 但是启动第四个客户端时, 发现服务器对于第四个连接的状态存在问题了

listen的第二个参数叫做底层的全连接队列的长度,算法是:n+1表示在不accept的情况下,服务器最多能够维护的链接个数。因此我们刚刚最多只能有3个客户端同时连接我们

客户端状态正常 , 但是服务器端出现了 SYN_RECV 状态 , 而不是 ESTABLISHED 状态
这是因为 , Linux 内核协议栈为一个 tcp 连接管理使用两个队列:
  1. 半链接队列(用来保存处于SYN_SENTSYN_RECV状态的请求)
  2. 全连接队列(accpetd队列)(用来保存处于established状态,但是应用层没有调用accept取走的请求)
而全连接队列的长度会受到 listen 第二个参数的影响 . 全连接队列满了的时候, 就无法继续让当前连接的状态进入 established 状态了 . 这个队列的长度通过上述实验可知, listen 的第二个参数 + 1.

当我们把第一个客户端关闭的时候,我们再来查看状态,我们发现此时,他的状态就变成了CLOSE_WAIT,结合我们四次挥手的流程图, 可以认为四次挥手没有正确完成.

小结:

对于服务器上出现大量的 CLOSE_WAIT 状态 , 原因就是服务器没有正确的关闭 socket, 导致四次挥手没有正确完成. 这是一个 BUG. 只需要加上对应的 close 即可解决问题

当全部关闭客户端时,状态便全部变成了CLOSE_WAIT

验证TIME_WAIT状态

现在做一个测试 , 首先启动 server, 然后启动 client, 然后用 Ctrl-C 使 server 终止 , 这时马上再运行 server, 结果是

此时我们立马让服务其CTRL-C

 这是因为虽然server的应用程序终止了,但TCP协议层的连接并没有完全断开,因此不能再次监听同样的server端口。

  • TCP协议规定,主动关闭连接的一方要处于TIME_WAIT状态,等待两个MSL的时间才能回到CLOSED状态
  • 我们使用Ctrl-C终止了server,所以server是主动关闭连接的一方,在TIME_WAIT期间仍然不能再次监听同样的server端口
  • MSL在RFC1122中规定是2分钟。但是各操作系统的实现不同, Centos7上默认配置的值是60s。
为什么是 TIME_WAIT 的时间是 2MSL?
  1. MSLTCP报文的最大生存时间, 因此TIME_WAIT持续存在2MSL的话就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失(否则服务器立刻重启, 可能会收到来自上一个进程的迟到的数据, 但是这种数据很可能是错误的);
  2. 同时也是在理论上保证最后一个报文可靠到达(假设最后一个ACK丢失, 那么服务器会再重发一个FIN. 这时虽然客户端的进程不在了, 但是TCP连接还在, 仍然可以重发LAST_ACK);

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

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

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

解决:

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

 

滑动窗口

TCP如果每一个发送数据段,都要给一个ACK的确认应答,收到ACK后在发送下一个数据段,这样做有一个比较大的确定啊,就是性能较差,尤其是数据往返的时间较长的时候。

为了解决这个问题,TCP引入了一个滑动窗口的概念。即使在往返时间较长的情况下,它也能控制网性能的下降。确认应答不再是以每个分段,而是以更大的单位进行确认时,转发时间将会被大幅度的缩短。也就是说,发送端主机在发送一个段以后不必要一直等待确认应答,而是继续发送。

  • 窗口大小指:无需等待确认应答而可以继续发送数据的最大值。上图的窗口大小就是4000个字节.(4个段)
  • 发送前4个段的时候,不需要等待任何ACK,直接发送。
  • 收到第一个ACK以后,滑动窗口向后移动,继续 发送第5个段的数据;以此类推
  • 操作系统内核为了维护这个滑动窗口,需要开辟发送缓冲区来记录当前还有那些数据没有应答;只有确认应答过的数据,才能从缓冲区删掉。
  • 窗口越大,则网络的吞吐量就越高

 那么如果出现了丢包,如何进行重传?这里分两种情况讨论

情况一:数据包已经抵达,ACK丢失了。这种情况下,部分ACK丢了并不要紧,因为可以通过后续的ACK进行确认。

 情况二:数据包直接丢了

  • 当某一段报文端丢失之后,发送端会一直收到1001这样的ACK,就像是在提醒发送端"我想要的是1001一样"
  • 如果发送端主机连续3次收到了同样"1001"这样的应答,就会将对应的数据1001-2000重新发送
  • 这个时候接收端收到了1001之后,再次返回的ACK就是7001(因为2001-7000)接收端其实之前就已经收到了,被放到了接收端操作系统内核的接收缓冲区中。

这种机制被称为"高速重发控制"(也叫快重传)。滑动窗口不一定向右滑动,滑动窗口有可能增大也有可能减小。

start_index什么时候会向右走:

start_index等于确认序号;end_index=start_index+窗口大小

拥塞控制

虽然TCP有了滑动窗口这个大杀器,能够高效可靠的发送大量的数据,但是如果在刚开始阶段就发送大量的数据,仍然可能引发问题。因为网络上有很多计算机,可能当前的网络状态就已经比较拥堵,在不清楚当前网络状态下,贸然发送大量的数据,是很有可能引起雪上加霜的。

因此为了解决这个问题,TCP引入慢启动机制,先发少量的数据,探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。

  • 此处引入了一个概念称为拥塞窗口
  • 发送开始的时候,定义拥塞窗口大小为1
  • 每次收到一个ACK应答,拥塞窗口加1
  • 每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为实际发送的窗口

因此发送方向接收方一次可以发送的数据量 = min ( 对方的接受能力,网络的拥塞窗口 ) 

因此滑动窗口的大小 = min (对方窗口大小的剩余值,网络的拥塞窗口)

因此end_index  = start_index + min(窗口大小,拥塞窗口)

像上面这样的拥塞窗口增长速度 , 是指数级别的 . " 慢启动 " 只是指初使时慢 , 但是增长速度非常快 .
  • 为了不增长的那么快 , 因此不能使拥塞窗口单纯的加倍 .
  • 此处引入一个叫做慢启动的阈值
  • 当拥塞窗口超过这个阈值的时候 , 不再按照指数方式增长 , 而是按照线性方式增长

  • 当TCP开始启动的时候,慢启动阈值等于窗口最大值
  • 在每次超时重发的时候,慢启动阈值会变成原来的一半,同时拥塞窗口置为1

少量的丢包,我们仅仅是触发超时重传,大量的丢包,我们就认为网络拥塞,当TCP通信开始后,网络吞吐量会逐渐上升,随着网络发生拥堵,吞吐量会立即下降。拥塞控制,归根结底是TCP协议想尽可能快的把数据传输给对方,但是又要避免给网络造成太大压力的折中方案。

延迟应答

如果接受数据的主机立刻返回ACK应答,这时候返回的窗口可能比较小.

  • 假设接收端缓冲区为1M.一次收到了500K的数据;如果立刻应答,返回的窗口就是500K
  • 但实际上可能处理端处理的速度很快,10ms之内就把500K数据从缓冲区消费掉了
  • 在这种情况下,接收端处理还远没有达到自己的极限,即使窗口再放大一些,也能处理过来
  • 如果接收端稍微等一下再应答,比如等待200ms再应答,那么这个时候返回的窗口大小就是1M

一定要记得,窗口越大,网络吞吐量就越大,传输效率就越高,我们的目标是在保证网络不拥塞的情况下尽量提高传输效率

那么所有的包都可以延迟应答吗?肯定也不是

  1. 数量限制:每隔N个数据包就应答一次 (一般采取这个)
  2. 时间限制:超过最大延迟时间就应答一次
具体的数量和超时时间 , 依操作系统不同也有差异 ; 一般 N 2, 超时时间取 200ms;

捎带应答 

在延迟应答的基础上 , 我们发现 , 很多情况下 , 客户端服务器在应用层也是 " 一发一收 " . 意味着客户端给服务器说了 "How are you", 服务器也会给客户端回一个 "Fine, thank you";
那么这个时候 ACK 就可以搭顺风车 , 和服务器回应的 "Fine, thank you" 一起回给客户端。

面向字节流

创建一个TCP的socket,同时在内核中创建一个发送缓冲区和一个接受缓冲区

  • 调用write时,数据会先写入发送缓冲区中
  • 如果 发送的字节数 太长,会被拆分成多个TCP的数据包发出
  • 如果发送的字节数太短,就先会在缓冲区里等待,等待缓冲区长度差不多了,或者其他合适的时机发送出去
  • 接受数据的时候,数据也是从网卡驱动程序到达内核的接受缓冲区
  • 然后应用程序可以调用read从接受缓冲区拿数据
  • 另一方面,TCP的一个连接,既有发送缓冲区,也有接受缓冲区,那么对于这一个连接,既可以读数据,也可以写数据,这个概念叫做全双工

由于缓冲区的存在,TCP程序的读和写不需要一一匹配,例如:

  • 写一个100个字节的数据时,可以调用一次write写100个字节,也可以调用100次write,每次写1个字节
  • 读100个字节数据时,也完全不需要考虑写的时候是怎么写的,即可以一次read 100个字节,也可以一次read 一个字节,重复100次。

应用层只管向发送缓冲区拷贝数据,而TCP会按照自己的规则发送数据。写入的时候与写入的格式没有关系。读取的时候与读取的格式毫不相关,因此发送和接受都与格式毫不相关。这就叫做面向字节流。

tcp是面向字节流的,根本不关心任何的数据格式。但是要正确使用这个数据,必须得有特定的格式。这个格式是应用层进行处理的(保证读取到一个完整的报文)

粘包问题

  • 粘包问题中的“包”,是指应用层的数据包
  • 在TCP的协议头中,没有如同UDP一样的“报文长度”这样的字段,但是有一个序号这样的字段
  • 站在传输层的角度,TCP是一个一个报文过来的,按照序号排好序放在缓冲区中。
  • 站在应用层的角度,看到的只是一串连续的字节数据
  • 那么应用程序看到了这么一连串的字节数据,就不知道从那个部分开始到哪个部分,是一个完整的应用层数据包

避免粘包问题:明确报文与报文之间的边界

  • 对于定长报文,保证每次都按固定大小读取即可
  • 对于变长的包,可以在报头的位置,约定一个包总长度的字段,从而就知道这个报文的结束位置
  • 对于变长的报还可以在包和包之间使用明确的分隔符(保证分隔符和正文不冲突即可)

UDP是否存在粘包问题?

不存在

  • 对于UDP协议,如果还没有上层交付数据,UDP的报文长度仍然在,同时,UDP是一个一个把数据交付给应用层,就有很明确的数据边界
  • 站在应用层的角度,使用UDP的时候,要么收到完整的UDP报文,要么不收,不会出现"半个"的情况

TCP异常情况

进程终止:进程终止会释放文件描述符,依然可以发送FIN,和正常关闭没有什么区别

机器重启:和进程终止的情况相同

机器掉电/网线断开:接收端认为链接还在,一旦接收端有写入操作,接收端发现连接已经不在了,就会进行reset,即使没有写入操作,TCP自己内置了一个保活定时器,会定期询问对方是否还在,如果对方不在,也会把连接释放掉。

另外,应用层的某些协议,也有一些这样的检测机制。例如HTTP长连接中会定期检测对方的状态。

用UDP实现可靠传输

udp可以参考tcp可靠性机制,在应用层实现类似的逻辑

例如:

  • 引入序列号,保证数据顺序
  • 引入确认应答,保证对端收到了数据
  • 引入超时重传,如果隔一段时间没有应答,就重发数据。
  • ................

谷歌开发了一个QUIC是基于UDP开发的。文档链接:(英文)RFC 9000: QUIC: A UDP-Based Multiplexed and Secure Transport (quicwg.org)

QUIC ,即 快速UDP网络连接 ( Quick UDP Internet Connections ), 是由 Google 提出的实验性网络传输协议 ,位于 OSI 模型传输层。 QUIC 旨在解决 TCP 协议的缺陷,并最终替代 TCP 协议, 以减少数据传输,降低连接建立延 迟时间,加快网页传输速度。

 QUIC的特点

  1. 连接建立低时延
  2. 多路复用
  3. 无队头阻塞
  4. 灵活的拥塞控制机制
  5. 连接迁移
  6. 数据包头和包内数据的身份认证和加密
  7. FEC前向纠错
  8. 可靠性传输
  9. 其他

TCP小结

TCP保证可靠性的策略:

  • 校验和
  • 序列号(按序到达)
  • 确认应答
  • 超时重发
  • 连接管理
  • 流量控制
  • 拥塞控制

提高性能的策略:

  • 滑动窗口
  • 快速重传
  • 延迟应答
  • 捎带应答

基于TCP应用层的协议

  • HTTP/HTTPS
  • SSH
  • Telnet

等等

猜你喜欢

转载自blog.csdn.net/qq_58325487/article/details/129400757
今日推荐