欢迎来到尧图网

客户服务 关于我们

您的位置:首页 > 健康 > 美食 > 传输层协议——udp/tcp

传输层协议——udp/tcp

2024/11/30 12:29:53 来源:https://blog.csdn.net/weixin_64099089/article/details/142214899  浏览:    关键词:传输层协议——udp/tcp

目录

再谈端口号

udp 协议

理解报头

udp特点

缓冲区

udp使用的注意事项

tcp协议

TCP的可靠性与提高效率的策略

序号/确认序号

窗口大小

ACK:

PSH

URG

RST

保活机制

重传

三次握手(SYN)

四次挥手(FIN)

流量控制

滑动窗口

拥塞控制

延迟应答

捎带应答

面向字节流

粘包问题

TCP连接异常情况

TCP小结

TCP连接队列


再谈端口号

端口号用于标识一台主机上的唯一进程。

我们使用 bind 给我们的套接字绑定端口号的ip,就意味着我们的进程已经准备好接收来自该 ip 通过我们绑定的端口中进来的数据。但是我们在前面的编码中也能知道,这个绑定的IP其实更多的是对到达该端口的数据进行进一步筛选。而端口号才是传输层找到目的进程的关键。我们的主机上的不同的服务,通过端口号的不同来区分。

在tcp/ip协议中,用源端口,目的端口,源IP,目的IP,协议号这样的五元组来标识一个通信。

传输层解决端口问题,IP协议解决IP问题,其实有了这两个就已经能够表示通信双方进程了,而协议号则是用于在协议栈中进行向上交付的时候起作用。

同时,并不是所有的端口号都能绑定我们的套接字,端口号被分为两个范围:

0~1023 :知名端口号,其实就是相当于被特定的协议固定绑定的端口号,我们自己的服务一般无法绑定。

1024~65535:操作系统动态分配的端口号,我们自己主动绑定或者操作系统自动为套接字绑定端口号都是在这个范围内的。

传输层通过端口号将数据交付给指定的进程。 那么一个端口号注定只能绑定一个进程,但是一个进程却不一定只能绑定一个端口号。

我们要保证协议栈从底向上找到一个唯一的进程,也就是要保证从端口号到进程的映射关系唯一,而并不需要保证从进程到端口号的唯一。从任何一个端口号看进程都是唯一的。

端口号作用在传输层,确保向上交付的唯一性。

我们可能会有一个疑惑,TCP层是如何通过端口号找到对应的套接字的?我们的 bind 具体起了什么作用?

在操作系统中, 有很多开启需要我们能够快速定位一个进程,所以OS并不仅仅是将PCB放入一个单链表中进行进程管理,还可能需要将每个进程的PCB添加到其他的数据结构中,这样实现起来其实并不复杂,无非是多几个指针或者数据结构中保存的就是指针,消耗也不大,但是能够快速定位进程或者说PCB。 就比如在网络这里,当我们bind的时候,可能在操作系统层面维护了一个哈希表,而哈希表中 key 就是我们的端口号,value 就是该端口绑定的进程的PCB,那么bind绑定端口号的时候,可能还会将我们的 PCB 和端口的映射关系添加到 这个哈希表中。 未来TCP或者其他的服务想要通过端口号去找到这个进程,就能通过这张哈希表来查找。

当然实际的情况肯定比这要复杂的多,操作系统在软件设计的时候还设计了 Sock 层,也就是套接字层,底层会和文件关联起来,那么通过Sock 也能找到对应的文件描述符。

介绍一个命令:

在网络服务的测试中,我们使用 ps 命令查看进程信息可能过于繁琐,以及我们可能用不到这么多信息,有时候我们只想要知道指定名字的进程的端口号,这时候我们就可以使用 pidof 命令。

比如我们可以这样玩:

pidof server | xrgs kill -9

在管道中使用 xrgs 能够将前接命令的输出结果拼接到后接命令的后面。

udp 协议

学习所有的协议,我们都无可避免的要学习他的 报头 部分,因为只有学习了报头,我们才能知道他是如何解包和向上交付的。

udp报文格式:

udp的报头其实很简单,因为他只负责传输数据,不负责数据的安全或者可靠性。

16位源端口号和16位目的端口号其实不需要我们怎么解释,所有传输层的协议都需要这两个字段。而 16 位udp长度则是表示 udp 报文的总长度(包括报头和有效载荷),16位的udp检验和是为了验证我们的报文的完整性。

检验和是为了验证报文的完整性,包括报头和有效载荷,如果我们的报文发生差错或者损坏,那么对方的udp协议计算出的检验和就和我们报文中的检验和对不上,就说明数据损坏了,如果数据损坏,难么就会直接丢弃。 虽然 udp 协议不负责可靠传输,但是这些基本的数据检验也还是要有的。

大部分协议的数据部分都是可选的,因为协议不只有数据报文,还会有管理报文,当然有时候管理报文也会携带数据,但是并不一定,所以就算我们看到一个 udp 报文只有报头没有有效字段,只要他的校验和检测通过,也是没问题的。

那么知道了udp的报文格式之后,我们也很好理解如何进行解包和分用了。

udp协议采用的是定长报头,那么拿到一个完整报文之后,只需要先将前八个字节拿出来,剩下的就是有效载荷。

udp是面向数据报的,我们不需要关心如何拿到一个完整报文,我们如果收到数据就一定是一个完整的报文,不会多也不会少。

那么如何分用? 根据报头中的目的端口号。

所以udp协议的封装解包分用这些过程都是非常简单清晰的。

理解报头

Linux内核是用C语言写的,而传输层和网络层既然是操作系统的一部分,那么自然也是使用C语言写的。协议我们在前面也写过,所谓的协议不就是结构化的数据吗,那么在C语言中就是 struct 结构体或者位端来实现。我们就简单理解为一个结构体,udp的报头不就是一个

struct _udp_header{ uint16_t src_port ; uint16_t dst_port ; uint16_t len ; uint16_t check} ;这样的字段吗,当然也可能是用位端来实现的,总之都是这样一段结构化的数据。 那么上层在将数据交给 udp 的时候,假如上层的数据的长度就是 datalen ,因为udp没有发送缓冲区, 那么在 udp 层 就直接申请一段长度为datalen+8 的空间,然后进行强转和指针的操作将这个结构体和数据填充到这段空间中,这不就形成了一个udp报文了吗? udp协议的话形成报文之后直接交给下层。

因为udp不需要做很多确保可靠性的工作,所以他的协议相比于tcp来说很简单。

udp特点

无连接:知道对端的ip和端口号就能直接进行传输,不需要建立连接

不可靠:没有确认机制,没有重传机制。如果因为网络问题导致该数据段没有发到对端,udp协议层也不会给应用层返回任何错误信息。

简单来说,udp只管将应用层给的报文封装成 udp 报文,然后交给网络层之后,他就不管了。

面向数据报:不能够灵活的控制读写数据的次数和数量。

在udp通信中,数据在传输层就是以报文为单位进行向下交付的,我们无法控制说一次将多个报文合并成一个进行发送或者将一个报文拆分成多个进行发送,这不是udp该考虑的。

同时,在接收端收到一个个的udp报文,首先也是会放在缓冲区等待应用层读取,但是在udp的接收缓冲区中,报文之间的边界是非常严格的。

最终就体现了: 发送端发送了几次,那么接收端就必须接收一样的次数才能收完数据。

应用层交给udp不管多长的报文(当然不能大于2^16 -8 ,udp的报头中的16位长度字段),udp都会原样封装udp报头交给网络层,不会拆分也不会合并。

而tcp是面向字节流的,发数据可能发了多次,而接受方不知道对方发了多少次,在tcp层维护的接收缓冲区也不会为报文之间设定边界,那么接收方需要读取多少次就取决于每次读取的大小了,而不取决于发送方的发送次数。 

由于 tcp的面向字节流的特性,我们可以更灵活的控制读写数据的次数和数量。

当然与之相对的,由于数据之间没有边界,就需要用户层自己定制协议来明确报文之间的边界。

缓冲区

我们在前面学习了初级的io,也学习了tcp的缓冲区的一点知识,我们能够知道,调用read/write/recv/send等系统接口的时候,我们是在应用层进行调用,而调用这些函数也并不是直接将数据写到文件或网络,或者直接从文件和网络中读取数据。在调用 write/send这些接口时,其实只是将数据从我们的用户定义的缓冲区拷贝了一份到内核的缓冲区中,而在网络中就是将数据交付给了下层,而下层要对数据做处理比如添加报头自然也需要一定的内核空间来完成,或者需要对应的发送缓冲区等。

以tcp为例,我们再调用 write 等接口将数据写到了对应的sock之后,并没有将数据发送到网络中,而是拷贝到了tcp为这个套接字所维护的发送缓冲区,而发送缓冲区中存的是我们上层交付下去的原始数据,tcp只有在要发送数据的时候,才对要发送的数据拿出来添加报头在交付给网络层

那么具体是什么时候发送呢?这是由tcp协议自己决定的,或者我们也可以说是由操作系统决定的。而对方的tcp协议读到数据之后,有效载荷分离出来放到对应的套接字的接收缓冲区中,等待上层进行读取。所以上层的读取的本质也是从对应套接字的接收缓冲区拷贝到用户缓冲区。

同理,客户端发送数据也是拷贝到他的发送缓冲区中,也不会影响他的接收缓冲区,他可以同时发送和接收,或者简单来说,就是既可以读又可以写,这种工作模式叫做全双工。

当我们调用这些拷贝函数将数据拷贝到了内核缓冲区之后,我们的函数就直接返回了,站在用户层面上来说就是已经将数据发送出去了,因为后面的事情他也参与不了了。 那么函数直接返回其实也是节省了用户的时间,不需要等待网络IO完成才返回,那么在用户层看来就是提高了网络发送的效率。

udp的缓冲区:

udp没有真正意义上的缓冲区,因为它不需要,只需要那一块空间把报头加上然后交给下一层就行了,也不需要一些可靠性机制。

udp具有接收缓冲区,但是这个缓冲区不能保证收到的udp报文的顺序和发送udp报文的顺序一致(这本身就是不可靠的一种,而udp不关心可靠性)。而如果缓冲区满了, 后续到来的udp报文会被直接丢弃。

同时,udp的套接字也是读和写能同时进行的,也就是可以同时双向传输,所以udp也是全双工

udp使用的注意事项

我们可以看到 udp 头部的报文长度字段,只有16位,说明 udp 的报文长度最长只能是 2^16 个字节,去掉包头之后有效数据就是 2^16 -8 ,也就是说,一个udp报文最大只能是64kb。这在当前的互联网环境下是一个非常小的数字。

那么当我们要发送的数据(加上udp报头)大于 64 kb的时候,udp就无法发送,我们必须在手动进行分包,将大的数据拆分成多个udp能发送的大小的数据。当然,最终在接受方我们也要有将拆分的数据重新拼接起来的能力,最要注意的就是 udp 报文的到达顺序是不可控的,这些都只能由我们应用层自己定策略去实现了。

基于udp的应用层协议:NFS(网络文件系统),TFTP(简单文件传输协议),DHCP(动态主机配置协议,后续网络层会讲),BOOTP(启动协议,用于无盘设备启动),DNS(域名解析)。

tcp协议

我们说了应用层调用io接口的本质是拷贝,拷贝到内核缓冲区之后具体什么时候发送取决于TCP协议的策略,所以TCP协议又叫做传输控制协议。

那么我们重点要学习的就是他的传输控制都采用了那些策略?

还是一样的,我们先来看 tcp 的报文格式

这个报头的内容就比 udp 的报头复杂得多了。

我们先挑一些能看懂的。 

首先 16位源端口和目的端口这没什么好说的,传输层必须有的。检验和也在我们的意料之中。

其他的字段我们好像都不熟悉。这些字段我们要慢慢讲,这些字段基本都与TCP的策略有关,我们一边学习TCP的策略,一边学习报头字段。

首先,我们最想知道的肯定是 TCP 的报头如何和有效载荷分离?分用倒不用我们关心了,因为有了目的端口号其实就已经能够完成分用了。

TCP的报头标准是 20 个字节,这是任何一个TCP报头中都会携带的字段,但是他的报头中有一个字段是可选的选项,把么接收方的TCP协议如何将报头和有效载荷分离呢?

我们注意到,TCP 标准报头中有一个字段是 4 位首部长度,从字段的名称我们就能直到他是表示报头长度的。可是4个比特位表示的数值范围也就是[0,15] ,我们的TCP仅仅是标准报头就已经有20个字节了,何况可能还会携带选项呢?

其实,首部长度表示的数值确实就是 [0,15],但是他的单位不是1字节,而是 4 字节,也就是说,能够表示 [0,60]字节的报头长度。

而又由于标准报头的长度是 20字节,所以 TCP的报头的长度范围在 [20,60] 之间

那么接收方TCP协议收到一个TCP报文之后,因为标准报头部分是固定携带的,所以它可以先提取出固定位置的者 4 位的首部长度,然后就能将有效载荷与报头进行分离,分离出来之后,通过指定的端口号将有效载荷交付到对应的 sock 的接收缓冲区中。 

有人可能会说,那么TCP协议怎么保证拿到的是一个完整的报文呢?

这就有点陷入误区了,我们说TCP是面向字节流的,可是没有说他的下层的协议是面向字节流的。TCP下层的IP,MAC协议都可以说是面向数据报的,那么TCP协议收到的信息就一定是一个完整的TCP报文,这是由网络层和更下层决定的。 而TCP进行封装的时候,则是从字节流的发送缓冲区拿数据出来加TCP报头,在封装的时候TCP只考虑传输的字节个数,不考虑数据的类型。

而标准报头是20字节,意味着正常情况下这个字段就是  0101

TCP报头中剩余的字段我们会穿插在后面的内容中一起讲解。

TCP的可靠性与提高效率的策略

在谈TCP的可靠性之前,我们先来谈一个大家可能会存在的疑惑:为什么网络传输的时候会存在不可靠的问题?为什么我们以前在谈IO的时候就没有讲过不可靠?

比如我们调用 write 像文件中写入数据,他们也是内存和外设的IO,为什么就不会存在不可靠的问题呢?

我们的计算机是有一个一个看似独立的硬件组成的,比如键盘,显示器,网卡,磁盘,cpu等,数据能够在不同的硬件之间流通,以及不同主机能够通过网络来进行通信,这就说明硬件和硬件之间其实并不是孤立的,而是存在某种联系。在我们的一台计算机中,所有的硬件都是通过“线”来连接的,一般我们把内存和外设之间的线叫做 IO 总线,内存和cpu之间的线叫做 系统总线。而我们的网络中两台主机之间的通信我们可以理解为通过网线。

其实在内存和外设之间进行数据交换时,也有自己的协议,也正是因为有协议,我们的操作系统就能够通过协议控制外设。 

但是我们以前在内存和外设之间的IO中,没有谈过这些协议的可靠性,为什么呢? 

本质就是因为他们的距离太近了。 距离近就意味着出错概率小,同时由于硬件的发展,我们几乎可以将这个概率忽略不计。

而网络通信 和上面的IO的最大的特点就是 传输距离变长了。

举例一旦变长,信息传输可能就会出现问题,比如信号衰减,比如数据在网络转发途中经过了异常的设备,一旦距离变长,导致参与的设备多了之后,出错的可能性就会变大。

我们回到TCP协议,我们既然要保证可靠性,那么常见的不可靠的现象是什么呢?

丢包,报文顺序不一致,校验错误,重复报文等等

传输距离长了,存不存在绝对的可靠性? 当然不存在,比如我们现在要跟火星上的设备通信,用TCP能保证可靠性吗?所以我们后面讲的可靠性只是在一定程度上保证了可靠性,而不是绝对的可靠性。

序号/确认序号

既然如此,我们就针对不可靠的场景,来学习TCP的保证可靠性的策略。

首先针对丢包和顺序不一致的场景,我们如何保证对方是否收到了我们的消息?

就好比,你和你的朋友隔着一条马路进行交谈,你问他:“你吃饭没?” ,你怎么才能确定他完整听到了你的话?其实很简单,只要对方回应:“吃过了。”或者说 “吃过了,你呢?” ,我们就能保证对方听到了我们说的话。 为什么这么说呢?因为他对我们的话进行的应答。如果他没听到,就不会说 吃过了 这样的和我们的消息强相关的消息,这是不正常的逻辑。

同时,对面说的话我们他又要怎么得知我们收到了呢?以及我们如何让对方得知我们确实听到了他的话呢? 其实也很简答,只要我们根据他的内容,进行应答,那么我们就是再想办法让对方得知或者确认我们收到了他的消息。

在上面的这样的对话中,除了第一个消息,后面的每一个消息其实都在对对方的消息进行应答,当然,在应答的同时可能还携带了我们的消息。但是这样一直套娃下去,我们能发现,最后发出的一个消息我们是没有应答的,因为他是最后一个消息了,后面没有消息再来对这个消息进行应答了。

当收到对方的应答之后,我们就能确认上一个消息百分之百被对方收到了,通信双方都是如此。

所以我们所谓的可靠性,永远都是对历史消息在做应答保证历史消息可靠性

而双方通信一定存在最新的消息,最新消息一定是没有应答的,所以最新消息无法保证可靠性

所以不存在绝对的可靠性,只有相对的可靠性,我们所说的可靠性永远不谈最新消息。

而TCP在通信的时候,也是这样的应答机制来对历史报文做确认的。

TCP在通信的时候也有三种模式:

在上面的图中,我们可能会有一个小问题: 对于那些单纯应答的报文,我们需要对其应答吗?

不用。

还是上面的例子,如果你问你的朋友“吃饭没?”,而对方收到了这个消息,然后给你一个应答,而不携带其他的消息,"吃过了" ,那么这时候,你如果没有收到这个应答,从你的角度看来,你是会认为你的消息对方没有听到,那么你就会重新问一遍。 而对方这时候如果收到了重复的问题,这时候也会再次回答你,当然在他看来就是你发重复的消息了,那么对这个重读的消息他不会额外外做其他的事,只会重新给你应答一下。

无论何种工作模式,双方都要对收到的每一个携带数据的报文进行应答,如果只是应答报文不携带数据,那么就不需要应答。

但是这里还有一个问题,在生活中我们是使用与对方消息强相关的回答来进行应答,但是在网络通信中呢? 同时,因为在网络传输中举例可能较长,会出现发送和接受的顺序不一致的问题,TCP如何将其有序接收呢?

这两个问题都要求使用 TCP 协议的报文都需要有一个字段用来标识本报文

只有收到的报文有标识了,才能对其进行应答,否则我们无法表明是对那个或者哪一些报文的应答。

而报文的标识就是我们的TCP报头中的 32 位序号。

未来TCP通信的每一个数据段(传输层对报文的叫法),不管是纯应答报文还是携带了数据的报文,不管是管理报文,应答报文还是数据报文,他们的报头中都有序号。

而报头中的另一个字段,32位的确认序号,就是用来确认应答的。

那么收到的数据的序号和我们所需要应答的报文的确认序号有什么关系呢?

我们可以用一个简单的图来描述一个方向上的序号和确认序号的关系:

如果收到的报文的序号是 x ,那么对这个报文的应答的确认序号就是 x+1 .

其实这个确认序号不仅仅是对当前这个报文做应答。

确认序号的定义:接收方已经收到了确认序号之前的所有的连续的报文

TCP报文中的序号其实不仅仅可以用来作为应答的一个根据,他还是对报文自身的一个定位,或者说他的序号其实在一定程度上代表了他的顺序。 

序号大的一定是后发送的,序号小的一定是先发送的。

而TCP保证可靠性,自然也就需要保证对报文发送和接受顺序的一致性。但是由于网络的情况是变化的,我们谁也无法保证先发送的报文一定会先到达,所以TCP的接收端在真正将报文解包之前,由于要保证报文的有序性,他其实会先对收到的TCP报文根据序号进行排序,然后依次对报文进行解包,将有效载荷放到接收缓冲区。而如果中间某个报文丢失了,那么在接收端看来,虽然后续报文可能没丢失,但是为了保证可靠性特别是有序性,TCP接收方先不对后续的报文做解包分用,而是先保留在自己的缓冲区中,但是因为后面的报文已经收到了,所以也都要做应答。而丢失报文之后的报文的应答序号,则会使丢失报文的第一个数据的序号。(序号其实可以理解为一个char类型的数组的下标,整个发送或者接收缓冲区我们可以理解为char类型的数组,都是以字节为单位的)。

TCP接收方可以根据报文的首部长度字段和报文总长度得到有效载荷的长度,然后通过对比前一个报文的序号和当前报文的序号以及有效载荷的长度,就能得知数据是否有缺失或者说丢包。

如果判断丢包了,那么TCP会先暂停对后续的报文的解包和分用,而是先保留,当然也会对收到的报文进行应答,只不过由于丢包,那么接收方对丢失报文的后续报文的应答序号不会是该报文的序号+1,连续收到的报文中最后一个报文的序号+1。

发送方收到连续几个应答报文的确认号相同时(一般是3个,就会触发重传机制),他就知道中间有数据包丢包了,这时候就会对丢失的数据包重传。

至于为什么确认序号的定义是对确认序号之前的所有报文都做应答,这一点我们在滑动窗口部分会知道。

但是,为什么需要两个序号了,一个序号和一个确认序号?只是用一套序号不行吗?

不行,TCP是全双工的, 双方能同时发送报文,同时TCP一般是并发式的发送报文,如果只是用一套序号的话无法支撑全双工的工作模式。

窗口大小

双方在通信的时候,距离可能很远,客户端可能会给服务端发送大量的数据段。

但是如果发送方发送数据的速度太快或者说数据报文太大,而导致对方来不及接受,最终接收方的接收缓冲区满了,后来的报文也只能被丢弃,可靠性差。 

而如果发送方发送数据的速度太慢,会导致接收缓冲区的数据来的太慢,影响接收方的上层的业务处理速度,效率太低。

那么为了确保可靠性和效率,我们的发送方就需要以一个合适的速度发送数据,但是什么叫做合适呢?就是发送的数据或者大小要根据对方的接受能力来决定,那么接受能力是怎么体现的呢?接收方的接收缓冲区的剩余空间大小就是对方的接受能力的体现。所以我们的发送方需要知道对方的接收缓冲区的大小才好决定发送数据的速度。

于此同时,因为TCP是全双工的,双向通信的时候,对方也需要知道我们的接收缓冲区的剩余空间大小来决定他的发送速度,或也要将自己的接收缓冲区的剩余空间发送给对方,让对方控制好发送的速度。

于是就有了 16 位窗口大小的字段。

窗口大小就是表示当前自己的接收缓冲区的剩余空间的大小。

报文是谁发的,那么这个报文的报头中的窗口大小表示的就是谁的接收缓冲区的剩余空间大小。这还是很好理解的。

那么不仅是我们要将自己的接受能力告诉对方,对方发给我们的TCP报文中也会有这个字段,那么我们也就能知道对方的接收能力。

双方通过报文的窗口大小交换了接受能力,那么双方都会控制以合适的速度来发送数据,换句话说,正是因为知道了对方的接收能力,双方都能够控制自己的发出的数据量和速度,那么两个方向的流量就得到了控制。

窗口大小字段可以支撑TCP的流量控制

ACK:

TCP报文其实也是有类型的,比如有的报文是请求链接的报文,有的是断开连接的报文,有的是数据报文,有的是应答报文,有的既是数据报文也是应答报文,还有一些其他的管理报文,不同的类型的报文有不同的功能,这些报文的类型怎么标识呢?就是通过这 6 个标记位。

首先我们最熟悉的应答报文,应答报文不仅仅是填充确认序号就完了,如果一个报文有应答功能,他必须将报头中的 ACK 标记位由 0 置 1。 

所以基本上我们发送完连接请求之后,通篇的报文绝大多数的ACK标记位都会被置为1,因为在通信中的报文,都可能会承担着对历史报文的确认。当然第一个数据报文不是对任何报文的确认。

PSH

TCP报文中会有16位窗口大小,来告知对方我们的接收缓冲区的剩余空间的大小。TCP的接收缓冲区,数据由TCP协议放进去,由上层取走,这就是一种典型的生产消费模型。

但是如果TCP的上层处理数据的速度很慢,导致从缓冲区取数据的速度很慢,最终导致接收缓冲区的数据积压,那么接收缓冲区的空间就会越来越少,极端情况下,当接收缓冲区满了之后,我们发送给对方的窗口大小就变成了 0 ,这时候对方就无法继续发送数据了,那么只能等待。

这里会有一个小问题:当我们的接收缓冲区满了之后,我们回复给对方的窗口大小就是 0 ,那么对方就不会再继续发送消息了,而如果我们的服务器单纯是一个被动的不会主动发送数据的程序,那么这时候就意味着即便我们上层取走数据,缓冲区更新了,我们不会主动告知对方我们的新的窗口大小? 难道对方就会一直等待我们发送新的窗口大小?那么这时候不是套娃了吗?

所以发送方在对方的接收缓冲区满了之后,会暂停发送数据,但是并不意味着就不会发送TCP报文了,对方的窗口为 0 只是意味着无法在接受数据,并不意味着不携带数据的管理报文无法接受。我们的发送方在收到对方的窗口为0 的报文之后,会设置一个定时器,当定时器结束之后,主动发送 窗口探测报文,对方收到窗口探测报文之后,就会回复一个ACK,携带当前的窗口大小。当然我们的接收方在检测到上层取走了缓冲区的数据之后,也会主动发送一个ACK报文,通过报文中的窗口大小和确认序号提醒对方可以发数据了以及该发那一部分数据。

而如果发送方发送了多次窗口探测,对方的ACK中始终表示窗口为0,这时候就会再次发起一个询问报文或者说窗口探测报文,但是这次会把报头中的 PSH 标记位置为 1 . PSH 其实就是 push ,其实就是催促对方的上层赶紧将数据读走更新缓存区,接收方的TCP协议在收到PSH报文的时候,会做一些工作来提醒上层将数据读走,做什么工作呢?其实很简单,是什么导致对方上层迟迟不读走数据?要么是处理速度确实慢到了一定程度,还有一个很大的原因可能就是上层认为读取条件不就绪,所有并未进行读取,这时候我们的接收方就会尽快让读取条件就绪,至于为什么接收缓冲区满了,我们的读取条件还会不就绪,这在后面的高级IO的多路转接中会讲解。 当然接收缓冲区一直是满的原因也可能单纯就是发送方发送数据太快而导致的。

总之,目前我们能理解的就是,PSH的目的就是催促对方读取数据,更新缓存区

URG

在前面我们知道了,TCP保证有序是根据收到的报文的序号来进行排序的,因为报文的乱序本身就是一种不可靠的表现,所以TCP必须通过序号来进行排序。

那么在接受方的接收缓冲区看来,上层读数据就是按序的,在上帝视角看来,接收缓冲区的本质也就是一个字节流式的队列,先发的先被读取。

但是,如果我们有数据需要插队呢?或者说有数据想要被高优先级处理呢? 这就需要用到URG标记位了。

如果我们的报文中涵盖了需要被特殊尽快读取的数据,我们就可以将标记位 URG 置为1,置 1 之后就表示我们当前的报文中的有效载荷中是携带了紧急数据的,注意,并不是说,将URG置为1之后,整个报文的有效载荷就都是紧急数据了。

既然紧急数据在有效载荷中,但是我们也说了,有效载荷中只有一部分是紧急数据,那么紧急数据具体是哪一部份呢?

报头的紧急指针的值就表示了紧急数据的位置。虽然它叫做指针,但是他其实是一个偏移量,偏移量的值就是紧急数据在有效载荷中,相对于有效载荷的开始位置的字节数。

但是紧急指针只是指明了紧急数据的开始位置,他具体多大呢? 不需要知道这个紧急数据多大,因为他只能是一个字节。 所以其实偏移量对应的哪个字节的数据就是紧急数据,把整个报文的有效载荷看成一个char data[ ]数组的话,紧急数据就是 data[偏移量] 。

我们把URG代表的这个单字节的紧急数据也成为带外数据,为什么叫带外数据呢? 因为普通数据是要在缓冲区中排队被读取的,而这个紧急数据不是放在缓冲区,因为它需要有先被读取,他是被放在特定的位置,具体在哪就看TCP的实现了。

但是仅仅一个字节的紧急数据有什么用呢?什么时候/场景下需要插队呢?

绝大多数场景下我们都不会插队,也就是说,大多数情况下我们用不上这个紧急指针。虽然带外数据只有一个字节,但是他已经能够表示 128 个值,通信双方可以实现约定好受到什么样的带外数据就执行什么样的功能。比如用来询问客户端或者服务器是否健康或者询问接收能力等。因为是带外数据,所以不需要按部就班的按照顺序在缓冲区中读到该数据,而是直接从带外数据的存储区域读到。而同样的,我们的回应也可以以带外数据的方式发送给对方。

带外数据的通信不需要经过接收缓冲区漫长的等待,所以双方在使用带外数据通信的时候不需要排队,高优先级被处理,他的策略和普通的TCP的数据是不同的。

怎么读到带外数据呢?

我们以前读取TCP缓冲区的时候,调用的 recv 或者 recvfrom 接口,都有一个参数叫做 flags ,我们之前都不太关注,是因为还没有用到对应的选项, 而今天这里就可以用到一个选项。

使用 MSG_OOD 选项就是读取带外数据。同时man手册这里也说了,有的协议可能将带外数据放在正常接收缓冲区数据流的前面,这时候带外数据其实就是在接收缓冲区的,这时候我们无法使用这个选项。 当然TCP肯定是能用的。

而我们要发送带外数据的时候,send 的时候也可以选择 MSG_OOD 选项,表示发送一个带外数据。

RST

TCP通信前双方必须先经过三次握手建立连接,但是三次握手一定能成功吗?不一定,这时候会导致发起方单方面认为连接建立,但是接收方并没有完成三次握手,我们后面会讲。以及TCP断开连接的时候要经过四次挥手,但是四次挥手也不一定会成功,这时候被动断开连接的一方也可能没有完成四次我搜。同时,即便连接建立成功了,在后续的通信过程中也可能因为某些原因导致单方面出问题,比如某一方断电了,那么他的操作系统直接挂掉,并没有时间去进行四次挥手,告知对方释放数据结构等,可是对方的TCP却并不清楚其中一方已经断开链接了,那么他就有可能还会继续发送数据,而对方重启之后,是不知道曾经有这样一个连接因为异常掉线了。

总之,在出现这种连接认知不一致的情况下,也就是一方认为连接还在,一方认为连接已经断开,这时候如果认为连接还在的一方给对方发数据,这时候发的肯定是普通的数据,也就是不会携带发起连接请求的字段。那么这时候,断开连接的一方就会很懵逼,因为TCP通信是要建立在连接的基础上的,只有先建立连接才能通信。 这时候断开连接的一方收到这个报文之后,不会对这个报文进行处理,而是会给对方发送一个 RST 标记位置为1的报文或者说单纯就是一个报头,告知对方,这个连接已经出异常了,无法继续通信,让对方通信发起连接请求

所以RST 也叫复位标记位,通信双方中, 单方面认为连接已经断开时,在后续通信时用来处理连接认知不一致的问题,使用RST标记位来让连接复位,或者说重置连接

保活机制

当然,一般是出现了意外才会用到 RST 标记位。 如果是正常的通信过程中,TCP会有一个保活计时器,如果双方长时间不发送数据或者说没有数据交互,计时器时间到了,这时候其实就会触发 TCP 的保活机制,也就是一方会主动发送一个保活探测报文,如果对方进行了应答,那么就会重置保活计时器,而如果对方没有应答,那么会多发送几次,防止是因为丢包而导致报文没有送达,那么如果多次保活探测报文都没有收到对方的应答,那么就会认为对方崩溃或者不可达,就会断开这个连接。

TCP的保活机制能够及时发现失效连接或者不可达的连接,释放资源。

但是有一个缺点就是可能会因为网络的短暂断开,比如网络拥塞或者直接断网了,导致保活探测报文根本就没有发送到对方,而我方却误判对方掉线,这时候会断开正常的连接。同时如果是因为网络拥塞而导致的探测报文不可达,那么后续频繁的发送探测报文也会加重网络的负担。

选项中的窗口调节因子: 

我们知道报头中的 16 为窗口大小表示的就是自己接收缓冲区的剩余空间大小,可是16位最多也就表示 64 kb ,如果我们想要发送的数据比这还大呢或者让我们的缓冲区变得更大一些,可以接收更大数据,这时候只有 16 位的窗口怎么做到呢?  其实在报头中的选项中可以添加一个 窗口扩大因子,支持更新出更大的窗口大小,当然前提也要我们在操作系统层面将接收缓冲区先变大。

如果我们要发送的数据过大的时候,我们就需要在应用层主动进行拆分,这一点TCP和UDP都是一样的,我们可以注意到 send 或者 write 这些函数都有一个返回值,返回值表示实际写入了多少个字节,我们可以根据返回值来判断是否全部发送成功,所以实际上我们的发送过程应该是循环式的,如果缓冲区满了,我们的数据没发送完,这时候要么就等下一次写就绪要么就阻塞式写入。

关于序号:

TCP其实在缓冲区中是对每一个字节进行了编号的,而发送的数据段的报头的序号就是有效载荷中最大的编号。

重传

TCP如果发生丢包,怎么解决呢?重传。

丢包的原因其实很简单,由于我们已经通过窗口大小来控制发送速度了,所以不会是因为发送的太快而导致丢包,那么丢包就只能是 因为在转发的路上报文真的丢失了。

但是丢包有两种情况:

1 数据丢了,那么对方没有收到数据,也就不会发送应答,而发送方在长时间未收到这段数据的应答的时候,就会触发超时重传

2 数据没有丢,但是接收方发送的应答在路上丢包了,这时候发送方也是长时间未收到这段数据的应答,也会触发超时重传。这时候发送方重发数据,接收方再次收到这个数据时,会判定是重复报文,将其丢弃,因为这个报文或者说这个报文的序号在接受方这里已经确认收到了,但是接收方还是会进行应答。

所以数据到底有没有丢包,其实发送方并不知道,他认为在一定时间内没有收到接收方对该报文的应答,就会认为这个数据丢了,计时器一到就会进行超时重传。

那么就有一个问题:发送方在把一个数据发送出去之后,是不是立马将这份数据从发送缓冲区中移除?

当然不应该,他要维持一段时间,直到收到对这份数据的应答,或者超时了,进行重传,再次等待应答。 只有收到了应答,才能把数据移除,为了支持超时重传,就注定了发送出去的数据必须在发送缓冲区维持一段时间。

那么把数据从发送缓冲区移除是什么意思呢? 

很简单,就是让这块空间的数据无效,可被覆盖

具体是如何工作的我们要看后面的滑动窗口。

回到我们的超时重传。

超时重传的超时该如何设定?是固定的吗?

首先他一定不是固定的。这个超时时间至少要能够满足数据从发送放到接收方,应答从接收方发回发送方,被发送方接受,也就是超时时间其实至少要是 发送数据和接收应答的一轮的时间。 而这个时间本质上是由网络情况决定的,如果网络情况好,那么数据的传输速度快,超时时间就应该短一点,提高效率。 如果网络情况差,那么超时时间就需要设定长一点,保证超时时间足够报文在双方完成一次交互。

当然如果我们的网络情况很好,而超时时间设的很长,就会导致整体的重传效率不高。而如果网络环境很差,超时时间很短的话,就会导致频繁触发超时重传。

所以TCP的超时时间是有自己的策略的。

最理想的情况下,找到一个最小的时间,保证确认应答一定能在这个时间内返回给发送方。

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

Linux中,超时以 500ms 为一个单位进行控制。每次判定超时重发的超时时间都是500ms的整数倍。

第一次超时重传是在 500ms 没有收到应答的时候进行重传,重传之后,这个报文的超时定时器设为  2*500 ms ,如果还是没有收到应答,那么继续重传,这次重传之后设置超时定时器为 4*500ms,以此类推,以指数形式递增。

累积到一定的重传次数,TCP就会认为网络或者对端主机出现异常, 强制关闭连接或者reset重置该链接。

一般一个报文的最大重传次数是 3 次。

强制关闭连接就不会进行四次挥手了,因为我们认为对端无法收到报文了,既然也无法收到挥手报文。

三次握手(SYN)

三次握手和四次挥手都是TCP连接管理的机制。

首先我们先来学习建立连接的机制:三次握手

三次握手的规则:

1   一方主动发起连接请求,其实就是发送一个SYN标志位的报头。发出这个SYN请求之后,状态变为SYN_SEND; 这是第一次挥手

2 对方收到连接请求,收到之后变为SYN_RCVD状态。然后对SYN请求进行确认,表示同意对方的连接请求,同时也发出一个SYN请求,表示同步建立连接的共识。

在TCP中不管是服务器还是客户端,双方的地位是对等的。而建立连接和断开连接的时候,都是需要双方都同意对方的连接或者断开连接的请求,才算成功。

3 发起方收到对方的ACK和SYN,发出一个ACK对对方的SYN进行确认,只要发出这个ACK,就变为ESTABILSHED,连接状态。此时在发起方看来,三次握手已经完成了,连接已经建立(主观上)。

4 对方收到ACK,状态变为 ESTABLISHED,连接状态,此时对方认为三次握手完成,连接已经建立。

通过上面的过程,我们发现,在建立连接的时候,双方对连接的认知是有一个时间窗口的。

所谓的三次握手,其实就是各自吞吐了三个报文,对于他而言三次握手就完成了,主观上认为连接已经建立。

三次握手是建立连接的机制,三次握手并不一定成功,但是只要三次握手成功了,就可以说连接成功建立了。

为什么三次握手不一定成功呢?

首先,我们看到,前两个报文都是有应答的,但是第三个报文是一个纯应答报文,没有应答,发出或者收到这个ACK的时候就认为连接建立了,那么就要开始正常通信了。

而其实这个ACK既然是一个报文,就有可能在网络中丢包,而由于这是一个纯ACK,所以在发起方看来也不需要超时重传等机制(前两个报文由于需要ACK所以是有超时重传的),那么就算丢包了,发起方也不知道同时也不会进行重发,而对于被动连接的一方,如果没有收到这个ACK,那么就会触发超时重传机制,也就是会重新发送 ACK+SYN 。如果多次重发ACK+SYN,都没有收到对方的ACK,那么就会强制关闭这个连接

那么这个时候如果发起方发送数据,那么被动连接方就会返回 RST 来标识连接的错误,并让发起方重新发起连接请求

TCP的可靠性指的是建立连接之后数据传输的可靠性,并不保证建立连接之前的可靠性。

从上面我们也可以看出来,我们其实并不担心三次握手失败,因为TCP有其他的机制来重发第二个报文或者通过RST来重新建立连接

只是三次握手期间,双方的连接建立会有一个时间窗口,在这个时间窗口内,双方对于连接的认知是不一致的。

同时,我们在前面就已经讲过,三次握手其实是交换了一些数据,同时双方的操作系统在底层维护了有关连接的数据结构,为连接创建的数据结构才是连接的最本质,只不过核心工作都是由操作系统完成的。 而维护一个链接是要有时间和空间的成本的,这一点也无须多说。

在被动连接方收到了连接发起方的 SYN 之后,也就是变为 SYN_RCVD 状态的时候,其实也已经为连接创建了数据结构,分配了内存,因为连接所需的对方的数据已经有发起方的SYN发送过来了,但是这时候由于没有收到对方同意连接请求,所以此时不会把这个连接放到 连接队列中,而是暂时放在半连接队列中。

同时还有一个细节就是:三次握手的前两次不能携带数据,但是第三次握手也就是这个最后的ACK,是可以携带数据的。为什么前两次不行呢? 其中有一个原因就是因为不知道对方的窗口大小(以及能否正常收发)窗口大小是在SYN报文中就会告诉对方的,也就是初始窗口大小,同时由于前两个报文即使到了对方,这时候连接还没建立,所以无法进行数据的传输。但是第三个报文不一样,第三个报文只要被对方收到,此时连接就已经完全连接了,那么就可以进行数据的处理了,这也是为了保证安全性

那么为什么建立连接一定是三次握手,不是一次,两次或者四次或者更多次?

一次握手肯定是不行的,因为只有一次报文的交互的话,连这个SYN连接请求都不一定能正确被对方收到。 如果我们一次握手就能够建立连接,那么意味着未来我们的客户端可以循环式的不断发起SYN,但是在客户端通过某些方法不为该连接分配内存或者直接单方面让该链接失效,这时候服务器就需要维护好这些已经建立好的连接,那么而服务器的可用的连接资源就会越来越少,那么正常用户的连接就可能因为服务器连接资源被用完了而无法连接成功。在单主机情况下就会把服务器的网络资源全部消耗完,这个就叫做SYN洪水。 

除了容易操作攻击之外,就算是正常的连接,一次握手连双方的接受能力都没有进行交互,无法进行流量控制,滑动窗口等策略。

两次握手也不行。还是一样的问题,对于正常的连接,虽然两次握手能够完成接受能力或者收发能力的确认,但是由于服务器回应的ACK+SYN可能丢失,那么连接其实可能建立失败,而且没有对这个报文应答机制。当然这不是最重要的,因为会有保活机制和RST机制来重新建立连接。

但是很容易遭受攻击,只有两次握手的话,只要服务器发出了SYN+ACK,那么在服务器看来,连接就已经建立了,那么就需要维护该连接。但是客户端由于不需要应答,,那么其实是可以忽略服务器发回来的SYN+ACK的,那么还是一样,在客户端没有建立好连接的前提下,服务器就先把连接建立了,客户端空手套白狼了,一旦遇上SYN洪水,服务器的资源也会被消耗完。

三次握手因为TCP是全双工的,所以必须验证双方都能收发消息,那么让双方都收和发消息的最少次数就是三次握手。三次握手能用最小的成本验证双方信道的通畅以及全双工。

所以一次握手和两次握手不行的原因还有一个就是因为无法验证全双工。

同时,在三次握手机制下,第三次握手发出的时候,客户端就已经将连接建立好了,而服务器则是要受到这个报文才算建立好连接,如果客户但不发出这第三个报文,那么服务端也不会把建立起来。 这样一来,要想让服务端受到伤害,首先客户端需要受到等同的伤害。而由于服务器的配置一般是比客户端要高得多的,所以三次握手的连接建立机制能够有效规避 单主机 对服务器的SYN洪水攻击。

那是不是三次握手就能解决收到攻击的问题了呢? 

首先,对于这样的安全问题本来就不是TCP这个通信协议该解决的,三次握手机制是为了解决建立连接的问题,而不是针对网络安全而制定的。 但是为什么说 一次握手和两次握手 因为这个原因就不好呢?难道计算机世界也有双标?  其实是因为 一次握手和两次握手 留给黑客的漏洞太大了,单主机就可以完成瘫痪一个服务器的“壮举”,所以会作为一次握手和两次握手的缺点。 而三次握手在自己的职责范围内尽可能避免了单主机的SYN洪水。

即使是三次握手,他也只能在一定程度上规避单主机的攻击,但是对于多个主机一起攻击这种方式,还是无法避免。 比如黑客在网络中散播了一批木马病毒,控制很多个主机在同一时间对服务器不断循环式的发起连接请求,这时候我们的服务器还是会存在大量的连接,当机器足够多时,服务器资源消耗完,那么后续正常的访问就无法建立连接了,也就是拒绝连接,无法提供服务了

这一批被感染的机器称为 肉鸡 ,这种攻击方式称为 ddos ,服务拒绝式攻击

这种攻击方式是无法解决的,因为只要肉鸡足够多,什么服务器都能打趴,这就叫做大力出奇迹

三次握手也会存在一个问题,就是服务器在收到客户端的连接请求,也就是 第一次握手之后,这时候就已经创建好数据结构了,建立了半连接, 即便后续客户端不再发送 ACK ,服务器也会为了这个可能会建立的连接而维持半连接,但是如果一段时间内连接没有建立,服务器就会释放这个半连接的资源。这一点我们在前面讲过了,因为服务端迟迟收不到客户端的ACK的时候,会触发超市重传,而重传一定次数之后,就会强制断开连接。但是如果有源源不断的SYN发过来,也会慢慢蚕食服务器的资源。 SYN洪水的问题不能是由三次握手来解决,而是要有更多安全机制,比如服务端设置黑名单白名单等,服务端设置防火墙等,同时TCP协议也有针对这种情况的策略就是     SYN cookie策略,但是都只能在一定程度上规避,不是最终的解决方案了。

首先不管怎么说,三次握手能验证全双工了,那么四次,五次以及更多次也一定能够验证全双工。

四次握手行不行:四次握手有一个问题就是第四个报文是服务器发出的,那么就意味着一定是服务端先建立连接的,那么就会出现跟两次握手一样的问题,即使客户端把连接单方面断开了,服务端也还傻乎乎的维持这个连接,这可不是半连接了,而是已经建立好的连接。而已经建立好的连接的保活机制一般定时器都比较长,是按小时为单位的。  

同理,所有的偶数次握手建立连接的方式都有类似的问题,都不可行。

那么五次,七次,九次等行不行呢? 行。 但是他们的效果和三次握手的效果其实是差不多的,既然三次握手已经能够解决问题了,为什么要这么多次握手呢? 多一次握手就意味着要多一些消耗以及损耗连接建立的速度,没必要了。

最后,其实三次握手我们也可以理解为 四次握手,很简单,因为第二封报文其实是把 ACK 和 SYN整合在一个报文发出去了,也就是 SYN 和ACK标记位都置为1了,我们也可以拆成两份报文进行发送,但是还是那句话,一个报文能做到了,就没必要发两个报文。

TCP为什么要连接?

因为要保证可靠性。 那么为什么建立连接就能保证可靠性了呢? 其实建立连接并不能直接保证可靠性,但是三次握手期间,双方的操作系统会创建对应的结构体,而结构体中的字段就能保证通信的可靠性。 TCP 所有的可靠性的策略都是要基于一定的数据基础的,而这些数据就是保存在连接的结构体中。 

TCP怎么知道当前发了哪些报文,哪些报文丢失了,那些报文超时了要重传?当前收到了多少保温?重传超时时间是多久等,这些TCP的可靠性的特征,最终一定是体现在TCP连接的结构体中的。 所以连接结构体是TCP保证可靠性的数据结构基础,而三次握手是创建连接结构体的基础,所以TCP的三次握手建立连接间接保证了可靠性。

四次挥手(FIN)

四次挥手的过程及双方状态变化:我们假设A是断开连接的发起方,B是被动断开连接的一方。

1  A 发出一个 FIN 标记位置为 1 的报文,请求断开连接,同时也意味着 A不会再给 B 发送数据了,同时也不再接收对方的数据。 在发出这个FIN之后,A的状态变为 FIN_WAIT_1

2  B收到A发来的FIN请求,立马回复一个ACK,只要发出这个ACK报文,B的状态就变成 CLOSE_WAIT,。

 A收到 B的 ACK ,此时状态变为 FIN_WAIT_2 ,等待B的断开连接的请求。 因为TCP是全双工的,且双方地位对等,所以断开连接也需要双方发起并征得对方同意。

3  B的上层读取完了缓冲区的数据,关闭了通信的文件描述符 。 此时B发送 FIN 报文给 A,发出这个报文之后,B的状态变为 LAST_ACK

4  A收到B的FIN请求之后,立马回复一个 ACK ,发出这个ACK之后,A的状态变为 CLOSE_WAIT,并不会立马断开连接释放资源,而是会继续维持这个链接一段时间

 而B收到这个ACK之后,就断开链接释放资源了。

我们说发送 FIN 之后就表示后续不会再发送数据了,那么后面的几个报文算什么? 

我们说的是不再发送数据,但不意味着双方不会进行管理报文的交互,双方的操作系统还是要为断开连接做一些工作的。

发起断开连接请求的一方的TCP协议是怎么知道上层不会再发送数据了?

其实TCP无法直接得知,但是她能够根据文件描述符的状态得知。 因为只要上层将文件描述符关闭了,就意味着上层已经不再发送和接收数据了,那么他的TCP协议就会发送FIN请求来断开连接。

所以四次挥手的触发条件就是 其中一方调用 close 关闭通信的文件描述符

任何一方都有可能会主动断开连接,可能是客户端,也可能是服务器。而三次握手的发起方则一般是客户端向服务器发起连接请求。

我们在上面也发现了,被动断开连接方发出ACK报文,也就是第二次挥手之后,会有一段时间处于 CLOSE_WAIT 状态,这个状态会持续多久呢? 

这取决于他的上层什么时候把数据读完然后调用 close 关闭通信的文件描述符。如果这时候上层一直不调用close ,那么他就会卡在CLOSE_WAIT状态,不会发出第三次挥手报文,那么就无法完成四次挥手了。 

我们也可以用一个TCP的代码来观察一下这个状态:我们可以使用前面TCP服务器用到的代码,然后把服务器的关闭文件描述符的代码删除,也就是服务器即使读到了文件结尾,也不关闭文件描述符,而是继续死循环,这样一来,就会一直处于 CLOSE_WAIT状态。

我们发现客户端断开连接之后,我们使用 netstat -ntp 查询的时候,由于我们的服务器已经使用ctrl C 使用信号退出了,那么对应的显示中就不会显示具体的进程,这其实也是正常的。 因为不管是建立连接还是断开连接主要还是由双方的操作系统完成,进程退不退出其实无所谓的。

我们也能看到,当服务器迟迟不关闭文件描述符时,连接未曾断开,服务器处于 CLOSE_WAIT阶段,而客户端处于 FIN_WAIT_2 状态,这与我们的描述是相符的。

过一段时间再去查的话,我们就能发现客户端就已经把连接强制关闭了,他也有对应的超时和保活机制来应对这种情况。如果此时服务器再关闭文件描述符,发送FIN请求的话,就会触发 RST 重置连接然后再进行四次挥手。

目前我们也能确认一个结论: 被动断开连接的一方一直不调用 close 的话,就会一直处于 CLOSE_WAIT状态。

如果我们的服务器上出现了大量的 CLOSE_WAIT状态,一般有两种情况:

1 服务器代码有bug,没有调用close 关闭文件描述符。

2 服务器有压力,可能一直在推送消息给其他的客户端,导致来不及close。

最后还有一个 TIME_WAIT 状态,这是断开连接的发起方在发送最后一个 ACK 之后处于的状态,同时他也会维持这个状态一段时间,这是为什么呢?这个时间一般是多长?

我们把一个消息从客户端到服务端或者从服务端到客户端的最大时间称为 MSL, 也就是maximum segment lifetime , 也就是段的最大生存时间。 而我们的主动发起断开连接的一方持续TIME_WAIT的时间一般是 二倍的 MSL。不同操作系统对MSL的大小的定义不同。

为什么要等到 二倍的MSL之后才关闭连接呢?

我们看到,四次挥手的前三个报文是不怕丢弃的,因为有丢包重传和超时重传的机制,也就是可以补发。但是 第四个 报文是没有应答的,那么就有可能在网络转发途中丢失。

如果发起方在发出最后一个ACK之后就立马断开连接了,一旦ACK丢失,那么对方一直收不到ACK就会出发超时重传机制,重传第三个报文,而由于发起方已经释放资源了,自然也就收不到这个消息以及返回应答了。当然最终被动发再进行了多次重传之后也会强制断开连接,但是这其实异常的状况。

所以发起方在发出这个ACK之后等待 2 倍的MSL ,在TIME_WAIT期间,如果对方没有收到应答,那么他就会重传第三个报文,而重传的第三个报文在这个时间内能保证被收到,这时候就可以补发这个ACK,当然这个TIME_WAIT状态也会更新,时间会重置。

所以TIME_WAIT的第一个作用就是 保证最后一个 ACK 被对方收到

同时还有一种情况,就是双方握手很成功,四次握手很快就完成了,但是对方还有一些报文是在四次握手之前发送的,但是由于网络的原因可能还滞留在网络中,在赶来的路上,这时候,发起方维持一段时间的 TIME_WAIT 就能够保证收到这些网络中的滞留报文,对其处理,其实就是把报文读走,避免对下一个使用这个端口的进程造成影响

为什么会出现这种情况呢?我们无法确保收到报文的顺序和发出报文的顺序一致,因为网络是变化的,只不过TCP会对收到的报文进行排序,那么在上层看来收到的顺序和发出的顺序就一致了。 同时,FIN发出后,断开连接不再接收报文了,更准确来说,是这个时间点以后发出的报文就不接收了,但是已经发出的报文还是要接收的,因为报文已经在网络中了,收不回去。所以即便客户端上层调用了 close 看似关闭了链接,其实底层并没有把链接完全关闭,他还要接受已经在网络中的报文并对其进行应答。 当然最终可还是有概率,在2倍的MSL内,还有在网络中的报文没有到达,而新的服务使用了这个端口,那么这个数据就有可能被新的服务接受到,在这种情况下,如果序号不一致就直接丢弃,如果需要一致,那么倒是真的可能会出现新老数据的干扰问题。

但是序号一致的概率是十分十分小的,因为TCP服务器启动的时候,发送的第一个序号是采用随机数来指定的,也就是将发送缓冲区的第一个字节的序号,实在服务器启动的时候通过随机数的方案指定的。

同时,这也解释了为什么我们使用TCP服务的时候,如果是服务器主动断开连接或者退出的时候,无法马上重启再次绑定这个端口号。 就是因为这个端口号还在被我们上一个服务器占用。但是其实这个状态我们如果有必要,是可以直接关闭的。

比如我们的服务器收到了大量的客户端请求,导致服务器崩溃,那么之前的处于连接状态的连接,就相当于被服务器主动断开了,那么服务器的端口就会维持大量的 TIME_WAIT,而在TIME_WAIT时间内无法再次绑定,这时候其实是一种很大的损失。

那么怎么解决呢?

其实很简单,只需要我们设置套接字复用就行了。

第一个参数表示要设置的套接字,第二个参数表示在什么层面上设置,我们选择SOL_SOCKET,也就是套接字层,第三个参数有就是要设置的属性,套接字复用我们使用 SO_REUSEADDR,第四个参数我们在我们定义一个 1 ,然后传地址进去,他其实相当于一个bool值,因为最早的时候是没有bool值的概念的,最后一个参数就是这个参数的大小。

设置 SO_REUSEADDR 的作用就是在这个服务的生命周期结束时,这个端口可被复用,也就是说当他处于TIME_WAIT状态的时候,如果有其他的服务要求绑定这个端口,那么就主动让出,关闭服务,因为他本来也就要关闭了,处于TIME_WAIT只是为了保证更好的关闭而已。

那么这个函数什么时候调用呢?创建完监听套接字绑定端口之后,在设置监听状态之前进行调用。也就是在我们的服务器的 init 阶段。

        int opt =1; n = setsockopt(_listensock,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof opt);if(n==-1) exit(SETSOCKOPE_ERR);

设置完套接字复用之后,服务器崩溃的时候,就可以立马进行重启,绑定同一个端口。

流量控制

其实流量控制我们在前面的窗口大小已经讲的差不多了。 流量控制就是根据对方返回的窗口大小来决定给对方发送的数据的速度和大小。 

那么,发送方或者说通信双方在第一次发送消息时是怎么知道对方的窗口大小的呢? 很简单,TCP三次握手的时候,三次握手不仅是在创建数据结构,验证全双工,其实也会将初始的窗口也发送给对方,如果需要窗口扩大,也是在SYN报文中的选项中就要指定的。 根据窗口的大小,就连可以在对方的接受能力之内,尽量更快的发送数据,能够提高通信的效率。

如果窗口为 0 了,那么我们无法继续发送数据,对方自然也就不会应答,难道就再也不发消息了吗? 不是, 有两种策略:

1 发送方会定时给对方发生窗口探测报文,不会携带数据,那么接收方也会回应一个报文,携带当前窗口大小.

2 接收方会在上层取走数据之后给发送方发送一个窗口更新的报文给对方,这个报文不需要应答。

策略 2 的通知报文是有可能丢包的,因为他没有应答,同样,策略1 的应答也可能丢包, 但是丢包也没事,因为发送方会定期发送窗口探测报文,总会有报文到达的。

如果我们进行了窗口扩大,那么怎么表示窗口大小的呢? TCP首部是有一个选项字段的,选项字段可以携带一个窗口扩大因子,实际的窗口大小是16位窗口大小左移 这个扩大因子的数值 个位。

滑动窗口

在前面我们重点讲过:我们的数据在未收到应答之前,会暂存在发送缓冲区,这是为了支持超时重传,同时,收到对应的应答之后,会立马移除该数据,惰性移除。

同时TCP为了提高效率,在一般情况下都是并行发送的,并行发送多少个报文后续再拥塞控制中会讲,总之是会并行发送,如果我们的网络带宽足够的话,这些报文也是并行在网络中进行转发的。就相当于用传送一个报文的时间传送了多个报文,应答也是如此,并行发送大大提高了效率。

那么也就是说,在对方的窗口足够大时,发送方可能会一次性发送多个报文,以提高发送效率

我们以一个方向上的通信为例:

我们的send或者 write 是把数据写到TCP为对应的套接字维护的发送缓冲区中,那么就注定了,我们的发送缓冲区可能会存在四种区域:已确认数据区,已发送未确认数据区,为发送数据区,未填充区

我们假设每一次发送的数据大小都是1000字节,便于画图和理解。同时,由于我们的序号是32位的,很大很大,而我们的缓冲区其实并不会一次性存在这么多数据,同时操作系统也不会让我们浪费资源,我们的缓冲区的大小一定是有限的,他的底层一定是使用环形数组来实现的,所以本质上已确认数据区和未填充区可以看作一个连续的区间。当然如果我们的缓冲区满了,也就是已发送未确认和未发送的数据占满了缓冲区,我们上层也就无法进行写入了,因为写时间不就绪了。

滑动窗口就是已发送但未收到应答的区间。我们也可以称之为正在发送中。

那么怎么看待滑动窗口呢?

首先,我们可以把发送缓冲区看成一个字节流或者直接看成一个大的字符数组,那么滑动窗口就是这个数组的一部分,那么他的范围就可以用两个下标来表示 :  win_start 和 win_end (实际的下标时 win_start%size 和 win_end%size,size表示缓冲区的大小), 那么所谓的窗口滑动,其实就是下标在不断更新,或者说下标在不断增大。 

同时,滑动窗口是发送方可以在不需要等待应答的时候一次可以发送的数据的最大值,或者说数据总量,他就是已发送但是未收到应答的部分,那么滑动窗口的大小该如何设定呢?

首先我们可以确认的是,滑动窗口的大小肯定跟对方的接受能力有关,也就是跟对方发过来的窗口大小有关。

我们这里简单的认为 滑动窗口的大小就是对方发过来的窗口大小(这是错误的,实际上滑动窗口大小由多个因素决定,我们这里只是为了讲明白滑动窗口的移动过程,这个过程在后续其他的策略加进来之后也会不断完善)。

我们假设滑动窗口的大小就是对方发过来的窗口大小。

首先,滑动窗口一定不会向左移动,也就是上面的逻辑图中的左,因为左侧是已经确认的数据。那么滑动窗口可以不向右移动吗? 可以,比如对方上层并没有去缓冲区中读数据,那么对方的接受能力一直在减小,那么我们的滑动窗口可能只是在不断收到应答的过程中,左边界向右移动,而右边界可能不变。所以滑动窗口可以向右移动也可以不向右移动右边界保持不变。

那么滑动窗口具体是怎么移动的呢?

很简单,假设报文中的窗口字段就是我们新的滑动窗口大小,我们称为 size ,而收到的报文中,确认序号其实就是在指定更新之后的滑动窗口左边界。因为确认序号的定义就是确认序号之前的所有数据都已经完整收到了,所以滑动窗口会将收到的报头中的确认序号作为左边界值,win_start=ack,而窗口大小是size,那么右边界就是 win_end = win_start + size 。 那么这一轮发送方要发送的数据就是 新的右边界和老的右边界之间的数据(右边界可能没变,也可能变化很大,如果很大,不一定是一个报文发出去,可能会发多个)

而最终,由于对方一直不读取数据,窗口大小会变为 0 ,那么右边界也是一直不变的,同时左边界最终会和有边界相等,然后发送方就会进行等待。

丢包会对滑动窗口产生什么影响?

1 数据丢失:

对于丢失的报文之后的报文,即使收到了,应答的时候确认号还是和对丢包的前一个报文的应答一样,我们假设上层并未读取数据,那么右边界不会更新,同时,左边界由于确认号没有变,也不会更新。

所以数据包丢失对滑动窗口的更新是没有影响的

2 应答丢了

这时候,只要收到了对后续报文的应答,那么即使前面的报文丢失了也无所谓,因为后续收到的应答也是对前面的报文做确认。

当然如果应答全丢了,那么发送方会重传,只要重传的数据包有一个到了接收方,接收方丢弃这个重复报文,同时返回他已经收到的最后一个报文的序号的下一个作为确认号。

所以确认序号除了告知对方滑动窗口的起始位置,还支持我们滑动窗口以及重传等一系列的规则设定。

如果收到的确认序号比滑动窗口左边界的序号大,那么就说明这个确认序号的前面的序号全部收到了,可能中间有应答丢了,但是不影响数据收到的事实,此时窗口的左边界移动到确认序号处。

如果收到的确认序号是窗口左边界,连续多次受到,说明报文丢了,触发超时重传。

滑动窗口向后移动的时候,如果右边界更新之后超过了缓冲区的数组的大小了,这时候怎么办?不影响,因为我们说了,缓冲区可以看成一个环形数组,右边界可以一直增大,但是它实际代表的位置确实右边界的值模上缓冲区的大小。

如果滑动窗口右边界向右移动的过程中,计算出来的窗口大小超过了待发送数据区或者当前有边界后面已经没有待发送数据了,这时候怎么办? 其实也很简单,两种策略,一种是有多少发多少,反正闲着也是闲着,另一种就是等数据凑够一定的数量之后再一次性发送。这其中也可以有其他的额策略。

所以,在数据不够的情况下,我们的滑动窗口的大小其实并不等于对方发过来的窗口大小,可能比对方法的窗口小。

其实,有了滑动窗口这个概念,就支持了TCP一次发送大量数据,滑动窗口也提高了TCP的发送效率,我们不能单纯认为 udp 的发送效率就一定比TCP高。

TCP除了一系列保证可靠性的及之外,还有一系列用于提高效率的策略,只不过可靠性策列过于耀眼,导致我们忽略了他在效率方面的考量。

关于滑动窗口的初步介绍就到这里,后面的知识其实都跟滑动窗口有一定关系,在后续也会对滑动窗口的窗口大小的设定进行进一步的解析,比如拥塞控制。

拥塞控制

双方进行通信时,假设主机A给主机B发了1000个报文,而后续通过应答以及后续的重传得知,其中有几个报文丢了,在这种情况下,其实是一种正常的现象,因为在网络中丢包是很正常的。但是如果1000个报文中,只有很少一部分到了主机 B ,大部分报文都没有得到应答,这时候,是因为TCP协议的关系吗? 肯定不是,因为TCP有一系列保证可靠性的机制,不可能出现这么严重的事故,那么这时候就不能是简单的重传策略了。

我们以前讲的可靠性的策略,是解决端到端的问题的,也就是针对通信双方来做的可靠性的保证,让双方不出现大的失误,而双方不管怎么指定发送和接受的策略,数据的传输终究是在网络中进行的,而前面讲的策略并没有考虑网络可能也会出问题。 

所以TCP必须要针对网络可能会出现的问题做一些策略。  

TCP的可靠性不仅考虑了双方主机的问题,也考虑了网络的问题,当然是在自己的能力范围内将网络的情况考虑在内

当网络出现问题时,就不应该是直接对所有丢失的报文进行超时或者丢包重传了,因为网络出现了问题,或者说网络出现了拥塞, 这时候重传了还是会出现一样的问题。而且,如果一次性重传的报文数太多,不仅还是会出现大量丢失的问题,还会加重网络的拥塞。所以在网络出现拥塞时,我们不能直接大量的重传。

而在网络中的其他主机也会遵守这样的约定,在这一段时间内,向网络中发送的报文数就会急剧减少,而网络是有恢复能力的,可能我们慢发数据一段时间之后,网络就恢复了。

这就是拥塞控制的大的概念。

在网络出现拥塞后,难道我们就不再发送数据了吗? 不是的,我们只是不能直接发送大量报文,但是我们也必须要有一定的机制来探明网络的恢复情况,因为网络情况是时刻在变化的,可能上一刻还是拥塞的,下一刻就恢复了,这时候如果我们不发送数据,就是一种效率的浪费。

在网络拥塞发送后,TCP就会开始 慢启动 ,我们先来做一个简单的理解就是: 网络拥塞之后,由于我们的主机都无法确定网络的状态的变化,所以会定时发送报文用于探测网络状况。发生拥塞之后,过一段时间,发送一个报文,如果这一个报文没有收到应答,就说明网络状况还是很差,那么等待一段时间之后再来发送一个报文探测。 如果发送一个报文之后,成功收到应答了,那么说明网络已经有能力提供这种少量的报文的转发,但是他也有可能已经完全恢复了,所以我们就可以一点一点增加发送的报文的个数,比如一个报文发送成功后,下一次就发送两个报文,两个报文还是发送成功的话,下一次就发送四个报文,依此类推,如果中间又出现了大量丢包,也就是网络拥塞,这时候再回到初始状态,用一个报文来探测网络状态。

当然这里所说的报文个数是一次可以向网络中发送的最大的报文个数,如果你的数据量不需要分成多个报文来发送,那么自然就不需要发送这么多。

这个慢启动机制,由于网络中的其他的TCP服务也在遵守,所以再发生拥塞的最开始的阶段,整个网络的数据量大大减少,就能给网络回复的时间。

与此同时,TCP双方刚建立连接的时候,由于网络情况也不清晰,所以在通信的开始阶段也会使用慢启动策略,可以说是防患于未然。

上面所说的一次可以向网络中发送的报文的最大数量我们也称之为拥塞窗口

在通信开始的时候,拥塞窗口的值被定义为 1 ,每次收到一个 ACK 应答,拥塞窗口就加一。

那么按照这个算法,第一轮的拥塞窗口为 1 ,第一轮没有丢包的话,第二轮的拥塞窗口就增加到 2 了,以此类推,第三轮就是 4 ,第四轮就是 8 ,第五轮就是 16 。 当然这里还会有其他的因素影响,但是至少我们能够知道,慢启动的慢只是开始阶段的慢,但是由于他是指数增长的,后期增长速度会很快,所以不用担心发送报文数限制的太小而导致效率低下的问题。

同时,学习了拥塞窗口之后,我们也就能知道, 滑动窗口的更新一定也要受限于当前拥塞窗口的大小

究竟什么情况我们才判定为网络拥塞?发多少个报文时,丢了多少个报文就认为网络拥塞了? 这其实很难判定,于是我们就有了这个 拥塞窗口,在TCP报文的发送方看来,这个拥塞窗口的值就能代表网络的接受能力。 只要他发送的报文不超过网络的接受能力,那么就不会出现大量丢包。

所以。TCP在发送数据的时候,不仅要考虑对方的接受能力,还要考虑网络的接受能力

那么我们的滑动窗口的值就应该是

滑动窗口 = min ( 网络接收能力(拥塞窗口) , 对端的接受能力,待发送的数据大小 )

慢启动机制下,拥塞窗口的增长是指数增长的,满只是刚开始时慢,后续增长很快。 因此我们也不能单纯看网络接受能力来决定滑动窗口的大小,要综合考虑,也不能超过对方的接收能力。

为了不使拥塞窗口增长的这么快,我们为拥塞窗口的慢启动引入了一个阈值,当拥塞窗口超过这个阈值时,不再按照指数方式增长,因为到达一定大小之后,增长再快也没有意义了,主要或取决于对方接受能力。

在TCP建立连接刚开始通信时,这时候也是采用满增长,但是这时候由于没有参考值,也就是阈值没有参考的对象,一般在TCP协议中或者系统中写进配置文件或者是根据网络状况来决定,我们不关心第一次慢增长的阈值是怎么来的。

那么我们来画一个表来大概表示一下慢增长的机制:

在达到阈值之后,窗口变为线性增长或者说加法增长,我们可以把这种机制称为 拥塞避免

每次发生拥塞之后,会更新阈值为当前窗口的一半,我们称之为 乘法减小。然后窗口值更新为1,重新进入慢启动阶段。

拥塞窗口的大小一定是不断在变化的,因为网络的情况也一定是在不断变化的。

而前期的指数增长,增长的很快,是为了在网络接收能力内,尽可能发送更多的报文,提高发送效率。 而到达阈值之后开始线性增长,线性增长的过程本身也就是一个探测网络接收能力上限的过程。

所以TCP报文的发送会具有一定的周期性。当然这并不绝对,因为拥塞窗口增长到一定大小时,滑动窗口就不再取决于拥塞窗口,而是取决于对方的接收能力了。如果我们发送的数据报文的个数有限,那么可能也不会遭遇网络拥塞。

所以,网络中的主机的拥塞窗口变得很大,说明网络非常健康,而每一个人发送的数据量都不大,这也很正常,因为这时候取决于对方的接收能力,那么有了拥塞控制,TCP就能尽量避免网络拥塞时还不断给网络中增加负担,让网路慢慢恢复,从而也能让自己的发送更快。

那么拥塞控制是为了提高效率还是保证可靠性呢? 都有,他在两者之间找到一个平衡点,在保证可靠性的同时,尽可能提高效率。

延迟应答

延迟应答顾名思义,就是收到报文之后不在第一时间应答,而是延迟一段时间再应答

为什么要延迟应答呢?

我们要考虑一种情况,接收端的数据处理很快,如果立马应答,那么就有可能在这个ACK到达对方之前,我们的上层就已经又读走一部分数据,窗口又更新了。这种情况下如果我们延迟应答的话,那么回复给对方的窗口大小就会比立马应答的更大,那么对方就可以发送更多的数据,效率就提高了。

但是,这也只是一种概率事件,不一定延迟应答就一定能够回复一个更大的窗口,也可能在这段时间内上层并没有读走数据。虽然它是一个概率事件,但是我们能想到,他的概率绝对不小,因为从我们写的服务器和客户端的读取的逻辑来看,只要读取条件就绪,我们就会去读取数据。而这类概率事件,我们把时间线拉长,其实就是必然事件,所以延迟应答我们还是认为可以提高效率。

延迟应答可以提高网络吞吐量,本质就是给对方回复了一个更大的窗口。

但是有一个问题,即使我们回复了一个更大的窗口,可如果对方的网络拥塞窗口很小呢?那对方发送的数据量也可能不会受到我们回复的窗口的影响,而是取决于对方的拥塞窗口。但是网络拥塞也是一个概率事件,同时网络拥塞其实不是一个正常的现象,他一定是一个小概率时间,大部分情况下不会出现。同时就算拥塞窗口很小也没关系,我们要在保证网络不出现拥塞的情况下尽量一次传输更多的数据,这时候网络的情况并不算好,我们本身就不能发送太大的数据。

窗口越大,网络吞吐量就越大,传输效率就越高,我们的目标是在保证网络不拥塞的前提下,尽可能提高传输效率。

那么延迟应答该怎么做呢?

首先,每一个包都需要延迟应答吗?

不需要,因为延迟应答窗口变大毕竟还是概率事件,同时如果我们每一个包都延迟应答,可能会导致对方超时重传。 

所以延迟应答的报文有数量限制,一般是每隔N个包就应答一次,一般操作系统N取2.

同时延迟应答也有时间限制,超过最大延迟时间就对这个报文应答,一般是200ms

同时,延迟应答的延迟时间一定不能比超时重传的时间大。

延迟应答不会影响发送方吗?不会误认为丢包吗?

不会,因为延迟应答的这个报文的下一个报文的应答,会将该报文也一并应答了。

比如每隔两个报文延迟一个,第 1 个报文延迟应答了,序号是1000,而第二个报文的序号是2000,这时候接收方是会立马对第二个报文进行应答的,不会连续延迟应答,所以对第二个报文的确认序号是 2001 ,那么对方收到之后,就知道了 2001 之前的所有数据都收到了,那么后续这个延迟的应答就只有更新窗口的作用了。当然前提是这两个报文需要时间相近。

延迟应答也享受着确认序号的福利,确认序号默默扛下了所有。

捎带应答

捎带应答其实很简单,TCP是全双工的,双方任意时刻都可以发送数据(满足窗口条件)。

如果A发送了一个报文给B,那么B需要对其进行应答,如果B不想给A发数据的话,那么就发一个纯应答的报文就行了。而如果这时候B也刚好要给A发数据,那么这时候就可以在发送正常数据的时候,把ACK也置为1,并填充对应的确认序号。 也就是一个报文既携带数据,也有应答的作用

目前我们已经将TCP的各个字段讲的差不多了,但是选项字段我们却没怎么讲解,但是他在实际中却很重要,很多的重要的字段比如说我们前面说的计时器,保活机制,窗口扩大因子以及我们后续会讲到的MSS(在握手阶段交换)等等都在选项字段中体现,如果有兴趣可以在网上查阅资料了解。
 

面向字节流

TCP协议是否关心上层交给他的数据是什么类型有什么意义?以及这些数据中哪些是应用层协议的报头,那么是有效载荷?

他不关心也不需要关心。因为TCP是面向字节流的,在TCP看来,不管是什么样的数据,到了TCP这里,所有上层给的数据都是一个一个字节的二进制数据,至于数据到底是什么,这是对方的应用层该关心的,TCP只负责将数据完整高效的送到对方

我们在发送的时候,也就是交给TCP的时候,只要缓冲区还有空间,TCP就直接将其拷贝到缓冲区中,我们可以将一个报文一次交给TCP,也可以拆成多次交给TCP。

同时,未来接收方的TCP在收取的的时候,上层也只关心当前读到了多少数据,而不关心数据是怎么到的,也不会限制数据的读取方式。数据的类型的定义最终还是有应用层来做。

这种数据特点就叫做面向字节流。

那么有哪些情况不是字节流呢?最经典的就是udp报文。udp的报文则是我们发送了几次,或者说udp服务器收到了几个udp报文,那么上层就必须读取几次才能把这些报文读走。

粘包问题

由于TCP的报文中,没有和udp右移的报文长度的字段,但是有一个序号的机制,所以TCP客户端也是能将数据完整的交给接收缓冲区的,但是之后的读取和报文的边界的确定就是用户层做的了。

如果我们读取缓冲区的数据时,没有读好,或者说没有把一个报文完整读完,而滞留了一部分在缓冲区的开头,那么就会影响我们下一次的读取。这一点其实也很好理解,就拿我们前面自己定义的协议来说,如果我们从缓冲区读取一个报文的时候,没有读完,那么下一次读取的时候,由于我们是要先找到报头字段的,而要找报头字段是通过保温开头的特殊字段来找的,这时候,如果前面滞留了一些不应该出现的字段,就会导致我们无法正确读到报头,自然就无法读取一个完整报文了。

这个就叫粘包问题。

那么UDP会面临这个问题吗?不会,因为udp将数据交付给上层的时候,是以报文为单位交付的,数据的边界很明显。

那么如何解决字节流或者说TCP的粘包问题? 

解决粘包问题的核心思想还是要确定报文与报文之间的边界。

1 对于定长的报文,我们保证每次按照固定大小从缓冲区中拿数据

2 对于变长的报文,可以在报头位置约定一个表示总长度或者数据长度的字段(自描述字段),让我们知道报文的结束位置。

3 对于变长的报文,也可以在报文和报文之间使用明确的特殊字符来分隔(特殊字符),当然这就需要限定报文数据不能和分隔符冲突。

同时我们也能知道为什么TCP报文的报头中没有类似于报文长度的字段。因为它只需要确保能够将报头和有效载荷分离就行了,并不需要考虑给上层一个完整的报文,它是面向字节流的。

TCP连接异常情况

我们所谓的TCP的客户端和服务端无非就是两个进程,这一点我们还是要清楚的。

1 如果其中一个进程突然崩溃了,这时候会发生什么? 

进程崩溃了其实不会对TCP造成影响,因为操作系统会自动回收进程的资源,包括关闭文件描述符,同时操作系统也会正常完成四次挥手的工作来和对方断开连接,既然正常四次挥手,就业会有一段时间的TIME_WAIT状态。 所以说,其中一方崩溃其实就跟正常断开连接一模一样,因为断开连接只需要进程关闭文件描述符,其他的工作都是由操作系统完成的,就算进程无法安全释放文件描述符而退出了,操作系统也会自动释放掉这个文件描述符。

2 主机/操作系统关机或者重启。

其实这也和正常退出是一样的。 因为操作系统如果是正常关机或者重启的话,会先终止掉正在运行的进程,然后再关机,那么还是会主动释放掉文件描述符,完成四次挥手断开连接。

3 一方突然被拔网线/断电

这时候其实连接就单方面断开了,同时因为物理条件已经不具备了,所以异常的一方也无法告知对方。 但是也不需要担心,因为TCP有对应的保活机制,如果长时间不发报文,正常的一方会发生保活探测报文,如果检测到对方已经异常掉线了,那么会强制关闭连接。

TCP小结

可靠性机制: 检验和(保证数据完整性) ,序号(有序交付),确认号(支持应答和其他的策略),超时/丢包重传(三次重传失败强制断开连接),连接管理(三次握手/四次挥手,保活,RST等),流量控制(控制发送速度),滑动窗口(支持重传),拥塞控制(防止大量丢包)。

提高效率:流量控制,滑动窗口,拥塞窗口,延迟应答,捎带应答,快速重传(三次一样的确认号就重传)

基于TCP的应用层协议: HTTP,HTTPS,SSH,Telnet,FTP,SMTP

TCP和udp对比

TCP用于可靠传输的情景,比如文件传输,重要状态更新等

udp用于对高速传输和实时性要求比较高的通信,比如直播,广播,视频

如何用udp实现可靠性机制:

我们可以参考TCP的可靠性机制 。 

引入序号和确认序号以及应答机制,来保证数据的到达, 同时引入滑动窗口,超时重传等来支持丢包时的重传,以及 流量控制拥塞控制等来控制发送的速度,防止大量丢包。

TCP连接队列

这是为了填补一个学习网络套接字是埋下的坑,listen的第二个参数,backlog

在man手册中说的是 backlog 参数是用来定义我们的监听套接字底层的连接队列的长度。(实际的连接队列的长度是我们传的参数的值再加一)

为什么要有一个连接队列呢?连接队列有什么用?

众所周知,我们的计算机或者说分配给我们的服务进程的资源是有限的,那么最终能同时服务的客户端数也是有限制的,我们就简单理解为上层能创建的进程或者线程有限。那么连接数达到这个上限之后,再有其他客户端想要访问这个服务器,如果我们服务器直接拒绝连接的话,这当然可以,但是我们要考虑到一个情况,就是当我们上层服务完一个客户端之后,会断开连接释放资源,这时候就有资源再建立一个连接进行服务了,可是有可能在接下来一段时间没有新的连接到来,那么这份资源是不是就是空闲了,我们可以说是资源的浪费

但是如果我们在服务的客户端数量达到上限之后,再来新的连接请求的话,我们不直接拒绝,而是完成三次握手建立连接,然后将建立好的连接放在一个等待队列中进行等待。 那么当上层服务端一个客户端之后,由于底层等待队列中有已经建立好的连接,那么就可以将刚释放的资源立马利用起来。

这就是连接队列的意义,本质就是让我们有资源空闲下来的时候能够立马使用,提高资源利用率

但是我们也要考虑一个问题,就是这个等待队列我们能设置很长吗?

不能,不要忘了,维持连接就是维持对应的数据结构,这些数据结构也是要在内存中占据空间的,或者我们说这个连接队列本身就需要额外使用一些资源,如果我们把连接队列设置的过长的话,那么在连接队列后段的连接可能一时半会也轮不到,这时候也是一种资源的浪费,有这么多资源估计都可以再创建一些进程来服务了,得不偿失。

那么连接队列中的连接是如何被上层拿走的呢?

很简单,通过 accept 函数,在套接字讲解部分中,我们就已经明确了,accept 并不是建立连接,而是获取底层已经建立好的连接,从哪获取呢?就是从监听字的连接队列中。

tcp协议要为上层维护一个连接队列,这个队列即不能太长,也不能太短。队列的最终长度取决于listen的第二个参数。

我们把这个连接队列称之为全连接队列全连接就代表连接已经建立了,或者说三次握手已经完成

同时在前面三次握手的时候,我们也讲过一个半连接状态,因为三次握手的发起方在发起SYN的时候,就已经将对方所需的数据全部通过这个SYN报文传输过去了,如果对方的连接队列有空闲的话,这时候对方就会在发出SYN之后就开始为这个链接创建连接结构体,那么在收到第三次握手报文的时候,由于结构体已经创建好了,就要可以直接放入到全连接队列中。其实就相当于把等待第三个ACK的时间利用起来了。如果我们的连接队列都已经满了,服务器收到第三个ACK的时候先不受理,那么就阻塞在了SYN_RECV状态,也自然不会创建结构体,这时候就是处于半连接状态。或者我们也可以直接理解为三次握手已经开始却还会完成的状态就是半连接状态。 但是此时客户端由于发出了第三个握手报文,那么客户端的状态就是 ESTABLISHED 状态了。

如何验证这个连接队列的长度?

很简单,我们还是那前面的代码来测试,在start中我们不调用 accept 从底层获取链接,而是死循环休眠, 同时我们设置 连接队列的长度为一个较小的值,便于我们测试。

此时看似我们把四个客户端都打开了,但是其实最后一个打开的客户端并没有建立好连接,而是出于半连接的状态。

我们发现,其他三个连接都已经完全建立好了,双方都是 ESTABLISHED 状态,而第四个连接服务器则还处于 SYN_RECV状态,也就是收到SYN之后,但是还没有发出下一个报文。

而我们也发现了,在客户端看来,已经处于ESTABLISHED 状态了,那么就是客户端已经发出了第三个握手报文,但是服务器这时候由于连接队列已满,暂时不会受理这个ACK。这就叫做处于握手的中间状态,也叫做半连接状态。

我们过一段时间在来查看一下双方状态:

这时候我们就发现服务器已经把这个半连接强制关闭了,但是客户端还处于连接状态。 未来客户端发送数据的时候,就会收到 RST 重新进行三次握手建立连接,至于能不能成功,还是取决于服务器的状态。

TCP底层允许最多 backlog 个完整连接,后续来的连接就只能是半连接了,而半连接如果在一段时间内没有完成握手变为全连接吗,就会呗server 端关闭。

全连接队列我们也可以理解为TCP维护的一个缓冲区,用来存放连接,随时填补服务器上层服务完毕的空位。

版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com