j7~头像
关注
【Linux 网络】四十一.《网络基础(传输层协议:UDP、TCP)》--终篇封面图

【Linux 网络】四十一.《网络基础(传输层协议:UDP、TCP)》--终篇

在上节内容中,我们学习了tcp传输控制协议的确认应答(ACK)机制,连接管理机制,TCP 流量控制,接下来我们继续学习今天的内容:

一.TCP 协议(传输控制协议)

1.滑动窗口

在发送数据后,但没收到应答之前,我们必须要把数据先保存起来,以支持后续可能出现的超时重传,那么保存在哪里呢?

那就是滑动窗口;

我们在前面说过,多个报文一般是并行发送,即还没收到应答,下一个报文就已经发送出去了,这样做目的是为了提高效率。

所以把发送缓冲区分成三个部分:

  1. 已经发送并且已经收到 ACK 的数据(上层考虑数据时可以直接覆盖掉)。
  2. 已经发送还但没有收到 ACK 的数据。
  3. 还没有发送的数据。

滑动窗口的本质sender 方可以一次性向对方推送数据的上限。滑动窗口是自己的发送缓冲区的一部分,通过不断地滑动来重新划分三段区间


(1)理解滑动窗口

我们把缓冲区看成一个数组,那么滑动窗口的移动其实就是下标进行更新


A. 滑动窗口的大小

滑动窗口存在上限,窗口大小取决于接收方的接收能力。无论窗口如何滑动,都要保证数据不会超出对方的接收上限,也就是滑动窗口大小 ≤ 接收方接收能力。

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

不一定。窗口可以向右滑动,也可以保持不动。如果接收方应用层迟迟不读取缓冲区的数据,就会出现滑动窗口左边界不断右移,但是右边界保持不变的情况。

滑动窗口可以为 0 吗?

可以。当 win_start 和 win_end 相等时,滑动窗口大小就是 0。 如果发送方持续发送数据,但是接收方上层程序一直不读取缓冲区数据,TCP 窗口大小 tcp_win 就会持续缩小,表现为滑动窗口左侧不断右移、右侧保持不动,最终滑动窗口缩减为 0。

滑动窗口如何滑动更新?

当发送端收到对方的应答时,如果应答报文中的确认序号为 ACK_SEQ,收到的应答报文中的滑动窗口大小为 tcp_win,此时就可以将 win_start 更新为 ACK_SEQ,win_end 更新为 win_start + tcp_win。

如果收到的 ACK 不是最左侧数据(最开始的报文)的应答,而是中间的,有可能吗?会有影响吗?

是有可能的。因为 TCP 是可靠传输,不可能出现乱序,所以如果收到了中间数据的应答,一定是发生了丢包


超时重传背后的含义:在没有收到应答时,数据必须被暂时保存起来。

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

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

  • 窗口大小指的是无需等待确认应答而可以继续发送数据的最大值。
  • 假设窗口大小是 4000 字节,也就是四个段,那么发送前四个段的时候不需要等待任何 ACK,可以直接发送。
  • 收到第一个 ACK 后,滑动窗口向后移动,继续发送第五个段的数据,依次类推。
  • 操作系统内核为了维护这个滑动窗口,需要开辟发送缓冲区,用来记录当前还有哪些数据没有应答。只有确认应答过的数据,才能从缓冲区删掉。
  • 窗口越大,允许在途的未确认数据越多,理论上网络的吞吐率就越高。

问题来了,如果滑动窗口一直向右滑动,是否会出现越界问题总有空间用完的时候,该如何处理呢?

答案是不会出现越界问题,TCP 的发送缓冲区被内核组织成了环形结构

我们前面学过,流量控制是以可靠性问题为主,防止我们发送过多的数据;以效率问题为辅,对一个已经传输的数据进行丢弃,就得重传,因此就影响了效率。

那我们所说的这个滑动窗口解决的是效率问题还是可靠性问题?

答案是:滑动窗口的侧重点在于解决效率问题,因为它可以限制缓冲区的范围,能够一次性向对方发送大量的数据。以效率为主,可靠为辅


(2)那么如果出现了丢包,如何进行重传?(会出下以下几种情况)

情况一 —— 数据包已经抵达,ACK 应答丢了

根据确认序号的定义,如果收到的是 3001,那么说明 3000 以前的数据全部都收到了,那么就把 win_start 移动到 3001 即可。

在这种情况下,部分 ACK 丢了并不要紧,因为我们可以通过后续的 ACK 进行确认


情况二 —— 数据包直接丢了

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


而如果连续收到三个相同的确认序号,就会触发重传机制,该机制称为 “快重传”,也叫高速重发控制。

快重传可以快速重传丢失的数据段,当发送端连续收到三次重复应答时就会触发,不需要像超时重传那样等待重传定时器超时之后,才执行重传操作。

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

总结:滑动窗口的左端就是通过确认序号确定的,右端是通过左端和对方接收缓冲区的剩余空间决定的。接收端在 ACK 报文里携带的窗口大小,就是它当前接收缓冲区的剩余空间,发送端用这个窗口大小来更新发送窗口的右端。

那么问题又来了既然有了快重传,为什么还要有超时重传?

那是因为快重传是有条件的必须收到连续三个以上的同样的 ACK快重传和超时重传不是对立的,而是协作的。


2.拥塞控制

1000 个报文丢掉一两个很正常,重复发即可,但如果 1000 个报文有 998个都丢了,那我们还要选择重传吗?

举例:一个班60 个人考试,结果只有2个人挂科了,那大概率是这个人的问题;但如果挂了 58 个,那还是学生的问题吗?

对于这种大面积的丢包情况,TCP 就会考虑是网络拥塞问题,此时重传就没什么用了,重传也只会加重网络故障问题。

那我们如何解决网络拥塞问题?

当网络出现拥塞问题时,通信双方虽然不能提出特别有效的解决方案,但双方主机可以做到不加重网络的负担。双方通信时如果出现大量丢包,不应该立即将这些报文进行重传,而应该少发数据甚至不发数据,等待网络状况恢复后双方再慢慢恢复数据的传输速率。

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

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

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


拥塞窗口

单台主机一次向网络中发送大量数据时,可能会引发网络拥塞,为了避免这个问题,TCP 引入了一个上限值,叫拥塞窗口。超过拥塞窗口这个值的时候,就可能引发网络拥塞问题。

发送开始的时候,拥塞窗口定义为 1。每次接收到一个 ACK 应答,就加 1。每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为实际发送数据的窗口大小,也就是滑动窗口的大小。

滑动窗口大小 = min(拥塞窗口,对方窗口大小[接收能力])

慢启动阶段,每收到一个 ACK,拥塞窗口加 1。由于一个 RTT 内会收到多个 ACK,所以一个 RTT 内拥塞窗口翻倍,整体上呈指数增长。如果不考虑对方接收数据的能力,那么滑动窗口的大小就只取决于拥塞窗口的大小,此时拥塞窗口的大小变化为:1 2 4 8 …… 但指数增长是非常恐怖的,此时就有可能导致网络再次拥塞。

为了解决这个问题,就引入了慢启动的阈值。当拥塞窗口的大小超过这个阈值时,就不再按指数的方式增长,而是按线性的方式增长,每个 RTT 只加 1。慢启动只是指初始时慢,但增长速度非常快。为了不增长得那么快,因此不能使拥塞窗口单纯地加倍。

前期慢开始是为了让网络自主恢复,后面快是为了尽快恢复通信。

  • 当 TCP 开始启动的时候,慢启动阈值设置为对方窗口大小的最大值。
  • 在每次超时重发的时候,慢启动阈值会变成原来的一半,同时拥塞窗口置回 1,如此循环下去。如果是收到三个重复 ACK 触发的快速重传,拥塞窗口不会置回 1,而是变成原来的一半,然后进入快速恢复阶段。

少量丢包会触发重传,TCP 不会立即大幅降低发送速率。大量丢包或连续重复 ACK,TCP 就认为网络可能发生拥塞,会大幅降低发送速率。

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


3.延迟应答

现在接收方缓冲区有很多数据,但是应用层有很大概率会马上把数据拿走,如果等一等再应答就可以返回更大的窗口。需要注意的是,延迟应答的目的不是为了保证可靠性,而是留出一点时间让接收缓冲区中的数据尽可能被上层应用层消费掉,此时再进行 ACK 响应的时候报告的窗口大小就可以更大,从而增大网络吞吐量,进而提高数据的传输效率。

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

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

记住:窗口越大,网络吞吐量就越大,传输效率就越高,目标是在保证网络不拥塞的情况下尽量提高传输效率。

所有的包都可以延迟应答吗?

不是。

  • 数量限制:每隔 N 个包就应答一次。
  • 时间限制:超过最大延迟时间就应答一次。这个时间必须远小于 TCP 的重传超时时间(RTO),否则会触发发送端不必要的超时重传。

延迟应答具体的数量和超时时间依操作系统不同也有差异,一般 N 取 2,超时时间在几十毫秒级别。


4.捎带应答

接收方收到数据要给发送方一个应答,如果刚好接收方也要发送数据,可以直接一起返回。捎带应答的核心是把 ACK 搭载在数据报文上一起发给对方,省掉一个单独的纯 ACK 报文。它减少了报文数量,从而提高了网络利用率,双方通信时就可以不用再发送单纯的确认报文了。

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

这里需要说明的是,捎带应答和延迟应答不是依赖关系,而是可以配合使用的两种优化手段。延迟应答是为了等上层应用把数据消费掉、方便返回更大的窗口;捎带应答是为了省掉单独的 ACK 报文。两者常常同时生效,但不是谁基于谁。


5.面向字节流

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

  1. 调用 write 时,数据会先写入发送缓冲区中。
  2. 如果发送的字节数太长,会被拆分成多个 TCP 的数据包发出。
  3. 如果发送的字节数太短,就会先在缓冲区里等待,等到缓冲区长度差不多了,或者其他合适的时机发送出去。TCP 不一定会等缓冲区满才发。TCP 有自己的发送策略,比如 Nagle 算法会攒小包,但有些情况(如设置了 TCP_NODELAY,或者有紧急数据)会立即发送。所以“等缓冲区长度差不多”只是其中一种常见情况,不是绝对规则。
  4. 接收数据时,数据也是从网卡驱动程序到达内核的接收缓冲区。
  5. 然后应用程序可以调用 read 从接收缓冲区拿数据。
  6. 另一方面,TCP 的一个连接既有发送缓冲区,也有接收缓冲区。那么对于这一个连接既可以读数据,也可以写数据,这个概念叫做全双工

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

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

这就实际上对于 TCP 来说,它并不关心发送缓冲区当中的是什么数据,在 TCP 看来这些只是一个个的字节数据,它的任务就是将这些数据准确无误地发送到对方的接收缓冲区当中就行了,而至于如何解释这些数据完全由上层应用来决定,叫做面向字节流这也是为什么 TCP 会有粘包问题,因为 TCP 不保留应用层的消息边界,只保证字节顺序和可靠性,应用层必须自己划分消息边界。

对比 UDP,UDP 不是面向字节流的,发 1 次必须就要读 1 次,发 10 次就必须读 10 次。这种报文和报文在传输层有明显边界的协议就叫做面向数据报。UDP 的收发次数必须匹配,指的是边界对应关系,不是可靠传输的保证。UDP 只保证一次发送对应一个数据报,但如果数据报在网络中丢失或乱序,UDP 本身不负责重传和排序。


6.粘包问题

a.什么是粘包

因为 TCP 是面向字节流的,所以需要应用层来分开这些报文。如果处理得不好,就会出现多读了或者少读了,从而影响到后续报文,这种问题就叫做粘包

首先要明确,粘包问题中的“包”是指应用层的数据包。

  • 在 TCP 的协议头中,没有如同 UDP 一样的“报文长度”这样的字段,但是有一个序号这样的字段。不过要注意,TCP 首部里也有长度相关的字段,只是它不是用来标记应用层消息长度的。真正让 TCP 无法像 UDP 那样区分消息边界的,是 TCP 本身不保留应用层消息边界。
  • 站在传输层的角度,TCP 是一个个报文过来的,按照序号排好序放在缓冲区中。但接收端缓冲区里是按字节序号排列的,不是一个一个消息段。TCP 只保证字节序,不保留消息边界,这是粘包问题的根源。
  • 站在应用层的角度,看到的只是一串连续的字节数据。那么应用程序看到了这么一连串的字节数据,就不知道从哪个部分开始到哪个部分,是一个完整的应用层数据包。所以应用层必须自己设计消息边界规则,比如固定长度、长度字段、特殊分隔符等,才能正确地从字节流里切出一个个完整的消息。

b.如何避免粘包问题呢?

明确两个包之间的边界。解决粘包问题的本质就是要确定报文与报文之间的边界。

  • 对于定长的包,保证每次都按固定大小读取即可。例如上面的 Request 结构,是固定大小的,那么就从缓冲区从头开始按 sizeof(Request) 依次读取即可。
  • 对于变长的包,可以在包头的位置,约定一个包总长度的字段,从而就知道了包的结束位置。
  • 对于变长的包,还可以在包和包之间使用明确的分隔符。应用层协议是程序员自己来定的,只要保证分隔符不和正文冲突即可。

c对于 UDP 协议来说,是否也存在“粘包问题”呢?

  • UDP 的报文与报文之间的边界是明确的,因为 UDP 有标准报头,报头定长,并且有 16 位 UDP 长度字段,所以当它收到报文时,去掉报头剩下的就是有效载荷,就能够保证读到的就是完整的报文。
  • UDP 的报文边界由 UDP 首部中的长度字段决定,接收端内核在收到 UDP 数据报时,就已经知道这个数据报有多长,因此应用层每次读取的都是一个完整的 UDP 数据报。
  • 站在应用层的角度,使用 UDP 的时候,要么收到完整的 UDP 报文,要么不收。不会出现“半个”的情况。

7.TCP 异常情况

(1)进程终止

两个已经建立连接的进程,其中一个进程突然挂掉了,此时建立好的连接会怎么样?

其实连接也是个文件,而文件描述符是随进程的,进程退出,操作系统就会 close 掉这个文件。所以操作系统会正常四次挥手断开连接,跟自己 close 掉没区别。

进程终止会释放文件描述符,仍然可以发送 FIN,和正常关闭没有什么区别。不同的是,这是被动触发的关闭,不是应用层显式调用 close,但从协议角度看效果是一样的。


(2)机器重启

和进程终止的情况相同。

当重启主机时,操作系统会先杀掉所有进程然后再进行关机重启,因此机器重启和进程终止的情况是一样的,此时双方操作系统也会正常完成四次挥手,然后释放对应的连接资源。


(3)机器掉电 / 网线断开

当客户端掉线后,服务器端在短时间内无法知道客户端掉线了,因此在服务器端会维持与客户端建立的连接,但这个连接也不会一直维持,因为 TCP 是有保活策略的。

认为连接还在的一方,通常是没掉线的那一方,会定期发送探测报文,等对方回应。如果探测多次没有回应,就判定对方掉线,直接断开并释放连接。这是 TCP 的保活机制。

掉线的一方不在,另一方如果有数据要发,就会发送报文;由于对方已经不在,收不到 ACK,多次重传失败后就会触发 RST。即使没有写入操作,TCP 自己也内置了一个保活定时器,会定期询问对方是否还在。如果对方不在,也会把连接释放。

另外,应用层的某些协议也有一些这样的检测机制。例如 HTTP 长连接中,也会定期检测对方的状态。例如 QQ,在 QQ 断线之后也会定期尝试重新连接。


8.小结

为什么 TCP 这么复杂?

简单说因为要保证可靠性,同时又尽可能的提高性能

(1)可靠性

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

(2)提高性能

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

(3)其他

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

(4)基于 TCP 应用层协议

  • HTTP

  • HTTPS

  • SSH

  • Telnet

  • FTP

  • SMTP

同时,也包括自己写的 TCP 程序时自定义的应用层协议。


二.TCP / UDP 对比

  • TCP 和 UDP 之间的优点和缺点,不能简单、绝对地进行比较。
  • TCP 面向连接、可靠、面向字节流、有流量控制和拥塞控制,开销大。UDP 无连接、不可靠、面向数据报、无流量控制和拥塞控制,开销小。
  • TCP 用于可靠传输的情况,应用于文件传输、重要状态更新等场景,比如文件传输、网页浏览、邮件、远程登录。
  • UDP 用于对高速传输和实时性要求较高的通信领域。例如,早期的 QQ、视频传输、语音通话、在线游戏、DNS 查询等,另外 UDP 可以用于广播。

归根结底,TCP 和 UDP 都是程序员的工具,什么时机用、具体怎么用,还是要根据具体的需求场景去判定。选择 TCP 还是 UDP,本质是在可靠性和实时性、开销之间做权衡。要可靠就选 TCP,要快就选 UDP,也可以两者结合使用,比如 HTTP/3 就是基于 UDP 实现的可靠传输(QUIC)。


三.理解 listen 的第二个参数

accept 要不要参与三次握手的过程呢?

不需要参与三次握手,accept 从底层直接获取已经建立好的连接。

换而言之,需要先建立好连接,然后才能 accept 获取对应的连接。

三次握手由内核 TCP 协议栈完成,accept 是应用层从已完成连接队列里取出一个已经建立好的连接,返回一个新的 socket 文件描述符给应用层。内核负责连接的建立,应用层负责连接的获取和处理。


如果不调用 accept,能否建立连接成功呢?

能。因为能否建立连接跟 accept 没有关系,虽然会有影响,但不起决定性因素。一定是要先建立好连接,然后才能调用 accept 成功。


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

并不是。服务器在进行连接获取的时候,服务器本身要维护一个连接队列,不能没有、不能太长,和 listen 的第二个参数有关。

TCP 在建立连接时,服务器会维护两个队列。一个是半连接队列,也叫 SYN 队列,存放收到了 SYN 但还没完成三次握手的连接。另一个是全连接队列,也叫 Accept 队列,存放已经完成三次握手、等待应用层 accept 取走的连接。listen 的第二个参数 backlog,在不同的系统上含义略有不同,历史上它表示半连接队列和全连接队列的长度上限,在 Linux 上主要影响全连接队列的长度。

如果全连接队列满了会怎么样?

当全连接队列已满,而新的连接又完成三次握手时,服务器可能会直接丢弃这个连接,或者根据系统配置返回 RST,具体行为依操作系统和配置而定。所以 accept 不及时,确实可能导致部分连接建立失败。

listen 的 backlog 不是越大越好。队列太长会占用内核内存,太短又会导致连接被丢弃,所以要结合实际负载情况设置。


listen 的第二个参数意义底层全连接队列的长度 = listen的第二个参数 + 1

这个实验要验证的是:Linux 下,listen 的第二个参数(backlog)实际控制的是全连接队列的长度,且队列长度 = backlog + 1。

实验思路是:让服务端只监听、不 accept,然后开多个 telnet 连接,观察什么时候开始连接被拒绝。当 backlog = 1 时,队列长度是 2,也就是只能容纳 2 个 ESTABLISHED 连接;第 3 个连接开始就会出现 SYNC_RECV 或被拒绝的情况


a.代码如下:

Sock.hpp

#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

class Sock {
private:
    const static int gbacklog = 1;

public:
    int Socket() {
        int sockfd = socket(AF_INET, SOCK_STREAM, 0);
        if (sockfd < 0) {
            std::cerr << "socket create failed" << std::endl;
            exit(2);
        }

        int opt = 1;
        setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT,
                   &opt, sizeof(opt));
        return sockfd;
    }

    void Bind(int sock, uint16_t port, std::string ip = "0.0.0.0") {
        struct sockaddr_in local;
        memset(&local, 0, sizeof local);
        local.sin_family = AF_INET;
        local.sin_port   = htons(port);
        inet_pton(AF_INET, ip.c_str(), &local.sin_addr);

        if (bind(sock, (struct sockaddr*)&local, sizeof(local)) < 0) {
            exit(3);
        }
    }

    void Listen(int sockfd) {
        if (listen(sockfd, gbacklog) < 0) {
            std::cerr << "listen failed" << std::endl;
            exit(4);
        }
    }

    int Accept(int listenfd, std::string* clientip, uint16_t* clientport) {
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);

        int sockfd = accept(listenfd, (struct sockaddr*)&peer, &len);
        if (sockfd > 0) {
            *clientip   = inet_ntoa(peer.sin_addr);
            *clientport = ntohs(peer.sin_port);
        }
        return sockfd;
    }
};

main.cc

#include "Sock.hpp"
#include <unistd.h>

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

    while (true) {
        sleep(1);   // 故意不 accept
    }
    return 0;
}

注意main.cc只 sleep,不 accept。这样所有建立好的连接都会堆在全连接队列里,方便我们观察队列的行为。

Makefile

TcpServer: main.cc
	g++ -std=c++17 $^ -o $@

.PHONY: clean
clean:
	rm -f TcpServer

b.编译运行

make
./TcpServer

服务端启动后,什么都不打印,一直 sleep。


c.实验步骤

第一次实验:两台机器连接

用两个终端分别执行:

telnet 127.0.0.1 8080

两个 telnet 都能连上,telnet> 提示符正常显示。

在第三个终端执行:

netstat -ntp

会看到两条 ESTABLISHED:

tcp 0 0 127.0.0.1:8080 127.0.0.1:48850 ESTABLISHED -
tcp 0 0 127.0.0.1:8080 127.0.0.1:48852 ESTABLISHED -

这说明:backlog = 1,实际能容纳 2 条已完成连接


第二次实验:第三台机器连接

再开第三个 telnet:

telnet 127.0.0.1 8080

d.观察现象

第三个 telnet 会卡住,显示:

Trying 127.0.0.1...

一直在尝试连接,无法进入 telnet> 提示符。

此时再执行 netstat -ntp

tcp 0 0 127.0.0.1:8080 127.0.0.1:48850 ESTABLISHED -
tcp 0 0 127.0.0.1:8080 127.0.0.1:48852 ESTABLISHED -
tcp 0 0 127.0.0.1:8080 127.0.0.1:48856 SYN_RECV -

关键现象第三个连接停留在 SYN_RECV 状态,说明它的三次握手没有完成。

为什么?

因为全连接队列已满(2 条),第三个连接的第三次握手 ACK 到达后,服务端没有空间放它,所以它只能在 SYN 队列里等待,或直接被丢弃。三次握手无法完成,客户端就卡在 Trying... 阶段。


第三台机器上发生了这个变化

在服务端被观察时,SYN_RECV 出现了。此时客户端的 telnet 卡住。

之后如果服务端调用了 accept,全连接队列空出位置,这个 SYN_RECV 的连接就能完成三次握手,进入 ESTABLISHED。但我们的 main.cc 里没有 accept,所以它会一直卡住。


d.结论

listen 的第二个参数(backlog)在 Linux 上主要控制全连接队列的长度,实际队列长度为 backlog + 1。

  • backlog = 1 → 队列长度 = 2 → 能容纳 2 条 ESTABLISHED 连接

  • 第 3 条连接进来时,队列已满,三次握手无法完成,停留在 SYN_RECV 或直接被拒绝

这也解释了为什么 accept 要及时调用——它把已完成连接从队列里取走,腾出空间给新连接。如果应用层不 accept,队列很快会满,新连接就无法完成三次握手。


实验中需要注意的几点

第一,为什么 backlog = 1 实际是 2?

这是 Linux 的历史遗留问题。历史上 backlog 表示 SYN 队列长度,而内核实际会做一些调整,导致 backlog + 1 才能反映真实队列长度。不同版本内核可能有差异,这个实验在 CentOS 上默认行为就是 +1


第二,为什么第三个连接是 SYN_RECV 而不是直接被拒绝?

服务端收到第三个 SYN 后,会回 SYN + ACK,客户端回 ACK。但因为全连接队列已满,服务端没有空间放它,三次握手无法完成。这个连接就停在 SYN_RECV 状态,内核可能会重试,或者最终超时清除。


第三,如果把 backlog 改大一点会怎样?

gbacklog 改成 2 或更大,就能容纳更多已完成连接。可以自己改一下做对比实验:改大后,能连上的 telnet 数量会变多。


e.实验小结:

listen 的第二个参数在 Linux 上主要控制全连接队列长度,实际队列长度 = backlog + 1。实验中把 backlog 设为 1,最多只能容纳 2 条 ESTABLISHED 连接;第 3 条连接进来时会停留在 SYN_RECV 状态,三次握手无法完成。这说明 accept 必须及时调用,否则队列很快会被填满,新连接无法建立。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2501_93351213/article/details/166256324

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--