传输层 --- TCP (下篇)

目录

1. 超时重传

1.1. 数据段丢包

1.2. 接收方发送的ACK丢包

1.3. 超时重传的超时时间如何设置

2. 流量控制

3. 滑动窗口

3.1. 初步理解滑动窗口

3.2. 滑动窗口的完善理解

3.3. 关于快重传的补充

3.4. 快重传和超时重传的区别

4. 拥塞控制

4.1. 拥塞控制的宏观认识

4.2. 慢启动机制

4.2.1. 慢启动的宏观认识

4.2.2. 慢启动如何实现的

5. 延迟应答

6. 捎带应答

7. 面向字节流

8. 数据包粘包问题

9. TCP 异常情况

10. TCP 总结

10.1. TCP的相关机制

10.2. 基于TCP应用层协议

10.3. UDP和TCP的差别

10.4. 如何用UDP实现可靠传输 (经典面试题)

10.5. TCP相关实验

10.5.1. 验证全连接队列长度 = listen 第二个参数 + 1


1. 超时重传

  • 当通信双方在交互数据时, 可能由于网络拥塞等原因,导致发送方发送的数据丢包;
  • 而我们知道,发送方是如何确定接收方收到了数据呢? 答案是,通过接收方的ACK确认应答;
  • 换言之,只要发送方收到了匹配的ACK应答,发送方就能确定接收方收到了数据;
  • 而如果发送方在特定时间范围内,没有收到接收方的应答,那么发送方就认为数据段丢包了,对方没有收到,因此进行重传数据段,而这种机制我们称之为超时重传。

一般而言,超时重传有两种情况, 其一,数据段自身丢包了;其二,接收方发送的ACK应答丢包了。 接下来,我们看看这两种情况:

1.1. 数据段丢包

数据段丢包格式如下:

客户端向服务端发送数据段,可能因为网络拥塞等原因,导致数据无法到达服务端,即发生数据段丢包。

如果客户端在一个特定时间间隔没有收到服务端发送的ACK确认应答,那么客户端就认为刚刚自己发送的数据段,服务端没收到,因此,会进行重发,而这就是超时重传。

1.2. 接收方发送的ACK丢包

上面的情况,是属于客户端发送的数据段丢包,导致客户端进行超时重传。

也有这样的可能, 客户端发送的数据段,服务端收到了,并进行ACK确认应答,可是这个ACK应答确丢包了,导致客户端在一个特定时间间隔内没有收到服务端的确认应答,因此,客户端也认为对方没有收到我的数据段,触发超时重传。

过程如下:

针对于上面两种情况 (发送方数据段丢包和接收方的ACK确认应答丢包),发送方能判别出来吗? 答案是不能,其次这件事也没有意义,发送方根本不关心。

只要发送方发出数据段后,在特定时间间隔内没有收到对方的ACK确认应答,那么它就认为,对方没有收到我的数据段,因此进行重发,故称之为超时重传。

针对于第二种情况,即接收方的ACK确认应答丢包,发送方进行超时重传,会导致接收方收到两份相同的数据段,因为对于客户端发送的第一次数据段,接收方是收到了的,但是我们不用担心,因为TCP数据段中有序号字段,当接收方收到了重复数据段,接收方可以根据序号进行去重,这也是保证可靠性的一种。

1.3. 超时重传的超时时间如何设置

超时时间太短的情景,具体如下:

发送方发送数据,接收方也受到了,并且接收方进行了ACK应答,此时这个ACK应答也没有丢包,但是由于超时时间太短,发送方又发送了该数据段。

因此,如果这个超时时间太短,可能导致发送方以较高概率的发送重复数据段,进而导致接收方会收到较高概率的重复数据段 (这实际上就是通信效率低效的表现),甚至,大量的重复数据段也会导致网络负担增加,进而可能出现网络拥塞的情况。

超时时间太长的情景,具体如下:

发送方发送数据,数据段直接丢包了, 但是由于超时时间太长,发送方等了很久都没有重传数据,导致服务端长时间没有收到数据,因此,导致处理数据效率降低,进而导致整体通信效率降低。

因此,我们认为,超时时间不能太长,也不能太短。

那么超时时间能固定吗?

答案是:不能,因为数据段丢包有相当一部分原因是网络的问题,即如果网络好的时候,我们的超时时间就应该设置的短一点;如果网络很差,那么超时时间就应该长一点。

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

那么超时时间如何确定? 

  1. Linux中,超时以500ms为一个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍;
  2. 如果重发一次,仍旧得不到应答,随后,就会等待 2 * 500ms 后在进行重传;
  3. 如果仍旧得不到应答,就会等待 4 * 500ms 进行重传, 以此类推;
  4. 累积到一定的重传次数, TCP就认为网络或者对端主机出现异常,强制关闭连接。

2. 流量控制

接收方处理数据的速率是有限的, 如果发送方发送数据太快, 导致接收方接收缓冲区被写满,这个时候如果发送方继续发送数据,那么就会造成丢包,继而引起重传等等一系列连锁反应。

因此TCP为了支持可靠传输,可以让发送方根据接收方的接受能力 (接收缓冲区的剩余大小) 动态调整自身发送数据的速率,而这种机制,我们称之为流量控制。

流量控制如何实现的呢?

  1. 接收方可以将自己的接收缓冲区的剩余大小填充到TCP报头中的 "16位窗口大小" 字段中,通过ACK应答通知发送端;
  2. 窗口越大,说明我的接受能力就越强,一般情况下,网络吞吐量越高,传输效率越快,但并不是绝对的 (受拥塞窗口的影响);
  3. 接收方一旦发现自己的接收缓冲区可用空间不足,就会将窗口大小设置成一个更小的值通知给发送端;
  4. 发送端收到这个窗口之后,就会减慢自己的发送速率;
  5. 如果接收方的接收缓冲区满了 (接受能力为0),此时就会将窗口置为0并通知发送端,发送端收到后,就不会再发送 (用户) 数据,但是需要定期发送一个窗口探测数据段,以便于接收端可以尽快将更新后的窗口大小告诉给发送端;
  6. 对于接收端而言,如果窗口发生变化,也会选择时机向发送方发送窗口更新通知,及时告诉发送方自己的接受能力,以提高通信效率。

流量控制是两个方向的流量控制。 如下理解:

TCP是全双工的,代表着通信的任何一方,既可以进行收数据,也可以进行发数据,且我们知道, 任何一方都是有接收缓冲区和发送缓冲区的。

假设主机A和主机B通过TCP通信:

  • 站在主机A视角,A向B主机发数据,那么A就需要通过B主机的接受能力  (窗口大小) 来动态调整自身发送数据的速率;
  • 站在主机B视角,B向A主机发数据,B主机也需要通过A主机的接受能力 (窗口大小) 来动态调整自身发送数据的速率;

总而言之,TCP是全双工的,且通信双方都有发送缓冲区和接收缓冲区,因此流量控制是两个方向的流量控制,通过双向的流量控制,通信双方可以动态调整自身发送数据速率。

现在有一个问题, 当通信双方第一次正式数据通信时,发送方知道接收方的接受能力吗? 即如果第一次发送方发送的数据就超过了接收方的接受能力,导致数据丢包,这种情况存在吗?

答案是:不存在,因为在双方通信之前,需要建立连接,也就是三次握手的过程,而在三次握手期间,通信双方会互传数据段,在这个过程中,双方就可以知道对方的接受能力 (接收缓冲区的剩余大小),因此,在正式通信时,发送方就可以根据对方的接受能力,调整自己发送数据的速率,也就不会出现第一次发送的数据就超过了接收方的接受能力这种情况。

从这里我们也可以看出,三次握手的必要性,因为它可以在正式通信之前,互相了解对方相关信息 (例如对方的接受能力),以便进行有效的数据传输。

当接收方的接收缓冲区剩余空间为0,此时接收方就会将窗口置为0并通知发送端,发送端就会停止发送数据。

  • 如果接收方窗口得到了更新,那么会自动向发送方发送一个窗口更新通知,告知发送方此时接收方的接受能力,以便于发送方尽快传输数据;
  • 当发送方等待了一段时间 (超时重发的时间) 没有收到接收方窗口更新通知,那么发送方就会向接收方发送一个窗口探测包 (这个数据段不包含正式通信数据),让接收方确认应答 (用以告知发送方,接收方的窗口大小是多少);

在一般情况下,当接收方的接受能力为0时,上面两种方案是同时存在的。

我们知道,TCP报头中的窗口大小是一个16位整数,那么它的最大值也就65535字节,即TCP 窗口最大就是65535字节吗?

实际上,TCP报头中的选项中还包含一个窗口扩大因子M,实际窗口大小是TCP中窗口字段的值左移 M 位。

3. 滑动窗口

TCP被广为所知的更多是可靠性,比如TCP为了保证网络传输可靠,有序号和确认序号、校验和、确认应答、按序到达、去重机制、连接管理、超时重传、流量控制等等,但事实上, TCP也为了传输效率进行过努力,体现上就是滑动窗口 (当然也有其他策略)。

滑动窗口的主要目的: 尽可能地提高网络传输的效率。

现在,我们已经对确认应答机制有一个清晰的认识,比如发送方发送一个数据段,接收方收到后,就会对其进行ACK确认应答, 发送方收到ACK后 (确认对方收到了),再发送下一个数据段。

例如:

上面这种一发一收的方案 (是严格的确认应答机制),没有错,但是性能比较低,因为在这种情况下,发送方和接收方的通信是串行执行的,即发送一次数据段,等待确认后再发送下一个数据段,网络传输效率就比较低。

因此,为了提高网络传输效率,发送方可以一次性发送多个数据段,并且不需要等待每个数据段的确认就可以继续发送下一个数据段,将多个数据段的等待时间 (例如网络IO的时间,等待对方应答的时间等等)  重叠在一起,进而提高网络传输效率。

由于确认应答机制的存在,理论上,发送的每一个数据段要有相应的应答,但在实际网络传输中,允许一些确认应答丢失,只要在整体上,能够告诉发送方,它发送的数据,接收方全部收到了即可。

并且,我们知道, TCP是有流量控制的,换言之,发送方是需要根据对方的接受能力动态调整发送数据的速率,因此尽管可以一次性发送多个数据段,但是,也必须在接收方的接受能力范围之内 (这是目前的认识) 。

3.1. 初步理解滑动窗口

如下图所示:

可以看到, 滑动窗口本质上就是一个区间,或者范围这段区间 (范围) 代表着发送方当前一次性可以发送数据的最大值。注意,这个最大值不仅受到接收方的接受能力影响,还受到拥塞窗口的影响 (拥塞窗口后面谈)。

滑动窗口在哪里呢?

答案是: 滑动窗口在自己的发送缓冲区中, 属于自己的发送缓冲区的一部分。

滑动窗口的目的:

  • 提高效率: 通过允许发送方发送多个数据段而不需要等待每个数据段的确认应答,进而提高网络的利用率和传输效率。发送方可以在一个窗口内发送多个数据段,而无需等待每个数据段的确认,从而减少了通信的延迟,提高传输效率。
  • 保证接收方有能力接收: 同时,滑动窗口也要保证接收方有能力接收这些数据。通过动态调整滑动窗口的大小,根据接收方的接受能力和网络拥塞情况,发送方可以确保发送的数据量不会超出接收方的处理范围 (以及网络的接受范围,后续谈) ,从而避免了数据丢失和网络拥塞的问题。

3.2. 滑动窗口的完善理解

我们将发送缓冲区想象成一个字符数组,上层 send 时将数据拷贝到这个数组中。

滑动窗口的本质: 可以视为一个指针或数组下标,它指示了发送缓冲区中哪些数据可以被发送。

如何描述这个滑动窗口呢?

例如: int win_start,win_end;win_start 表示滑动窗口的起始位置,而 win_end 表示滑动窗口的结束位置。

那么如何实例化这个滑动窗口呢?

例如: win_start = 0; win_end = win_start + 最小值(接收方的接受能力, 拥塞窗口大小);从而确保发送的数据量不会超出接收方的处理能力和网络的拥塞程度。

滑动窗口的移动和变化,实质上就是对数组下标进行操作

比如滑动窗口右移,就是增加 win_start 和 win_end 的值,从而将滑动窗口向右移动;

滑动窗口变化则可能是根据接收方的接受能力和网络情况动态调整滑动窗口的大小,以适应当前的通信环境。

滑动窗口必须向右移动吗?

不一定,滑动窗口是一段范围,这段范围代表着发送方发送数据的最大值 (这个最大值是接收方的接受能力和拥塞窗口的较小值),这个范围是动态变化的,受接收方的接受能力和网络影响。

  • 如果接收方上层一直不取数据,导致接收方的接收缓冲区的剩余空间越来越小,接受能力越来越弱,那么这个滑动窗口的大小就会变小,因此可能会左移 (win_end -= 特定值) ;
  • 甚至,即使接收方的接收能力很好,但由于网络非常拥塞,也会导致滑动窗口的大小变小,因为发送方需要降低发送速率以减缓网络拥塞的程度,在这种情况下,也会出现左移的场景 (win_end -= 特定值)。

因此,这里的滑动窗口是动态变化的,受到接收方的接受能力以及网络状况的影响。

滑动窗口可以为0吗?

当然可以为0,如果你理解了滑动窗口代表着发送方发送数据的最大值 (这个最大值是接收方的接受能力和拥塞窗口的较小值),你就可以解决上面的这个问题。

  1. 设想一下,如果接受方的接收缓冲区的剩余大小为0,那么你发送方发送数据的最大值是多少呢?
  2. 很明显,是0,因为对方已经没有能力接受新的数据了,在发,只会导致数据段丢包,因此,此时的滑动窗口就是0,即win_start == win_end;

在这种情况下,发送方不应该发送任何正式通信数据,以避免数据丢失。

滑动窗口更新

当发送方发送一个数据段,接收方收到后,并返回一个ACK确认应答,那么此时发送端的滑动窗口如何更新呢?

很简单,ACK确认应答中不是有确认序号吗? 这个确认序号代表着,之前发送方发送的数据我都收到了,下次从我这个确认序号的位置开始发数据,因此, win_start = 收到的应答中的确认序号;win_end = win_start + 特定值 (接收方的接受能力和拥塞窗口的较小值);

上面这个很好理解,但如果没有收到开始发送的数据段的应答 (丢包),而收到了中间的数据段的应答,影响吗?滑动窗口如何更新?

答案是:不影响,假如发送方发送了 1 ~ 1000,1001 ~ 2000, 2001 ~ 3000 这三个数据段,接收方收到了这三个数据段,并进行确认应答,但是1 ~ 1000 和 1001 ~ 2000的确认应答丢失了,但是发送方收到了 2001 ~ 3000的确认应答 (确认序号3001),那么发送方可以肯定,接收方一定收到了这三个数据段,故win_start = 确认序号 (在这里具体为3001);win_end = win_start + 特定值;

因此,我们要永远记住确认序号的意义:它代表着确认序号之前的数据,接收方已经收到了,发送方下次发送数据的时候,应该从确认序号这个位置开始发送。

上面是ACK确认应答丢失,那么如果数据段丢包了呢?此时滑动窗口如何更新?

假如客户端向服务端发送了三个数据段,分别是1 - 1000,1001 - 2000, 2001 - 3000,但是由于网络拥塞的原因,1001 - 2000 的数据段丢包了,但是剩下的两个数据段服务端收到了,那么此时,服务端该如何确认应答呢?

注意: 尽管此时服务端收到了 1 - 1000 和 2001 - 3000 这两个数据段,但是由于 1001 - 2000 这个数据段丢包了,因此在确认应答时,数据段中的确认序号不能填充3001,而只能填充1001。

因此此时对于客户端而言,它收到的确认应答数据段中的确认序号只能是1001,即1 - 1000的数据可以在客户端的发送缓冲区释放了,但是,1001 - 2000 和 2001 - 3000 这两个数据段不能被释放,必须依旧在发送缓冲区中,等待触发超时重传。

而此时的客户端中的滑动窗口如何更新呢?

因为它收到的确认应答中的确认序号只能是1001,故 win_start = 确认序号 (在这里具体为1001),win_end = win_start + 特定值;

超时重传,背后的含义就是没有收到确认应答的时候,数据必须被暂时保存起来。

超时重传,代表的就是发送方已经发了某个数据段,在经过特定时间后,发送方没有收到对方的应答,导致发送方认为,对方没有收到这个数据段,因此,需要重传这个数据段。

可是,发送方为什么能够重传这个数据呢?

是因为,发送方的发送缓冲区中还有这部分数据,其换言之,发送缓冲区的数据只有在收到相应的确认应答之后,才会被移除或者覆盖;

而如果没有收到应答,即使这个数据已经发出去了,也不能进行移除或者覆盖,因为还有可能要进行超时重传。

因此,我们发现滑动窗口更新策略其实很简单,它只根据收到的确认应答中的确认序号进行更新 win_start,再通过 win_start 和接收方的接受能力以及网络状况更新win_end

因为确认序号代表的是,确认序号之前的数据我已经收到了,你不用再把这些数据划分到滑动窗口的范围内,即这些数据可以再发送缓冲区中移除或者覆盖了,下次发送数据的时候,应该从我这个确认序号的位置开始发送,即更新滑动窗口。

对应到操作就是,win_start = 确认序号;win_end = win_start + 特定值。

同时,我们也可以看到,即使传输数据中,发生了数据丢包或者ACK确认应答丢包,都不影响,因为确认序号的存在,可以让发送方判定对方是否收到了数据,收到了哪一个数据。

我们知道了滑动窗口是如何变化的,那么滑动窗口如果一直向右滑动,存在越界问题吗

不存在,实际上,TCP的发送缓冲区通常是一个环形结构 (通过线性结构模拟出环形结构),这意味着当滑动窗口向右滑动时,如果到达发送缓冲区的末尾,它将会绕回到发送缓冲区的开头 (无非就是取模运算),形成一个环形的发送缓冲区。

通过这种环形结构,即使滑动窗口一直向右滑动,它也不会导致越界问题。相反,滑动窗口会在环形结构内循环滑动,确保了数据的连续传输和可靠性。

3.3. 关于快重传的补充

有时候,我们也会遇到下面的这种情况:

主机A和主机B在网络通信时:

  1. 主机A依次向主机B发送 1 - 1000、1001 - 2000、2001 - 3000、3001 - 4000、4001 - 5000、5001 - 6000、6001 - 7000 的数据段。
  2. 在发送1 - 1000时,主机B成功收到,并返回 "1001" 的确认应答;
  3. 但是主机A在发送 1001 - 2000 时, 这个数据段丢包了。
  4. 接下来,主机A就继续发送数据段,从2001 - 3000开始,并且后续数据段没有丢包,全被主机B收到,并存放到接收端的接收缓冲区中。
  5. 但是,因为 1001 - 2000 这个数据段丢包,在接下来主机B的确认应答中的确认序号都是 1001,代表着 1 - 1000的数据它收到了。
  6. 此时,就好像是在告诉发送端 (主机A),别发其他的数据段了,我要1001 - 2000的数据段。
  7. 因此,主机A在收到多个相同的确认序号  (1001) 后,意识到,1001 - 2000的数据段丢包了,主机B需要 1001 - 2000的数据段,因此向主机B重传 1001 - 2000 的数据段。
  8. 主机B收到后,向主机A确认应答,确认序号为7001, 代表着已经收到了 1 - 7000字节的数据。

当发送端在发送数据时,如果收到连续三次同样的确认应答 (在超时重传的特定时间范围内),例如上面的 "1001",那么发送端就意识到 1001 - 2000的数据段丢包了,因此立即进行重发 (而不是等待超时时间后在进行重传),而这种机制,我们称之为高速重发控制,也称之为快重传。

因为滑动窗口机制的存在,可以让发送端一次性发送大量的数据段,当发送的某个数据段丢包时,可以基于其他数据段的确认应答判断是哪一个数据段丢包。

具体就是,当发送端收到三次连续相同的ACK确认应答,进而让发送端可以快速确认是哪一个数据段丢包,进而可以立即重发,这种机制,我们就称之为快重传机制。

3.4. 快重传和超时重传的区别

快重传和超时重传的触发条件不一致。

  • 超时重传: 当发出的数据段在特定时间内没有收到对应的确认应答,那么发送方认为对方没有收到该数据段,故进行重发数据段;
  • 快重传: 在超时时间范围内,如果发送方收到了连续三次相同的确认应答,那么发送方就可以确定是哪一个数据段丢包,立即进行重发数据段,而不会等待超时。
  • 超时重传侧重于传输可靠性,快重传侧重于传输效率。

因此,这两个重传方案是互相协作的,而并不冲突。超时重传是在超时后才触发,而快速重传则是基于连续重复的确认应答触发。它们的主要目的都是确保数据能够可靠地传输到接收端,只不过快重传更侧重于传输效率。

我们发现,确认序号的设计是TCP协议中的一个重要机制,承载了诸多功能和特性的实现。

确认序号的主要作用是确保数据的可靠传输。通过确认序号,接收端可以告知发送端,哪些数据已经成功接收,从而发送端可以根据这些信息做出相应的调整,保证数据的完整性和顺序性。

确认序号是许多TCP机制的基础字段,包括但不限于:

  • 确认应答:接收方向发送方发送确认序号,告知已经成功接收到哪些数据段;
  • 超时重传:发送方通过确认序号判断哪些数据段未收到确认应答,从而进行重传;
  • 快速重传:通过连续收到的确认应答判断丢失的数据段,进行快速重传,加快恢复速度;
  • 滑动窗口:发送端利用确认序号来动态调整滑动窗口大小,实现流量控制和数据流的顺序传输。

4. 拥塞控制

4.1. 拥塞控制的宏观认识

我们现在已将TCP的很多机制都学了,例如:序号机制、确认应答、按序到达、去重、连接管理、超时重传、快重传、流量控制、滑动窗口,我们发现,上面的诸多机制都是解决端与端传输可靠性和效率问题,即不同主机之间的通信问题,比如流量控制,其解决的是发送方需要根据接受方的接受能力来决定自身的发送数据的速率。

但是我们发现,在进行网络通信时,数据是需要经过网络传输的,因此,网络问题我们也必须要考虑,而拥塞控制就是根据网络状况决定如何传输的问题。

那么现在问题来了, 如何判别数据丢包是因为通信双方 (端与端) 的问题,还是网络问题呢?

很简单,通过丢包的比率来判别。

例如,双方进行通信时:

  • 如果只有少量丢包,那大概率就是通信双方的问题,此时重传数据段就好了;
  • 如果有大量丢包, 那大概率就是网络问题了,比如网络拥塞。

既然是网络拥塞导致数据段大量丢包,那么此时还是进行数据重传吗?

  • 记住, 绝对不能在重传了,原因如下:
  • 其一,因为此时网络已经拥塞了,即使进行重传, 数据也无法传输到对方;
  • 其二,当网络已经十分拥塞,那么会带来大量丢包,如果此时采用重传策略,那么就会大量的重传,此时只会加重网络拥塞的程度,甚至导致网络瘫痪。

有人会有这样的想法,如果网络拥塞了,我一台主机进行重传,数据量也不大啊,网络这么大,能容纳如此多的数据,我如果重传,那这些数据也是杯水车薪吧,对网络也不会造成太大负担。

  • 上面的这个思路是非常局限的,对于网络拥塞,我们不能将视角仅仅局限于通信的两台主机,网络是由大量的主机和设备组成的复杂系统,而且在实际应用中通常会有大量的数据传输;
  • 因此对于网络拥塞,我们需要有一个宏观视角, 在这个网络通信的主机有非常多, 如果网络拥塞了, 每台主机采用都是TCP/IP协议,如果此时的策略是重传,那么就会导致大量的重传数据在网络中传输,也就会加重网络拥塞的程度 (雪上加霜),甚至导致网络瘫痪。

既然不能使用重传,那么该如何解决这种问题呢? 因此我们需要引入慢启动机制。

4.2. 慢启动机制

4.2.1. 慢启动的宏观认识

虽然 TCP 有滑动窗口机制,能够高效可靠的发送大量的数据,但如果一开始就发送大量的数据,是可能引发问题的。

因为网络上有很多的计算机,可能当前的网络状态就已经十分拥塞,在不清楚当前网络状态下,发送方如果贸然发送大量数据,很有可能是导致网络瘫痪的最后一根稻草 (因为可不止一台主机发数据哦)。

  1. 因此人们发现,在双方主机通信时,接收方的接受能力固然重要,是发送方发送数据的关键因素,但是,网络状况也是影响发送方发送数据的关键因素;
  2. 即使对方的接受能力非常强,如果网络十分糟糕,也不能发送大量的数据,需要考虑网络状态;
  3. 可是如何考虑呢?
  4. 因为,TCP协议作为软件层面的通信协议,无法直接解决网络拥塞等硬件问题,因此在发送数据时需要采取一种试探性的机制来适应网络状况;
  5. 热启动机制就是其中一种常见的方法;

热启动机制通过在发送数据时先发送少量数据,然后根据网络的反馈动态调整发送数据量的方式,来试探网络的可用带宽和延迟情况。

  • 如果网络应答良好,发送方就逐渐增加发送数据的量;
  • 如果网络出现拥塞或延迟较高,发送方就适当减少发送数据的量 (甚至可以等待一会再发送),以避免进一步加剧网络负载或导致数据丢失。

我们可以通过下图,来感性认识一下热启动机制:

发送方在发送数据时, 第一次发送一个数据段,如果收到应答了,那么下次发送两个数据段,如果又收到应答了,那么下次发送四个数据段,如果收到应到了,后续过程,以此类推。

这样发送方就可以根据网络状态,动态调整自己发送数据的速率,避免在网络状态十分糟糕时,一次性发送大量的数据,导致大量丢包且网络更加拥塞,甚至网络瘫痪。

可是又有人疑惑了,就算网络此时拥塞了,我一台主机发送少量数据进行试探,也不能减缓网络拥塞的程度啊。

  • 对于网络拥塞,我们不能将视角仅仅局限于一台主机或者通信双方,而应该有一个宏观视角;
  • 在这个网络通信的主机有非常多,而TCP/IP协议栈是整个网络中的通用协议,因此网络中的所有主机都会采取类似慢启动的策略;
  • 发送方会发送少量数据进行试探性探测,如果没有收到应答或者收到了拥塞通知,发送方会暂时停止发送数据或者降低发送速率。这样一来,网络中的所有主机都会降低数据发送速率,从而减轻网络拥塞的程度,这是其一;
  • 此外,发送方暂停发送数据或者降低发送速率的过程也为网络提供了缓解拥塞的机会。当发送方暂停发送数据或者降低发送速率时,网络中的路由器和交换机有更多的时间来处理数据包,从而减少了数据包的丢失和网络拥塞的程度,这是其二。

因此,通过拥塞控制算法,整个网络可以在一定程度上自我调节,以应对拥塞情况,提高网络的稳定性和可靠性。

4.2.2. 慢启动如何实现的

首先,我们需要引入一个概念:拥塞窗口。

拥塞窗口:单台主机一次性向网络发送大量数据时,可能引发网络拥塞的上限值。 是一个数字,衡量的是网络能接受发送方发送数据的最大值。

  • 拥塞窗口是一个动态值,其大小取决于网络拥塞的程度。具体讲,当网络拥塞程度较低时,拥塞窗口可以增大,允许发送方发送更多的数据;当网络拥塞程度增加时,拥塞窗口会减小,以减缓数据传输速率,从而避免进一步加剧网络拥塞;
  • 因此,发送方发送数据时,不仅要考虑接收方的接受能力,还要考虑网络的状态 (即拥塞窗口的大小),因此,发送方滑动窗口的大小 = 拥塞窗口和接收方的接受能力的较小值。

我们可以举例理解拥塞窗口。

发送方将拥塞窗口的起始值设置为一个较小的值,通常为1个数据段的大小;

  1. 初始时,发送方的拥塞窗口为1个数据段;

  2. 发送方发送1个数据段,并等待接收方的确认;

  3. 接收方收到数据后,发送确认应答,发送方收到确认后,将拥塞窗口大小增加到2个数据段;

  4. 发送方接着发送2个数据段,并等待接收方的确认;

  5. 接收方收到数据后,发送确认应答,发送方收到确认后,将拥塞窗口大小增加到4个数据段;

  6. 以此类推,拥塞窗口随着每次成功接收到确认而增加;

  7. 如果发送方在某个阶段数据段大量丢包,或者收到了拥塞通知,那么发送方认为网络出现了拥塞,将拥塞窗口大小重置为一个较小的值(通常是初始值),即1个数据段。

可是我们发现一个问题,上面的拥塞窗口大小是以指数级别增长的 (1->2->4),可我们都知道,指数增长有一个特点:指数爆炸,这样增长下去,是有问题的。比如:

  • 当拥塞窗口大小增长得过快时,可能会超过网络的容量,导致网络拥塞;
  • 过快的增长可能会使得某些连接占据了过多的网络资源,导致其他连接无法获得足够的资源,造成网络使用的不公平性。

因此,我们所要的慢启动,不是一直持续使用指数增长,这不合理。我们想要的慢启动是:初始值小,增长的快,但后续过程,比如在特定时机后,就不要增长的太快,缓慢增长即可。

因此为了不让拥塞窗口一直以指数级别增长,我们在此处需要引入一个慢启动的阈值,当拥塞窗口超过这个阈值后 (在特定时机),发送方不在按照指数级别增长 (慢开始阶段) ,而会转变为线性增长 (拥塞避免阶段)。

现在有一个问题,为什么慢启动机制,需要使用指数级别增长呢?

首先,我们知道,指数增长有个特点:前期值很小,增长慢;中后期值很大,增长快。

当网络拥塞时:

  • 前期值很小,增长满:因为此时拥塞窗口的值很小,可以进行试探网络状态,并且不会给网络太大压力,可以让网络有时间自我调节;
  • 中后期值很大,增长快:当网络恢复后,因为此时拥塞窗口的值很大,增长得也快,就可以尽快的提高通信双方的效率。                   

因此,之所以用指数增长,有两个目的:

  • 想解决网络拥塞问题;
  • 尽快恢复双方通信,提高效率;

  • 当TCP开始启动时,慢启动阈值通常被设置为一个相对较大的默认值;
  • 当网络拥塞后,慢启动阈值会变为上一次拥塞窗口最大值的一半,同时,拥塞窗口置为1;
  • 当双方在通信时,如果数据段少量丢包,此时触发超时重传即可;
  • 如果发生大量的数据段丢包,那么发送方就认为此时网络拥塞了;
  • 拥塞控制是既想将数据尽快的交付于对方 (提高效率),又要避免给网络造成太大压力 (网络拥塞) 的折中方案;

5. 延迟应答

通信双方在通信时,发送方发送数据,接收方收到后,会进行确认应答,其中就包含接收方的接受能力,发送方根据这个接受能力动态调整自身发送数据的速率 (当然也要考虑拥塞窗口)。

假设网络状态十分良好,暂不考虑拥塞窗口,发送方发送数据,如果接收方收到后,立即应答,那么确认中的窗口大小必然是比较小的。

如何理解呢?

接收方收到数据后,并不是把这个数据一直存放在接收缓冲区中,而是等待上层将数据提取走,因此,当发送方发送数据时,接受方收到后,并不立即进行应答,而是等待一段时间,在这段时间中,上层很有可能就将接收缓冲区的数据提取走,那么此时,接收方在进行应答,这时的接受能力 (接收缓冲区的剩余大小) 就更强了,发送方就可以提高自己发送数据的速率 (发送方的滑动窗口就越大,传续效率就更高),而这就是延迟应答的思路。

延迟应答:当接收方收到数据后,并不立即进行应答,会等一会 (在这个过程中,等待上层将数据提取走),应答中的窗口就会更大,发送方就可以提高发送数据的速率,进而提高网络传输效率。

很显然,延迟应答是为了提高网络传输效率的。

但要注意,并不是所有的数据段都会延迟应答;

一般情况下,会有数量限制和时间限制:

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

具体的数量和超时时间,不同操作系统也有差异;一般N取2,超时时间取200ms;

6. 捎带应答

望文生义,捎带应答就是,允许在发送数据的同时,将确认信息(ACK)携带在同一个数据段中。

  • 在双方通信时,当双方都要向对方发数据,那么如果一方发数据,另一方收到后先进行应答,再发数据,这在某些情况下是多此一举的,此时在网络中就会有大量的只包含确认的数据段,这实际上是效率低下的一种表现。
  • 因此,此时双方通信时,在传递数据的同时也可以包含着ACK标志位,即将数据和应答组成一个数据段,发送给对方,这样可以减少网络上的额外数据段,提高网络传输效率。
  • 需要注意的是,捎带应答的实现可能会受到一些限制,比如在某些情况下,可能无法立即捎带应答,比如等待数据就绪,此时可能需要先进行应答。

可以看到,捎带应答也是为了提高网络效率的一种机制。

7. 面向字节流

当通信双方使用TCP协议通信时,内核会为双方各自创建一个发送缓冲区和接收缓冲区。

  1. 当上层用户调用 write/send 时,本质是将数据拷贝到了发送方的发送缓冲区中;
  2. 如果发送的字节数太长,TCP会将其拆分成多个数据段发出;
  3. 如果发送的字节数太短,这些数据会先存放在发送缓冲区中,等待发送缓冲区长度一定时,或者其他的时机发送给对方;
  4. 接受数据的时候,数据是从网卡驱动程序到达接收缓冲区;
  5. 此时上层的应用程序就可以调用 read/recv 将接收缓冲区的数据拷贝到上层;
  6. 同时,作为通信的一方,既有发送缓冲区也有接收缓冲区,换言之,既可以发数据,又可以收数据,体现了全双工。

由于发送缓冲区和接收缓冲区的存在,上层进行读写不需要一一匹配,体现了流式特征,例如:

  • 当发送方发送100个字节的数据,上层用户可以调用一次 write,发送这100个字节的数据;也可以调用100次 write,每次发送1个字节;
  • 而接收方在读取这个数据时,上层用户也可以用一次 read 读取这100个字节的数据,也可以调多次 read 读取这100个字节的数据。 

换言之,上层用户的读和写不需要一一匹配,接收方只需要将发送方发出的数据,全部读取到即可。

而TCP在传输这些数据时,不会关心这些数据上层是怎么处理的,你是调了一次write,还是多次write,TCP根本不关心,TCP只需要将上层用户拷贝到发送缓冲区的数据可靠地传输给对方就好了,至于发送的数据的边界是什么?如何解释这些数据?这些问题,不是我TCP需要关心的,而是你应用层得到这些字节流数据后,自行区分边界,自行解释这些数据,这种数据传输方式我们就称之为面向字节流。

面向字节流是一种数据传输的方式,它将数据视为连续的字节流,这些数据没有固定的边界,TCP只关注数据的连续性,而不关心数据在传输过程中的具体划分和如何解释等问题。

而与面向字节流相对应的就是面向数据报。

面向数据报是一种数据传输方式,它将数据划分成独立的数据报,每个数据报都包含了足够的信息来独立传输和处理。

面向数据报,如何理解呢?

上层用户将数据给了UDP协议,UDP就必须按照用户发送数据的规则,即用户是怎么发的,我就怎么传输给对端,对端就可以得到用户发送的数据。

体现在应用层就是,上层用户调一次 sendto,对端用户就必须调一次 recvfrom 接受这些数据,因为这些数据是被看成一个整体数据报的。故UDP是面向数据报的通信协议。

扩展一下,在以前学习文件时,为什么我们说文件流呢?

上层用户调用特定接口将数据写入文件,本质是写入了内核缓冲区中,此时上层用户的工作就结束了。

而在操作系统看来,它并不关心这些数据的边界是什么?以及如何被解释的问题?它只负责将内核缓冲区的数据按照特定策略 (怎么写、写多少,操作系统关心) 写入磁盘文件即可,至于这些数据如何被区分和被解释的问题是上层用户需要处理的事情,与我操作系统无关。

因此,这种数据传输方式被称为面向字节流,因为数据被视为一串连续的字节流进行传输和处理,而并不关心这些数据的具体细节。

同时,我们也学习过管道,也说过,管道是面向字节流的。

当两个进程在使用管道通信时,发出数据的一方可能连续发送了10次数据,接收方可能一次就将这10次数据全读上来了 (只要用户缓冲区足够),因为站在管道的视角,我根本就不关心这些数据是什么?也不关心数据边界和如何被解释的问题? 我只需要将这一连串的字节流从一方传输给另一方就好了,其他我不关心,因此我们说管道是面向字节流的。

以上就是我们对面向字节流的理解。 

8. 数据包粘包问题

因为TCP是面向字节流的,故在实际传输中,存在着粘包问题。

  • 首先,明确一点,这里的包是指的数据包,即应用层的数据包;
  • 在TCP的协议报头中,是没有类似UDP中的报文长度或者HTTP中的有效载荷长度,它没有,但是TCP是有序号字段的;
  • 站在传输层角度,TCP的数据段是一个一个传输的,接收端可以通过数据段中的序号排序,并存放在接收缓冲区中;
  • 因此当用户在上层调用 recv/read 将接收缓冲区的数据拷贝到上层,得到的就是连续的字节流数据;
  • 应用层由于得到的是一连串的字节流数据,因此就需要判断这些字节流数据的边界,用以区分不同完整的数据包 (对方应用层发送的数据包);

故现在的问题就是,应用层如何区分不同数据包的边界,三个方案,具体如下:

  • 对于定长的用户层数据包,保证每次都按固定大小读取即可。例如一些结构化数据,因为是固定大小的,那么就从缓冲区从头开始按 sizeof(结构化数据) 依次读取即可;
  • 对于变长的用户层数据包,可以在数据包的头部位置,约定一个数据包总长度的字段,从而就知道了数据包的整体大小,即可以区分不同的数据包; 
  • 对于变长的用户层数据包,还可以在数据包和数据包之间使用明确的分隔符。当然需要保证这个分隔符不会与正文冲突,比如使用 "\r\n",当读到一个 "\r\n" ,代表着读取完一个数据包;

对于UDP协议而言,存在粘包问题吗?

不存在,因为UDP采用的是定长报头 (8字节) + 16位UDP报文长度。

  • 站在传输层角度,通过定长报头和UDP报文长度,UDP自身就可以区分不同的数据包;
  • 站在应用层角度,上层用户在 recvfrom 时,要么收到一个完整的数据包,要么不收,不会出现粘包问题;
  • 因此我们说UDP是面向数据报的传输层协议。

9. TCP 异常情况

当通信双方连接建立完毕后,如果此时发生了异常情况,这个连接如何处理呢?

  • 进程终止:我们知道关闭连接,体现在应用层就是 close 特定的文件描述符,而众所周知,进程结束后,操作系统会释放进程所占用的资源,包括文件描述符,因此,当进程退出,其潜台词就是这个退出的进程主动向对方发起 FIN 数据段,请求断开连接,进行四次挥手过程,正常断开连接;
  • 机器重启:机器重启,即计算机需要先关闭,而在计算机关闭之前,操作系统需要结束正在运行的进程以释放资源,因此我们有时候会遇到在关机时,弹出一个窗口,说进程正在运行,是否退出,因此,这个退出的进程就是断开连接 (4次挥手) 的主动方,也会正常断开连接;
  • 机器掉电/网线断开:假设客户端掉电或者网线被拔了,服务端并不会立即知道对方已经不在,依旧认为连接还存在,但是一旦服务端发送数据给客户端,就会发现,对方连接已经不存在了,因此会进行 RST;即使服务端没有发送数据,由于TCP有保活机制,即内置了一个保活定时器,会定期询问对方是否存在,如果对方不在,也会将连接断开;

在某些应用层协议中,特别是像HTTP长连接这样的协议中,也可以实现类似于TCP的保活机制,以确保连接的健壮性。

  1. 在HTTP长连接中,客户端和服务器之间的连接在一次请求-响应周期之后不会立即关闭,而是会保持一段时间,以便后续的请求可以复用同一个连接;
  2. 在这种情况下,为了确保连接的有效性,客户端或服务器可能会定期发送心跳消息,以检测对方的状态;
  3. 如果一方在一段时间内没有收到对方的响应,就会认为连接已经失效,并采取相应的措施,比如重新建立连接或关闭连接;

这种心跳检测机制可以帮助应用层协议及时发现连接异常,提高连接的可靠性和稳定性。

10. TCP 总结

10.1. TCP的相关机制

TCP主要是为了保证传输可靠性,但也在效率和其他方面做了努力。

可靠性:

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

提高性能:

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

其他:

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

10.2. 基于TCP应用层协议

  • HTTP
  • HTTPS
  • SSH
  • Telnet
  • FTP
  • SMTP

当然,也包括用户写TCP程序时自定义的应用层协议;

10.3. UDP和TCP的差别

UDP和TCP的差别:

  • UDP 不解决传输可靠性问题,因此它不具备可靠性,但也就意味着它会更简单,传输效率更快;
  • TCP 用了大量的机制保证了传输可靠性,这也意味着它更复杂,维护成本更高,相对UDP而言,传输效率低;

从这里我们也可以体会出,可靠性,并不是优点,也不是缺点,它只代表了协议的特征;

因此这两种传输层协议,都是程序员的工具而已,使用它们,我们需要根据场景而定,比如:

  • 如果场景对丢包的容忍度高一点,那么建议使用UDP协议,因为它更简单、成本更低、且效率更高;
  • 但如果,场景对丢包的容忍度非常低,数据的可靠性要求非常高,那么毋庸置疑,肯定是TCP协议优先,因为它使用了大量机制来保证可靠性。

总而言之,我们不要对这两种协议抱有偏见,它们只不过使用场景不同罢了。

10.4. 如何用UDP实现可靠传输 (经典面试题)

思路很简单,TCP是如何保证可靠性的,那么就在应用层实现类似的逻辑;

例如:

  • 序列号: 对发送的每个数据包进行编号,接收端按序接收数据包,确保数据顺序性;
  • 确认应答: 发送端在发送数据包后等待接收端的确认应答,确保对端收到了数据;
  • 超时重传: 如果发送端在一定时间内未收到确认应答,则认为数据丢失,进行重传操作;
  • 流量控制: 控制发送速率,避免发送过快导致接收端无法处理;
  • 拥塞控制: 监测网络拥塞情况,调整发送速率,避免网络拥塞引起的丢包率增加。

综上所述,通过引入这些机制(包括但不限于),可以在UDP上实现类似于TCP的可靠传输特性。

10.5. TCP相关实验

我们知道,accept 函数是用来获取连接,并返回一个服务套接字,该套接字用于服务器与客户端之间的通信。通信双方在通信时,需要先建立连接,即三次握手,而三次握手是由TCP自主完成的。

accept 参与三次握手的过程吗?

  • 答案是,不参与。accpet 只需要从底层直接获取已经建立好的连接即可;换言之,当连接还没有建立完毕时,accept 只能阻塞等待,当连接建立完毕后,accept 才会成功获取连接,并返回给上层一个套接字。

如果上层用户不调用accept,通信双方能够建立连接成功吗?

  • 可以成功建立连接。三次握手是在TCP协议层面自动完成的,与 accept 函数无关 (并不参与三次握手即连接建立的过程),accept 只是在应用层获取已经建立的连接。

如果服务器上层来不及调用 accept,并且对端还来了大量的连接请求,难道所有的连接请求都应该先建立好吗?

首先,不能全部建立,服务器来不及调用 accept,说明服务器都已经很忙了,如果此时还要建立大量的新连接,而连接是需要成本的,这样一来,服务器的资源就更少了,进而影响服务器的正常运行,甚至引发宕机。

既然不能全部建立,那么这些连接请求如何处理,要说明这个问题,我们就必须谈论 listen 系统调用的第二个参数。

服务器是为用户提供服务的,用户通过客户端访问服务器;

假如服务器已经有了大量的连接,上层已经来不及调用accept处理新的连接了,但是,此时客户端又向服务器发送大量了新的连接请求,但是服务器已经暂时没有能力 accept 这些连接,因此,服务器提出了一个方案,建立一小部分连接 ,然后用一个队列,先保存好这些连接,等待服务器中的其他连接结束,只要其他连接一结束,服务器就将这个队列中的第一个连接处理 (accept),进而让它访问服务器;

服务器为什么有这个队列呢?

首先,因为服务器是为了让用户访问它的资源的,访问的用户越多越好,但由于服务器自身的原因,无法让所有用户同时访问资源,因此设计了一个队列,将暂时无法访问的用户的连接存在这个队列中,保证其他连接一旦结束,就可以 accept 队列中的第一个连接,进而访问服务,保证服务器自己的资源是100%利用的。

那能不能将这个队列设置的特别大呢?

不能,因为,这个队列是为了存储暂时无法accept的连接 (这个连接已经建立成功了,已经完成三次握手的过程) ,如果太大了,那么会存储大量的无法accept的连接,而连接是需要成本的,服务器存储了这么多大量无法accept的连接,为什么不直接将其中的一些连接accept呢,让其访问服务?

是因为,你服务器此时此刻无法让这些连接访问服务,因此,你用一个队列保存这些尚不能accpet的连接,因此这种连接不能太多,即保存这种无法accept的连接的队列不能太大。

即这个队列,不能没有,也不能太大。 

你说了,这个保存无法accept的连接的队列不能太大,即它只能保存一部分完成三次握手且无法accept的连接,那么如果此时这个队列满了,在来连接请求如何处理呢?

  • 那么此时这些连接请求就无法完成连接,即无法完成整个三次握手过程;
  • 实际上,这种未完成三次握手过程的连接,操作系统也会使用一个队列进行保存,只不过此时这些连接的生命周期很短,如果服务器来不及处理这些连接请求,操作系统会根据一定的策略丢弃这些未完成三次握手的连接。这样可以避免半连接队列过度堆积,影响服务器的正常运行。

实际上,在 Linux 操作系统内核协议栈中,通常会为 TCP 连接管理维护两个队列:

  • 全连接队列 (accept队列):这个队列用来保存处于已经完成三次握手过程且应用层尚未调用accept的连接,因为该连接已经完成三次握手过程,因此状态是ESTABLISHED;一旦应用层调用 accept 函数,内核会从全连接队列中取出连接,应用层通过accept返回的套接字进行通信。
  • 半连接队列:这个队列用于存储已经接收到 SYN 报文但是尚未完成三次握手的连接请求。对于发起 SYN 的一方 (通常是客户端),其连接状态最终处于 ESTABLISHED;对于接收 SYN 报文的一方 (通常是服务端),其连接状态处于 SYN_RECV。

在 Linux 中,全连接队列的长度通常由 listen 系统调用的第二个参数影响。

具体就是,全连接队列长度 = listen 第二个参数 + 1

10.5.1. 验证全连接队列长度 = listen 第二个参数 + 1

sock.hpp 代码如下:

其中listen中的第二个参数为1;

#ifndef __SOCK_HPP_
#define __SOCK_HPP_
 
#include <iostream>
#include <cstring>
 
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>

// listen 的第二个参数是1
const int full_connection_queue_len = 1;
 
namespace Xq
{
  class Sock
  {
  public:
    Sock() :_sock(-1) {}
 
    // 创建套接字
    void Socket(void) 
    {
      _sock = socket(AF_INET, SOCK_STREAM, 0);
      if(_sock == -1)
      {
        exit(1);
      }
    }
 
    //  将该套接字与传入的地址信息绑定到一起
    void Bind(const std::string& ip, uint16_t port)
    {
      sockaddr_in addr;
      addr.sin_family = AF_INET;
      addr.sin_port = htons(port);
      addr.sin_addr.s_addr = ip.empty() ? INADDR_ANY : inet_addr(ip.c_str());
 
      int ret = bind(_sock, reinterpret_cast<const struct sockaddr*>(&addr), sizeof(addr));
      if(ret == -1)
      {
        exit(2);
      }
    }
 
    // 封装listen 将该套接字设置为监听状态
    void Listen(void)
    {
      int ret = listen(_sock, full_connection_queue_len);
      if(ret == -1)
      {
        exit(3);
      }
    }
 
    // 封装accept
    // 如果想获得客户端地址信息
    // 那么我们可以用输出型参数
    // 同时我们需要将服务套接字返回给上层
    int Accept(std::string& client_ip, uint16_t* port)
    {
      struct sockaddr_in client_addr;
      socklen_t addrlen = sizeof client_addr;
      bzero(&client_addr, addrlen);
 
      int server_sock = accept(_sock, \
          reinterpret_cast<struct sockaddr*>(&client_addr), &addrlen);
      if(server_sock == -1)
      {
        return -1;
      }
      // 将网络字节序的整数转为主机序列的字符串
      client_ip = inet_ntoa(client_addr.sin_addr);
      // 网络字节序 -> 主机字节序
      *port = ntohs(client_addr.sin_port);
      // 返回服务套接字
      return server_sock;
    }
 
    //  向特定服务端发起连接
    void Connect(struct sockaddr_in* addr, const socklen_t* addrlen)
    {
      int ret = connect(_sock,\
          reinterpret_cast<struct sockaddr*>(addr), \
          *addrlen);
      if(ret == -1)
      {
        perror("connect error");
      }
    }
 
    // 服务端结束时, 释放监听套接字
    ~Sock(void)
    {
      if(_sock != -1)
      {
        close(_sock);
      }
    }
 
  public:
    int _sock; // 套接字
  };
}
 
#endif

服务器代码:

端口号为6666,最核心的是,服务器没有accept;

#include <iostream>
#include "Sock.hpp"
 
int main()
{
  Xq::Sock sock;
  sock.Socket();
 
  std::string server_ip = "";
  uint16_t server_port = 6666;
  sock.Bind(server_ip, server_port);
  sock.Listen();
 
  while(true)
  {
    // 注意, 服务器没有accept
    sleep(1);
  }
 
  return 0;
}

首先,我们验证,accept 不参与连接建立,即三次握手过程,只要,客户端发起连接请求,并且连接成功建立,那么就说明accept不参与连接建立。

通过telnet连接我们的服务器,如下:

可以看到,尽管服务器没有accept获取连接,也不影响连接的正常建立,即accept并不参与连接建立的过程。 

那么接下来的问题,因为我们将listen的第二个参数设置为1,是不是如我们之前所言,全连接长度 = listen第二个参数 + 1?

验证如下: 

继续 telnet 连接我们的服务器: 

现在已经有两个连接,这两个连接是已经连接成功,但没有被accept的连接。

那么继续telnet 连接服务器,这个连接会成功吗? 如下:

可以看到,此时如果再来新的连接,服务器不会连接成功,而是会处于SYN_RECV,同时我们发现,此时客户端会连接成功,处于ESTABLISHED,但并不影响我们之前的判断,全连接队列的长度 = listen 第二个参数 + 1;

注意,这里的连接指的是,已经完成三次握手过程,即连接建立成功,但是应用层没有调用accept 的连接。 

如果此时全连接队列已满,再来多个连接请求,这些连接就会被保存到半连接队列中,如下:

同时,我们发现,半连接队列中的连接生命周期很短,如果服务器来不及处理这些连接请求,操作系统会根据一定的策略丢弃这些未完成三次握手的连接,如下:

从上图可以看出, 半连接队列中的连接,会被丢弃,至于半连接队列的长度,根据操作系统设定,但肯定也不会太大。

至此,我们的验证成功,即全连接队列的长度 = listen 第二个参数 + 1;

 TCP done;

相关推荐

最近更新

  1. TCP协议是安全的吗?

    2024-04-05 23:34:03       16 阅读
  2. 阿里云服务器执行yum,一直下载docker-ce-stable失败

    2024-04-05 23:34:03       16 阅读
  3. 【Python教程】压缩PDF文件大小

    2024-04-05 23:34:03       15 阅读
  4. 通过文章id递归查询所有评论(xml)

    2024-04-05 23:34:03       18 阅读

热门阅读

  1. postcss安装和使用

    2024-04-05 23:34:03       13 阅读
  2. 六、c++代码中的安全风险-fopen

    2024-04-05 23:34:03       16 阅读
  3. 【LeetCode】454. 四数相加 II

    2024-04-05 23:34:03       18 阅读
  4. Spark面试整理-解释Spark MLlib是什么

    2024-04-05 23:34:03       14 阅读
  5. 鸿蒙原生应用开发-网络管理Socket连接(三)

    2024-04-05 23:34:03       15 阅读