【Linux网络编程】传输层协议 - TCP

发布于:2025-07-28 ⋅ 阅读:(12) ⋅ 点赞:(0)

目录

TCP协议段格式

确认应答(ACK)

超时重传

连接管理

三次握手

四次挥手

流量控制

滑动窗口

拥塞控制

延迟应答

捎带应答

面向字节流

粘包问题

TCP连接异常的情况

TCP小结


TCP全称为传输控制协议。人如其名,要对数据的传输进行一个详细的控制。我们之前说过,TCP每一个套接字都有一个发送缓冲区和一个接收缓冲区。当我们使用write将数据发送时,实际上是将数据拷贝到了TCP套接字的发送缓冲区当中,本质就是拷贝给了OS。未来这个数据怎么发,什么时候发,发多少,出错了怎么办都不由用户控制,而是由OS的TCP协议自主决定。OS是通过时钟中断来控制的。所以,TCP叫做传输控制协议。这是有一个前提的,就是TCP必须先要能拿到数据,然后在数据发送的过程中区控制发送的节奏。UDP是没有发送缓冲区的,sendto后直接就一直向下封装了。所以UDP没办法做到传输控制。

TCP协议段格式

struct tcphdr {
    __be16  source;     // 源端口号(16位,大端字节序)
    __be16  dest;       // 目标端口号(16位,大端字节序)
    __be32  seq;        // 序列号(32位,标识数据字节流的起始位置)
    __be32  ack_seq;    // 确认号(32位,期望收到的下一个序列号)
    __u16   res1:4,     // 保留位(4位,必须置0)
            doff:4,     // 数据偏移(4位,TCP头部长度,单位:4字节)
            fin:1,      // FIN标志(1位,表示关闭连接)
            syn:1,      // SYN标志(1位,表示建立连接)
            rst:1,      // RST标志(1位,表示重置连接)
            psh:1,      // PSH标志(1位,表示推送数据给应用层)
            ack:1,      // ACK标志(1位,表示确认号有效)
            urg:1,      // URG标志(1位,表示紧急指针有效)
            ece:1,      // ECN-Echo(显式拥塞通知)
            cwr:1;      // Congestion Window Reduced(拥塞控制)
    __be16  window;     // 窗口大小(16位,接收方的可用缓冲区大小)
    __be16  check;      // 校验和(16位,覆盖头部和数据)
    __be16  urg_ptr;    // 紧急指针(16位,仅当URG=1时有效)
};

我们会发现,TCP报头中的内容相较于UDP会更多,因为TCP需要保证可靠性。在这里,我们先对这些字段的含义作一个简略的说明,关于这些字段的具体含义,我们在后面结合具体的可靠性的方案来进行介绍。

TCP报头=TCP标准报头 + 选项。TCP标准报头是固定为20字节的。源端口号和目的端口号是标识两台主机上对应的进程的。TCP为了保证可靠性,所以当对方接收到报文时,是需要确认的。假设现在是客户端给服务器发送消息,这个服务器可能会接收到这个客户端发过来的多条消息,为了区别这一些报文,就需要给报头添加上一个序号。这个序号除了有确认到达的功能,还有按照序号进行按序到达的功能。因为客户端发送数据时,服务端接收到数据的顺序不一定是客户端发送数据的顺序,为了保证可靠性,服务端还需要对接收到的顺序进行排序。

1. TCP是如何解包的?

先将报文的前20字节读出来,这是TCP标准报头,其中就包含4位首部长度,表示的是标准报头+选项的长度,然后再将选项读出来,即完成了解包。

4位首部长度是有基本的计算单位的:4字节。报头长度 = 4位首部长度 * 4。所以,报头的长度=[20,60]。报头中还有标志位,后面再说。窗口大小是与流量控制有关的,后面说。校验和不用管。紧急指针和标志位有关,后面说。

2. TCP是如何分用的?

因为有目的端口号,所以可以根据目的端口号进行分用。

确认应答(ACK)

与确认应答有关的报头字段是序号和确认序号。客户端给服务端发送数据,数据在网络中传输时,是需要花时间的。为了让客户端知道发送出去的数据是否被服务器成功接收了,TCP会要求服务器在接收到消息后,进行一次应答。只要客户端接收到了应答,就说明服务器已经收到了。发送消息时,由接收方给发送方应答的策略,称为确认应答。也就是说,客户端发送了一条请求,会收到两个应答,一个是确认应答,一个是对于请求的应答。TCP可靠性的核心就是确认应答。

在上面这个图中,只有一个确认应答,没有对于请求的应答,这是因为存在捎带应答机制,会将这两个应答合并成一个应答,我们后面会介绍。

服务器给客服端发送确认应答,本质也是发送消息,怎么保证服务器给客户端发送的确认应答客户端能够收到呢?难道又让客户端给服务器发送确认应答吗?这不就类似于死循环了吗。长距离通信的时候,其实没有100%的可靠性!!!因为:总有一条最新的消息是没有应答的。也就是说,客户端给服务器发消息,服务器会发送确认应答,发送完确认应答后,客户端是不会给服务器发送应答的。这里的可靠性不保证最新的一条消息。其实,客户端给服务器发消息,客户端会担心服务器是否收到消息,所以服务器会发一个确认应答给客户端,对于发过去的这个应答,服务器并不会关心客户端是否接收到。因为只要客户端收到了,即可保证可靠性,这个可靠性保证的并不是最新一条消息的可靠性,而是历史报文的可靠性。客户端如果没有收到服务器的应答,还会再向服务器发送请求的,询问服务器是否接收到了消息,直到服务器发送应答。所以,总有一条最新的消息没有应答,潜台词就是老消息是100%有应答的。

TCP的报文在发送缓冲区发送后,不会立即被释放,需要等到接收到相应的确认应答表示服务器收到后,才会释放。

什么是应答?什么是请求?

请求是TCP报头+有效载荷。应答就是一个裸的TCP报头。

有了上面这个认识,再来看序号和确认序号。我们以客户端向服务器发送消息为例,服务器向客户端发送消息也是同理的。按照我们刚刚的说法,客户端给服务器发消息后,需要等服务器发送回来确认应答,才能发送第二条消息,所以整个过程是串行的。如果是这样的话,通信效率就太低了吧。所以TCP在设计时,是允许客户端一次向服务器发送多条消息的,然后服务器再对这些消息进行应答,每一个报文都会有应答,所以是可以保证可靠性的。这种方式说明了客户端能够接收到很多应答,可是站在客户端的角度,怎么能知道这些对应的是自己发送出去的那一条消息呢?所以,需要给每一个TCP报文带上编号。应答时,应答的报文的序号一般是对应报文的序号+1。发送的报文序号填在报头的序号中,应答的报文序号填在报头的确认序号中。确认序号 = 序号 + 1:表示确认序号之前的内容已经全部收到了

TCP报头中有序号,是增加可靠性的。因为报文在网络传输过程中可能会遇到各种各样的网络情况,先发的报文可能后到。而报文如果是乱序的,这也是不可靠的表现。为了保证报文在接收方的缓冲区中能有序,就会给报文带上序号。序号的意义:保证报文的按序到达

TCP报头中为什么既有序号,又有确认序号呢?要完成上面的操作,实际上只需要1个序号就可以了,发送时就当成是序号,接收到时就当成是确认序号。如果服务器单纯只想对客户端进行应答,可以发一个裸的报头;如果服务器既想对客户端进行应答,又想向客户端发送消息,此时是可以将两条发送合并成一条的,这种机制称为捎带应答机制。因为发送的消息一定有序号,而又要应答,所以又需要有一个确认序号。现在,我们知道了捎带应答,就需要对我们前面的一个错误认识进行更正了。实际上,当客户端给服务器发送请求时,服务器确实会发送一个确认应答和一个对于请求的应答。TCP协议是在保证可靠性的同时尽可能地提高效率,所以会将这两个应答合并成一个发送给客户端。所以,客户端发送一个请求后,只会收到一个应答,这个应答中既包含了确认应答的内容,又包含了对于请求的应答。

客户端给服务器发送消息时,可能一下子发送了多条消息,如果服务器来不及接收呢?对于来不及接收的消息,服务器会直接丢弃。在TCP这里丢弃并不怕,因为当太久没有收到应答时,客户端是会进行重发的。但是,OS不做浪费时间、浪费空间的事情。所以,虽然客户端确实可以重传,但这种做法并不是最优的。所以当服务器来不及接收,或者接收能力已经受限时,应该让客户端知道这个情况,从而减少客户端的发送量。所以,客户端会动态调整自己的发送量,来调整至服务器能接收,这种策略称为流量控制

客户端是怎么知道要进行流量控制的呢?要解决这个问题,我们就需要先知道服务器的接收能力由什么决定。是由服务器的接收缓冲区的剩余空间大小决定的。我们知道,客户端每次给服务器发送消息时,服务器会进行确认应答,这个确认应答中一定有TCP报头,TCP报头中包含一个字段,16位窗口大小,发送确认应答时,会将自己的接收缓冲区的剩余大小填到窗口大小当中,这样,客户端就能够知道服务器的接收能力了。因为双方是会互相发消息的,所以流量控制双方都在进行。流量控制不仅仅可以应对服务器来不及接收的情况。如果服务器的接收缓冲区剩余空间非常大,此时是可以通过流量控制来增加数据量发送的。

一个报文的序号是怎么来的?我们会发现,TCP报头中并没有有效载荷的长度,因为TCP是面向字节流的,TCP不对这个报文的有效载荷做任何解释。收到一个TCP报文,拿走报头后,直接将剩余的内容放到接收缓冲区当中,随着接收增多,接收缓冲区中会积压很多的TCP数据,这些数据就混在一起了,就形成了流式结构。正因为TCP是面向字节流的,所以它的报头中不需要有描述有效载荷长度的字段。实际上,TCP的发送缓冲区和接收缓冲区大小都是固定的,根据16位窗口大小,TCP的接收缓冲区最大是2~16字节。当然,可以通过选项区调整接收缓冲区大小,但是无论如何,两个缓冲区的大小都是固定的。我们现在就将TCP的缓冲区想象成一个字符数组,char outbufer[N]。

当我们将应用层指定长度的内容拷贝到缓冲区当中,就是将数据放到了这个数组当中,对于这些数据,每一个字节都有了编号,这个编号就是上面序号的来源。虽然真正的缓冲区是链表,但是可以通过转化算法来完成。这里就简单使用数组来模拟。将来发送报文时,在报头的序号中,填的就是数组下标,假设我们的报文长度是100字节,占据的下标是1到100,那么序号就是1。因为保存报文的是字符数组,所以发送本质上就是将客户端中一个char数组中的内容拷贝到服务器的一个char数组当中,这种过程称为字节流。

发送数据时,应用层将数据拷贝到发送缓冲区,未来TCP还会将数据发送出去,这就是用户与OS的生产者消费者模型。接收数据时同样是生产者消费者模型。接收数据时,若接收缓冲区中没有数据,就会阻塞,本质是在做生产者消费者模型的同步问题。发送缓冲区满了,写要等待也是同理。所以,这就是一个基于char数组的生产者消费者模型。上一次谈论到基于字节流读写阻塞的情况还是在学习管道的时候,所以,管道在通信时,也是基于字节流,也是一个生产者消费者模型。所以,管道自带互斥、同步机制。

超时重传

发送数据时,是会存在丢包问题的。丢包问题分为两种,一种是数据真的丢了,另一种是数据没丢,确认应答丢了。主机A发送出去数据后,就会设定一段时间间隔,当时间到了之后,若还没收到确认应答,就会进行数据重发,这个机制称为超时重传机制。

主机A能够确定是哪一种丢包吗?不能。无论是哪一种,处理方法都是一样的。其实主机A甚至不能确定自己的报文是否真的丢了。因为可能在传输过程中某个路由器的压力过大,或者某个路由器故障了,导致报文在路由器处排队,报文没丢。当然,报文也可能真的丢了。所以,主机A只能规定超时重传。

如果是主机B的确认应答丢了,主机A又进行了重传,主机B不就接收到了两份同样的报文吗?报文中是有报头的,报头中的序号就可以用来去重。

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

OS是如何做到超时功能的?之前在讲信号时,就有介绍定时器,既然OS可以完成定时器的功能,就一定可以完成超时功能。OS也是有与定时相关的系统调用的。

连接管理

TCP是要进行连接管理的。在正常情况下,TCP要经过三次握手建立连接,四次挥手断开连接

在TCP报头中,可以看到是有6个标志位的。当代的TCP协议已经不止6个了,但是我们只看这6个即可。现在,我们先介绍与连接管理有关的标志位。为什么要有这么多个标志位?对于一个服务器,它是会对应多个客户端的,而这些客户端在与服务器通信之前,都需要先建立连接。可能有的客户端正在给服务器发送建立连接的请求,有的客户端正在给服务器发送获取数据的请求,有的客户端正在给服务器发送断开连接的请求,这些请求都是TCP报文。这就说明了服务器端的OS收到的报文是有不同种类的,对于不同的种类需要有不同的策略。所以,OS必须能区分出报文的不同种类。所以,报头中的标志位是为了区分报文的种类的

与连接管理有关的标志位有3个,这里我们重点看这3个:

  • ACK:表明自己是一个确认报文。ACK是1就代表是确认报文。至于数据有或没有都可以。因为有捎带应答,所以大部分TCP报文的ACK都是1;
  • SYN:同步标志位。服务器收到的报文SYN是1,说明是建立连接的请求;
  • FIN:连接断开标记位。通信结束的时候,进行握手协商。

我们以一个例子来帮助理解三次握手和四次挥手。我们举一个例子帮助理解,假设现在我走在街上想让一个女生成为我的女朋友,我直接过去说"你能成为我的女朋友吗?",女生回答说"好啊,什么时候?",我说"就现在。",这就是以最少的握手次数,建立了男女朋友关系。后来,要分手了,我说"分手吧",女生说"好啊,但是现在是你对我提出分手,我同意了,我也要向你提出分手",女生又说“分手吧",我说"好啊"。此时分手这件事双方都同意了,这个过程就是四次挥手。

现在,我们正式来看客户端与服务器之间如何进行三次握手和四次挥手。

注意:上面这些SYN、ACK等都是一个完整的报文,只是将对应的标志位置1了。 

三次握手

客户端想与服务器建立连接之前,需要先与服务器进行三次握手。首先发送一个SYN为1的报文,服务器会发送一个SYN和ACK均为1的确认应答,客户端再发送一个ACK为1的确认应答。此时就成功建立了连接。TCP客户端和服务端在握手时,是基于状态机的。客户端在发送了SYN的报文后,客户端的状态就变成了SYN_SENT,服务端在接收到报文后,会进行回复,状态就会变成SYN_RCVD,客户端收到这个确认应答后,会再发送ACK报文,此时连接建立完成,客户端的状态变成ESTABUSHED,服务器收到报文后,状态也会变成RSTABUSHED。我们将理解半径再扩大一些。

服务器最先会创建一个套接字,然后绑定一个IP地址和端口号,然后将这个套接字设置为监听状态,再使用accept等待客户端连接,服务器进程就会阻塞在accept这里。当accept返回,就得到了一个新的文件描述符,就可以继续向后通信了。在等待期间,三次握手是由双方OS自动完成的。accept并不参与三次握手的过程,accept会等待底层将三次握手完成,然后再将底层的新连接获取上来。实际上,只要服务器的套接字处于监听状态,调不调用accept都可以建立连接,都可以完成三次握手。因为accept是在等三次握手,并不参与三次握手。客户端在建立连接前会先建立套接字,然后调用connect,connect也没有参与三次握手,只是在用户层让os发送一个SYN的报文,SYN发出去后,connect就阻塞了,握手成功后,connect就会返回。总结:connect触发三次握手,然后就不参与了,accept不参与三次握手。握手的过程是由双方os自动完成的

客户端发送了SYN,就变成了SYN_SENT,服务端接收到了SYN就变成了SYN_RCVD,客户端将ACK发出去,就认为自已将连接建立好了,变为ESTABUSHED。所以客户端是没办法保证这个ACK一定能被对方收到的。三次握手本质就是在赌,赌最后一个ACK服务器能够收到。

三次握手可以失败吗?可以。在通信时,客户端会先认为连接建立好了,过了几秒之后,服务器收到了ACK,才会认为连接建立好了。当服务器最终没有收到ACK时,客户端认为建立了连接,服务器并没有建立连接,当客户端向服务器发送消息时,发送失败是会进行异常处理的。这个后面说。

为什么是三次握手?客户端:“我想与你建立连接”,服务器:“好的”,服务器:“我也想与你建立连接”,客户端:“好的”,所以三次握手的本质就是四次握手。只不过客户端进行悄带应答。使得四次握手变成了三次握手。三次握手可与保证从左向右、从右向左的可靠性。

为什么在建立连接之前要先进行三次握手?

  1. 建立双方主机通信的意愿共识。建立连接,需要整个双方同意。TCP是全双工的,要建立两个朝向上的连接;
  2. 双方验证全双工信道的通畅性。通过握手,在双方通信之前,验证双方的通信信道的通畅性,也就是双方的网络是可以支持全双工通信的。在三次握手过程中,客户端如果收到了来自服务器的应答,就证明了自己可以接收数据,也可以发送数据。服务器也是同理。

三次握手成功,就成功建立了连接,什么是连接?

文件角度。一旦成功建立了连接,服务器端就会通过accept获取到一个文件描述符,未来通过这个文件描述符通信。所以,一条连接,一定会和一个文件对应。OS角度,连接在OS内部,一定会存在很多个。所以,OS一定要对连接进行管理。所以,所谓连接,就是双方在各自的OS内部建立连接结构体对象。前面说过,在握手的过程中是有状态变化的,其实就是结构体对象中有字段在描述状态。维护连接是需要成本的(时间+空间),所以当服务器内部的连接太多时,服务器就会变卡

四次挥手

当客户端与服务器是建立了连接的,有一方想断开连接时,就需要进行四次挥手。

四次挥手从左向右,从右向左都保证了可靠性。为什么是四次?因为断开连接需要征得了双方同意,才能断开连接。之所以要征得双方同意,是因为TCP是全双工的,要关闭两个朝向上的连接。

为什么四次挥手时不进行悄带应答呢?客户端想与服务端断开连接,所以向服务端发送断开连接的申请,服务端同意了,但这并不意味着服务端想与客户端断开连接,客户端断开连接,而服务端没断开连接时,允许服务端给客户端发送消息,不允许客户端给服务端发送消息。而在三次握手处,客户端发送建立连接的申请,服务端只要同意了,就代表它也想和客户端建立连接,是可以悄带应答的。

在应用层是谁触发四次挥手的呢?调用close,调用close后,就会向对方发送FIN。注意:四次挥手需要双方都调用一次close。

如果客户端已经调用close关闭了连接,服务器还没有关闭连接,而是继续发了消息,此时客户端要怎么拿到服务器发送的消息呢?使用close关闭连接就收不到了。实际上,在使用套接字时,除了可以使用close关闭,还可以使用shutdown,第一个参数表示要关闭的文件描述符,第二个参数表示关闭的方式。是关闭读,关闭写,还是关闭读写。所以,对于先关闭的一方,可以只关闭写,此时仍然可以从这个文件描述符中读。

#include <sys/socket.h>

int shutdown(int sockfd, int how);

具体采用close,还是shutdown,不由TCP决定,而是由上层应用决定。一般情况下,通信完成后都是直接close。是如何做到关闭写端之后就不能再写了?打开文件的时候,会有打开文件的形式,即读、写、读写等,这些形式是会记录在文件的struct file中的,当我们将一个网络文件的写端关闭时,就会进行两次挥手,但是文件结构并不释放。因为是全双工的,所以文件的打开形式一定是读写,所以还会修改一个打开形式,改为读。未来还想写时,直接在文件层就被拦住了。这种通信叫做半通信。

客户端给服务器发送了个关闭连接的请求,服务器应答后状态会变成CLOSE_WAIT,客户端接收到应答后状态会变成FIN_WAIT2,如果服务器不断开连接,状态不是会一直处于CLOSE_WAIT吗?我们是可以通过netstat指令来查看TCP连接状态的。服务器是会一直处于CLOSE_WAIT的,但是客户端过了一会就变成了TIME_WAIT状态。这就是为什么我们之前服务器端,一旦使用完一个文件描述符,就一定要关闭的原因。当没有及时关闭文件描述符时,除了会造成文件描述符泄漏,还会造成内存泄漏,因为连接没关,所以在服务端的与连接有关的结构体都不会释放,造成了内存泄漏。就会造成服务器卡顿。当我们将服务器进程退出,TCP连接状态就会变成LAST_ACK,因为文件描述符的生命周期是随进程的,将服务器进程退出后,处于CLOSE_WAIT状态的文件描述符就会被关闭,就会进行剩下的两次握手。但是客户端已经退出了,所以不会得到客户端的应答,过了一会后,服务器会自动变成CLOSED状态。

正常进行四次挥手时,主动断开连接的一方会进入TIME_WAIT状态。客户端将ACK报文发出去那一刻,就认为自己将连接已经关了,就会将状态设置为TINE_WAIT状态,连接已经关了的状态应该是CLOSE,为什么要让客户端在TINE_WAIT状态等待一段时间后,再让客户端的状态进入CLOSE呢?

TCP 协议规定,主动关闭连接的一方要处于 TIME_ WAIT 状态,等待两个 MSL的时间后才能到 CLOSED 状态。 在通信时,双方都会计算在网络中报文传递的最长时间。这个时间就称为MSL。因为在通信过程中,可能存在一些被判定为丢包,并且已经重传的报文,但是这些报文可能并没有真的丢,只是在中途的某个路由器处。若主动断开连接的一方是服务器,服务器关闭之后,会立即重启,而此时之前以为丢包的数据刚好传过来了,此时客户端与服务器是没有建立连接的,直接就将数据发过来了,此时服务器是能够知道这个客户端是没有建立连接的,就会将这个游离性的报文直接丢弃掉,此时是没有问题的。但是如果服务器刚好立即重启,而客户端又刚好来连接,并且通过三次握手建立好连接之后,之前的游离报文刚好到达了,此时就可能造成TCP服务器误处理。所以要等待两个MSL,让历史中的游离报文,在网络中消散。出现这种情况的概率是极低的,但是客户端与服务端之前的通信是非常非常频繁的,所以也是有可能出现的。当然,即使等待了两个MSL也不一定能够彻底地解决这个问题,但是能够大幅度地缓解这个问题出现。另外,被动关闭的一方若没有收到ACK,则会再给主动关闭的一方发送FIN,主动关闭的一方没有立即关闭就可以对重发的这个FIN进行应答。尽可能的正常进行4次挥手,完成连接断开

网络是非常复杂的,所以很多事情是不能保证完全成功的,只能在一定概率上保证。像前面的三次握手也是可能失败的,以为第三次的ACK不一定会被接收。所以TCP有很多策略是基于概率的,这就要求TCP未来需要有一些异常连接的处理能力,这就与我们接下来要讲的TCP报头的字段有关了

我们之前在客户端与服务端通信时,若服务端先断开连接,再启动服务器会发现是无法启动的,会提示绑定错误。因为此时服务器处于TIME_WAIT状态,连接还没有关闭,所以IP地址和端口还在被使用。而一个端口只能被一个进程绑定,所以再启动时就会因为绑定失败而无法重启。因为这个端口号是用于处理异常问题的,所以这个端口号实际上也是可以用的,只是默认情况下是不能用的。若想用,可以使用系统调用setsockopt。在创建成功套接字后,设置套接字的选项。

#include <sys/socket.h>

int setsockopt(
    int sockfd,             // 套接字文件描述符
    int level,              // 选项的协议层(如 SO_REUSEADDR)
    int optname,            // 选项名称(如 SO_REUSEADDR、TCP_NODELAY)
    const void *optval,     // 指向选项值的指针
    socklen_t optlen        // 选项值的长度
);

这个函数用于设置套接字选项,SOL_SOCKET表示选项作用于socket层,SO_REUSEADDR表示允许绑定处于TIME_WAIT状态的地址,SO_REUSEPORT表示允许多个进程绑定相同的IP和端口

TIME_WAIT一般是多长?或者说MSL是多长?

MSL在RFC1122中规定为两分钟,但是各操作系统的实现不同,在Centos7/Unbuto上默认配置的值是60s。所以,TIME_WAIT就是120s。

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

流量控制

接收端处理数据的速度是有限的,如果发送端发的太快,导致接收端的缓冲区被打满,这个时候如果发送端继续发送,就会造成丢包,继而引起丢包重传等等一系列连锁反应。虽然TCP能够进行处理,但是是不建议处理的。因此TCP支持根据接收端的处理能力,来决定发送端的发送速度。这个机制就叫做流量控制

我们来看接收方窗口大小为0的情况。理论上主机A就不应该继续发送报文了,因为有非常大的概率主机B会直接将报文丢弃。主机A应该等到主机B的接收缓冲区有空间了,也就是主机B上层应用将接收缓冲区内的数据读走了,再发送报文。可是主机A怎么知道主机B的接收缓冲区什么时候有空间呢?所以主机A在被主机B告知剩余空间为0后,会等待一段时间,当时间过长时,会向主机B发送一个窗口探测的报文。根据TCP的确认应答机制,主机A给主机B发送报文,主机B就一定要有应答,这个应答就一定包含了窗口大小。这个窗口探测是不包含数据的,只是一个裸的TCP报头。主机B就能根据这个报文没有数据,判断出这是在询问它的窗口大小。所以,当主机B的窗口为0时,主机A可以通过定期发送窗口探测,来获取主机B的窗口大小。

可是如果窗口探测的报文丢失了呢?另外,主机A是定期地询问,如果不在询问的时间,主机B的窗口已经不为0了呢?所以,主机B的窗口大小如果更新了,是会主动给主机A发送窗口更新通知的,就是一个只有TCP报头的报文。

在TCP报头中,表示窗口大小是16位的,所以理论上接收缓冲区的大小应该是2^16,但是实际上在双方通信的过程中,如果觉得接收缓冲区太小了,可以在TCP报头中带上一个选项 -- 窗口扩大因子M,实际窗口的大小是窗口字段的值左移M位。双方通信时,只要在选项中带上M,接收到报文的主机就能够通过这个M计算出接收缓冲区的实际大小。

两个问题:

  1. 流量控制 != 发送变慢。像上面的图中,第一次发送了一条报文,第二次发送了三条报文,属于发送变快。
  2. 首次发送时,怎么知道接收方的接收能力呢?这个并不需要担心,因为在正式通信之前是进行过三次握手的,即是做过报文交换和窗口协商的。

滑动窗口

流量控制主要与接收缓冲区有关,而滑动窗口就是与发送缓冲区有关。

之前说过,TCP对于每一条发送的报文都是有确认应答的,如果要等到接收到了确认应答,再发送下一条消息,这样效率太低了,所以TCP是支持并行发送的,也就是一次可以发送多条报文,再进行批量化地应答。因为是存在流量控制的,所以主机A一次可以给主机B发送多条报文,但是也是需要考虑流量控制的。为了支持TCP并行地发送大量数据,而且还不能让TCP发送太多数据。在发送缓冲区中有一个区域叫做滑动窗口。

红色表示的是发送缓冲区中有数据的区域,蓝色表示的是可以直接发送的区域,蓝色左边的红色区域是已经发送、已经确认的区域,蓝色右边的红色是待发送区域。可直接发送的区域也叫做滑动窗口。所以,滑动窗口实际上就是发送缓冲区的一部分。

理解滑动窗口

因为发送缓冲区是面向字节流的,所以我们可以将发送缓冲区想象成一个char类型的数组,char sendbuffer[N],这样整个发送缓冲区都是面向字节流的。用户将数据拷贝给发送缓冲区时,每一个字节都填入有了序号。既然是数组,那么就可以使用一个下标为start,一个下标为end,所以,所谓的滑动窗口,就是由start和end构建的一个下标范围。

1.滑动窗口的大小是多少呢?因为发送的数据不能超过对方接收缓冲区的剩余空间大小,所以滑动窗口的大小就是对方接收缓冲区的剩余空间大小。这样就能够保证无论怎么发,都不会发超过对方接收能力的数据。

2.我们会发现,在图中,假设接收能力是4000,为什么要发成4条报文,每条大小是1000,而不是直接发送1条报文呢?因为数据链路层不允许发送太大的报文。

滑动窗口最重要的几个问题

1. 如何理解滑动窗口的滑动?

实际上就是start和end在做+=。 TCP是要保证数据按序到达的,应答也是如此。而应答中会有一个确认序号,确认序号1001表示的是1001之前的已经全部收到了,下次发从1001开始。还有窗口大小,就是对方的接收能力。所以,start=确认序号,end=start+窗口大小。此时就向后滑动了。因为应答是按序到达的,所以滑动窗口就是一直向右移动的。

在上图中,发送过去一个长度为1000的数据后,返回的窗口大小还是4000,因为可能接收方应用层又从接收缓冲区中读了1000字节的数据。所以,流量控制是通过滑动窗口实现的

2. 滑动窗口只能向右移动?可以向左移动吗?

不能向左移动,只能向右移动。因为还没发的数据始终在右边。

3. 滑动窗口可以变大吗?可以不变吗?可以变小吗?可以为0吗?

是可以变大的,当接收方的接收缓冲区内有数据被用户层拿走时,就变大了。也是可以变小的,当接收方的应用层一直不读取接收缓冲区内的数据时,是会变小的,并且如果一直发,一直不读,是可以到0的。发多少读多少,就是不变。

4. 滑动窗口滑出了发送缓冲区会导致越界吗?

不会。我们要将发送缓冲区想象成环形结构。滑动窗口的左侧,已经发送、已经确认的区域本质是数据已经被删除,未来可以被覆盖掉。不会越界,但是会满。所以,发送数据,就是一个基于环形队列的生产者消费者模型。

理解滑动窗口异常丢包问题

对于主机A,无论是数据丢,还是应答丢,都是一样的效果,因为主机A都接收不到应答。对于丢包,可以分为3种情况,最左侧丢了,中间丢了,最右侧丢了,像上面的图滑动窗口中有4个报文,最左侧1条,中间2条,最右侧1条。

最左侧丢了

此时主机A能接收到的主机B的确认应答有3条,这3条应答报文的确认序号要填多少呢?确认序号的定义是表示该序号之前的报文已经全部收到了。因为是最左侧的丢了,所以它们的确认序号都会填1。也就是说,滑动窗口不会右移。此时主机A接收到的确认应答中确认序号都是1,所以就能够知道至少是1-1000丢了,当然,其他的也可能丢。主机A就会对这条报文进行补发。当主机B接收到了补发的数据,此时4条都接收到了,确认应答的确认序号直接就到4001了。所以,对于最左侧丢失,我们不用担心。

我们之前说过,对于从发送缓冲区发送出去的数据,不能立即删除,需要先保存起来,因为未来可能需要重传,对于保存起来的数据,就在滑动窗口内部。所谓从发送缓冲区中删除,就是让滑动窗口右移。

中间丢了

假设是1001-2000丢了。剩下3个报文的确认应答中的确认序号是多少呢?都是1001。主机A接收到应答报文之后,start=1001,根据窗口大小修改end,也就是滑动窗口会向右移动,移动完成之后,最左侧的就是报文丢失的。所以,中间丢失问题,转换成了最左侧丢失问题。此时主机A就会发现,1001-2000是一定丢了的,所以会对1001-2000进行补发。

最右侧丢了

当最右侧丢失了,3个报文的确认应答的确认序号肯定是3001。所以滑动窗口就会进行移动。就又变成了最左侧丢失问题。

所以,因为确认序号的定义,所有丢包问题都会被转化为最左侧丢包问题。而最左侧丢失问题就是补发报文,所以我们不需要担心报文丢失问题。当然,这几种丢失问题是可以组合的,但是组合的分析与上面是一样的,最终都转换成了最左侧丢失问题。如果是1-1000和1001-2000都丢了,那么应答报文中确认序号就是1,主机A只能确定1-1000丢了,所以会先补发这个,补发完成接收到确认应答后,就可以进而发现1001-2000也丢了,就会再补发。一次只会补发1个,不会全补发。

所以,滑动窗口是流量控制的一种实现方案,也是超时重传、确认应答的实现的底层机理

当主机A收到3个确认应答的确认序号时相同的时,会对这个确认序号所对应的历史报文进行补发,这种机制称为“高速重发控制"(快重传)。正常来说每一条确认应答的确认序号都应该不一样,因为报文的序号是不一样的。既然有了快重传,为什么还要有超时重传呢?因为快重传是有条件的,当只给对方发了1个或者2个报文时,是无法触发快重传的。所以,快重传和超时重传是同时存在的。

假设客户端发送了3条报文,序号分别是1-100、101-200、201-300,服务器接收到的顺序是201-300、101-200、1-100,那么确认应答的发送顺序是怎么样的呢?确认应答中的下一条发送序号填几呢?

  • 收到201-300时:ACK=1(重复ACK)
  • 收到101-200时:ACK=1(重复ACK)
  • 收到1-100时:ACK=301(累积确认1-300)

剩余3个标志位

接下来看看TCP报头中的剩余三个标记位。URG、PSH、RST。

PHS

当主机A接收到来自主机B的确认应答中窗口大小是0时,主机A过段时间会进行窗口探测。但是这里最关键的问题是,主机B的上层有没有将数据拿走。当主机A发送多次窗口探测后,发现还是0,就会发送一个PHS标志位为1的报文给主机B。PHS标志位:告诉对方,请尽快将你缓冲区的数据交给上层。其实上面所说的情况属于是比较极端的,也可能表达的意思是数据比较重要,请尽快进行交付。这里的让上层尽快拿取数据,可以理解为唤醒上层的进程。具体后面会说。

RST

三次握手时,若最后的ACK报文丢失了,此时客户端认为已经建立好了连接,而服务器还没有建立连接,如果此时客户端直接给服务器发送消息,服务器就会返回一个只包含报头的报文,并且会将RST标志位置1。客户端接收到这个报文之后,就会知道自己的连接建立异常了,就会对连接进行重置。重置就是客户端释放连接,然后重新三次握手。上面这种情况也是比较极端的,也可能是客户端在于服务器通信的过程中,服务器挂掉了,然后又立即重启了,此时客户端是不知道的,客户端正常发消息时,服务器就可以给客户端发送RST为1的报文,重新建立连接。

URG

因为TCP是会保证数据的按序到达的,所以是没办法让一些数据插队,让这些数据提前被上层处理的。但是在现实中是有场景需要进行插队的。假设现在正在使用百度云盘上传一个大文件,就会给服务器发送非常大量的数据,服务器的接收缓冲区内可能都是这个大文件,后来突然不想上传了,所以点击了终止上传,实际上就是发送了一个终止的报文插入到了正常报文里,对方必须将接收缓冲区内的数据和还在网络中的数据处理完了才能处理这条报文,如果网络情况不是特别好,这样就会导致要很久才能终止。所以,在发送终止上传的报文时,可以给这个报文的URG标志位设置为1,标识这个报文是一个紧急报文,此时即使接收缓冲区内还有数据,也会预先处理这个报文中的数据。此时只是将URG标志位设置为1了,那这个报头中的紧急数据在哪里呢?所以在TCP报头中有一个16位的紧急指针,是一个偏移量,标识的是在有效载荷中,紧急数据在什么位置。此时只是知道紧急数据的偏移量,紧急数据的大小是多少呢?紧急数据只占一个字节。这个紧急数据实际上是一个状态码。

#include <sys/socket.h>

ssize_t recv(int sockfd, void *buf, size_t len, int flags);

ssize_t send(int sockfd, const void *buf, size_t len, int flags);

要读取紧急数据可以使用recv,以前flags都是置为0,表示读取常规数据。要读取紧急数据,设置为MSG_OOB。紧急数据也叫做带外数据。发送紧急数据使用send。选项也要是MSG_OOB。

拥塞控制

我们会发现到现在介绍的都是两端之间的一些规则,但是真正在进行网络通信时,也是需要关心网络情况的。比如客户端给服务器发送消息时,当网络情况较好,就可以发送地快一些,反之则慢一些,不能只考虑接收端的情况。所以,TCP不仅仅只考虑了两端主机的问题,还考虑了网络的问题,称为拥塞控制

假设客户端给服务器发送了10000条消息,如果有2、3条报文丢失,重传即可,那如果有9000条消息丢失了呢,此时还能重传吗?很明显不能。当只有少量报文丢失时,可理解为主机A自己的问题,但如果有大量报文丢失,就一定是网络的问题了。当出现大量报文丢失时,发送方就会判定网络出现了网络拥塞问题。如何解决这个问题呢?重传或者其他策略。实际上是不能进行重传的,因为此时已经出现网络拥堵了,再发送报文只会加重网络拥堵的情况。在这里要注意,网络中不仅仅只有一个客户端在发送报文,可能会有非常多个客户端在发送报文,而这些客户端所采用的都是TCP/IP协议,所以当一台主机识别到网络拥堵了,其他主机也都会识别到网络拥堵,所以单客户端重传影响可能不大,但是如果所有客户端都重传,那么就会造成网络拥堵的加重。所以学习拥塞控制时,不能只考虑两台主机间的通信,要考虑到所有采用TCP/IP协议的主机。正确的做法是让所有检测到网络拥堵的客户端都等一会再重传,给充足的时间让网络恢复。并且重传时,先发送少量数据,摸清当前网络的拥堵状态,再决定按照多大的速度传输数据。这个过程称为慢启动机制

为了能够进行慢启动,引入一个拥塞窗口的概念。当发送的数据超过拥塞窗口的值时,就有很大的概率导致网络拥塞,反之则有很大的概率不会造成网络拥堵。拥塞窗口是一个数字,在发送端维护起来,并不在TCP报文里。

可是之前说过,发送方一次发送的数据量是由自己的滑动窗口的大小决定的吗?因为一次发送多少不仅仅由接收端的接收能力决定,还由网络决定。所以,滑动窗口=min(对端接收缓冲区剩余大小,拥塞窗口)。也就是说,滑动窗口的大小是由对端的接收能力和网络情况共同决定的

所以,主机A一旦识别到网络拥堵,就会采用慢启动,是通过将拥塞窗口的大小修改成1来实现的。每接收到一个确认应答时,拥塞窗口就会+1。所以,拥塞窗口的增长速度是指数级别的。指数增长是非常快的,为什么网络的设计者会采用指数增长作为慢启动的算法呢?指数增长有一个特点,前期慢,增长快。而一旦发生网络拥堵了,就应该前期慢一点,符合前期少量发送的需求,而当我们慢慢增长发现都可以接收到确认应答,就应该尽快进行网络通信的恢复,所以利用指数增长的增长快的特点。

阻塞窗口难道会一直增大吗?为了不增长的那么快,因此不能使拥塞窗口单纯的加倍,此处引入慢启动的阈值,当拥塞窗口超过这个阈值的时候,不再按照指数方式增长,而是按照线性方式增长。

所以,现在在发送方,会有两个值,一个是阻塞窗口,一个是慢启动阈值。在加法增长时,若网络又堵塞了,就会将阻塞窗口变成1,重新执行慢启动,并且会重新设置慢启动值,新的慢启动阈值=上一次阻塞窗口大小/2,称为乘法减小。所以,拥塞控制算法=慢启动+加法增大+乘法减小

为什么拥塞窗口要一直变化呢?(尤其是增大),当他大于接收方接收缓冲区的剩余大小时,再增大已经没有意义了。拥塞窗口的定义是当发送的数据量超过拥塞窗口的值时,就有很大的概率导致网络拥堵,当小于拥塞窗口的值时,大概率不会造成网络拥堵。所以,拥塞窗口本质上是对网络状况的评估值。而网络的拥堵、健康等状况,一定是变化的,所以就要求发送方要不断地更改拥塞窗口的值,来评估网络状况。

延迟应答

在TCP的发送缓冲区和接收缓冲区当中,都是只有数据,没有TCP报头的。发送时是要发送时再添加TCP报头然后发送出去,接收时是先去除报头然后再放入接收缓冲区。当接收方接收到报文之后,将数据放入到接收缓冲区,可以先不进行确认应答,而是等一会再进行确认应答,在等一会期间,用户层就可能将接收缓冲区内的数据读取一部分,此时就可以通过应答返回一个更大的窗口大小了,从而让客户端一次可以发送更多的数据。这就叫做延迟应答。注意:延迟应答不一定会出现上面的效果,因为用户层不一定获取数据,并且发送的数据量也不完全由接收端的接收缓冲区剩余大小决定。怎么进行延迟应答呢?

  • 数量限制:每隔N个包就应答一次;
  • 时间限制:超过最大延迟时间就应答一次。

这两种限制是同时采用的。具体的数量和超时时间,依操作系统不同也有差异;一般N取2,超时时间取200ms。在上面这幅图中,一定要保证接收到1-1000和1001-2000的应答时,1-1000没有到超时重传的时间。可以看到,在这里是每两条报文才进行一次应答。

所以,对于一些报文是可以没有应答的。也就是说,基于流量控制、滑动窗口,TCP允许少量ACK丢失或者隔报应答。像上面的图,如果1-1000、1001-2000、2001-3000的应答丢失了是没问题的

捎带应答

TCP通信双方的通信地位是对等的。也就是说,主机A对主机B进行连接管理时,主机B也在对主机A进行连接管理;主机A对主机B进行流量控制时,主机B也在对主机A进行流量控制。销带应答的过程之前已经说过了,这里就不过多赘述了。在三次握手时,实际上并不仅仅在交换连接,还在交换双方的窗口大小、起始序号等,只要是报头中有的字段,双方都可以交换。因为网络中是存在游离报文的,所以通过握手发送过去的起始序号并不一定是0,而会是一个随机值,并且这个随机值双方都会记录下来,未来在发送报文时,报文的序号是真实的序号+随机值,对方拿到报文后,解析出里面的序号,再-随机值,就能够拿到真实的序号了。这种做法能减少游离报文对通信的干扰

注意:三次握手时,前两次握手的报文是不能携带数据的,而第三次握手的报文是可以携带数据的。第一次是因为客户端并不知道服务器的初始参数,所以不能携带数据,第二次虽然服务器已经知道了客户端的初始参数,但是为了可靠性和安全性,仍然不允许携带参数。第三次握手是需要将ACK置1的,若携带了数据,就是梢带应答。

面向字节流

TCP是面向字节流的,TCP对于它的发送缓冲区和接收缓冲区内的数据,它根本不知道是什么。所以,它不会管从发送缓冲区发送出去的报文是否完整,从接收缓冲区内读到的数据是否完整。报文的完整性需要用户层自己控制。所以,向文件中读写,也要有向网络中读写的意识,进行序列和反序列化等,并要保证报文的完整性。这就是Linux一切接文件的一种体现。

粘包问题

粘包问题是用户层基于字节流,读取应用层报文时没有读到完整报文的情况。因为TCP是面向字节流的,TCP报头里没有有效载荷的长度,所以是会出现粘包问题的。那要如何解决粘包问题呢?在应用层,要有明确报文边界的方式。明确报文边界的一些做法:报文大小固定、使用特殊符号作为分隔符、固定长度的报头+报头中添加自描述字段。

TCP连接异常的情况

假设客户端和服务器建立了连接,其中一方挂掉了,这个连接会怎么办呢?

所谓服务挂掉了,其实就是进程退出了,而文件的生命周期是随进程的,双方通信是通过套接字进行通信的,一个套接字对应一个文件,所以会由双方的OS进行四次挥手。其实无论是三次握手,还是调用close正常进行四次挥手,握手和挥手的过程都是由OS完成的

假设客户端和服务器建立了连接,其中一方所在的机器重启了,这个连接会怎么办呢?

在机器重启之前,是要关闭掉所有启动的进程的,所以会由双方的OS进行四次挥手。

假设客户端和服务器建立了连接,其中一方机器掉电或网线断开,这个连接会怎么办呢?

假设是客户端出现异常。这个情况非常突然,客户端是来不及进行四次挥手的,没有进行四次挥手,则服务器还认为连接是正常的,客户端是会将与连接有关的数据结构等释放掉的,因为OS是可以识别到网络断开的。当服务器检测到客户端长期不活跃时,服务器就会给客户端发送询问报文,若发送多次后,始终得不到客户端的回应,服务器就会自己将这个连接关掉。这个机制称为连接保活。但是,在实际中,TCP的保活机制一般是不使用的,是由客户端和服务器的应用层在做的。比如说登录了QQ,但是长时间没有与别人聊天,此时是不应该断开连接的,TCP只是维护一个连接,它并不了解应用层。所以,保活机制应该交给客户端和服务器的应用层,比如说客户端启动时可以专门启动一个现成,这个线程每隔几秒就给服务器发送一个请求,看是否每次都能得到服务器的应答,这就是保活。客户端每隔几秒就给服务器发送一次请求,但是如果有一段时间不发了,所以就会将这个连接断开了。只有应用层才能结合具体的场景,做出更完善的保活机制

用UDP实现可靠传输

参考 TCP 的可靠性机制, 在应用层实现类似的逻辑,例如:

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

TCP小结

为什么TCP这么复杂?因为要保证可靠性,同时又尽可能的提高性能。

可靠性:

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

提高性能:

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

其他:

  • 定时器(超时重传定时器,保活定时器,TIME_WAIT 定时器等)

网站公告

今日签到

点亮在社区的每一天
去签到