传输层协议——TCP协议

发布于:2024-05-16 ⋅ 阅读:(68) ⋅ 点赞:(0)

目录

一、TCP协议

二、TCP协议格式

三、序号和确认序号

四、窗口大小

五、六个标记位

六、三次握手和四次挥手

七、滑动窗口

八、拥塞控制

九、延迟应答和捎带应答

1、延迟应答

2、捎带应答

十、面向字节流

十一、粘包问题

十二、TCP异常情况

十三、再谈listen函数

十四、总结


一、TCP协议

传输控制协议(TCP,Transmission Control Protocol)是一种面向连接的、可靠的、基于字节流的传输层通信协议。

TCP协议是当今互联网当中使用最为广泛的传输层协议,其根本原因就是TCP协议提供了详尽的可靠性保证。能够给用户更好的上网体验。

二、TCP协议格式

TCP协议格式如下:

16位检验和:由发送端填充,采用CRC校验。接收端校验不通过,则认为接收到的数据有问题。(检验和包含TCP首部+TCP数据部分)。 

TCP协议如何向上进行交付。 

通过16位目的端口号,可以直接将数据交给上层应用层的进程。

TCP协议如何将报头和有效载荷分离。 (如何解包)

首先,我们要知道,TCP协议的报头是可以携带选项的,所以选项也属于报头的一部分。TCP协议采用定长报头的方式,即选项之上的部分大小为固定的20字节。我们在读取了选项之上的部分后,可以拿到4位首部长度,再乘以4它就是报头的总大小,用其减去20字节,就是选项大小。然后就可以读取完成选项。剩下的就是有效载荷了。

注:4位首部长度的单位是4字节,也就是0000个4字节->1111个4字节的大小,也就是0个4字节到15个4字节大小 [0字节,15*4字节]。因为TCP协议标准报头大小为20字节,所以报头的大小范围是 [20字节,60字节]。因此,如果报头没有携带选项,4位首部长度填写的就是0101。

确认应答机制(保证可靠性)

对于UDP,一方只需要将报文发送到网络中,就表示已经完成任务,它不会关心数据是否丢包,不会关心对方是否真正收到,因此也不会在丢包后进行重传。

而对于TCP,为了保证数据传输的可靠性,设立了一种确认应答机制,即:一方发送报文数据给另一方,如果收到,另一方将会给其返回确认收到的报文,即确认应答,告诉对方数据收到。

所以,一方发送出去的报文,只要有匹配的应答,就能够保证,刚刚发送过去的报文对方一定收到了。如果没有对应的应答,TCP还有数据重传等方法来保证可靠性。

当然,我们无法保证,最新一次的确认应答被对方收到,TCP也无法保证100%可靠。那么这样还是可靠的吗?当然是的,因为只有最新一次的确认应答无法保证被对方收到,而之前的通信的数据都能够保证被对方收到。

超时重传机制(保证可靠性)

当主机A向主机B发送消息的时候,如果主机A在一定时间内没有收到主机B的应答报文,那么主机A就会对数据进行超时重传。

主机A没有收到主机B的应答报文的情况有两种:1、主机A发送的报文丢了。2、应答报文丢了。

那么,如果是应答丢了的话,主机A就会给主机B重传数据。如果应答报文多次丢失的话,主机A就会给主机B重传多次相同的报文。那主机B就有多个相同的报文了。这时,主机B会根据序号对重复报文进行去重。

超时重传的超时时间应该如何设置?

网络好的时候,超时时间应该短一点,网络不好的时候,超时时间应该长一点。 

三、序号和确认序号

问题引入

现在有一个问题:TCP是基于字节流式的,所以客户端可能一次性向服务端发送多个请求报文,而服务端收到所有请求,处理完成后,会对这些请求一一进行响应,即客户端会同时收到多个响应报文,那么怎么区分哪个响应报文是对应的哪个请求呢?这就需要序号和确认序号起作用了。

序号和确认序号

每一个报文都有一个序号,来标定该报文。因为客户端可能一次性向服务端发送多个报文,可是服务端就收到的报文的顺序不一定就是客户端发送的顺序。比如:客户端按顺序一次性向服务端发送了序号为1000,2000,3000的报文,可是服务端收到的顺序是3000,1000,2000,这时,服务端会根据序号对报文进行排序,就得到了报文的正确顺序,然后就可以处理请求了。反过来,客户端也可以对服务端发送过来的报文进行排序。这就是序号的作用了。

确认序号:表示该数字之前的数字所代表的序号,所对应的报文我已经全部收到了。下次发送,可以从确认序号指明的序号开始发送。

举个例子:服务器在收到了请求报文,序号分别为:1000,2000,3000。如果收到序号为1000的报文,应答报文的确认序号就是1001,表示1001之前的所有序号的报文已经收到。

如果序号为1000和2000的报文都收到了,服务端返回确认序号是1001和确认序号是2001的应答报文,而如果确认序号是1001的应答报文丢失了,只拿到确认序号是2001的应答报文,那么我也可以认为服务端收到了序号为1000的报文,因为确认序号2001表示2001之前的所有序号的报文已经收到。所以这是允许部分确认丢失的(1001丢失了),或者不给应答(比如:收到了序号为1000,2000,3000的报文,直接给应答报文的确认序号写上3001,表示序号为3001之前的所有报文已经收到,即只有一次应答)。

如果,服务端只收到了序号为1000和3000的报文,没有收到序号为2000的报文,那么服务端只能返回确认序号为1001的报文,不能返回确认序号是3001的报文,因为序号为2000的报文没有收到。

序号,确认序号与发送缓冲区的关系

 

对于发送缓冲区,我们可以把它看作一个 char buffer[NUM] 的数组,所以我们可以把每个数据看作一个一个的字节,每一个字节都对应数组的一个下标。比如我们现在要发送0到100下标的数据,我们将其作为整体放入报文中,该报文所对应的序号就是最后一个数据的下标,100。

另一方收到报文后,在返回报文的确认序号中就会填上101,表示序号为100之前的报文收到,接下来你应该从序号为101的位置发送数据。

接下来,就发送下标101到200的数据。对于接收缓冲区,它也是一个 char buffer[NUM] 的数组,接收方会根据序号的大小,将数据放入接收缓冲区对应的位置。这样上层就能够有序地拿到数据了。

四、窗口大小

TCP是全双工的。其具有发送缓冲区和接收缓冲区。接收缓冲区用来暂时保存接收到的数据。发送缓冲区用来暂时保存还未发送的数据。

我们编写代码时,所使用的write,read等函数,并不是直接向网络中发送数据或者读取数据。发送数据时,send,write等函数是先将应用层的用户数据拷贝到传输层TCP协议的发送缓冲区中。接收数据时,recv,read等函数是直接将传输层TCP协议的接收缓冲区中的数据拷贝到应用层。

前面我们说了TCP是传输控制协议,也就是说,发送缓冲区的数据怎么发送,每次发送多少,发错了怎么办,丢包了怎么办等问题都由传输层协议TCP决定和解决。

而TCP既然有接收和发送缓冲区,而且缓冲区一定是有大小的。那就说明如果一方来不及读取,而另一方又在不断发送数据的话,接收缓冲区迟早会被写满,写满后就无法再接收数据了,这时发送端再发送数据过来就会造成数据被丢弃。

TCP支持根据接收端的接收数据的能力来决定发送端发送数据的速度,这个机制叫做流量控制。

因此TCP报头当中就有了16位的窗口大小,这个16位窗口大小当中填的是自身接收缓冲区中剩余空间的大小,也就是当前主机接收数据的能力。

接收端在对发送端发来的数据进行响应时,就可以通过16位窗口大小告知发送端自己当前接收缓冲区剩余空间的大小,此时发送端就可以根据这个窗口大小字段来调整自己发送数据的速度。

五、六个标记位

为什么要有6个标记位?

TCP报文的种类多种多样,除了正常通信时发送的普通报文,还有建立连接时发送的请求建立连接的报文,以及断开连接时发送的断开连接的报文,确认报文以及其他类型的报文。

服务端可能会收到大量的不同种类的报文,而不同种类的报文对应的是不同的处理方法,所以我们要能够区分报文的类型。而TCP协议就是使用报头当中的六个标记位来进行区分的,这六个标志位都只占用一个比特位,为0表示假,为1表示真。

SYN:报文当中的SYN位被设置为1,表明该报文是一个连接建立的请求报文。

FIN:表明该报文是一个连接断开的请求报文。

ACK:表明该报文是一个确认应答报文。凡是该报文具有应答特征,该标志位都会被设置为1。

RST:连接重置。我们在进行三次握手时,客户端最后一旦发送出去了ACK报文,就认为自己连接建立好了,那么如果ACK在网络中丢失了,服务端没有收到ACK报文,就不会建立连接。此时客户端自己认为连接建立好了,就开始通信,而服务端收到通信报文后发现这个客户端并没有和自己建立连接,所以服务端此时直接给客户端回一个报文,而这个报文的RST标记位被置成1,告诉客户端连接出异常了,需要断开连接重新发起连接。

PSH:对于接收端的接收缓冲区,如果接收端上层应用层非常忙,来不及从接收缓冲区里拿数据,最终导致接收缓冲区会越来越满,假设发送方把接收方接收缓冲区打满了,如果上层一直不把数据拿走,发送方要一直等而无法发送数据,等了一段时间,发收方可能不耐烦了,然后发询问报文,会把PSH标记位进行设置为1,催促接收方让你的上层赶紧把数据取走,我不能等了,赶紧把数据取走。催促接收方,让上层尽快取走数据!

URG:上面通过序号,我们知道TCP的报文虽然不一定是按序到达的,但是可以根据序号进行排序,按序号进行处理。如果我们的数据想要插队呢? 这就有了URG。如果报文中有需要被尽快读取的数据,可以将URG标志位置为1 ,表明这个报文中的有效载荷是涵盖有紧急数据的。而我们使用16位紧急指针来标识紧急数据在有效载荷中的偏移量,找到紧急数据的位置。紧急数据的大小只有1个字节。

六、三次握手和四次挥手

如何理解连接

因为有大量的客户端将来可能会连接服务端,所以在服务端一定存在大量的连接,那么操作系统一定会对这些连接进行管理。如何管理呢?先描述,再组织。

所谓的连接,本质是操作系统内核中的一种数据结构,当连接建立成功后,就是在内核中创建对应的数据结构对象,用来描述这个连接。然后,对若干个连接对象进行某种数据结构的组织。

三次握手

三次握手的本质就是客户端向服务端发起连接请求,建立连接的过程。双方通信之前必须要先建立连接,经历三次握手。

第一步。上图中的SYN是一个tcp报文(我们暂且称它为SYN报文),它的SYN标志位置为1,说明是一个连接请求的报文。当客户端想要和服务端建立连接时,客户端最先就会发送该报文,去向服务端请求建立连接。客户端只要把SYN报文发出就会把自己状态变成SYN_SENT(同步发送)。

第二步。当服务端收到SYN报文后,把自己状态变SYN_RCVD(同步收到)。服务端审核成功允许连接后就会给客户端发送,SYN和ACK标志位为1的报文(我们暂且称它为SYN/ACK报文)。这个报文既是向客户端发起连接建立的报文(同意建立连接),也是应答报文,告诉客户端之前请求连接的报文已经收到。

第三步。当客户端收到SYN/ACK报文后,知道了服务端允许建立连接了,于是就发送ACK报文给服务端。一旦客户端把ACK发出,它就认为把连接建立好了,因为客户端认为三次握手已经完成了。然后,服务端收到了ACK报文,就为该客户端创建数据结构,并进行管理,开始服务。

为什么要三次握手?一次握手,两次握手或者四次以上的握手行不行? 

一次握手:一次握手代表着只要客户端向服务端发送SYN报文,客户端就认为连接建立好了,而服务端就要建立一个连接并且认为连接建立好了。 如果,此时有不法分子不断地向服务端发送大量的SYN报文(SYN洪水),那么服务端没有什么办法,只能一一建立连接,所以一瞬间服务端的资源就可能被占满了。这很不合理。

两次握手:客户端先向服务端发送SYN报文,服务端收到SYN报文后,一旦服务端向客户端发送了SYN/ACK报文,服务端就认为连接建立好了,此时服务端内核中已经有了该连接相关的数据结构。然后只有当客户端收到SYN/ACK报文,客户端才认为连接建立好了。如果,现在有不法分子不断地向服务端发送大量的SYN报文,并且他在收到SYN/ACK报文后,直接将其丢弃,这样服务端一瞬间也会有大量的连接连接,服务端资源又被占用了,而不法分子的客户端因为将SYN/ACK报文丢弃了,所以没有建立连接,也就没有消耗资源,这种情况对于服务端来说也是不能允许的。

三次握手:最后一次握手发出ACK报文是客户端做的,而是由服务器来最终确认连接是建立好的,也就是说你要让我服务器连接建立好,你客户端要先把连接建立好。也就是服务端收到ACK报文后,才在内核中创建连接相关的数据结构。服务端建立好连接的前提是客户端已经建立好了连接,那么这样即使有不法分子使用SYN洪水攻击服务端,那么不法分子的客户端一定也会挂上许多连接,资源消耗是对等的。这样是能够接受的。

TCP通信是全双工的,那客户端和服务端都必须既能收消息也能发消息,可是通信之前首先要保证双方既能收又能发。而三次握手是可以验证全双工通信信道是通畅的。

四次以上的握手:四次,最后一次发ACK的是服务器,它要先把连接建立好,和两次握手一样的问题。偶数次握手的情况都有这样的情况。

奇数次握手如三次握手是可以的,但更重要的就是三次握手就可以完成连接的建立了,还要五次、七次、九次等奇数次握手有什么用呢,这样只会浪费更多的时间和资源。

注:因为在三次握手时会有报文交换,所以我们可以在三次握手时,得知对方缓冲区的大小(TCP报头窗口大小),进而支持后续的流量控制。

四次挥手

四次握手的本质就是客户端和服务端断开连接的过程。

首先我们需要明确的是,建立连接是一方主动发起,断开连接是双方的事,又因为tcp是全双工的,所以需要征得双方同意。 

在某次正常通信完成后,客户端想要断开连接(客户端主动断开连接),于是就发送FIN报文,发出后处于FIN_WAIT_1状态。 服务端收到FIN报文,同意断开连接,就向客户端发送ACK报文,发出后处于CLOSE_WAIT状态,这个时候服务器连接还没关只是处于预关闭状态。客户端收到ACK报文后,就处于FIN_WAIT_2状态。

之后,服务端向客户端发送FIN报文,请求断开连接,发出后处于LAST_ACK状态。客户端收到FIN报文后,同意断开连接,紧接着发送ACK报文,发出后处于TIME_WAIT状态。服务端收到ACK报文后,就进入CLOSED状态。客户端在处于TIME_WAIT状态一段时间后,也就会进入CLOSED状态。

CLOSE_WAIT状态和TIME_WAIT状态

主动断开连接的一方,最终状态是TIME_WAIT状态。被动断开连接的一方,两次挥手完成,会进入CLOSE_WAIT状态。

那么怎样服务端能够保持CLOSE_WAIT状态呢?让服务器不要调用close关闭文件描述符。那服务器只是被动触发完成两次挥手,因为不会调用close所以也不会给客户端发送FIN也就不会进入LAST_ACK状态。服务器一直处于CLOSE_WAIT状态。 

#include "Sock.hpp"

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

    while (true)
    {
        uint16_t clientport;
        std::string clientip;
        int sockfd = sock.Accept(listen_, &clientip, &clientport);
        if (sockfd > 0)
        {
            std::cout << "[" << clientip << ":" << clientport << "]##" << sockfd;
        }
    }

    return 0;
}

启动服务器并使用telnet连接服务器。客户端和服务器全部处于ESTABLISHED状态。如下图:

然后我们让客户端断开连接。重复连接并断开连接多次:

  

我们发现,服务端有多个连接处于CLOSE_WAIT状态!将来如果还有大量的连接来的话,将会有更多的处于CLOSE_WAIT状态的连接。这样服务端的文件描述符会越来越少,资源也会越来越少,最终可能会导致服务端挂掉。所以,断开连接后,服务端一定要关掉对应的文件描述符!

验证TIME_WAIT状态: 

#include "Sock.hpp"

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

    while (true)
    {
        uint16_t clientport;
        std::string clientip;
        int sockfd = sock.Accept(listen_, &clientip, &clientport);
        if (sockfd > 0)
        {
            std::cout << "[" << clientip << ":" << clientport << "]##" << sockfd << std::endl;
        }
        sleep(10);
        close(sockfd);
        std::cout << sockfd << "closed" << std::endl;
    }

    return 0;
}

 启动服务器并使用telnet连接服务器。如下图:

服务端休眠10秒,客户端进入TIME_WAIT状态。如下图:

10s后,服务端会关掉文件描述符,接着客户端退出,查看时发现连接没有了。

那么下面我们让服务端主动断开连接,而主动断开连接的一方,会进入TIME_WAIT状态

我们发现服务端会保持TIME_WAIT状态一段时间,而这段时间再次启动服务器是不行的!我们查看错误码是2,说明绑定失败了!说明TIME_WAIT状态下,虽然连接断开了,但是服务端的IP地址,端口号port等信息任然是被占用的。

这种情况在实际生活中我们是不能允许的。为什么?比如,双11的时候,淘宝的服务器因为负荷过大,挂掉了,那么如果不能马上就让服务器重新起来的话,那么这个损失是无法估计的。所以在服务器挂掉后,服务器应该要能够立即重新启动。

我们可以使用setsockopt()函数解决这个问题。

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);

修改代码如下:我们在创建完成套接字后,调用这个函数。

主动断开连接的一方,为什么要维持一段时间的TIME_WAIT状态?

我们把一个报文从客户端到服务器或者从服务器到客户端最大时间叫做MSL(单向传输时间最大传送单元)。即一端到另一端所用的最长时间。 

1、客户端和服务端在进行四次挥手断开连接的时候,可能有之前通信的报文还滞留在网络中,没有被送达到服务端或者客户端。我们需要等待TIME_WAIT时间(2*MSL)。让滞留的报文有足够的时间,能够从网络中进行消散(要么被送达,要么被丢弃)。

为什么要进行消散?如果,客户端断开连接后又立马建立连接,那么历史遗留报文就没有消散,它就会影响最新一次连接后进行的正常通信。

2、主动断开连接的一方最后一次发出ACK后,就进入TIME_WAIT状态。而被动断开连接的一方只有收到最后一次ACK后才认为自己的连接断开了。

那么,如果主动断开连接的一方没有TIME_WAIT状态的话,那主动断开连接的一方一把ACK发过去就立刻把连接就全部都关了进入CLOSED状态。如果最后一次ACK丢失了的话,被动断开连接的一方就无法确认连接断开了。被动断开连接的一方当然可以重新询问,但是另一端已经把连接关闭了根本不会做任何响应了。所以,这种情况也是不能够被允许的。

因此,我们就需要TIME_WAIT状态,且时间为2*MSL。因为2*MSL刚好是一端进行补发FIN另一端ACK响应到达的时间。保证最后一个ACK尽可能被对方收到。

七、滑动窗口

对于确认应答机制,对每一个发送的数据段,都要给一个ACK确认应答。收到ACK后再发送下一个数据段。这样做有一个比较大的缺点:就是性能较差,尤其是数据往返的时间较长的时候。

既然这样一发一收的方式性能较低,那么我们一次发送多条数据,就可以大大的提高性能(其实是将多个段的等待时间重叠在一起了)。这就是滑动窗口所起到的作用了。

虽然在进行通信时,可以一次性向对方发送大量的报文,但不能将自己发送缓冲区当中的数据全部打包发送给对端,在发送数据时还要考虑对方的接收能力。

滑动窗口(解决效率问题)

我们可以将发送缓冲区当中的数据分为三部分:1、已经发送并且已经收到ACK的数据。2、已经发送还但没有收到ACK的数据。3、还没有发送的数据。

如下图。其中,中间蓝色的那段数据区间我们就称之为滑动窗口。

比如对于下面的发送缓冲区:

如果我们接下来收到了确认序号为3001的报文,那么我们的滑动窗口需要向右移动。

当发送方发送出去的数据段陆陆续续收到对应的ACK时,就可以将收到ACK的数据段归置到滑动窗口的左侧,并根据收到的报文报头中的窗口大小来决定,是否需要将滑动窗口右侧的数据归置到滑动窗口当中。

滑动窗口的本质。

滑动窗口的本质:滑动窗口实际就可以看作是两个指针限定的一个范围,比如我们用 start 指向滑动窗口的左侧,end指向的是滑动窗口的右侧,此时在 start 和 end 区间范围内的就叫做滑动窗口。 

当发送端收到对方的响应时,如果响应当中的确认序号为x,窗口大小为 win,此时就可以将start更新为 x,而将 end 更新为 start + win。

滑动窗口一定会整体右移吗? 

滑动窗口不一定会整体右移的。比如,对于下图,对方的接收能力为4000。我们将滑动窗口区域中的所有数据发出后,收到了确认序号为4001的报文,说明序号为4001之前的报文全部收到,那么此时滑动窗口的 start 需要向右移动。

但对方上层一直不从接收缓冲区当中读取数据,此时当对方收到3001-4000的数据段时,对方的窗口大小就由4000变为了3000。

而当3001-4000的数据段归置到滑动窗口的左侧后,滑动窗口的大小刚好就是3000,因此滑动窗口的右侧不能继续向右进行扩展。

因为滑动窗口区间内已经有大小为3000的数据发送过去了,只是还没有收到应答而已。如果此时贸然扩大窗口,发送数据,这个数据只能被丢弃。

既然这样,滑动窗口的大小也可以为0。因为上层一直不读取数据,接收能力越来越小,并且滑动窗口数据对应的应答也陆陆续续到来,start不断右移,end不动。

快重传

当1001-2000的数据包丢失后,发送端会一直收到确认序号为1001的响应报文,就是在提醒发送端“下一次应该从序号为1001的字节数据开始发送”。

如果发送端主机连续三次收到了同样一个 "1001" 这样的应答,就会将对应的数据 1001 - 2000 重新发送。

这个时候接收端收到了 1001-2000 之后,再次返回的ACK就是7001了(因为2001 - 7000)接收端其实之前就已经收到了。

这种机制被称为 "高速重发控制"(快重传)。

八、拥塞控制

前面我们所讲的内容,都是基于两台主机双方的。可是,主机间的通信,还要受中间角色,网络的影响。比如,我们平时加载网页很慢,就是网络中通信的主机太多,导致网络拥塞了。

两个主机在进行TCP通信的过程中,出现个别数据包丢包的情况是很正常的,此时可以通过快重传或超时重发对数据包进行补发。但如果双方在通信时出现了大量丢包,则有可能是网络出了问题,即双方通信信道网络出现了拥塞问题。

如何解决网络拥塞问题?

因为每个网络中都有大量的主机在使用这个网络,所以难免会出现网络拥塞的问题,网络出现问题一定是网络中大部分主机共同作用的结果。此时,通信双方虽然不能解决网络的拥塞问题,但双方主机可以做到不加重网络的负担。

网络拥塞时影响的不只是一台主机,而几乎是该网络当中的所有主机,此时所有使用TCP传输控制协议的主机都会执行拥塞控制相关的算法。

 拥塞控制

两台通信的主机,如果在刚开始阶段就发送大量的数据,就可能会引发拥塞问题。因为网络上有很多的主机,有可能当前的网络状态就已经比较拥塞了,因此在不清楚当前网络状态的情况下,贸然发送大量的数据,就可能会引起网络拥塞问题。 

所以,TCP引入了慢启动机制,在刚开始通信时先发少量的数据,弄清楚当前的网络拥堵状态,再决定按照多大的速度传输数据。

拥塞窗口:即可能引起网络拥塞的值,如果一次发送的数据超过了拥塞窗口的大小就可能会引起网络拥塞。

所以主机每次发送数据的时候,不仅要考虑对方的窗口大小,也要考虑网络拥塞情况,即拥塞窗口大小。

拥塞窗口是以指数级别进行增长的,但指数级增长是非常快的,因此“慢启动”实际只是初始时比较慢,越往后增长的越快。如果拥塞窗口的值一直以指数的方式进行增长,此时就可能在短时间内再次导致网络出现拥塞。

所以为了避免因为拥塞窗口一直以指数级别进行增长而导致网络再次拥塞,就有了慢启动的阈值,当拥塞窗口的大小超过这个阈值时,就不再按指数的方式增长,而按线性的方式增长,当增长到出现网络拥塞时,阈值变为原来的一半,然后再次慢启动。如下图:

九、延迟应答和捎带应答

1、延迟应答

一端主机给另一端主机发送数据,收到数据后,该主机会返回一个ACK应答,同时会同步自己的接收能力。如果该主机能够给对方同步一个更大的接收能力,那么对方主机就能够一次性向我发送更多的数据,一次性就能向网络中推送更多的数据,这样单次IO的效率就更高了。那么如何保证能够给对方同步一个更大的接收能力呢?

接收方在收到数据后,先不要马上就发送ACK报文,而是先等上层把数据读走,这样缓冲区的剩余大小就会更大。这时再发送ACK报文,接收方能够同步给发送方的窗口大小也就更大了。

举个例子:假设接收方的缓冲区剩余空间大小为1M,一次收到500K的数据后,如果立即进行ACK应答,此时返回的窗口就是500K。但实际接收方处理数据的速度很快,10ms之内就将接收缓冲区中500K的数据取走。如果接收端稍微等一会再进行ACK应答,比如等待100ms再应答,那么这时返回的窗口大小就是1M。这就是延迟应答。

当然,能够这么做的前提就是,上层有足够合理的策略能够及时将接收缓冲区的数据取走,以保证接收缓冲区有足够大的空间。

不是所有的数据包都可以延迟应答:

1、数量限制:每个N个包就应答一次。

2、时间限制:超过最大延迟时间就应答一次(这个时间不会导致误超时重传)。

延迟应答具体的数量和超时时间,依操作系统不同也有差异,一般N取2,超时时间取200ms。

2、捎带应答

主机A给主机B发送了一条消息,当主机B收到这条消息后需要对其进行ACK应答,但如果主机B此时正好也要给主机A发送消息,此时就不用单独发送一个ACK应答报文,我们还可以让这个报文携带上主机B要给主机A发送的数据,此时主机B发送的这个报文既发送了数据,又完成了对收到数据的响应,这就叫做捎带应答。

十、面向字节流

我们在发送数据时,调用write函数就可以将数据写入发送缓冲区中,而接下来发送缓冲区当中的数据就是由TCP自行进行发送的。每次发送多少,怎么发送,完全是由TCP协议自行决定。而调用read函数读取接收缓冲区中的数据时,也可以按任意字节数进行读取,怎么读取,读取多少也完全由TCP协议决定。

比如,写100个字节数据时,可以调用一次write函数将100字节数据一次性写入发送缓冲区,也可以调用100次write函数,每次写一个字节到发送缓冲区。读100个字节数据时,也完全不需要考虑写的时候是怎么写的,既可以一次性读取完100个字节,也可以一次读取一个字节,重复读取100次。

对于TCP来说,它并不关心发送缓冲区当中的是什么数据,在TCP看来这些只是一个一个的字节数据,它的任务就是将这些数据可靠高效地发送到对方的接收缓冲区当中就行了,而至于如何解释这些数据完全由上层应用来决定,这就叫做面向字节流。

十一、粘包问题

站在传输层的角度,TCP是一个一个报文过来的,按照序号排好序放在缓冲区中。
但站在应用层的角度,看到的只是一串连续的字节数据。
那么应用程序看到了这么一连串的字节数据,就不知道从哪个部分开始到哪个部分,是一个完整的应用层数据包。

解决粘包问题

要解决粘包问题,本质就是要明确报文和报文之间的边界。

对于定长的包,保证每次都按固定大小读取即可。
对于变长的包,可以在报头的位置,约定一个包总长度的字段,从而就知道了包的结束位置。比如HTTP报头当中就包含Content-Length属性,表示正文的长度。
对于变长的包,还可以在包和包之间使用明确的分隔符。因为应用层协议是程序员自己来定的,只要保证分隔符不和正文冲突即可。

十二、TCP异常情况

进程终止:进程终止会释放文件描述符(套接字也是文件描述符),也就相当于断开连接,底层仍然可以发送FIN,进行四次挥手。和正常关闭没有什么区别。
机器重启:和进程终止的情况相同。
机器掉电/网线断开:接收端认为连接还在,一旦接收端有写入操作,接收端发现连接已经不在了, 就会进行reset。即使没有写入操作,TCP自己也内置了一个保活定时器,会定期询问对方是否还在。如果对方不在,也会把连接释放。

十三、再谈listen函数

listen的第二个参数

 我们再来看看listen函数的原型:

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);

在编写TCP服务器代码时,我们在进行了套接字的创建和绑定之后,需要调用listen函数将创建的套接字设置为监听状态,此后服务器就可以调用accept函数获取建立好的连接了。那么首先我们需要明确:accpept函数的作用只是从os底层获取连接,而连接建立的过程三次握手是由os在底层自动完成的。 

TCP在进行连接管理时会用到两个连接队列:

1、全连接队列(accept队列):用于保存处于ESTABLISHED状态,但没有被上层调用accept函数取走的连接,即已完成三次握手的连接。
2、半连接队列:用于保存处于SYN_SENT和SYN_RCVD状态的连接,也就是还未完成三次握手的连接。这种连接没法被accept获取。

全连接队列的长度和listen第二个参数有关,一般TCP全连接队列的长度等于listen第二个参数的值加一。

如果将listen的第二个参数值设置为3,此时服务器最多允许存在4个处于ESTABLISHED状态的连接。在服务器端已经有4个ESTABLISHED状态的连接的情况下,再有客户端发来建立连接请求,此时服务器端就会新增状态为SYN_RCVD的连接,该连接实际就是放在半连接队列当中的。

此后就算再有客户端发来连接请求,在服务器端也不会新增任何状态的连接。

只有全连接队列中有连接被获取,半连接中的连接才能够完成三次握手,进入全连接队列,才能被上层获取。

十四、总结

TCP协议既要保证可靠性,同时又尽可能的提高通信效率。

可靠性:检验和。序列号。确认应答。超时重传。连接管理。流量控制。拥塞控制。

提高效率:滑动窗口。快重传。延迟应答。捎带应答。