图解网络 — TCP 篇
2022-09-06 18:20:29 63 举报
AI智能生成
小林coding 图解网络 HTTP 篇 脑图总结
作者其他创作
大纲/内容
TCP 三次握手与四次挥手面试题<br>
TCP 基本认识
TCP 头格式有哪些
先来看看 TCP 头的格式,标注颜色的表示与本文关联比较大的字段,其他字段不做详细阐述。<br>
<b>序列号</b>:在建立连接时由计算机生成的随机数作为其初始值,通过 SYN 包传给接收端主机,<br>每发送一次数据,就「累加」一次该「数据字节数」的大小。<b>用来解决网络包乱序问题</b>。
<b>确认应答号</b>:指下一次「期望」收到的数据的序列号,发送端收到这个确认应答以后可以认为<br>在这个序号以前的数据都已经被正常接收。<b>用来解决丢包的问题</b>。
控制位:<br><ul><li><b>ACK</b>:该位为 1 时,「确认应答」的字段变为有效,TCP 规定除了最初建立连接时的 SYN 包之外该位必须设置为 1 。</li><li><b>RST</b>:该位为 1 时,表示 TCP 连接中出现异常必须强制断开连接。</li><li><b>SYN</b>:该位为 1 时,表示希望建立连接,并在其「序列号」的字段进行序列号初始值的设定。</li><li><b>FIN</b>:该位为 1 时,表示今后不会再有数据发送,希望断开连接。当通信结束希望断开连接时,<br>通信双方的主机之间就可以相互交换 FIN 位为 1 的 TCP 段。</li></ul>
为什么需要 TCP?它工作在那一层
IP 层是「不可靠」的,它不保证网络包的交付、不保证网络包的按序交付、也不保证网络包中的数据的完整性。
如果需要保障网络数据包的可靠性,那么就需要由上层(传输层)的 TCP 协议来负责。
因为 TCP 是一个工作在 <b>传输层 </b>的 <b>可靠 </b>数据传输的服务,它能确保接收端接收的网络包是 <b>无损坏、无间隔、非冗余和按序的</b>。
什么是 TCP
TCP 是 <b>面向连接的、可靠的、基于字节流 </b>的传输层通信协议。
<b>面向连接</b>:一定是「一对一」才能连接,不能像 UDP 协议可以一个主机同时向多个主机发送消息,也就是一对多是无法做到的;<br>
<b>可靠的</b>:无论的网络链路中出现了怎样的链路变化,TCP 都可以保证一个报文一定能够到达接收端;<br>
<b>字节流</b>:用户消息通过 TCP 协议传输时,消息可能会被操作系统「分组」成多个的 TCP 报文,如果接收方的程序如果不知道「消息的边界」,<br>是无法读出一个有效的用户消息的。并且 TCP 报文是「有序的」,当「前一个」TCP 报文没有收到的时候,即使它先收到了后面的 TCP 报文,<br>那么也不能扔给应用层去处理,同时对「重复」的 TCP 报文会自动丢弃。
什么是 TCP 连接
RFC 793 定义的「连接」:<br>Connections: The reliability and flow control mechanisms described above require that TCPs initialize and maintain certain status information <br>for each data stream. The combination of this information, including sockets, sequence numbers, and window sizes, is called a connection.<br>
简单来说就是,<b>用于保证可靠性和流量控制维护的某些状态信息,这些信息的组合,包括 Socket、序列号和窗口大小称为连接。</b>
所以我们可以知道,建立一个 TCP 连接是需要客户端与服务器端达成上述三个信息的共识。<br><br><ul><li><b>Socket</b>:由 IP 地址和端口号组成</li><li><b>序列号</b>:用来解决乱序问题等</li><li><b>窗口大小</b>:用来做流量控制</li></ul>
如何唯一确定一个 TCP 连接?
TCP 四元组可以唯一的确定一个连接,四元组包括如下:<br><ul><li>源地址</li><li>源端口</li><li>目的地址</li><li><span style="font-size: inherit;">目的端口</span></li></ul>
<b>源地址和目的地址 </b>的字段(32位)是在 <b>IP 头部中</b>,作用是通过 IP 协议发送报文给 <b>对方主机</b>。
<b>源端口和目的端口 </b>的字段(16位)是在 <b>TCP 头部中</b>,作用是告诉 TCP 协议应该把报文发给<b> 哪个进程</b>。
有一个 IP 的服务器监听了一个端口,它的 TCP 的最大连接数是多少?
服务器通常固定在某个本地端口上监听,等待客户端的连接请求。<br><br>因此,客户端 IP 和 端口是可变的,其理论值计算公式如下:
对 IPv4,客户端的 IP 数最多为 2 的 32 次方,客户端的端口数最多为 2 的 16 次方,<br>也就是服务端单机最大 TCP 连接数,约为 2 的 48 次方。
当然,服务端最大并发 TCP 连接数远不能达到理论上限,会受以下因素影响:<br><ul><li><b>文件描述符限制</b>,每个 TCP 连接都是一个文件,如果文件描述符被占满了,会发生 too many open files。<br>Linux 对可打开的文件描述符的数量分别作了三个方面的限制:</li></ul><span style="font-size: inherit;"> · <b>系统级</b>:当前系统可打开的最大数量,通过 cat /proc/sys/fs/file-max 查看;</span><br> · <b>用户级</b>:指定用户可打开的最大数量,通过 cat /etc/security/limits.conf 查看;<br> · <b>进程级</b>:单个进程可打开的最大数量,通过 cat /proc/sys/fs/nr_open 查看;<br><ul><li><b>内存限制</b>,每个 TCP 连接都要占用一定内存,操作系统的内存是有限的,如果内存资源被占满后,会发生 OOM。</li></ul>
UDP 和 TCP 有什么区别?分别的应用场景是什么?
UDP 不提供复杂的控制机制,利用 IP 提供面向「无连接」的通信服务。
UDP 协议真的非常简,头部只有 8 个字节( 64 位),UDP 的头部格式如下:
目标和源端口:主要是告诉 UDP 协议应该把报文发给哪个进程。
包长度:该字段保存了 UDP 首部的长度跟数据的长度之和。
校验和:校验和是为了提供可靠的 UDP 首部和数据而设计,防止收到在网络传输中受损的 UDP包。
TCP 和 UDP 区别
<b>1. 连接<br></b><ul><li>TCP 是面向连接的传输层协议,传输数据前先要建立连接。</li><li>UDP 是不需要连接,即刻传输数据。</li></ul>
<b>2. 服务对象</b><br><ul style=""><li style="">TCP 是一对一的两点服务,即一条连接只有两个端点。</li><li style="">UDP 支持一对一、一对多、多对多的交互通信</li></ul>
<b>3. 可靠性</b><br><ul><li>TCP 是可靠交付数据的,数据可以无差错、不丢失、不重复、按序到达。</li><li>UDP 是尽最大努力交付,不保证可靠交付数据。</li></ul>
<b>4. 拥塞控制、流量控制</b><br><ul style=""><li style="">TCP 有拥塞控制和流量控制机制,保证数据传输的安全性。</li><li style="">UDP 则没有,即使网络非常拥堵了,也不会影响 UDP 的发送速率。</li></ul>
<b>5. 首部开销</b><br><ul><li>TCP 首部长度较长,会有一定的开销,首部在没有使用「选项」字段时是 20 个字节,<br>如果使用了「选项」字段则会变长的。</li><li>UDP 首部只有 8 个字节,并且是固定不变的,开销较小。</li></ul>
<b>6. 传输方式</b><br><ul><li>TCP 是流式传输,没有边界,但保证顺序和可靠。</li><li>UDP 是一个包一个包的发送,是有边界的,但可能会丢包和乱序。</li></ul>
<b>7. 分片不同</b><br><ul><li>TCP 的数据大小如果大于 MSS 大小,则会在传输层进行分片,目标主机收到后,<br>也同样在传输层组装 TCP 数据包,如果中途丢失了一个分片,只需要传输丢失的这个分片。</li><li>UDP 的数据大小如果大于 MTU 大小,则会在 IP 层进行分片,目标主机收到后,<br>在 IP 层组装完数据,接着再传给传输层。</li></ul>
TCP 和 UDP 应用场景<br>
由于 TCP 是面向连接,能保证数据的可靠性交付,因此经常用于:<br><ul><li>FTP 文件传输;</li><li>HTTP / HTTPS;</li></ul>
由于 UDP 面向无连接,它可以随时发送数据,再加上 UDP 本身的处理既简单又高效,因此经常用于:<br><ul><li>包总量较少的通信,如 DNS 、SNMP 等;</li><li>视频、音频等多媒体通信;</li><li>广播通信;</li></ul>
为什么 UDP 头部没有「首部长度」字段,而 TCP 头部有「首部长度」字段呢?<br><br>原因是 TCP 有 <b>可变长 </b>的「选项」字段,而 UDP 头部长度则是 <b>不会变化 </b>的,无需多一个字段去记录 UDP 的首部长度。<br>
为什么 UDP 头部有「包长度」字段,而 TCP 头部则没有「包长度」字段呢?
先说说 TCP 是如何计算负载数据长度:
其中 IP 总长度 和 IP 首部长度,在 IP 首部格式是已知的。TCP 首部长度,<br>则是在 TCP 首部格式已知的,所以就可以求得 TCP 数据的长度。
大家这时就奇怪了问:“ UDP 也是基于 IP 层的呀,那 UDP 的数据长度也可以通过这个公式计算呀? 为何还要有「包长度」呢?”<br><br><b>因为为了网络设备硬件设计和处理方便,首部长度需要是 4字节的整数倍。</b><br>
如果去掉 UDP 「包长度」字段,那 UDP 首部长度就不是 4 字节的整数倍了,<br>所以作者觉得这可能是为了补全 UDP 首部长度是 4 字节的整数倍,才补充了「包长度」字段。
TCP 连接建立
TCP 三次握手过程是怎样的?<br>
TCP 是面向连接的协议,所以使用 TCP 前必须先建立连接,而建立连接是通过三次握手来进行的。三次握手的过程如下图:
一开始,客户端和服务端都处于 CLOSED 状态。先是服务端主动监听某个端口,处于 LISTEN 状态
客户端会随机初始化序号(client_isn),将此序号置于 TCP 首部的「序号」字段中,同时把 SYN 标志位置为 1 ,表示 SYN 报文。<br>接着把第一个 SYN 报文发送给服务端,表示向服务端发起连接,该报文不包含应用层数据,之后客户端处于 SYN-SENT 状态。
服务端收到客户端的 SYN 报文后,首先服务端也随机初始化自己的序号(server_isn),将此序号填入 TCP 首部的「序号」字段中,<br>其次把 TCP 首部的「确认应答号」字段填入 client_isn + 1, 接着把 SYN 和 ACK 标志位置为 1。最后把该报文发给客户端,该报文<br>也不包含应用层数据,之后服务端处于 SYN-RCVD 状态。
客户端收到服务端报文后,还要向服务端回应最后一个应答报文,首先该应答报文 TCP 首部 ACK 标志位置为 1 ,其次「确认应答号」<br>字段填入 server_isn + 1 ,最后把报文发送给服务端,这次报文可以携带客户到服务器的数据,之后客户端处于 ESTABLISHED 状态。
服务器收到客户端的应答报文后,也进入 ESTABLISHED 状态。
从上面的过程可以发现 <b><font color="#0000ff">第三次握手是可以携带数据的,前两次握手是不可以携带数据的</font>。</b>
一旦完成三次握手,双方都处于 ESTABLISHED 状态,此时连接就已建立完成,客户端和服务端就可以相互发送数据了。
如何在 Linux 系统中查看 TCP 状态?<br>
TCP 的连接状态查看,在 Linux 可以通过 netstat -napt 命令查看。
为什么是三次握手?不是两次、四次?<br>
什么是 <b><font color="#0000ff">TCP 连接</font></b>:<br><ul><li><span style="font-size: inherit;">用于保证可靠性和流量控制维护的某些状态信息,这些信息的组合,包括 </span><b style="font-size: inherit;"><font color="#0000ff">Socket、序列号和窗口大小</font> </b><span style="font-size: inherit;">称为连接。</span></li></ul><br>所以,重要的是回答 <b><font color="#0000ff">为什么三次握手才可以初始化Socket、序列号和窗口大小并建立 TCP 连接</font></b>。
接下来,以三个方面分析三次握手的原因:<br><ul><li>三次握手才可以阻止重复历史连接的初始化(主要原因)</li><li>三次握手才可以同步双方的初始序列号</li><li>三次握手才可以避免资源浪费</li></ul>
原因一:避免历史连接
RFC 793 指出的 TCP 连接使用三次握手的首要原因:<br>The principle reason for the three-way handshake is to prevent <br>old duplicate connection initiations from causing confusion.<br>
简单来说,三次握手的首要原因是 <b><font color="#0000ff">为了防止旧的重复连接初始化造成混乱</font></b>。
我们考虑一个场景,客户端先发送了 SYN(seq = 90) 报文,然后客户端宕机了,而且这个 SYN 报文还被网络阻塞了,服务端并没有收到,<br>接着客户端重启后,又重新向服务端建立连接,发送了 SYN(seq = 100) 报文(注意不是重传 SYN,重传的 SYN 的序列号是一样的)。<br><br>看看三次握手是如何阻止历史连接的:<br>
客户端连续发送多次 SYN 建立连接的报文,在 <b><font color="#0000ff">网络拥堵</font> </b>情况下:<br><ul><li>一个「旧 SYN 报文」比「最新的 SYN 」 报文早到达了服务端;</li><li>那么此时服务端就会回一个 SYN + ACK 报文给客户端;</li><li><span style="font-size: inherit;">客户端收到后可以根据自身的上下文,判断这是一个历史连接(序列号过期或超时),<br>那么客户端就会发送 RST 报文给服务端,表示中止这一次连接。</span></li></ul>
如果是两次握手连接,就无法阻止历史连接,为什么?<br>
主要是因为在两次握手的情况下,<b><font color="#0000ff">「被动发起方」没有中间状态给「主动发起方」<br>来阻止历史连接,导致「被动发起方」可能建立一个历史连接,造成资源浪费</font>。</b>
两次握手的情况下,「被动发起方」在收到 SYN 报文后,就进入 ESTABLISHED 状态,意味着这时可以给对方发送数据,但是「主动发起方」此时还没有进入 ESTABLISHED 状态,假设这次是历史连接,「主动发起方」判断到此次连接为历史连接,那么就会回 RST 报文来断开连接,而「被动发起方」在第一次握手的时候就进入 ESTABLISHED 状态,所以它可以发送数据的(丢失了),但是它并不知道这个是历史连接。它只有在收到 RST 报文后,才会断开连接。
可以看到,上面这种场景下,「被动发起方」在向「主动发起方」发送数据前,并没有阻止掉历史连接,<br>导致「被动发起方」建立了一个历史连接,又白白发送了数据,妥妥地浪费了「被动发起方」的资源。
因此,要解决这种现象,最好就是 <b><font color="#0000ff">在「被动发起方」发送数据前,也就是建立连接之前,<br>要阻止掉历史连接,这样就不会造成资源浪费,而要实现这个功能,就需要三次握手</font>。</b>
所以,<b><font color="#0000ff">TCP 使用三次握手建立连接的最主要原因是防止「历史连接」初始化了连接。</font></b>
原因二:同步双方初始序列号<br>
TCP 协议的 <b><font color="#0000ff">通信双方</font></b>, <font color="#0000ff"><b>都必须维护一个「序列号」</b></font>, 序列号是可靠传输的一个关键因素,它的作用:<br><ul><li>接收方可以去除重复的数据;</li><li>接收方可以根据数据包的序列号按序接收;</li><li>可以标识发送出去的数据包中, 哪些是已经被对方收到的(通过 ACK 报文中的序列号知道);</li></ul>
可见,序列号在 TCP 连接中占据着非常重要的作用,所以当客户端发送携带「初始序列号」的 SYN 报文的时候,<br>需要服务端回一个 ACK 应答报文,表示客户端的 SYN 报文已被服务端成功接收,那当服务端发送「初始序列号」<br>给客户端的时候,依然也要得到客户端的应答回应,<b><font color="#0000ff">这样一来一回,才能确保双方的初始序列号能被可靠的同步</font></b>。
四次握手其实也能够可靠的同步双方的初始化序号,但由于 <font color="#0000ff"><b>第二步和第三步可以优化成一步</b></font>,所以就成了「三次握手」。
<b><font color="#0000ff">而两次握手只保证了一方的初始序列号能被对方成功接收,没办法保证双方的初始序列号都能被确认接收。</font></b>
原因三:避免资源浪费<br>
如果只有「两次握手」,当客户端的 SYN 请求连接在网络中阻塞,客户端没有接收到 ACK 报文,就会重新发送 SYN ,<b><font color="#0000ff">由于没有第三次握手,<br>服务器不清楚客户端是否收到了自己发送的建立连接的 ACK 确认信号,所以每收到一个 SYN 就只能先主动建立一个连接</font></b>,这会造成什么情况呢?
如果客户端的 SYN 阻塞了,重复发送多次 SYN 报文,那么服务器在收到请求后就会 <b><font color="#0000ff">建立多个冗余的无效链接,造成不必要的资源浪费。</font></b>
即两次握手会造成消息滞留情况下,服务器重复接受无用的连接请求 SYN 报文,而造成重复分配资源。
小结
TCP 建立连接时,通过三次握手能 <b>防止历史连接的建立,能减少双方不必要的资源开销,能帮助双方同步初始化序列号</b>。<br>序列号能够保证数据包不重复、不丢弃和按序传输。
不使用「两次握手」和「四次握手」的原因:<br><ul><li>「两次握手」:无法防止历史连接的建立,会造成双方资源的浪费,也无法可靠的同步双方序列号;</li><li>「四次握手」:三次握手就已经理论上最少可靠连接建立,所以不需要使用更多的通信次数。</li></ul>
为什么每次建立 TCP 连接时,初始化的序列号都要求不一样呢?<br>
主要原因有两个方面:<br><ul><li><b><font color="#0000ff">为了防止历史报文被下一个相同四元组的连接接收</font></b>(主要方面);</li><li>为了安全性,防止黑客伪造的相同序列号的 TCP 报文被对方接收;</li></ul>
方面一
假设每次建立连接,客户端和服务端的初始化序列号都是从 0 开始:
过程如下:<br><ul><li>客户端和服务端建立一个 TCP 连接,在客户端发送数据包被网络阻塞了,然后超时重传了这个数据包,而此时<br>服务端设备断电重启了,之前与客户端建立的连接就消失了,于是在收到客户端的数据包的时候就会发送 RST 报文。</li><li>紧接着,客户端又与服务端 <b><font color="#0000ff">建立了与上一个连接相同四元组的连接</font></b>;</li><li>在新连接建立完成后,上一个连接中被网络阻塞的数据包正好抵达了服务端,<b><font color="#0000ff">刚好该数据包的<br>序列号正好是在服务端的接收窗口内</font></b>,所以该数据包会被服务端正常接收,就会造成数据错乱。</li></ul>
可以看到,<b><font color="#0000ff">如果每次建立连接,客户端和服务端的初始化序列号都是一样的话,<br>很容易出现历史报文被下一个相同四元组的连接接收的问题</font>。</b>
如果每次建立连接客户端和服务端的初始化序列号都「不一样」,就有大概率因为历史<br>报文的序列号「不在」对方接收窗口,从而很大程度上避免了历史报文,比如下图:
相反,如果每次建立连接客户端和服务端的初始化序列号都「一样」,就有大概率遇到<br>历史报文的序列号刚「好在」对方的接收窗口内,从而导致历史报文被新连接成功接收。
所以,每次初始化序列号不一样很大程度上能够避免历史报文被下一个相同四元组的连接接收,注意是很大<br>程度上,并 <b><font color="#0000ff">不是完全避免</font> </b>了(因为 <b><font color="#0000ff">序列号会有回绕的问题</font></b>,所以需要用 <b><font color="#0000ff">时间戳 </font></b>的机制来判断历史报文。
初始序列号 ISN 是如何随机产生的?<br>
起始 ISN 是基于时钟的,每 4 微秒 + 1,转一圈要 4.55 个小时。
RFC793 提到初始化序列号 ISN 随机生成算法:ISN = M + F(localhost, localport, remotehost, remoteport)。<br><ul><li>M 是一个计时器,这个计时器每隔 4 微秒加 1。</li><li>F 是一个 Hash 算法,根据源 IP、目的 IP、源端口、目的端口生成一个随机数值。<br>要保证 Hash 算法不能被外部轻易推算得出,用 MD5 算法是一个比较好的选择。</li></ul>
可以看到,随机数是会基于时钟计时器递增的,基本不可能会随机成一样的初始化序列号。
既然 IP 层会分片,为什么 TCP 层还需要 MSS 呢?<br>
先来认识下 MTU 和 MSS
<ul><li>MTU:一个网络包的最大长度,以太网中一般为 1500 字节;</li><li>MSS:除去 IP 和 TCP 头部之后,一个网络包所能容纳的 TCP 数据的最大长度;</li></ul>
如果在 TCP 的整个报文(头部 + 数据)交给 IP 层进行分片,会有什么异常呢?
当 IP 层有一个超过 MTU 大小的数据(TCP 头部 + TCP 数据)要发送,那么 IP 层就要进行分片,把数据分片成若干片,保证<br>每一个分片都小于 MTU。把一份 IP 数据报进行分片以后,由目标主机的 IP 层来进行重新组装后,再交给上一层 TCP 传输层。
这看起来井然有序,但这存在隐患的,<b><font color="#0000ff">当一个 IP 分片丢失,整个 IP 报文的所有分片都得重传</font>。</b>
<font color="#0000ff">因为 IP 层本身没有超时重传机制,它由传输层的 TCP 来负责超时和重传。</font>
当接收方发现 TCP 报文(头部 + 数据)的某一片丢失后,则不会响应 ACK 给对方,<br>那么发送方的 TCP 在超时后,就会重发「整个 TCP 报文(头部 + 数据)」。
因此,可以得知由 IP 层进行分片传输,是非常没有效率的。
所以,为了达到最佳的传输效能,TCP 协议在 <b><font color="#0000ff">建立连接的时候通常要协商双方的 MSS 值</font></b>,当 TCP 层发现数据<br>超过 MSS 时,则就先会进行分片,当然由它形成的 IP 包的长度也就不会大于 MTU ,自然也就不用 IP 分片了。
经过 TCP 层分片后,如果一个 TCP 分片丢失后,<b><font color="#0000ff">进行重发时也是以 MSS 为单位</font></b>,而不用重传所有的分片,大大增加了重传的效率。
第一次握手丢失了,会发生什么?<br>
当客户端想和服务端建立 TCP 连接的时候,首先第一个发的就是 SYN 报文,然后进入到 SYN_SENT 状态。
在这之后,如果客户端迟迟收不到服务端的 SYN-ACK 报文(第二次握手),就会触发<br>「超时重传」机制,重传 SYN 报文,而且 <b><font color="#0000ff">重传的 SYN 报文的序列号都是一样的</font></b>。
不同版本的操作系统可能超时时间不同,有的 1 秒的,也有 3 秒的,这个<br>超时时间是写死在内核里的,如果想要更改则需要重新编译内核,比较麻烦。<br>
当客户端在 1 秒后没收到服务端的 SYN-ACK 报文后,客户端就会重发 SYN 报文,那到底重发几次呢?
在 Linux 里,客户端的 SYN 报文 <b><font color="#0000ff">最大重传次数由 tcp_syn_retries 内核参数控制</font></b>,这个参数是可以自定义的,默认值一般是 5。<br>
通常,第一次超时重传是在 1 秒后,第二次超时重传是在 2 秒,第三次超时重传是在 4 秒后,<br>第四次超时重传是在 8 秒后,第五次是在超时重传 16 秒后。没错,<b><font color="#0000ff">每次超时的时间是上一次的 2 倍</font></b>。
当第五次超时重传后,会继续等待 32 秒,如果服务端仍然没有回应 ACK,客户端就 <b><font color="#0000ff">不再发送 SYN 包,然后断开 TCP 连接</font></b>。
所以,总耗时是 1+2+4+8+16+32=63 秒,大约 1 分钟左右。
举个例子,假设 tcp_syn_retries 参数值为 3,那么当客户端的 SYN 报文一直在网络中丢失时,会发生下图的过程:<br>
具体过程:<br><ul><li><span style="font-size: inherit;">当客户端超时重传 3 次 SYN 报文后,由于 tcp_syn_retries 为 3,已达到最大重传次数,于是再等待一段时间(时间为上一次超时时间的 2 倍),<br>如果还是没能收到服务端的第二次握手(SYN-ACK 报文),那么客户端就会断开连接。</span></li></ul>
第二次握手丢失了,会发生什么?<br>
当服务端收到客户端的第一次握手后,就会回 SYN-ACK 报文给客户端,这个就是第二次握手,此时服务端会进入 SYN_RCVD 状态。
第二次握手的 SYN-ACK 报文其实有两个目的 :<br><ul><li>第二次握手里的 ACK, 是对第一次握手的确认报文;</li><li>第二次握手里的 SYN,是服务端发起建立 TCP 连接的报文;</li></ul>
所以,如果第二次握手丢了,就会发生比较有意思的事情,下面就一起来探讨探讨吧!<br>
因为第二次握手报文里是包含对客户端的第一次握手的 ACK 确认报文,所以,如果客户端迟迟没有收到第二次握手,<br>那么客户端就觉得可能自己的 SYN 报文(第一次握手)丢失了,于是<font color="#0000ff"> </font><b><font color="#0000ff">客户端就会触发超时重传机制,重传 SYN 报文</font>。</b>
然后,因为第二次握手中包含服务端的 SYN 报文,所以当客户端收到后,需要给服务端<br>发送 ACK 确认报文(第三次握手),服务端才会认为该 SYN 报文被客户端收到了。
那么,如果第二次握手丢失了,服务端就收不到第三次握手,于是 <b><font color="#0000ff">服务端这边会触发超时重传机制,重传 SYN-ACK 报文</font>。</b>
在 Linux 下,SYN-ACK 报文的 <b><font color="#0000ff">最大重传次数由 tcp_synack_retries 内核参数决定</font></b>,默认值是 5。
因此,当第二次握手丢失了,<b><font color="#0000ff">客户端和服务端都会重传</font></b>:<br><ul><li>客户端会重传 SYN 报文,也就是第一次握手,最大重传次数由 tcp_syn_retries内核参数决定;</li><li>服务端会重传 SYN-ACK 报文,也就是第二次握手,最大重传次数由 tcp_synack_retries 内核参数决定。</li></ul>
举个例子,假设 tcp_syn_retries 参数值为 1,tcp_synack_retries 参数值为 2,<br>那么当第二次握手一直丢失时,发生的过程如下图:
具体过程:<br><ul><li>当客户端超时重传 1 次 SYN 报文后,由于 tcp_syn_retries 为 1,已达到最大重传次数,于是再等待一段时间(时间<br>为上一次超时时间的 2 倍),如果还是没能收到服务端的第二次握手(SYN-ACK 报文),那么客户端就会断开连接。</li><li>当服务端超时重传 2 次 SYN-ACK 报文后,由于 tcp_synack_retries 为 2,已达到最大重传次数,于是再等待一段时间<br>(时间为上一次超时时间的 2 倍),如果还是没能收到客户端的第三次握手(ACK 报文),那么服务端就会断开连接。</li></ul>
第三次握手丢失了,会发生什么?<br>
客户端收到服务端的 SYN-ACK 报文后,就会给服务端回一个 ACK 报文,也就是第三次握手,此时客户端状态进入到 ESTABLISH 状态。
因为这个第三次握手的 ACK 是对第二次握手的 SYN 的确认报文,所以当第三次握手丢失了,如果 <b><font color="#0000ff">服务端那一方迟迟<br>收不到这个确认报文</font></b>,就会触发超时重传机制,<b><font color="#0000ff">重传 SYN-ACK 报文</font></b>,直到收到第三次握手,或者达到最大重传次数。
注意,<b><font color="#0000ff">ACK 报文是不会有重传的,当 ACK 丢失了,就由对方重传对应的报文</font>。</b>
举个例子,假设 tcp_synack_retries 参数值为 2,那么当第三次握手一直丢失时,发生的过程如下图:<br>
具体过程:<br><ul><li>当服务端超时重传 2 次 SYN-ACK 报文后,由于 tcp_synack_retries 为 2,已达到最大重传次数,于是再等待一段时间<br>(时间为上一次超时时间的 2 倍),如果还是没能收到客户端的第三次握手(ACK 报文),那么服务端就会断开连接。</li></ul>
什么是 SYN 攻击?如何避免 SYN 攻击?<br>
SYN 攻击
我们都知道 TCP 连接建立是需要三次握手,假设攻击者短时间伪造不同 IP 地址的 SYN 报文,服务端每接收到一个 SYN 报文,就进入SYN_RCVD 状态,但服务端发送出去的 ACK + SYN 报文,无法得到未知 IP 主机的 ACK 应答,久而久之就会占满服务端的半连接队列,使得服务器不能为正常用户服务。
避免 SYN 攻击方式一<br>
其中一种解决方式是通过修改 Linux 内核参数,控制队列大小和当队列满时应做什么处理。
当网卡接收数据包的速度大于内核处理的速度时,会有一个队列保存这些数据包。控制该队列的最大值如下参数:<br><ul><li>net.core.netdev_max_backlog</li></ul>
SYN_RCVD 状态连接的最大个数:<br><ul><li>net.ipv4.tcp_max_syn_backlog</li></ul>
超出处理能时,对新的 SYN 直接回报 RST,丢弃连接:<br><ul><li>net.ipv4.tcp_abort_on_overflow</li></ul>
避免 SYN 攻击方式二<br>
先来看下 Linux 内核的 SYN 队列(半连接队列)与 Accpet 队列(全连接队列)是如何工作的?
正常流程:<br><ul><li>当服务端接收到客户端的 SYN 报文时,会将其加入到内核的「 SYN 队列」;</li><li>接着发送 SYN + ACK 给客户端,等待客户端回应 ACK 报文;</li><li>服务端接收到 ACK 报文后,从「 SYN 队列」移除放入到「 Accept 队列」;</li><li>应用通过调用 accpet() socket 接口,从「 Accept 队列」取出连接。</li></ul>
应用程序过慢:<br><ul><li>如果应用程序过慢时,就会导致「 Accept 队列」被占满。</li></ul>
受到 SYN 攻击:<br><ul><li>如果不断受到 SYN 攻击,就会导致 SYN 队列(半连接队列)被占满,从而导致无法在建立新的连接。</li></ul>
tcp_syncookies 的方式可以应对 SYN 攻击的方法:<br><ul><li>net.ipv4.tcp_syncookies = 1</li></ul>
<ul><li>当 「 SYN 队列」满之后,后续服务器收到 SYN 包,不进入「 SYN 队列」;</li><li>计算出一个 cookie 值,再以 SYN + ACK 中的「序列号」返回客户端,</li><li>服务端接收到客户端的应答报文时,服务器会检查这个 ACK 包的合法性。如果合法,直接放入到「 Accept 队列」。</li><li>最后应用通过调用 accpet() socket 接口,从「 Accept 队列」取出的连接。</li></ul>
TCP 连接断开
TCP 四次挥手过程是怎样的?<br>
天下没有不散的宴席,对于 TCP 连接也是这样, TCP 断开连接是通过 <b><font color="#0000ff">四次挥手</font> </b>方式。
双方都可以主动断开连接,断开连接后主机中的「资源」将被释放,四次挥手的过程如下图:
<ul><li>客户端打算关闭连接,此时会发送一个 TCP 首部 FIN 标志位被置为 1 的报文,也即 FIN 报文,之后客户端进入 FIN_WAIT_1 状态。</li><li>服务端收到该报文后,就向客户端发送 ACK 应答报文,接着服务端进入 CLOSED_WAIT 状态。</li><li>客户端收到服务端的 ACK 应答报文后,之后进入 FIN_WAIT_2 状态。</li><li>等待服务端处理完数据后,也向客户端发送 FIN 报文,之后服务端进入 LAST_ACK 状态。</li><li>客户端收到服务端的 FIN 报文后,回一个 ACK 应答报文,之后进入 TIME_WAIT 状态</li><li>服务器收到了 ACK 应答报文后,就进入了 CLOSED 状态,至此服务端已经完成连接的关闭。</li><li>客户端在经过 2MSL 一段时间后,自动进入 CLOSED 状态,至此客户端也完成连接的关闭。</li></ul>
可以看到,<b><font color="#0000ff">每个方向都需要一个 FIN 和一个 ACK</font></b>,因此通常被称为 <b><font color="#0000ff">四次挥手</font></b>。
这里一点需要注意是:<b><font color="#0000ff">主动关闭连接的,才有 TIME_WAIT 状态</font>。</b>
为什么挥手需要四次?<br>
再来回顾下四次挥手双方发 FIN 包的过程,就能理解为什么需要四次了。<br><br><li>关闭连接时,客户端向服务端发送 FIN 时,仅仅表示客户端不再发送数据了但是还能接收数据。</li><li>服务器收到客户端的 FIN 报文时,先回一个 ACK 应答报文,<b><font color="#0000ff">而服务端可能还有数据需要处理和发送</font></b>,<br> <b><font color="#0000ff">等服务端不再发送数据时</font></b>,<b><font color="#0000ff">才发送 FIN 报文 </font></b>给客户端来表示同意现在关闭连接。</li>
从上面过程可知,服务端通常需要等待完成数据的发送和处理,所以服务端的 ACK 和 FIN 一般都会分开发送,因此是需要四次挥手。
<b><font color="#0000ff">但是在特定情况下,四次挥手是可以变成三次挥手的</font>,</b>比如 <b><font color="#0000ff">开启了 TCP 延迟确认</font></b> 且 <b><font color="#0000ff">服务端没有数据要发送时</font>。</b>
第一次挥手丢失了,会发生什么?<br>
当客户端(主动关闭方)调用 close 函数后,就会向服务端发送 FIN 报文,<br>试图与服务端断开连接,此时客户端的连接进入到 FIN_WAIT_1 状态。
正常情况下,如果能及时收到服务端(被动关闭方)的 ACK,则会很快变为 FIN_WAIT2状态。
如果第一次挥手丢失了,那么 <b><font color="#0000ff">客户端迟迟收不到被动方的 ACK 的话,也就会触发<br>超时重传机制,重传 FIN 报文</font></b>,重发次数由 tcp_orphan_retries 参数控制。
当客户端重传 FIN 报文的次数超过 tcp_orphan_retries 后,就不再发送 FIN 报文,则会在等待一段<br>时间(时间为上一次超时时间的 2 倍),如果还是没能收到第二次挥手,那么直接进入到 close 状态。
举个例子,假设 tcp_orphan_retries 参数值为 3,当第一次挥手一直丢失时,发生的过程如下图:
具体过程:<br><ul><li>当客户端超时重传 3 次 FIN 报文后,由于 tcp_orphan_retries 为 3,已达到最大重传次数,于是再等待一段时间<br>(时间为上一次超时时间的 2 倍),如果还是没能收到服务端的第二次挥手(ACK报文),那么客户端就会断开连接。</li></ul>
第二次挥手丢失了,会发生什么?
当服务端收到客户端的第一次挥手后,就会先回一个 ACK 确认报文,此时服务端的连接进入到 CLOSE_WAIT 状态。
在前面我们也提了,<b><font color="#0000ff">ACK 报文是不会重传的</font></b>,所以如果服务端的第二次挥手丢失了,<b><font color="#0000ff">客户端就会<br>触发超时重传机制,重传 FIN 报文</font></b>,直到收到服务端的第二次挥手,或者达到最大的重传次数。
举个例子,假设 tcp_orphan_retries 参数值为 2,当第二次挥手一直丢失时,发生的过程如下图:
具体过程:<br><ul><li>当客户端超时重传 2 次 FIN 报文后,由于 tcp_orphan_retries 为 2,已达到最大重传次数,于是再等待一段时间<br>(时间为上一次超时时间的 2 倍),如果还是没能收到服务端的第二次挥手(ACK 报文),那么客户端就会断开连接。</li></ul>
这里提一下,当客户端收到第二次挥手,也就是收到服务端发送的 ACK 报文后,客户端就会<br>处于 FIN_WAIT2 状态,在这个状态需要等服务端发送第三次挥手,也就是服务端的 FIN 报文。
对于 <b><font color="#0000ff">close 函数关闭的连接</font></b>,由于 <b><font color="#0000ff">无法再发送和接收数据</font></b>,<b><font color="#0000ff">所以 FIN_WAIT2 状态不可以<br>持续太久</font></b>,而 tcp_fin_timeout 控制了这个状态下连接的持续时长,默认值是 60 秒。
这意味着对于调用 close 关闭的连接,如果在 60 秒后还没有收到 FIN 报文,客户端(主动关闭方)的连接就会直接关闭,如下图:
但是注意,如果 <b><font color="#0000ff">主动关闭方使用 shutdown 函数关闭连接</font></b>,指定了 <b><font color="#0000ff">只关闭发送方向,<br>而接收方向并没有关闭</font></b>,那么意味着主动关闭方还是可以接收数据的。
此时,如果主动关闭方 <b><font color="#0000ff">一直没收到第三次挥手</font></b>,那么 <b><font color="#0000ff">主动关闭方的连接将会一直处于 <br>FIN_WAIT2 状态</font></b>(tcp_fin_timeout 无法控制 shutdown 关闭的连接)。如下图:
因为调用 shutdown 函数表明客户端还可以接收数据,而还没收到服务器的 FIN 报文,表明服务器还有数据没发送完,所以客户端会一直等;<br>而调用 close 函数,客户端接收方向也关闭了,所以不管服务器还有没有数据发送,客户端都会在 tcp_fin_timeout 时间后关闭连接。
第三次挥手丢失了,会发生什么?
当服务端(被动关闭方)收到客户端(主动关闭方)的 FIN 报文后,<b><font color="#0000ff">内核会自动回复 ACK</font></b>,<br>同时连接处于 CLOSE_WAIT 状态,顾名思义,它表示 <b><font color="#0000ff">等待应用进程调用 close 函数关闭连接</font></b>。
此时,内核是没有权利替代进程关闭连接,<b><font color="#0000ff">必须由进程主动调用 close 函数来触发服务端发送 FIN 报文</font></b>。
服务端处于 CLOSE_WAIT 状态时,<b><font color="#0000ff">调用了 close 函数,内核就会发出 FIN 报文</font></b>,<br>同时连接进入 LAST_ACK 状态,等待客户端返回 ACK 来确认连接关闭。
如果迟迟收不到这个 ACK,服务端就会重发 FIN 报文,重发次数仍然由 tcp_orphan_retries <br>参数控制,这与客户端重发 FIN 报文的重传次数控制方式是一样的。
举个例子,假设 tcp_orphan_retries = 3,当第三次挥手一直丢失时,发生的过程如下图:
具体过程:<br><ul><li>当服务端重传第三次挥手报文的次数达到了 3 次后,由于 tcp_orphan_retries 为 3,达到了重传最大次数,于是再等待一段<br>时间(时间为上一次超时时间的 2 倍),如果还是没能收到客户端的第四次挥手(ACK报文),那么服务端就会断开连接。</li><li><b><font color="#0000ff">客户端因为是通过 close 函数关闭连接的,处于 FIN_WAIT_2 状态是有时长限制的</font></b>,如果 tcp_fin_timeout <br>时间内还是没能收到服务端的第三次挥手(FIN 报文),那么客户端就会断开连接。</li></ul>
第四次挥手丢失了,会发生什么?
当客户端收到服务端的第三次挥手的 FIN 报文后,就会回 ACK 报文,也就是第四次挥手,此时客户端连接进入 TIME_WAIT 状态。
在 Linux 系统,<b><font color="#0000ff">TIME_WAIT 状态会持续 2MSL 后才会进入关闭状态</font></b>。
然后,服务端(被动关闭方)没有收到 ACK 报文前,还是处于 LAST_ACK 状态。
如果第四次挥手的 ACK 报文没有到达服务端,<b><font color="#0000ff">服务端就会重发 FIN 报文</font></b>,重发次数仍然由前面介绍过的 tcp_orphan_retries 参数控制。
举个例子,假设 tcp_orphan_retries 为 2,当第四次挥手一直丢失时,发生的过程如下:
具体过程:<br><ul><li>当服务端重传第三次挥手报文达到 2 时,由于 tcp_orphan_retries 为 2, 达到了最大重传次数,于是再等待一段时间<br>(时间为上一次超时时间的 2 倍),如果还是没能收到客户端的第四次挥手(ACK 报文),那么服务端就会断开连接。</li><li>客户端在收到第三次挥手后,就会进入 TIME_WAIT 状态,开启时长为 2MSL 的定时器,<b><font color="#0000ff">如果途中再次<br>收到第三次挥手(FIN 报文)后,就会重置定时器</font></b>,当等待 2MSL 时长后,客户端就会断开连接。</li></ul>
为什么 TIME_WAIT 等待的时间是 2MSL?<br>
<b><font color="#0000ff">MSL</font></b> 是 Maximum Segment Lifetime,<b><font color="#0000ff">报文最大生存时间</font></b>,它是任何报文在网络上存在的最长时间,超过这个<br>时间报文将被丢弃。因为 TCP 报文基于是 IP 协议的,而 IP 头中有一个 TTL 字段,是 IP 数据报可以经过的最大<br>路由数,每经过一个处理他的路由器此值就减 1,当此值为 0 则数据报将被丢弃,同时发送 ICMP 报文通知源主机。
MSL 与 TTL 的区别: MSL 的单位是时间,而 TTL 是经过路由跳数。<br>所以 <b><font color="#0000ff">MSL 应该要大于等于 TTL 消耗为 0 的时间</font>,</b>以确保报文已被自然消亡。
<b>TTL 的值一般是 64,Linux 将 MSL 设置为 30 秒,意味着 Linux 认为数据报文经过 <br>64 个路由器的时间不会超过 30 秒,如果超过了,就认为报文已经消失在网络中了。</b>
TIME_WAIT 等待 2 倍的 MSL,比较合理的解释是: 网络中可能存在来自发送方的数据包,当<br>这些发送方的数据包被接收方处理后又会向对方发送响应,所以 <b><font color="#0000ff">一去一回需要等待 2 倍的时间</font></b>。
比如,如果被动关闭方没有收到断开连接的最后的 ACK 报文,就会触发超时重发 FIN 报文,<br>另一方接收到 FIN 后,会重发 ACK 给被动关闭方, 一去一回正好 2 个 MSL。
可以看到 <b><font color="#0000ff">2MSL 时长</font></b> 这其实是相当于 <b><font color="#0000ff">至少允许报文丢失一次</font></b>。比如,若 ACK 在一个 MSL 内丢失,<br>这样被动方 <b><font color="#0000ff">重发的 FIN 会在第 2 个 MSL 内到达</font></b>,TIME_WAIT 状态的连接可以应对。
为什么不是 4 或者 8 MSL 的时长呢?你可以想象一个丢包率达到百分之一的糟糕网络,<br>连续两次丢包的概率只有万分之一,这个概率实在是太小了,忽略它比解决它更具性价比。
2MSL 的时间是从 <b><font color="#0000ff">客户端接收到 FIN 后发送 ACK 开始计时的</font></b>。如果在 TIME-WAIT 时间内,因为客户端<br>的 ACK 没有传输到服务端,客户端又接收到了服务端重发的 FIN 报文,那么<font color="#0000ff"> <b>2MSL 时间将重新计时</b></font>。
在 Linux 系统里 2MSL 默认是 60 秒,那么一个 MSL 也就是 30 秒。<b><font color="#0000ff">Linux 系统停留在 TIME_WAIT 的时间为固定的 60 秒。</font></b>
其定义在 Linux 内核代码里的名称为 TCP_TIMEWAIT_LEN:
如果要修改 TIME_WAIT 的时间长度,只能修改 Linux 内核代码里 TCP_TIMEWAIT_LEN 的值,并重新编译 Linux 内核。
为什么需要 TIME_WAIT 状态?<br>
主动发起关闭连接的一方,才会有 TIME-WAIT 状态。
需要 TIME-WAIT 状态,主要是两个原因:<br><ul><li><b><font color="#0000ff">防止历史连接中的数据,被后面相同四元组的连接错误的接收</font></b>;(连接时初始随机序列号也是防止这个)</li><li><b><font color="#0000ff">保证「被动关闭连接」的一方,能被正确的关闭</font></b>;</li></ul>
原因一<br>
为了能更好的理解这个原因,先来了解序列号(SEQ)和初始序列号(ISN):<br><ul><li><b><font color="#0000ff">序列号</font></b>,是 TCP 一个头部字段,标识了 TCP 发送端到 TCP 接收端的数据流的一个字节,因为 TCP 是面向字节流的</li></ul> 可靠协议,为了保证消息的顺序性和可靠性,TCP 为每个传输方向上的每个字节都赋予了一个编号,以便于传输成功<br> 后确认、丢失后重传以及在接收端保证不会乱序。<b><font color="#0000ff">序列号是一个 32 位的无符号数,因此在到达 4G 之后再循环回到 0</font></b>。<br><ul><li><b><font color="#0000ff">初始序列号</font></b>,在 TCP 建立连接的时候,客户端和服务端都会各自生成一个初始序列号,它是基于时钟生成的一个随机数,来保证每个</li></ul> 连接都拥有不同的初始序列号。<b><font color="#0000ff">初始化序列号可被视为一个 32 位的计数器,该计数器的数值每 4 微秒加 1,循环一次需要 4.55 小时</font></b>。<br>
通过前面我们知道,<b><font color="#0000ff">序列号和初始化序列号并不是无限递增的,会发生回绕为初始值的情况,这意味着无法根据序列号来判断新老数据</font>。</b>
假设 TIME-WAIT 没有等待时间或时间过短,被延迟的数据包抵达后会发生什么呢?
如上图:<br><ul><li>服务端在关闭连接之前发送的 SEQ = 301 报文,被网络延迟了。</li><li>接着,服务端以相同的四元组重新打开了新连接,前面被延迟的 SEQ = 301 这时抵达了客户端,而且该数据报文的序列号刚好在客户端<br>接收窗口内,因此客户端会正常接收这个数据报文,但是这个数据报文是上一个连接残留下来的,这样就产生数据错乱等严重的问题。</li></ul>
为了防止历史连接中的数据,被后面相同四元组的连接错误的接收,因此 TCP 设计了 TIME_WAIT 状态,状态会持续 2MSL 时长,<br><b><font color="#0000ff">这个时间足以让两个方向上的数据包都被丢弃,使得原来连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的</font>。<br><br></b>MSL 是 Maximum Segment Lifetime的英文缩写,可译为“最长报文段寿命”,<br>它是任何报文在网络上存在的最长的最长时间,<b><font color="#0000ff">超过这个时间报文将被丢弃</font></b>。<b><br></b>
原因二
在 RFC 793 指出 TIME-WAIT 另一个重要的作用是:<br>TIME-WAIT - represents waiting for enough time to pass to be sure the remote <br>TCP received the acknowledgment of its connection termination request.<br>
也就是说,<b><font color="#0000ff">TIME-WAIT 作用是等待足够的时间以确保最后的 ACK 能让被动关闭方接收,从而帮助其正常关闭</font>。</b>
如果客户端(主动关闭方)最后一次 ACK 报文(第四次挥手)在网络中丢失了,<br>那么按照 TCP 可靠性原则,服务端(被动关闭方)会重发 FIN 报文。
假设客户端没有 TIME_WAIT 状态,而是在 <b><font color="#0000ff">发完最后一次回 ACK 报文就直接进入 CLOSED 状态</font></b>,如果 <b><font color="#0000ff">该 ACK 报文丢失了</font></b>,<br><b><font color="#0000ff">服务端则重传的 FIN 报文,而这时客户端已经进入到关闭状态了,在收到服务端重传的 FIN 报文后,就会回 RST 报文</font></b>。
服务端收到这个 RST 并将其解释为一个错误(Connection reset by peer),这对于一个可靠的协议来说不是一个优雅的终止方式。
为了防止这种情况出现,客户端必须等待足够长的时间,确保服务端能够收到 ACK,<b><font color="#0000ff">如果服务端没有收到 ACK,<br>那么就会触发 TCP 重传机制,服务端会重新发送一个 FIN,这样一去一来刚好两个 MSL 的时间</font></b>。
客户端在收到服务端重传的 FIN 报文时,TIME_WAIT 状态的等待时间,会重置回 2MSL。
TIME_WAIT 过多有什么危害?<br>
过多的 TIME-WAIT 状态主要的危害有两种:<br><ul><li>第一是 <b><font color="#0000ff">占用系统资源</font></b>,比如文件描述符、内存资源、CPU 资源、线程资源等;</li><li>第二是 <b><font color="#0000ff">占用端口资源</font></b>,端口资源也是有限的,一般可以开启的端口为 32768~61000,<br>也可以通过 net.ipv4.ip_local_port_range 参数指定范围。</li></ul>
客户端和服务端 TIME_WAIT 过多,造成的影响是不同的。
如果 <b><font color="#0000ff">客户端(发起连接方)的 TIME_WAIT 状态过多</font></b>,占满了所有端口资源,那么就 <b><font color="#0000ff">无法对「目的 IP+ 目的 PORT」<br>都一样的服务器发起连接了</font></b>,但是被使用的端口,还是可以继续对另外一个服务器发起连接的。
因此,客户端(发起连接方)都是和「目的 IP+ 目的 PORT 」都一样的服务器建立连接的话,当客户端的 TIME_WAIT 状态连接过多的话,<br>就会受端口资源限制,如果占满了所有端口资源,那么就无法再跟「目的 IP+ 目的 PORT」都一样的服务器建立连接了。
不过,即使是在这种场景下,只要连接的是不同的服务器,端口是可以重复使用的,所以客户端还是可以向其他服务器发起连接的,这是因为内核<br>在定位一个连接的时候,是通过四元组(源IP、源端口、目的IP、目的端口)信息来定位的,并不会因为客户端的端口一样,而导致连接冲突。
如果 <b><font color="#0000ff">服务端(发起连接方)的 TIME_WAIT 状态过多</font></b>,并不会导致端口资源受限,因为服务端只监听一个端口,而且由于一个四元组唯一确定一个<br>TCP 连接,因此理论上 <font color="#0000ff"><b>服务端可以建立很多连接</b></font>,但是 TCP 连接过多,<b><font color="#0000ff">会占用系统资源</font></b>,比如文件描述符、内存资源、CPU 资源、线程资源等。
如何优化 TIME_WAIT?<br>
这里给出优化 TIME-WAIT 的几个方式,都是有利有弊:<br><ul><li>打开 net.ipv4.tcp_tw_reuse 和 net.ipv4.tcp_timestamps 选项;</li><li>net.ipv4.tcp_max_tw_buckets</li><li>程序中使用 SO_LINGER ,应用强制使用 RST 关闭。</li></ul>
方式一:net.ipv4.tcp_tw_reuse 和 tcp_timestamps
如上的 Linux 内核参数开启后,则可以 <b><font color="#0000ff">复用处于 TIME_WAIT 的 socket 为新的连接所用</font></b>。
<span style="font-weight: normal;">有一点需要注意的是</span>,<font color="#0000ff">tcp_tw_reuse 功能只能用客户端(连接发起方),因为开启了该功能,<br>在调用 connect() 函数时,内核会随机找一个 time_wait 状态超过 1 秒的连接给新的连接复用</font>。
使用这个选项,还有一个前提,<b><font color="#0000ff">需要打开对 TCP 时间戳的支持</font></b>,即
这个时间戳的字段是在 TCP 头部的「选项」里,它由一共 8 个字节表示时间戳,其中第一个 4 字节字段<br>用来保存发送该数据包的时间,第二个 4 字节字段用来保存最近一次接收对方发送到达数据的时间。
由于 <b><font color="#0000ff">引入了时间戳,我们在前面提到的 2MSL 问题就不复存在了,因为重复的数据包会因为时间戳过期被自然丢弃</font></b>。
方式二:net.ipv4.tcp_max_tw_buckets<br>
这个值默认为 18000,<b><font color="#0000ff">当系统中处于 TIME_WAIT 的连接一旦超过这个值时,<br>系统就会将后面的 TIME_WAIT 连接状态重置</font></b>,这个方法比较暴力。
方式三:程序中使用 SO_LINGER<br>
我们可以通过设置 socket 选项,来设置调用 close 关闭连接行为。
如果 l_onoff 为非 0, 且 l_linger 值为 0,<b><font color="#0000ff">那么调用 close 后,会立该发送一个 RST 标志给对端,<br>该 TCP 连接将跳过四次挥手,也就跳过了 TIME_WAIT 状态,直接关闭</font></b>。
虽然这为跨越TIME_WAIT状态提供了一个可能,不过是一个非常危险的行为,不值得提倡。
前面介绍的方法都是试图越过 TIME_WAIT状态的,这样其实不太好。虽然 TIME_WAIT 状态<br>持续的时间是有一点长,显得很不友好,但是它被设计来就是用来避免发生乱七八糟的事情。
<b><font color="#0000ff">如果服务端要避免过多的 TIME_WAIT 状态的连接,就永远不要主动断开连接,让客户端去断开,由分布在各处的客户端去承受 TIME_WAIT。</font></b>
服务器出现大量 TIME_WAIT 的原因<br>
介绍
首先要知道 TIME_WAIT 状态是主动关闭连接方才会出现的状态,所以如果服务器出现<br>大量的 TIME_WAIT 状态的 TCP 连接,就是说明 <b><font color="#0000FF">服务器主动断开了很多 TCP 连接</font></b>。
问题来了,什么场景下服务端会主动断开连接呢?<br><br><ul><li>第一个场景:HTTP 没有使用长连接</li></ul><ul><li>第二个场景:HTTP 长连接超时</li></ul><ul><li>第三个场景:HTTP 长连接的请求数量达到上限</li></ul>
<i><font color="#FF00FF">第一个场景:HTTP 没有使用长连接</font></i>
在 HTTP/1.0 中默认是关闭的,如果浏览器要开启 Keep-Alive,它必须在请求的 header 中添加:
然后当服务器收到请求,作出回应的时候,它也被添加到响应中 header 里:
这样做,TCP 连接就不会中断,而是保持连接。当客户端发送另一个请求时,<br>它会使用同一个 TCP 连接。这一直继续到客户端或服务器端提出断开连接。
<b><font color="#0000FF">从 HTTP/1.1 开始, 就默认是开启了 Keep-Alive</font></b>,现在大多数浏览器都默认是使用 HTTP/1.1,<br>所以 Keep-Alive 都是默认打开的。一旦客户端和服务端达成协议,那么长连接就建立好了。
如果要关闭 HTTP Keep-Alive,需要在 HTTP 请求或者响应的 header 里添加 Connection:close 信息,也就是说,<br><b><font color="#0000FF">只要客户端和服务端任意一方的 HTTP header 中有 Connection:close 信息,那么就无法使用 HTTP 长连接的机制</font></b>。
关闭 HTTP 长连接机制后,每次请求都要经历这样的过程:建立 TCP -> 请求资源 -> 响应资源 -> 释放连接,那么此方式就是 <b><font color="#0000FF">HTTP 短连接</font></b>,如下图:
在前面我们知道,只要任意一方的 HTTP header 中有 Connection:close 信息,<br>就无法使用 HTTP 长连接机制,这样在完成一次 HTTP 请求/处理后,就会关闭连接。
问题来了,<b><font color="#0000FF">这时候是客户端还是服务端主动关闭连接呢?</font></b>
在 RFC 文档中,并没有明确由谁来关闭连接,<b><font color="#0000FF">请求和响应的双方都可以主动关闭 TCP 连接</font></b>。
不过,<b><font color="#0000FF">根据大多数 Web 服务的实现,</font><font color="#0000FF">不管哪一方禁用了 HTTP Keep-Alive,都是<br>由服务端主动关闭连接</font></b>,那么此时服务端上就会出现 TIME_WAIT 状态的连接。
<b><font color="#0000FF">客户端禁用了 HTTP Keep-Alive,服务端开启 HTTP Keep-Alive</font></b>,谁是主动关闭方?
当客户端禁用了 HTTP Keep-Alive,这时候 HTTP 请求的 header 就会有 Connection:close 信息,<br>这时 <font color="#0000FF"><b>服务端在发完 HTTP 响应后,就会主动关闭连接</b></font>。
为什么要这么设计呢?HTTP 是请求-响应模型,发起方一直是客户端,HTTP Keep-Alive 的初衷是 <b><font color="#0000FF">为客户端后续的<br>请求重用连接</font></b>,如果我们 <b><font color="#0000FF">在某次 HTTP 请求-响应模型中,请求的 header 定义了 connection:close 信息,那不再<br>重用这个连接的时机就只有在服务端了</font></b>,所以我们在 HTTP 请求-响应这个周期的「末端」关闭连接是合理的。
<b><font color="#0000FF">客户端开启了 HTTP Keep-Alive,服务端禁用了 HTTP Keep-Alive</font></b>,谁是主动关闭方?
当客户端开启了 HTTP Keep-Alive,而服务端禁用了 HTTP Keep-Alive,这时 <b><font color="#0000FF">服务端在发完 HTTP 响应后,服务端也会主动关闭连接</font></b>。
为什么要这么设计呢?<br><br><ul><li><b><font color="#0000FF">在服务端主动关闭连接的情况下,只要调用一次 close() 就可以释放连接,<br>剩下的工作由内核 TCP 栈直接进行了处理</font></b>,整个过程只有一次 syscall;</li></ul><br><ul><li>如果是要求 客户端关闭,则服务端在写完最后一个 response 之后需要把这个 socket 放入 <br>readable 队列,调用 select / epoll 去等待事件;然后 <b><font color="#0000FF">再调用一次 read() 才能知道连接已经被<br>关闭,这其中是两次 syscall</font></b>,多一次用户态程序被激活执行,而且<b><font color="#0000FF"> socket 保持时间也会更长</font></b>。</li></ul>
因此,当服务端出现大量的 TIME_WAIT 状态连接的时候,可以排查下是否客户端和服务端都开启了 HTTP Keep-Alive,因为 <b><font color="#0000FF">任意一方没有<br>开启 HTTP Keep-Alive,都会导致服务端在处理完一个 HTTP 请求后,就主动关闭连接,此时服务端上就会出现大量的 TIME_WAIT 状态的连接</font></b>。
针对这个场景下,解决的方式也很简单,让客户端和服务端都开启 HTTP Keep-Alive 机制。
<i><font color="#FF00FF">第二个场景:HTTP 长连接超时</font></i>
HTTP 长连接的特点是,只要任意一端没有明确提出断开连接,则保持 TCP 连接状态。
HTTP 长连接可以在同一个 TCP 连接上接收和发送多个 HTTP 请求/应答,避免了连接建立和释放的开销。
可能有的同学会问,如果使用了 HTTP 长连接,如果客户端完成一个 HTTP 请求后,<br>就不再发起新的请求,此时这个 TCP 连接一直占用着不是挺浪费资源的吗?
对没错,所以为了避免资源浪费的情况,web 服务软件一般都会提供一个参数,<br>用来指定 HTTP 长连接的超时时间,比如 nginx 提供的 keepalive_timeout 参数。
假设设置了 HTTP 长连接的超时时间是 60 秒,nginx 就会启动一个「定时器」,<b><font color="#0000FF">如果客户端在完后一个 HTTP 请求后,在 60 秒内都<br>没有再发起新的请求,定时器的时间一到,nginx 就会触发回调函数来关闭该连接,那么此时服务端上就会出现 TIME_WAIT 状态的连接</font></b>。
当服务端出现大量 TIME_WAIT 状态的连接时,如果现象是有大量的客户端建立完 TCP 连接后,很长一段时间没有<br>发送数据,那么大概率就是因为 HTTP 长连接超时,导致服务端主动关闭连接,产生大量处于 TIME_WAIT 状态的连接。
可以往网络问题的方向排查,比如是否是因为网络问题,导致客户端发送的数据一直没有被服务端接收到,以至于 HTTP 长连接超时。
<i><font color="#FF00FF">第三个场景:HTTP 长连接的请求数量达到上限</font></i>
Web 服务端通常会有个参数,来定义一条 HTTP 长连接上最大能处理的请求数量,当超过最大限制时,就会主动关闭连接。
比如 nginx 的 keepalive_requests 这个参数,这个参数是指一个 HTTP 长连接建立之后,nginx 就会为这个连接设置一个计数器,<br>记录这个 HTTP 长连接上已经接收并处理的客户端请求的数量。<b><font color="#0000FF">如果达到这个参数设置的最大值时,则 nginx 会主动关闭这个长连接,<br>那么此时服务端上就会出现 TIME_WAIT 状态的连接</font></b>。
keepalive_requests 参数的默认值是 100 ,意味着每个 HTTP 长连接最多只能跑 100 次请求,<br>这个参数往往被大多数人忽略,因为当 QPS (每秒请求数) 不是很高时,默认值 100 凑合够用。
但是,<b><font color="#0000FF">对于一些 QPS 比较高的场景,比如超过 10000 QPS,甚至达到 30000 , 50000 甚至更高,如果 keepalive_requests <br>参数值是 100,这时候就 nginx 就会很频繁地关闭连接,那么此时服务端上就会出大量的 TIME_WAIT 状态</font></b>。
针对这个场景下,解决的方式也很简单,调大 nginx 的 keepalive_requests 参数就行。
服务器出现大量 CLOSE_WAIT 的原因<br>
CLOSE_WAIT 状态是「被动关闭方」才会有的状态,而且如果「被动关闭方」没有调用 close 函数关闭连接,<br>那么就无法发出 FIN 报文,从而无法使得 CLOSE_WAIT 状态的连接转变为 LAST_ACK 状态。
所以,<b><font color="#0000FF">当服务端出现大量 CLOSE_WAIT 状态的连接的时候,说明服务端的程序没有调用 close 函数关闭连接</font></b>。
那什么情况会导致服务端的程序没有调用 close 函数关闭连接?这时候通常需要排查代码。
我们先来分析一个普通的 TCP 服务端的流程:<br><br><ol><li>创建服务端 socket,bind 绑定端口、listen 监听端口</li><li>将服务端 socket 注册到 epoll</li><li>epoll_wait 等待连接到来,连接到来时,调用 accpet 获取已连接的 socket</li><li>将已连接的 socket 注册到 epoll</li><li>epoll_wait 等待事件发生</li><li>对方连接关闭时,我方调用 close</li></ol>
可能导致服务端没有调用 close 函数的可能原因有四个。
<i><font color="#FF00FF">第一个原因</font></i>:第 2 步没有做,没有将服务端 socket 注册到 epoll,这样有新连接到来时,服务端没办法<br>感知这个事件,也就无法获取到已连接的 socket,那服务端自然就没机会对 socket 调用 close 函数了。
<i><font color="#FF00FF">第二个原因</font></i>: 第 3 步没有做,有新连接到来时没有调用 accpet 获取该连接的 socket,导致当有大量的客户端主动<br>断开了连接,而服务端没机会对这些 socket 调用 close 函数,从而导致服务端出现大量 CLOSE_WAIT 状态的连接。<br><br>发生这种情况可能是因为服务端在执行 accpet 函数之前,代码卡在某一个逻辑或者提前抛出了异常。
<i><font color="#FF00FF">第三个原因</font></i>:第 4 步没有做,通过 accpet 获取已连接的 socket 后,没有将其注册到 epoll,导致<br>后续收到 FIN 报文的时候,服务端没办法感知这个事件,那服务端就没机会调用 close 函数了。<br><br>发生这种情况可能是因为服务端在将已连接的 socket 注册到 epoll 之前,代码卡在某一个逻辑或者提前抛出了异常。
<i><font color="#FF00FF">第四个原因</font></i>:第 6 步没有做,当发现客户端关闭连接后,服务端没有执行 close 函数,可能是因为代码漏处理,<br>或者是在执行 close 函数之前,代码卡在某一个逻辑,比如发生死锁等等。
可以发现,<b><font color="#0000FF">当服务端出现大量 CLOSE_WAIT 状态的连接的时候,通常都是代码的问题,这时候我们<br>需要针对具体的代码一步一步的进行排查和定位,主要分析的方向就是服务端为什么没有调用 close</font></b>。
如果已经建立了连接,但是客户端突然出现故障了怎么办?<br>
TCP 有一个机制是 <b><font color="#0000ff">保活机制</font></b>。这个机制的原理是这样的:<br>定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用,<b><font color="#0000ff">每隔一个时间间隔,发送一个探测报文</font></b>,该探测<br>报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的 TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。<br>
在 Linux 内核可以有对应的参数可以设置保活时间、保活探测的次数、保活探测的时间间隔,以下都为默认值:<br><ul><li>tcp_keepalive_time=7200:表示保活时间是 7200 秒(2小时),也就 2 小时内如果没有任何连接相关的活动,则会启动保活机制;</li><li>tcp_keepalive_intvl=75:表示每次检测间隔 75 秒;</li><li>tcp_keepalive_probes=9:表示检测 9 次无响应,认为对方是不可达的,从而中断本次的连接。</li></ul>
也就是说在 Linux 系统中,最少需要经过 2 小时 11 分 15 秒才可以发现一个「死亡」连接。
注意,应用程序若想使用 TCP 保活机制需要通过 socket 接口设置 SO_KEEPALIVE <br>选项才能够生效,如果没有设置,那么就无法使用 TCP 保活机制。
如果开启了 TCP 保活,需要考虑以下几种情况:<br><br><ul><li>第一种,<b><font color="#0000ff">对端程序是正常工作的</font></b>。当 TCP 保活的探测报文发送给对端, 对端会正常响应,<br>这样 <b><font color="#0000ff">TCP 保活时间会被重置</font></b>,等待下一个 TCP 保活时间的到来。</li><li>第二种,<b><font color="#0000ff">对端程序崩溃并重启</font></b>。当 TCP 保活的探测报文发送给对端后,对端是可以响应的,<br>但由于没有该连接的有效信息,<b><font color="#0000ff">会产生一个 RST 报文</font></b>,这样很快就会发现 TCP 连接已经被重置。</li><li>第三种,<b><font color="#0000ff">是对端程序崩溃</font></b>,或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,<br>石沉大海,没有响应,连续几次,达到保活探测次数后,<b><font color="#0000ff">TCP 会报告该 TCP 连接已经死亡</font></b>。</li></ul>
TCP 保活的这个机制检测的时间是有点长,我们可以 <b><font color="#0000ff">自己在应用层实现一个心跳机制</font></b>。
比如,web 服务软件一般都会提供 keepalive_timeout 参数,用来指定 HTTP 长连接的超时时间。如果设置了 HTTP 长连接的超时时间是 60 秒,web 服务软件就会 <b><font color="#0000ff">启动一个定时器</font></b>,如果客户端在完后一个 HTTP 请求后,在 60 秒内都没有再发起新的请求,<b><font color="#0000ff">定时器的时间一到,就会触发回调函数来释放该连接</font></b>。
如果已经建立了连接,但是服务端的进程崩溃会发生什么?<br>
作者本人做了个实验,使用 kill -9 来模拟进程崩溃的情况,<br>发现 <b><font color="#0000ff">在 kill 掉进程后,服务端会发送 FIN 报文,与客户端进行四次挥手</font>。</b>
Socket 编程
针对 TCP 应该如何 Socket 编程?<br>
<ol><li>服务端和客户端初始化 socket,得到文件描述符;</li><li>服务端调用 bind,将 socket 绑定在指定的 IP 地址和端口;</li><li>服务端调用 listen,进行监听;</li><li>服务端调用 accept,等待客户端连接;</li><li>客户端调用 connect,向服务器端的地址和端口发起连接请求;</li><li>服务端 accept 返回用于传输的 socket 的文件描述符;</li><li>客户端调用 write 写入数据;服务端调用 read 读取数据;</li><li>客户端断开连接时,会调用 close,那么服务端 read 读取数据的时候,就会读取到了 EOF,<br>待处理完数据后,服务端调用 close,表示连接关闭。</li></ol>
这里需要注意的是,服务端调用 accept 时,连接成功了会返回一个已完成连接的 socket,后续用来传输数据。
所以,监听的 socket 和真正用来传送数据的 socket,是「两个」 socket,一个叫作 <b>监听 socket</b>,一个叫作 <b>已完成连接 socket</b>。
成功连接建立之后,双方开始通过 read 和 write 函数来读写数据,就像往一个文件流里面写东西一样。
listen() 方法 backlog 参数的意义?<br>
Linux 内核中会维护两个队列:<br><ul><li>半连接队列(SYN 队列):接收到一个 SYN 建立连接请求,处于 SYN_RCVD 状态;</li><li>全连接队列(Accpet 队列):已完成 TCP 三次握手过程,处于 ESTABLISHED 状态;</li></ul>
listen() 方法的两个参数:<br><ul><li>参数一 socketfd 为 socketfd 文件描述符</li><li>参数二 backlog,这参数在历史版本有一定的变化</li></ul>
在早期 Linux 内核 backlog 是 SYN 队列大小,也就是未完成的队列大小。
在 Linux 内核 2.2 之后,backlog 变成 accept 队列,也就是已完成<br>连接建立的队列长度,所以 <b>现在通常认为 backlog 是 accept 队列</b>。
<b>但是上限值是内核参数 somaxconn 的大小,也就说 accpet 队列长度 = min(backlog, somaxconn)。</b>
详细的 TCP 半连接和全连接队列,后面会讲到。
accept 发生在三次握手的哪一步?<br>
先看看客户端连接服务端时,发送了什么?<br><br><ul><li>客户端的协议栈向服务器端发送了 SYN 包,并告诉服务器端当前发送序列号 client_isn,客户端进入 SYN_SENT 状态;</li><li>服务器端的协议栈收到这个包之后,和客户端进行 ACK 应答,应答的值为 client_isn+1,表示对 SYN 包 client_isn 的确认,<br>同时服务器也发送一个 SYN 包,告诉客户端当前我的发送序列号为 server_isn,服务器端进入 SYN_RCVD 状态;</li><li>客户端协议栈收到 ACK 之后,使得应用程序从 connect 调用返回,表示客户端到服务器端的单向连接建立成功,<br>客户端的状态为 ESTABLISHED,同时客户端协议栈也会对服务器端的 SYN 包进行应答,应答数据为 server_isn+1;</li><li>ACK 应答包到达服务器端后,服务器端的 TCP 连接进入 ESTABLISHED 状态,同时服务器端协议栈使得 accept 阻塞调用返回,<br>这个时候服务器端到客户端的单向连接也建立成功。至此,客户端与服务端两个方向的连接都建立成功。</li></ul>
从上面的描述过程,我们可以得知 <b>客户端 connect 成功返回是在第二次握手,服务端 accept 成功返回是在三次握手成功之后。</b>
客户端调用 close 了,连接是断开的流程是什么?
看看客户端主动调用了 close,会发生什么?
<ul><li>客户端调用 close,表明客户端没有数据需要发送了,则此时会向服务端发送 FIN 报文,进入 FIN_WAIT_1 状态;</li><li>服务端接收到了 FIN 报文,TCP 协议栈会为 FIN 包插入一个文件结束符 EOF 到接收缓冲区中,<br>应用程序可以通过 read 调用来感知这个 FIN 包。<b>这个 EOF 会被放在已排队等候的其他已接收<br>的数据之后</b>,这就意味着服务端需要处理这种异常情况,因为 EOF 表示在该连接上再无额外数据<br>到达。此时,服务端进入 CLOSE_WAIT 状态;</li><li>接着,当处理完数据后,自然就会读到 EOF,于是也调用 close 关闭它的套接字,<br>这会使得服务端发出一个 FIN 包,之后处于 LAST_ACK 状态;</li><li>客户端接收到服务端的 FIN 包,并发送 ACK 确认包给服务端,此时客户端将进入 TIME_WAIT 状态;</li><li>服务端收到 ACK 确认包后,就进入了最后的 CLOSE 状态;</li><li>客户端经过 2MSL 时间之后,也进入 CLOSE 状态;</li></ul>
TCP 重传、滑动窗口、流量控制、拥塞控制
重传机制
TCP 实现可靠传输的方式之一,是通过序列号与确认应答。<br><br>在 TCP 中,当发送端的数据到达接收主机时,接收端主机会返回一个确认应答消息,表示已收到消息。
但在错综复杂的网络,并不一定能如上图那么顺利能正常的数据传输,万一数据在传输过程中丢失了呢?<br><br>所以 TCP 针对数据包丢失的情况,会用 <b><font color="#0000ff">重传机制</font> </b>解决。
接下来说说常见的重传机制:<br><ul><li>超时重传</li><li>快速重传</li><li>SACK</li><li>D-SACK</li></ul>
超时重传
重传机制的其中一个方式,就是在发送数据时,设定一个定时器,<font color="#0000ff"><b>当超过指定的时间后,<br>没有收到对方的 ACK 确认应答报文</b></font>,就会重发该数据,也就是我们常说的 <b><font color="#0000ff">超时重传</font></b>。
TCP 会在以下两种情况发生超时重传:<br><ul><li>数据包丢失</li><li>确认应答丢失</li></ul>
超时时间应该设置为多少呢?
先来了解一下什么是 RTT(Round-Trip Time 往返时延),从下图我们就可以知道:<br><b><font color="#0000ff">RTT</font></b> 指的是 <b><font color="#0000ff">数据发送时刻到接收到确认的时刻的差值</font></b>,也就是包的往返时间。<br>
<b><font color="#0000ff">超时重传时间是以 RTO (Retransmission Timeout 超时重传时间)表示</font></b>。
假设在重传的情况下,超时时间 RTO 「较长或较短」时,会发生什么事情呢?
上图中有两种超时时间不同的情况:<br><ul><li>当超时时间 <b><font color="#0000ff">RTO 较大时</font></b>,<b><font color="#0000ff">重发就慢</font></b>,丢了老半天才重发,没有效率,性能差;</li><li>当超时时间 <b><font color="#0000ff">RTO 较小时</font></b>,会导致可能 <b><font color="#0000ff">并没有丢就重发</font></b>,于是重发的就快,<br>会增加网络拥塞,导致更多的超时,更多的超时导致更多的重发。</li></ul>
精确的测量超时时间 RTO 的值是非常重要的,这可让我们的重传机制更高效。
根据上述的两种情况,我们可以得知,<b><font color="#0000ff">超时重传时间 RTO 的值应该略大于报文往返 RTT 的值</font>。</b>
至此,可能会觉得超时重传时间 RTO 的值计算,也不是很复杂嘛。好像就是在发送端发包时记下 t0 ,然后<br>接受到这个 ack 回来时再记一个 t1,于是 RTT = t1 – t0。没那么简单,<b><font color="#0000ff">这只是一个采样,不能代表普遍情况</font></b>。
实际上「报文往返 RTT 的值」是经常变化的,因为我们的网络也是时常变化的。也就因为「报文往返 RTT 的值」 <br>是经常波动变化的,所以「超时重传时间 RTO 的值」应该是一个<font color="#0000ff"> <b>动态变化的值</b></font>。
Linux 计算 RTO 时,估计往返时间,通常需要采样以下两个:<br><ul><li>需要 TCP 通过采样 RTT 的时间,然后进行加权平均,算出一个平滑 RTT 的值,<br>而且这个值还是要不断变化的,因为网络状况不断地变化。</li><li>除了采样 RTT,还要采样 RTT 的波动范围,这样就避免如果 RTT 有一个大的波动的话,很难被发现的情况。</li></ul>
RFC6289 建议使用以下的公式计算 RTO:
其中 SRTT 是计算平滑的RTT ,DevRTR 是计算平滑的RTT 与 最新 RTT 的差距。
在 Linux 下,α = 0.125,β = 0.25, μ = 1,∂ = 4。别问怎么来的,问就是大量实验中调出来的。
如果超时重发的数据,再次超时的时候,又需要重传的时候,TCP 的策略是 <b><font color="#0000ff">超时间隔加倍</font></b>。
也就是 <b><font color="#0000ff">每当遇到一次超时重传的时候,都会将下一次超时时间间隔设为<br>先前值的两倍。两次超时,就说明网络环境差,不宜频繁反复发送</font>。</b>
超时触发重传存在的问题是,<b><font color="#0000ff">超时周期可能相对较长</font></b>。那是不是可以有更快的方式呢?<br><br>于是就可以用「快速重传」机制来解决超时重发的时间等待。
快速重传
TCP 还有另外一种 <font color="#0000ff"><b>快速重传(Fast Retransmit)机制</b>,</font><b><font color="#0000ff">它不以时间为驱动,而是以数据驱动重传</font>。</b>
快速重传机制,是如何工作的呢?其实很简单,如下图:
在上图,发送方发出了 1,2,3,4,5 份数据:<br><ul><li>第一份 Seq1 先送到了,于是就 Ack 回 2;</li><li>结果 Seq2 因为某些原因没收到,Seq3 到达了,于是还是 Ack 回 2;</li><li>后面的 Seq4 和 Seq5 都到了,但还是 Ack 回 2,因为 Seq2 还是没有收到;</li><li><span style="font-size: inherit;"><b><font color="#0000ff">发送端收到了三个 Ack = 2 的确认,知道了 Seq2 还没有收到,就会在定时器过期之前,重传丢失的 Seq2</font>。</b></span></li><li>最后,收到了 Seq2,此时因为 Seq3,Seq4,Seq5 都收到了,于是 Ack 回 6 。</li></ul>
所以,快速重传的工作方式是当收到三个相同的 ACK 报文时,会在定时器过期之前,重传丢失的报文段。
快速重传机制只解决了一个问题,就是超时时间的问题,但是它依然面临着另外一个问题。<br>就是 <b><font color="#0000ff">重传的时候,是重传一个,还是重传所有的问题</font>。</b>
举个例子,假设发送方发了 6 个数据,编号的顺序是 Seq1 ~ Seq6 ,但是 Seq2、Seq3 都丢失了,那么接收方在收到 <br>Seq4、Seq5、Seq6 时,都是回复 ACK2 给发送方,但是发送方并不清楚这连续的 ACK2 是接收方收到哪个报文而回复的,<br>那是选择重传 Seq2 一个报文,还是重传 Seq2 之后已发送的所有报文呢(Seq2、Seq3、 Seq4、Seq5、 Seq6) 呢?<br><br><ul><li>如果只选择重传 Seq2 一个报文,那么重传的效率很低。因为对于丢失的 Seq3 报文,还得在后续收到三个重复的 ACK3 才能触发重传。</li><li>如果选择重传 Seq2 之后已发送的所有报文,虽然能同时重传已丢失的 Seq2 和 Seq3 报文,但是 Seq4、Seq5、Seq6 <br>的报文是已经被接收过了,对于重传 Seq4 ~Seq6 折部分数据相当于做了一次无用功,浪费资源。</li></ul>
可以看到,<b><font color="#0000ff">不管是重传一个报文,还是重传已发送的报文,都存在问题</font></b>。<br><br>为了解决不知道该重传哪些 TCP 报文,于是就有 SACK 方法。
SACK
还有一种实现重传机制的方式叫:SACK( Selective Acknowledgment), <b><font color="#0000ff">选择性确认</font></b>。
这种方式需要在 TCP 头部「选项」字段里加一个 SACK 的东西,<b><font color="#0000ff">它可以将已收到的数据的信息发送给「发送方」</font></b>,<br>这样发送方就可以知道哪些数据收到了,哪些数据没收到,知道了这些信息,就可以 <b><font color="#0000ff">只重传丢失的数据</font></b>。
如下图,发送方收到了三次同样的 ACK 确认报文,于是就会触发快速重发机制,通过 SACK <br>信息发现只有 200~299 这段数据丢失,则重发时,就只选择了这个 TCP 段进行重复。
<b><font color="#0000ff">如果要支持 SACK,必须双方都要支持</font></b>。在 Linux 下,可以通过 net.ipv4.tcp_sack 参数打开这个功能(Linux 2.4 后默认打开)。
D-SACK
Duplicate SACK 又称 D-SACK,其主要 <b><font color="#0000ff">使用了 SACK 来告诉「发送方」有哪些数据被重复接收了</font>。</b>
下面举例两个栗子,来说明 D-SACK 的作用。
栗子一号:ACK 丢包<br><br><ul><li>「接收方」发给「发送方」的两个 ACK 确认应答都丢失了,所以发送方超时后,重传第一个数据包(3000 ~ 3499)</li><li><b><font color="#0000ff">于是「接收方」发现数据是重复收到的,于是回了一个 SACK = 3000~3500</font></b>,告诉「发送方」 3000~3500 的数据早已被接收了,<br>因为 ACK 都到了 4000 了,已经意味着 4000 之前的所有数据都已收到,所以 <b><font color="#0000ff">这个 SACK 就代表着 D-SACK</font></b>。</li><li>这样<b><font color="#0000ff">「发送方」就知道了,数据没有丢,是「接收方」的 ACK 确认报文丢了</font></b>。</li></ul>
栗子二号:网络延时<br><br><ul><li>数据包(1000~1499) 被网络延迟了,导致「发送方」没有收到 ACK 1500 的确认报文。</li><li>而后面报文到达的三个相同的 ACK 确认报文,就触发了快速重传机制,但是在重传后,被延迟的数据包(1000~1499)又到了「接收方」;</li><li>所以「接收方」回了一个 SACK=1000~1500,因为 ACK 已经到了 3000,所以这个 SACK 是 D-SACK,表示收到了重复的包。</li><li>这样发送方就知道快速重传触发的原因不是发出去的包丢了,也不是因为回应的 ACK 包丢了,而是因为网络延迟了。</li></ul>
可见,D-SACK 有这么几个好处:<br><ul><li><span style="font-size: inherit;">可以 <b><font color="#0000ff">让「发送方」知道,是发出去的包丢了,还是接收方回应的 ACK 包丢了</font></b>;</span></li><li>可以知道 <b><font color="#0000ff">是不是「发送方」的数据包被网络延迟了</font></b>;</li><li>可以知道 <b><font color="#0000ff">网络中是不是把「发送方」的数据包给复制了</font></b>;</li></ul>
在 Linux 下可以通过 net.ipv4.tcp_dsack 参数开启/关闭这个功能(Linux 2.4 后默认打开)。<br>
滑动窗口
引入窗口概念的原因
我们都知道 TCP 是每发送一个数据,都要进行一次确认应答。当上一个数据包收到了应答了, 再发送下一个。
这个模式就有点像我和你面对面聊天,你一句我一句。但这种方式的缺点是效率比较低的。如果你说完一句话,我在处理<br>其他事情,没有及时回复你,那你不是要干等着我做完其他事情后,我回复你,你才能说下一句话,很显然这不现实。
所以,这样的传输方式有一个缺点:<b><font color="#0000ff">数据包的往返时间越长,通信的效率就越低</font></b>。
为解决这个问题,TCP 引入了<font color="#0000ff"> </font><b><font color="#0000ff">窗口</font> </b>这个概念。即使在往返时间较长的情况下,它也不会降低网络通信的效率。
那么有了窗口,就可以指定窗口大小,<b><font color="#0000ff">窗口大小就是指无需等待确认应答,而可以继续发送数据的最大值</font></b>。
窗口的实现实际上是 <b><font color="#0000ff">操作系统开辟的一个缓存空间</font></b>,发送方主机在等到确认应答返回之前,<br>必须在缓冲区中保留已发送的数据。如果按期收到确认应答,此时数据就可以从缓存区清除。
假设窗口大小为 3 个 TCP 段,那么发送方就可以「连续发送」 3 个 TCP 段,<br>并且中途若有 ACK 丢失,可以通过「下一个确认应答进行确认」。如下图:
图中的 ACK 600 确认应答报文丢失,也没关系,因为可以通过下一个确认应答进行确认,只要发送方收到了 <br>ACK 700 确认应答,就意味着 700 之前的所有数据「接收方」都收到了。这个模式就叫 <b><font color="#0000ff">累计确认</font> </b>或者 <b><font color="#0000ff">累计应答</font></b>。
窗口大小由哪一方决定?<br><br>TCP 头里有一个字段叫 Window,也就是窗口大小。<br><br><b><font color="#0000ff">这个字段是接收端告诉发送端自己还有多少缓冲区可以接收数据。于是发送端<br>就可以根据这个接收端的处理能力来发送数据,而不会导致接收端处理不过来</font>。<br></b><br>所以,通常窗口的大小是由接收方的窗口大小来决定的。<br>发送方发送的数据大小不能超过接收方的窗口大小,否则接收方就无法正常接收到数据。<br>
发送方的滑动窗口
我们先来看看发送方的窗口,下图就是发送方缓存的数据,根据处理的<br>情况分成四个部分,其中深蓝色方框是发送窗口,紫色方框是可用窗口:<br><br><ul><li>#1 是已发送并收到 ACK确认的数据:1~31 字节</li><li>#2 是已发送但未收到 ACK确认的数据:32~45 字节</li><li>#3 是未发送但总大小在接收方处理范围内(接收方还有空间):46~51字节</li><li>#4 是未发送但总大小超过接收方处理范围(接收方没有空间):52字节以后</li></ul>
在下图,当发送方把数据「全部」都一下发送出去后,可用窗口的大小就为 0 了,<br>表明可用窗口耗尽,在没收到 ACK 确认之前是无法继续发送数据了。
在下图,当收到之前发送的数据 32~36 字节的 ACK 确认应答后,如果发送窗口的大小没有变化,<b><font color="#0000ff">则滑动窗口往右边移动 5 个字节,<br>因为有 5 个字节的数据被应答确认</font></b>,接下来 52~56 字节又变成了可用窗口,那么后续也就可以发送 52~56 这 5 个字节的数据了。
程序是如何表示发送方的四个部分?
TCP 滑动窗口方案使用三个指针来跟踪在四个传输类别中的每一个类别中的字节。<br>其中两个指针是绝对指针(指特定的序列号),一个是相对指针(需要做偏移)。
<ul><li>SND.WND:表示发送窗口的大小(大小是由接收方指定的);</li><li>SND.UNA(Send Unacknoleged):是一个绝对指针,它指向的是已发送但未收到确认的第一个字节的序列号,也就是 #2 的第一个字节。</li><li>SND.NXT:也是一个绝对指针,它指向未发送但可发送范围的第一个字节的序列号,也就是 #3 的第一个字节。</li><li><span style="font-size: inherit;">指向 #4 的第一个字节是个相对指针,它需要 SND.UNA 指针加上 SND.WND 大小的偏移量,就可以指向 #4 的第一个字节了。</span></li></ul><br style="font-size: inherit;"><span style="font-size: inherit;">那么可用窗口大小的计算就可以是:</span><br style="font-size: inherit;"><b style="font-size: inherit;">可用窗口大 = SND.WND -(SND.NXT - SND.UNA)</b><br>
接收方的滑动窗口<br>
接收窗口相对简单一些,根据处理的情况划分成三个部分:<br><ul><li><span style="font-size: inherit;">#1 + #2 是已成功接收并确认的数据(等待应用进程读取);</span></li><li>#3 是未收到数据但可以接收的数据;</li><li>#4 未收到数据并不可以接收的数据;</li></ul>
其中三个接收部分,使用两个指针进行划分:<br><ul><li>RCV.WND:表示接收窗口的大小,它会通告给发送方。</li><li>RCV.NXT:是一个指针,它指向期望从发送方发送来的下一个数据字节的序列号,也就是 #3 的第一个字节。</li><li>指向 #4 的第一个字节是个相对指针,它需要 RCV.NXT 指针加上 RCV.WND 大小的偏移量,就可以指向 #4 的第一个字节了。</li></ul>
接收窗口和发送窗口的大小相等吗?
并不是完全相等,接收窗口的大小是 <b><font color="#0000ff">约等于</font> </b>发送窗口的大小的。
因为滑动窗口并不是一成不变的。比如,当接收方的应用进程读取数据的速度非常快的话,<br>这样的话接收窗口可以很快的就空缺出来。那么新的接收窗口大小,是通过 TCP 报文中的 <br>Windows 字段来告诉发送方。这个传输过程是存在时延的,所以接收窗口和发送窗口是约等于的关系。
流量控制
为什么要控制流量?
发送方不能无脑的发数据给接收方,要考虑接收方处理能力。如果一直无脑的发数据给对方,<br>但对方处理不过来,那么就会导致触发重发机制,从而导致网络流量的无端的浪费。<br>
为了解决这种现象发生,<b><font color="#0000ff">TCP 提供一种机制可以让「发送方」根据<br>「接收方」的实际接收能力控制发送的数据量,这就是所谓的流量控制</font>。</b>
下面举个栗子,为了简单起见,假设以下场景:<br><ul><li>客户端是接收方,服务端是发送方</li><li>假设接收窗口和发送窗口相同,都为 200</li><li>假设两个设备在整个传输过程中都保持相同的窗口大小,不受外界影响</li></ul>
根据上图的流量控制,说明下每个过程:<br><ol><li>客户端向服务端发送请求数据报文。这里要说明下,本次例子是把服务端作为发送方,所以没有画出服务端的接收窗口。</li><li>服务端收到请求报文后,发送确认报文和 80 字节的数据,于是可用窗口 Usable 减少为 120 字节,<br>同时 SND.NXT 指针也向右偏移 80 字节后,指向 321,<b><font color="#0000ff">这意味着下次发送数据的时候,序列号是 321</font>。</b></li><li>客户端收到 80 字节数据后,于是接收窗口往右移动 80 字节,RCV.NXT 也就指向 321,<br><b><font color="#0000ff">这意味着客户端期望的下一个报文的序列号是 321</font></b>,接着发送确认报文给服务端。</li><li>服务端再次发送了 120 字节数据,于是 <b><font color="#0000ff">可用窗口耗尽为 0,服务端无法再继续发送数据</font></b>。</li><li>客户端收到 120 字节的数据后,于是接收窗口往右移动 120 字节,RCV.NXT 也就指向 441,接着发送确认报文给服务端。</li><li><b><font color="#0000ff">服务端收到对 80 字节数据的确认报文后</font></b>,SND.UNA 指针往右偏移后指向 321,于是 <b><font color="#0000ff">可用窗口 Usable 增大到 80</font></b>。</li><li><b><font color="#0000ff">服务端收到对 120 字节数据的确认报文后</font></b>,SND.UNA 指针往右偏移后指向 441,于是 <b><font color="#0000ff">可用窗口 Usable 增大到 200</font></b>。</li><li><b><font color="#0000ff">服务端可以继续发送了</font></b>,于是发送了 160 字节的数据后,SND.NXT 指向 601,于是可用窗口 Usable 减少到 40。</li><li>客户端收到 160 字节后,接收窗口往右移动了 160 字节,RCV.NXT 也就是指向了 601,接着发送确认报文给服务端。</li><li>服务端收到对 160 字节数据的确认报文后,发送窗口往右移动了 160 字节,<br>于是 SND.UNA 指针偏移了 160 后指向 601,可用窗口 Usable 也就增大至了 200。</li></ol>
操作系统缓冲区与滑动窗口的关系
前面的流量控制例子,我们假定了发送窗口和接收窗口是不变的,但是实际上,发送窗口和接收窗口<br>中所存放的字节数,都是 <b><font color="#0000ff">放在操作系统内存缓冲区</font> </b>中的,而操作系统的缓冲区,<b><font color="#0000ff">会被操作系统调整</font></b>。
当应用进程没办法及时读取缓冲区的内容时,也会对我们的缓冲区造成影响。
那操作系统的缓冲区,是如何影响发送窗口和接收窗口的呢?<br>
先来看看第一个例子,当应用程序没有及时读取缓存时,发送窗口和接收窗口的变化。<br><br>考虑以下场景:<br><ul><li>客户端作为发送方,服务端作为接收方,发送窗口和接收窗口初始大小为 360;</li><li>服务端非常的繁忙,当收到客户端的数据时,应用层不能及时读取数据。</li></ul>
根据上图的流量控制,说明下每个过程:<br><ol><li>客户端发送 140 字节数据后,可用窗口变为 220 (360 - 140)。</li><li>服务端收到 140 字节数据,<b><font color="#0000ff">但是服务端非常繁忙,应用进程只读取了 40 个字节,还有 100 字节占用着缓冲区,<br>于是接收窗口收缩到了 260 (360 - 100)</font></b>,最后发送确认信息时,将窗口大小通告给客户端。</li><li>客户端收到确认和窗口通告报文后,发送窗口减少为 260。</li><li>客户端发送 180 字节数据,此时可用窗口减少到 80。</li><li>服务端收到 180 字节数据,<b><font color="#0000ff">但是应用程序没有读取任何数据,这 180 字节直接就留在了缓冲区,<br>于是接收窗口收缩到了 80 (260 - 180)</font></b>,并在发送确认信息时,通过窗口大小给客户端。</li><li>客户端收到确认和窗口通告报文后,发送窗口减少为 80。</li><li>客户端发送 80 字节数据后,可用窗口耗尽。</li><li>服务端收到 80 字节数据,<b><font color="#0000ff">但是应用程序依然没有读取任何数据,这 80 字节留在了缓冲区,<br>于是接收窗口收缩到了 0</font></b>,并在发送确认信息时,通过窗口大小给客户端。</li><li>客户端收到确认和窗口通告报文后,发送窗口减少为 0。</li></ol>
可见最后窗口都收缩为 0 了,也就是发生了窗口关闭。<font color="#0000ff"><b>当发送方可用窗口变为 0 时,</b></font><b><font color="#0000ff">发送方实际上会定时<br>发送窗口探测报文,以便知道接收方的窗口是否发生了改变</font></b>。这个内容后面会说,这里先简单提一下。
再来看看第二个例子:<br>当服务端系统资源非常紧张的时候,<b><font color="#0000ff">操作系统可能会直接减少了接收缓冲区大小</font></b>,这时应用<br>程序又无法及时读取缓存数据,那么这时候就有严重的事情发生了,会出现 <b><font color="#0000ff">数据包丢失</font> </b>的现象。<br>
说明下每个过程:<br><ol><li>客户端发送 140 字节的数据,于是可用窗口减少到了 220。</li><li><b><font color="#0000ff">服务端因为现在非常的繁忙,操作系统于是就把接收缓存减少了 120 字节,当收到 140 字节数据后,又因为应用程序没有读取任何数据,<br>所以 140 字节留在了缓冲区中,于是接收窗口大小从 360 收缩成了 100</font></b>,最后发送确认信息时,通告窗口大小给对方。</li><li>此时客户端因为还没有收到服务端的通告窗口报文,所以 <b><font color="#0000ff">不知道此时接收窗口收缩成了 100</font></b>,<br>客户端只会看自己的可用窗口还有 220,所以客户端就发送了 180 字节数据,于是 <b><font color="#0000ff">可用窗口减少到 40</font></b>。</li><li>服务端收到了 180 字节数据时,<b><font color="#0000ff">发现数据大小超过了接收窗口的大小,于是就把数据包丢失了</font>。</b></li><li>客户端收到第 2 步时,服务端发送的确认报文和通告窗口报文,尝试减少发送窗口到 100,<br>把窗口的右端向左收缩了 80,<b><font color="#0000ff">此时可用窗口的大小就会出现诡异的负值</font>。</b></li></ol>
所以,如果 <b><font color="#0000ff">发生了先减少缓存,再收缩窗口,就会出现丢包的现象</font></b>。
<b><font color="#0000ff">为了防止这种情况发生,TCP 规定是不允许同时减少缓存又收缩窗口的,<br>而是采用先收缩窗口,过段时间再减少缓存,这样就可以避免了丢包情况</font>。</b>
窗口关闭(零窗口问题)
在前面我们都看到了,TCP 通过让接收方指明希望从发送方接收的数据大小(窗口大小)来进行流量控制。
<b><font color="#0000ff">如果窗口大小为 0 时,就会阻止发送方给接收方传递数据,直到窗口变为非 0 为止,这就是窗口关闭。</font></b>
窗口关闭潜在的危险<br>
接收方向发送方通告窗口大小时,是通过 ACK 报文来通告的。<br>
那么,当发生窗口关闭时,<b><font color="#0000ff">接收方处理完数据后,会向发送方通告一个窗口非 0 的 ACK 报文,<br>如果这个通告窗口的 ACK 报文在网络中丢失了,那麻烦就大了</font></b>。<br><br>这会导致<b><font color="#0000ff">发送方一直等待接收方的非 0 窗口通知,接收方也一直等待发送方的数据</font></b>,<br>如不采取措施,这种相互等待的过程,会造成了 <b><font color="#0000ff">死锁</font></b> 的现象。<br>
TCP 是如何解决窗口关闭时,潜在的死锁现象呢?<br>
为了解决这个问题,TCP 为每个连接设有一个持续定时器,<br><b><font color="#0000ff">只要 TCP 连接一方收到对方的零窗口通知,就启动持续计时器</font>。</b>
如果持续计时器超时,就会发送 <b><font color="#0000ff">窗口探测 ( Window probe ) 报文</font></b>,<br>而对方在确认这个探测报文时,给出自己现在的接收窗口大小。<br><br><ul><li>如果接收窗口仍然为 0,那么收到这个报文的一方就会重新启动持续计时器;</li><li>如果接收窗口不是 0,那么死锁的局面就可以被打破了。</li></ul>
窗口探测的次数一般为 3 次,每次大约 30-60 秒(不同的实现可能会不一样)。<br>如果 3 次过后接收窗口还是 0 的话,有的 TCP 实现就会发 RST 报文来中断连接。
糊涂窗口综合征
如果接收方太忙了,来不及取走接收窗口里的数据,那么就会导致发送方的发送窗口越来越小。到最后,<br><b><font color="#0000ff">如果接收方腾出几个字节并告诉发送方现在有几个字节的窗口,而发送方会义无反顾地发送这几个字节,<br>这就是糊涂窗口综合症。</font></b>
要知道,我们的 <font color="#0000ff"><b>TCP + IP 头有 40 个字节</b></font>,<font color="#0000ff"><b>为了传输那几个字节的数据,要搭上这么大的开销,这太不经济了</b></font>。
就好像一个可以承载 50 人的大巴车,每次来了一两个人,就直接发车。除非家里有矿的大巴司机,<br>才敢这样玩,不然迟早破产。要解决这个问题也不难,大巴司机等乘客数量超过了 25 个,才认定可以发车。
现举个糊涂窗口综合症的栗子,考虑以下场景:<br><br>接收方的窗口大小是 360 字节,但接收方由于某些原因陷入困境,假设接收方的应用层读取的能力如下:<br><ul><li>接收方每接收 3 个字节,应用程序就只能从缓冲区中读取 1 个字节的数据;</li><li>在下一个发送方的 TCP 段到达之前,应用程序还从缓冲区中读取了 40 个额外的字节;</li></ul>
每个过程的窗口大小的变化,在图中都描述的很清楚了,可以发现窗口不断减少了,并且发送的数据都是比较小的了。
所以,糊涂窗口综合症的现象是可以发生在发送方和接收方:<br><ul><li><b><font color="#0000ff">接收方可以通告一个小的窗口</font></b></li><li><b><font color="#0000ff">而发送方可以发送小数据</font></b></li></ul>
于是,要解决糊涂窗口综合症,就要同时解决上面两个问题就可以了:<br><ul><li>让接收方不通告小窗口给发送方</li><li>让发送方避免发送小数据</li></ul>
怎么让接收方不通告小窗口呢?<br>
当「窗口大小」小于 min( MSS,缓存空间/2 ) ,也就是 <b><font color="#0000ff">小于 MSS 与 1/2 缓存大小中的最小值时,<br>就会向发送方通告窗口为 0</font></b>,也就阻止了发送方再发数据过来。(MSS:最大报文长度)
等到接收方处理了一些数据后,窗口大小 >= MSS,或者接收方缓存空间有一半可以使用,就可以把窗口打开让发送方发送数据过来。
怎么让发送方避免发送小数据呢?<br>
发送方通常的策略如下:<br><br>使用 Nagle 算法,该算法的思路是延时处理,<b><font color="#0000ff">只有满足下面两个条件中的任意一个条件,才能可以发送数据</font></b>:<br><ul><li>条件一:要等到 <b><font color="#0000ff">可用窗口大小 >= MSS</font></b> 并且 <b><font color="#0000ff">可发送数据大小 >= MSS</font></b>;</li><li>条件二:<b><font color="#0000ff">收到之前发送数据的 ack 回包</font></b>;</li></ul>
只要上面两个条件都不满足,发送方一直在囤积数据,直到满足上面的发送条件。
Nagle 伪代码如下:<br>
注意,<b><font color="#0000ff">如果接收方不能满足「不通告小窗口给发送方」,那么即使开了 Nagle 算法,也无法避免糊涂窗口综合症</font></b>,因为如果对端 ACK 回复很快<br>的话(<b><font color="#0000ff">达到 Nagle 算法的条件二</font></b>),Nagle 算法就不会拼接太多的数据包,这种情况下依然会有小数据包的传输,网络总体的利用率依然很低。
<b><font color="#0000ff">所以,接收方得满足「不通告小窗口给发送方」+ 发送方开启 Nagle 算法,才能避免糊涂窗口综合症。</font></b>
另外,Nagle 算法默认是打开的,如果对于一些需要小数据包交互的场景的程序,<br>比如,telnet 或 ssh 这样的交互性比较强的程序,则需要关闭 Nagle 算法。
可以在 Socket 设置 TCP_NODELAY 选项来关闭这个算法(关闭 Nagle 算法没有全局参数,需要根据每个应用自己的特点来关闭)
拥塞控制
为什么要有拥塞控制,不是有流量控制了吗?
前面的流量控制是避免「发送方」的数据填满「接收方」的缓存,但是并不知道网络的中发生了什么。
一般来说,计算机网络都处在一个共享的环境。因此也有可能会因为其他主机之间的通信使得网络拥堵。
<font color="#0000ff">在网络出现拥堵时,如果继续发送大量数据包,可能会导致数据包时延、丢失等,这时 TCP 就会重传数据,<br>但是一重传就会导致网络的负担更重,于是会导致更大的延迟以及更多的丢包,这个情况就会进入恶性循环被不断地放大....</font>
所以,TCP 不能忽略网络上发生的事,它被设计成一个无私的协议,当网络发送拥塞时,TCP 会自我牺牲,降低发送的数据量。<br>
于是,就有了 <b><font color="#0000ff">拥塞控制</font></b>,控制的目的就是 <b><font color="#0000ff">避免「发送方」的数据填满整个网络</font></b>。<br>
为了在「发送方」调节所要发送数据的量,定义了一个叫做「<b><font color="#0000ff">拥塞窗口</font></b>」的概念。
什么是拥塞窗口,和发送窗口有什么关系?
<b><font color="#0000ff">拥塞窗口 cwnd</font></b> 是 <b><font color="#0000ff">发送方</font> </b>维护的一个的状态变量,它会根据 <b><font color="#0000ff">网络的拥塞程度而动态变化</font></b>。
我们在前面提到过发送窗口 swnd 和接收窗口 rwnd 是约等于的关系,那么由于加入了拥塞窗口的概念后,<br>此时 <b><font color="#0000ff">发送窗口的值是 swnd = min(cwnd, rwnd)</font></b>,也就是拥塞窗口和接收窗口中的最小值。
拥塞窗口 cwnd 变化的规则:<br><ul><li><span style="font-size: inherit;">只要网络中没有出现拥塞,cwnd 就会增大;</span></li><li>但网络中出现了拥塞,cwnd 就减少;</li></ul>
如何知道当前网络出现了拥塞?<br><br>其实只要「发送方」没有在规定时间内接收到 ACK 应答报文,<br>也就是 <b><font color="#0000ff">发生了超时重传,就会认为网络出现了拥塞</font></b>。<br>
拥塞控制主要四算法
慢启动
TCP 在刚建立连接完成后,首先是有个慢启动的过程,这个慢启动的意思就是一点<br>一点的提高发送数据包的数量,如果一上来就发大量的数据,这不是给网络添堵吗?
慢启动的算法记住一个规则就行:<b><font color="#0000ff">当发送方每收到一个 </font><font color="#ff0000">ACK</font><font color="#0000ff">,拥塞窗口 cwnd 的大小就会加 1</font></b>。<br><font color="#0d0b22">(当 cwnd = n 时,一次发送 n 个数据,收到 n 个 ACK 后,</font><font color="#ff00ff"><i>其实 cwnd 增加了 2 倍</i></font><font color="#0d0b22">)</font><br>
这里假定拥塞窗口 cwnd 和发送窗口 swnd 相等,下面举个栗子:<br><ul><li>连接建立完成后,一开始初始化 cwnd = 1,表示可以传一个 MSS 大小的数据。</li><li>当收到一个 ACK 确认应答后,cwnd 增加 1,于是一次能够发送 2 个</li><li>当收到 2 个的 ACK 确认应答后, cwnd 增加 2,于是就可以比之前多发2 个,所以这一次能够发送 4 个</li><li>当这 4 个的 ACK 确认到来的时候,每个确认 cwnd 增加 1, 4 个确认 cwnd 增加 4,<br>于是就可以比之前多发 4 个,所以这一次能够发送 8 个。</li></ul>
慢启动算法的变化过程如下图:<br>
可以看出慢启动算法,发包的个数是 <b><font color="#0000ff">指数性的增长</font></b>。
那慢启动涨到什么时候是个头呢?<br><br>有一个叫 <b><font color="#0000ff">慢启动门限 ssthresh (slow start threshold)</font></b>状态变量。<br><ul><li>当 cwnd < ssthresh 时,使用慢启动算法。</li><li>当 cwnd >= ssthresh 时,就会使用「拥塞避免算法」。</li></ul>
拥塞避免
前面说道,当拥塞窗口 cwnd 「超过」慢启动门限 ssthresh 就会进入拥塞避免算法。<br>一般来说 ssthresh 的大小是 65535 字节。<br>
那么进入拥塞避免算法后,它的规则是:<b><font color="#0000ff">每当收到一个 ACK 时,cwnd 增加 1/cwnd</font>。</b><br>(当 cwnd = n 时,一次发送 n 个数据,收到 n 个 ACK 后,<font color="#ff00ff"><i>其实 cwnd 就增加了 1</i></font>)
接上前面的慢启动的栗子,现假定 ssthresh 为 8:<br><ul><li>当 8 个 ACK 应答确认到来时,每个确认增加 1/8,8 个 ACK 确认 cwnd 一共增加 1,<br>于是这一次能够发送 9 个 MSS 大小的数据,变成了<font color="#0000ff"> <b>线性增长</b></font>。</li></ul>
拥塞避免算法的变化过程如下图:<br>
可以发现,拥塞避免算法就是将原本慢启动算法的指数增长变成了线性增长,还是增长阶段,但是增长速度缓慢了一些。
就这么一直增长着后,网络就会慢慢进入了拥塞的状况了,于是就会出现丢包现象,这时就需要对丢失的数据包进行重传。
当触发了重传机制,也就进入了「拥塞发生算法」。
拥塞发生
当网络出现拥塞,也就是会发生数据包重传,重传机制主要有两种:<br><ul><li><b><font color="#0000ff">超时重传</font></b></li><li><b><font color="#0000ff">快速重传</font></b></li></ul>
发生超时重传的拥塞发生算法
当发生了「超时重传」,则就会使用拥塞发生算法。<br>
这个时候,ssthresh 和 cwnd 的值会发生变化:<br><ul><li><b><font color="#0000ff">ssthresh 设为 cwnd/2</font></b>,</li><li><b><font color="#0000ff">cwnd 重置为 1 (是恢复为 cwnd 初始化值,我这里假定 cwnd 初始化值 1)</font></b></li></ul>
怎么查看系统的 cwnd 初始化值?<br><br>Linux 针对每一个 TCP 连接的 cwnd 初始化值是 10,也就是 10 个 MSS,<br>我们可以用 ss -nli 命令查看每一个 TCP 连接的 cwnd 初始化值,如下图:<br>
拥塞发生算法的变化如下图:
接着,<b><font color="#0000ff">就重新开始慢启动,慢启动是会突然减少数据流的</font></b>。这真是一旦「超时重传」,<br>马上回到解放前。但是这种方式太激进了,反应也很强烈,会造成网络卡顿。
发生快速重传的拥塞发生算法<br>
还有更好的方式,前面我们讲过「<b><font color="#0000ff">快速重传算法</font></b>」。当接收方发现丢了一个中间包的时候,<br><b><font color="#0000ff">发送三次前一个包的 ACK,于是发送端就会快速地重传,不必等待超时再重传</font></b>。
TCP 认为这种情况不严重,因为大部分没丢,只丢了一小部分,则 ssthresh 和 cwnd 变化如下:<br><ul><li><b><font color="#0000ff">cwnd = cwnd/2 ,也就是设置为原来的一半</font></b>;</li><li><b><font color="#0000ff">ssthresh = cwnd</font></b>;</li><li><b><font color="#0000ff">进入快速恢复算法;</font></b></li></ul>
快速恢复
快速重传和快速恢复算法一般同时使用,<b><font color="#0000ff">快速恢复算法是认为,你还能收到 <br>3 个重复 ACK 说明网络也不那么糟糕</font></b>,所以没有必要像 RTO 超时那么强烈。
正如前面所说,进入快速恢复之前,cwnd 和 ssthresh 已被更新了:<br><ul><li>cwnd = cwnd/2 ,也就是设置为原来的一半;</li><li>ssthresh = cwnd;</li></ul>
然后,进入 <b><font color="#0000ff">快速恢复算法 </font></b>如下:<br><ul><li>拥塞窗口 cwnd = ssthresh + 3 ( 3 的意思是确认有 3 个数据包被收到了);</li><li><b><font color="#0000ff">重传丢失的数据包</font></b>;</li><li>如果 <b><font color="#0000ff">再收到重复的 ACK,那么 cwnd 增加 1</font></b>;</li><li><span style="font-size: inherit;">如果 <b><font color="#0000ff">收到新数据的 ACK 后,把 cwnd 设置为第一步中的 ssthresh 的值</font></b>,原因是该 ACK 确认了新的数据,说明从 <br>duplicated ACK 时的数据都已收到,该恢复过程已经结束,可以回到恢复之前的状态了,即再次进入拥塞避免状态;</span></li></ul>
快速恢复算法的变化过程如下图:
也就是没有像「超时重传」一夜回到解放前,而是还在比较高的值,后续呈线性增长。
为什么 <b><font color="#0000ff">收到新的数据的 ACK 后</font></b>,cwnd 设置回了 ssthresh?
在快恢复过程中,首先 ssthresh = cwnd / 2,然后 cwnd = ssthresh + 3,表示网络可能出现了拥塞,<br>所以需要减小 cwnd 以避免,<b>加 3 代表快速重传时发送方已经确认接受到了 3 个重复的数据包</b>;
随后继续重传丢失的数据包,如果再收到重复的 ACK,那么 cwnd 增加 1。加 1 代表每个收到的重复的 ACK 包,<br>都已经离开了网络。这个过程的目的是 <b><font color="#0000ff">尽快将丢失的数据包发给目标</font>,</b>从而解决拥塞的根本问题(三次相同的 ACK 导致的快速重传)
如果 <b><font color="#0000ff">收到新数据的 ACK 后,表示丢失的数据包都已经发送给目标了,目标也正确接收了</font></b>。<br>所以此时 <b><font color="#0000ff">需要将 cwnd 设置回 第一步中的 ssthresh 值,快恢复过程结束,接下来继续开始拥塞避免算法</font></b>。
TCP 实战抓包分析
让不可见的网络包“显形”
网络分析工具介绍
tcpdump 和 Wireshark 是两大分析网络的利器,这两大利器把我们“看不见”的数据包,呈现在我们眼前,一目了然。
tcpdump 和 Wireshark 有什么区别?
tcpdump 仅支持命令行格式使用,常用在 Linux 服务器中抓取和分析网络包。
Wireshark 除了可以抓包外,还提供了可视化分析网络包的图形页面。
所以,这两者实际上是搭配使用的,先用 tcpdump 命令在 Linux 服务器上抓包,<br>接着把抓包的文件拖出到 Windows 电脑后,用 Wireshark 可视化分析。
当然,如果你是在 Windows 上抓包,只需要用 Wireshark 工具就可以。
tcpdump 命令使用
tcpdump 提供了大量的选项以及各式各样的过滤表达式,来帮助你抓取指定的数据包,<br>不过不要担心,只需要掌握一些常用选项和过滤表达式,就可以满足大部分场景的需要了。
假设我们要抓取下面的 ping 的数据包:<br>
要抓取上面的 ping 命令数据包,首先我们要知道 ping 的数据包是 icmp 协议,<br>接着在使用 tcpdump 抓包的时候,就可以指定只抓 icmp 协议的数据包:
那么当 tcpdump 抓取到 icmp 数据包后, 输出格式如下:
从 tcpdump 抓取的 icmp 数据包,我们很清楚的看到 icmp echo 的交互过程了,首先发送方发起了 <br>ICMP echo request 请求报文,接收方收到后回了一个 ICMP echo reply 响应报文,之后 seq 是递增的。
常用的选项类,在上面的 ping 例子中,我们用过 -i 选项指定网口,<br>用过 -nn 选项不对 IP 地址和端口名称解析。其他常用的选项,如下表格:
再来看看常用的过滤表用法,在上面的 ping 例子中,我们用过的是 icmp and host 183.232.231.174,<br>表示抓取 icmp 协议的数据包,以及源地址或目标地址为 183.232.231.174 的包。其他常用的过滤选项,如下表格:
大家应该也发现了,tcpdump 虽然功能强大,但是输出的格式并不直观。
所以,在工作中 tcpdump 只是用来抓取数据包,不用来分析数据包,而是把 tcpdump <br>抓取的数据包保存成 pcap 后缀的文件,接着用 Wireshark 工具进行数据包分析。
Wireshark 分析方法
Wireshark 除了可以抓包外,还提供了可视化分析网络包的图形页面,同时,还内置了一系列的汇总分析工具。<br>
比如,拿上面的 ping 例子来说,我们可以使用下面的命令,把抓取的数据包保存到 ping.pcap 文件
接着把 ping.pcap 文件拖到电脑,再用 Wireshark 打开它。打开后,可以看到下面这个界面:
接着,在网络包列表中选择某一个网络包后,在其下面的网络包详情中,可以更清楚的看到,<br>这个网络包在协议栈各层的详细信息。比如,以编号 1 的网络包为例子:<br><ul><li>可以在数据链路层,看到 MAC 包头信息,如源 MAC 地址和目标 MAC 地址等字段;</li><li>可以在 IP 层,看到 IP 包头信息,如源 IP 地址和目标 IP 地址、TTL、IP 包长度、协议等 IP 协议各个字段的数值和含义;</li><li>可以在 ICMP 层,看到 ICMP 包头信息,比如 Type、Code 等 ICMP 协议各个字段的数值和含义;</li></ul>
Wireshark 用了分层的方式,展示了各个层的包头信息,把“不可见”的数据包,清清楚楚的展示了给我们。
从 ping 的例子中,我们可以看到网络分层就像有序的分工,每一层都有自己的责任范围和信息,<br>上层协议完成工作后就交给下一层,最终形成一个完整的网络包。
解密 TCP 三次握手和四次挥手
接下来用 wcpdump 和 Wireshark 抓取和分析 HTTP 协议网络包,并理解 TCP 三次握手和四次挥手的工作原理。
本次例子,我们将要访问的 http://192.168.3.200 服务端。在终端一用 tcpdump 命令抓取数据包:<br>
接着,在终端二执行下面的 curl 命令:
最后,回到终端一,按下 Ctrl+C 停止 tcpdump,并把得到的 http.pcap 取出到电脑。
使用 Wireshark 打开 http.pcap 后,就可以在 Wireshark 中,看到如下的界面:
我们都知道 HTTP 是基于 TCP 协议进行传输的,那么:<br><ul><li>最开始的 3 个包就是 TCP 三次握手建立连接的包</li><li>中间是 HTTP 请求和响应的包</li><li>而最后的 3 个包则是 TCP 断开连接的挥手包</li></ul>
Wireshark 可以用时序图的方式显示数据包交互的过程,从菜单栏中,点击 统计 (Statistics) -> 流量图 (Flow Graph),<br>然后,在弹出的界面中的「流量类型」选择 「TCP Flows」,你可以更清晰的看到,整个过程中 TCP 流的执行过程:
为什么三次握手连接过程的 Seq 是 0 ?
实际上是因为 Wireshark 工具帮我们做了优化,它默认显示的是序列号 seq 是相对值,而不是真实值。
如果你想看到实际的序列号的值,可以右键菜单, 然后找到「协议首选项」,<br>接着找到「Relative Seq」后,把它给取消,操作如下:
取消后,Seq 显示的就是真实值了:
可见,客户端和服务端的序列号实际上是不同的,序列号是一个随机值。
这其实跟我们书上看到的 TCP 三次握手和四次挥手很类似,作为对比,你通常看到的 TCP 三次握手和四次挥手的流程,基本是这样的:
为什么抓到的 TCP 挥手是三次,而不是书上说的四次?
当被动关闭方(上图的服务端)在 TCP 挥手过程中,<b><font color="#0000ff">「没有数据要发送」并且「开启了 TCP 延迟确认机制」,<br>那么第二和第三次挥手就会合并传输,这样就出现了三次挥手</font>。</b>
而通常情况下,服务器端收到客户端的 FIN 后,很可能还没发送完数据,所以就会先回复客户端一个 ACK 包,<br>稍等一会儿,完成所有数据包的发送后,才会发送 FIN 包,这也就是四次挥手了。
TCP 三次握手异常情况实战分析
场景:<br>本次实验用了两台虚拟机,一台作为服务端,一台作为客户端,它们的关系如下:<br><ul><li>客户端和服务端都是 CentOs 6.5 Linux,Linux 内核版本 2.6.32</li><li>服务端 192.168.12.36,apache web 服务</li><li>客户端 192.168.12.37</li></ul>
TCP 第一次握手的 SYN 丢包了,会发生什么?
为了模拟 TCP 第一次握手 SYN 丢包的情况,我是在拔掉服务器的网线后,立刻在客户端执行 curl 命令:
其间 tcpdump 抓包的命令如下:<br>
过了一会, curl 返回了超时连接的错误:
从 date 返回的时间,可以发现在超时接近 1 分钟的时间后,curl 返回了错误。
接着,把 tcp_sys_timeout.pcap 文件用 Wireshark 打开分析,显示如下图:
从上图可以发现, 客户端发起了 SYN 包后,一直没有收到服务端的 ACK ,<br>所以一直超时重传了 5 次,并且每次 RTO 超时时间是不同的:<br><ul><li>第一次是在 1 秒超时重传</li><li>第二次是在 3 秒超时重传</li><li>第三次是在 7 秒超时重传</li><li>第四次是在 15 秒超时重传</li><li>第五次是在 31 秒超时重传</li></ul>
可以发现,每次超时时间 RTO 是 <b>指数(翻倍)上涨的</b>,当超过最大重传次数后,客户端不再发送 SYN 包。<br>
在 Linux 中,第一次握手的 SYN 超时重传次数,是如下内核参数指定的:<br><ul><li>tcp_syn_retries 默认值为 5,也就是 SYN 最大重传次数是 5 次。</li></ul>
接下来,我们继续做实验,把 tcp_syn_retries 设置为 2 次:
重传抓包后,用 Wireshark 打开分析,显示如下图:
通过实验一的实验结果,我们可以得知,当客户端发起的 TCP 第一次握手 SYN 包,在超时时间内没收到服务端的 ACK,就会在超时重传 SYN 数据包,<br>每次超时重传的 RTO 是翻倍上涨的,直到 SYN 包的重传次数到达 tcp_syn_retries 值后,客户端不再发送 SYN 包。
TCP 第二次握手的 SYN、ACK 丢包了,会发生什么?
为了模拟客户端收不到服务端第二次握手 SYN、ACK 包,我的做法是在客户端加上防火墙限制,<br>直接粗暴的把来自服务端的数据都丢弃,防火墙的配置如下:
接着,在客户端执行 curl 命令:
从 date 返回的时间前后,可以算出大概 1 分钟后,curl 报错退出了。<br>
客户端在这其间抓取的数据包,用 Wireshark 打开分析,显示的时序图如下:
从图中可以发现:<br><ul><li><span style="font-size: inherit;">客户端发起 SYN 后,由于防火墙屏蔽了服务端的所有数据包,所以 curl 是无法收到服务端的 SYN、ACK 包,当发生超时后,就会重传 SYN 包</span></li><li>服务端收到客户的 SYN 包后,就会回 SYN、ACK 包,但是客户端一直没有回 ACK,服务端在超时后,重传了 SYN、ACK 包,<br><b>接着一会,客户端超时重传的 SYN 包又抵达了服务端,服务端收到后,然后回了 SYN、ACK 包,但是SYN、ACK包的重传定<br>时器并没有重置,还持续在重传,因为第二次握手在没收到第三次握手的 ACK 确认报文时,就会重传到最大次数。</b></li><li>最后,客户端 SYN 超时重传次数达到了 5 次(tcp_syn_retries 默认值 5 次),就不再继续发送 SYN 包了。</li></ul>
所以,我们可以发现,<b>当第二次握手的 SYN、ACK 丢包时,客户端会超时重发 SYN 包,服务端也会超时重传 SYN、ACK 包</b>。
咦?客户端设置了防火墙,屏蔽了服务端的网络包,为什么 tcpdump 还能抓到服务端的网络包?
添加 iptables 限制后, tcpdump 是否能抓到包 ,这要看添加的 iptables 限制条件:<br><ul><li>如果添加的是 INPUT 规则,则可以抓得到包</li><li>如果添加的是 OUTPUT 规则,则抓不到包</li></ul>
网络包进入主机后的顺序如下:<br><ul><li><span style="font-size: inherit;">进来的顺序 Wire -> NIC -> tcpdump -> netfilter/iptables</span></li><li>出去的顺序 iptables -> tcpdump -> NIC -> Wire</li></ul>
tcp_syn_retries 是限制 SYN 重传次数,那第二次握手 SYN、ACK 限制最大重传次数是多少?
TCP 第二次握手 SYN、ACK 包的最大重传次数是通过 tcp_synack_retries 内核参数限制的,其默认值如下:<br><ul><li>TCP 第二次握手 SYN、ACK 包的最大重传次数默认值是 5 次。</li></ul>
为了验证 SYN、ACK 包最大重传次数是 5 次,我们继续做下实验,我们先把客户端的 tcp_syn_retries 设置为 1,<br>表示客户端 SYN 最大超时次数是 1 次,目的是为了防止多次重传 SYN 后,把服务端 SYN、ACK 超时定时器重置。
接着,还是如上面的步骤:<br><ul><li>客户端配置防火墙屏蔽服务端的数据包</li><li>客户端 tcpdump 抓取 curl 执行时的数据包</li></ul>
把抓取的数据包,用 Wireshark 打开分析,显示的时序图如下:
从上图,我们可以分析出:<br><ul><li>客户端的 SYN 只超时重传了 1 次,因为 tcp_syn_retries 值为 1</li><li>服务端应答了客户端超时重传的 SYN 包后,由于一直收不到客户端的 ACK 包,所以服务端一直在超时重传<br> SYN、ACK 包,每次的 RTO 也是指数上涨的,一共超时重传了 5 次,因为 tcp_synack_retries 值为 5。</li></ul>
接着,我把 tcp_synack_retries 设置为 2,tcp_syn_retries 依然设置为 1:
依然保持一样的实验步骤进行操作,接着把抓取的数据包,用 Wireshark 打开分析,显示的时序图如下:
可见:<br><ul><li>客户端的 SYN 包只超时重传了 1 次,符合 tcp_syn_retries 设置的值;</li><li>服务端的 SYN、ACK 超时重传了 2 次,符合 tcp_synack_retries 设置的值</li></ul>
通过实验二的实验结果,我们可以得知,当 TCP 第二次握手 SYN、ACK 包丢了后,<br>客户端 SYN 包会发生超时重传,服务端 SYN、ACK 也会发生超时重传。<br><br>客户端 SYN 包超时重传的最大次数,是由 tcp_syn_retries 决定的,默认值是 5 次;<br>服务端 SYN、ACK 包时重传的最大次数,是由 tcp_synack_retries 决定的,默认值是 5 次。<br>
TCP 第三次握手的 ACK 包丢了,会发生什么?
为了模拟 TCP 第三次握手 ACK 包丢,我的实验方法是在服务端配置防火墙,屏蔽客户端 TCP 报文中标志位<br>是 ACK 的包,也就是当服务端收到客户端的 TCP ACK 的报文时就会丢弃,iptables 配置命令如下:<br>
接着,在客户端执行如下 tcpdump 命令:
然后,客户端向服务端发起 telnet,因为 telnet 命令是会发起 TCP 连接,所以用此命令做测试:
此时,由于服务端收不到第三次握手的 ACK 包,所以一直处于 SYN_RECV 状态:
而客户端是已完成 TCP 连接建立,处于 ESTABLISHED 状态:
过了 1 分钟后,观察发现服务端的 TCP 连接不见了:<br>
过了 30 分钟,客户端依然还是处于 ESTABLISHED 状态:
接着,在刚才客户端建立的 telnet 会话,输入 123456 字符,进行发送:
持续「好长」一段时间,客户端的 telnet 才断开连接:<br>
以上就是本次的实现三的现象,这里存在两个疑点:<br><ul><li>为什么服务端原本处于 SYN_RECV 状态的连接,过 1 分钟后就消失了?</li><li>为什么客户端 telnet 输入 123456 字符后,过了好长一段时间,telnet 才断开连接?</li></ul>
我们把刚抓的数据包,用 Wireshark 打开分析,显示的时序图如下:
上图的流程:<br><ul><li>客户端发送 SYN 包给服务端,服务端收到后,回了个 SYN、ACK 包给客户端,此时服务端的 TCP 连接处于 SYN_RECV 状态;</li><li>客户端收到服务端的 SYN、ACK 包后,给服务端回了个 ACK 包,此时客户端的 TCP 连接处于 ESTABLISHED 状态;</li><li>由于服务端配置了防火墙,屏蔽了客户端的 ACK 包,所以服务端一直处于 SYN_RECV 状态,没有进入 ESTABLISHED 状态,<br>tcpdump 之所以能抓到客户端的 ACK 包,是因为数据包进入系统的顺序是先进入 tcpudmp,后经过 iptables;</li><li>接着,服务端超时重传了 SYN、ACK 包,重传了 5 次后,也就是 <b>超过 tcp_synack_retries 的值(默认值是 5),然后就没有继续重传了,<br>此时服务端的 TCP 连接主动中止了,所以刚才处于 SYN_RECV 状态的 TCP 连接断开了,而客户端依然处于ESTABLISHED 状态</b>;</li><li>虽然服务端 TCP 断开了,但过了一段时间,发现客户端依然处于ESTABLISHED 状态,于是就在客户端的 telnet 会话输入了 123456 字符;</li><li>此时由于服务端已经断开连接,<b>客户端发送的数据报文,一直在超时重传,每一次重传,RTO 的值是指数增长的,<br>所以持续了好长一段时间,客户端的 telnet 才报错退出了,此时共重传了 15 次</b>。</li></ul>
通过这一波分析,刚才的两个疑点已经解除了:<br><ul><li>服务端在重传 SYN、ACK 包时,超过了最大重传次数 tcp_synack_retries,于是服务端的 TCP 连接主动断开了。</li><li>客户端向服务端发送数据包时,由于服务端的 TCP 连接已经退出了,所以数据包一直在超时重传,共重传了 15 次, telnet 就断开了连接。</li></ul>
TCP 第一次握手的 SYN 包超时重传最大次数是由 tcp_syn_retries 指定,TCP 第二次握手的 SYN、ACK 包超时重传<br>最大次数是由 tcp_synack_retries 指定,那 TCP 建立连接后的数据包最大超时重传次数是由什么参数指定呢?<br><br>TCP 建立连接后的数据包传输,最大超时重传次数是由 tcp_retries2 指定,默认值是 15 次,如下:<br><ul><li>如果 15 次重传都做完了,TCP 就会告诉应用层说:“搞不定了,包怎么都传不过去!”</li></ul>
那如果客户端不发送数据,什么时候才会断开处于 ESTABLISHED 状态的连接?<br>
这里就需要提到 TCP 的 <b>保活机制</b>。这个机制的原理是这样的:<br>定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用,每隔一个时间间隔,发送一个「探测报文」,该<br>探测报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的 TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。<br>
在 Linux 内核可以有对应的参数可以设置保活时间、保活探测的次数、保活探测的时间间隔,以下都为默认值:<br><ul><li>tcp_keepalive_time=7200:表示保活时间是 7200 秒(2小时),也就 2 小时内如果没有任何连接相关的活动,则会启动保活机制</li><li>tcp_keepalive_intvl=75:表示每次检测间隔 75 秒;</li><li>tcp_keepalive_probes=9:表示检测 9 次无响应,认为对方是不可达的,从而中断本次的连接。</li></ul>
也就是说在 Linux 系统中,最少需要经过 2 小时 11 分 15 秒才可以发现一个「死亡」连接。
这个时间是有点长的,所以如果我抓包足够久,或许能抓到探测报文。
通过实验三可以知道,在建立 TCP 连接时,如果第三次握手的 ACK,服务端无法收到,<br>则服务端就会 <b>短暂处于 SYN_RECV 状态</b>,而 <b>客户端会处于 ESTABLISHED 状态</b>。
由于服务端一直收不到 TCP 第三次握手的 ACK,则会一直重传 SYN、ACK 包,<br>直到重传次数超过 tcp_synack_retries 值(默认值 5 次)后,服务端就会断开 TCP 连接。
而客户端则会有两种情况:<br><ul><li>如果客户端没发送数据包,一直处于 ESTABLISHED 状态,则会启动 TCP 保活机制,发送探测报文,<br>然后经过 2 小时 11 分 15 秒才可以发现一个「死亡」连接,于是客户端连接就会断开连接。</li><li>如果客户端发送了数据包,一直没有收到服务端对该数据包的确认报文,则会一直重传该数据包,<br>直到重传次数超过 tcp_retries2 值(默认值 15 次)后,客户端就会断开 TCP 连接。</li></ul>
TCP 快速建立连接
客户端在向服务端发起 HTTP GET 请求时,一个完整的交互过程,需要 2.5 个 RTT 的时延。
由于第三次握手是可以携带数据的,这时如果在第三次握手发起 HTTP GET 请求,需要 2 个 RTT 的时延。
但是在下一次(不是同个 TCP 连接的下一次)发起 HTTP GET 请求时,经历的 RTT 也是一样,如下图:
在 Linux 3.7 内核版本中,提供了 <font color="#0000ff"><b>TCP Fast Open</b></font> 功能,这个功能可以 <b><font color="#0000ff">减少 TCP 连接建立的时延</font></b>。<br><ul><li>在第一次建立连接的时候,<b><font color="#0000ff">服务端在第二次握手产生一个 Cookie (已加密)并通过 SYN、ACK 包一起发给客户端</font></b>,<br>于是客户端就会缓存这个 Cookie,所以第一次发起 HTTP Get 请求的时候,还是需要 2 个 RTT 的时延;</li><li><b><font color="#0000ff">在下次请求的时候,客户端在 SYN 包带上 Cookie 发给服务端,就提前可以跳过三次握手的过程</font></b>,因为 Cookie 中维护了<br>一些信息,服务端可以从 Cookie 获取 TCP 相关的信息,这时发起的 HTTP GET 请求就只需要 1 个 RTT 的时延;</li></ul>
注:客户端在请求并存储了 Fast Open Cookie 之后,可以不断重复 TCP Fast Open 直至服务器认为 Cookie 无效(通常为过期)
在 Linux 上如何打开 Fast Open 功能?<br>
可以通过设置 net.ipv4.tcp_fastopn 内核参数,来打开 Fast Open 功能。<br>
net.ipv4.tcp_fastopn 各个值的意义:<br><ul><li>0 关闭</li><li>1 作为客户端使用 Fast Open 功能</li><li>2 作为服务端使用 Fast Open 功能</li><li>3 无论作为客户端还是服务器,都可以使用 Fast Open 功能</li></ul>
TCP Fast Open 抓包分析<br>
在下图,数据包 7 号,客户端发起了第二次 TCP 连接时,SYN 包会携带 Cooike,并且长度为 5 的数据。<br><br>服务端收到后,校验 Cooike 合法,于是就回了 SYN、ACK 包,并且确认应答收到了客户端的数据包,ACK = 5 + 1 = 6
TCP 重复确认和快速重传
当接收方收到乱序数据包时,会发送重复的 ACK,以便告知发送方要重发该数据包,<br><b>当发送方收到 3 个重复 ACK 时,就会触发快速重传,立刻重发丢失数据包。</b>
TCP 重复确认和快速重传的一个案例,用 Wireshark 分析,显示如下:<br><ul><li>数据包 1 期望的下一个数据包 Seq 是 1,但是数据包 2 发送的 Seq 却是 10945,说明收到的是乱序数据包,<br>于是回了数据包 3 ,还是同样的 Seq = 1,Ack = 1,这表明是重复的 ACK;</li><li>数据包 4 和 6 依然是乱序的数据包,于是依然回了重复的 ACK;</li><li>当对方收到三次重复的 ACK 后,于是就快速重传了 Seq = 1 、Len = 1368 的数据包 8;</li><li><span style="font-size: inherit;">当收到重传的数据包后,发现 Seq = 1 是期望的数据包,于是就发送了个确认收到快速重传的 ACK</span></li></ul><br>(注意:快速重传和重复 ACK 标记信息是 Wireshark 的功能,非数据包本身的信息。)
以上案例在 TCP 三次握手时协商开启了选择性确认 SACK,因此一旦数据包丢失并收到重复 ACK ,即使在丢失数据包<br>之后还成功接收了其他数据包,也只需要重传丢失的数据包。如果不启用 SACK,就必须重传丢失包之后的每个数据包。
如果要支持 SACK,必须双方都要支持。在 Linux 下,可以通过 net.ipv4.tcp_sack 参数打开这个功能(Linux 2.4 后默认打开)。
TCP 流量控制
TCP 流量控制概念
TCP 为了防止发送方无脑的发送数据,导致接收方缓冲区被填满,所以就有了滑动窗口的机制,<br>它可利用接收方的接收窗口来控制发送方要发送的数据量,也就是流量控制。
接收窗口是由接收方指定的值,存储在 TCP 头部中,它可以告诉发送方自己的 TCP <br>缓冲空间区大小,这个缓冲区是给应用程序读取数据的空间:<br><ul><li>如果应用程序读取了缓冲区的数据,那么缓冲空间区就会把被读取的数据移除</li><li>如果应用程序没有读取数据,则数据会一直滞留在缓冲区。</li></ul>
接收窗口的大小,是在 TCP 三次握手中协商好的,后续数据传输时,接收方<br>发送确认应答 ACK 报文时,会携带当前的接收窗口的大小,以此来告知发送方。
假设接收方接收到数据后,应用层能很快的从缓冲区里读取数据,那么窗口大小会一直保持不变,过程如下:
但是现实中服务器会出现繁忙的情况,当应用程序读取速度慢,那么缓存空间会慢慢被占满,于是为了保证发送方发送的数据不会超过<br>缓冲区大小,服务器则会调整窗口大小的值,接着通过 ACK 报文通知给对方,告知现在的接收窗口大小,从而控制发送方发送的数据大小。
抓包分析零窗口通知与窗口探测
假设接收方处理数据的速度跟不上接收数据的速度,缓存就会被占满,从而<br>导致接收窗口为 0,当发送方接收到零窗口通知时,就会停止发送数据。
如下图,可以看到接收方的窗口大小在不断的收缩至 0:
接着,发送方会定时发送窗口大小探测报文,以便及时知道接收方窗口大小的变化。
以下图 Wireshark 分析图作为例子说明:<br><ul><li>发送方发送了数据包 1 给接收方,接收方收到后,由于缓冲区被占满,回了个零窗口通知;</li><li>发送方收到零窗口通知后,就不再发送数据了,直到过了 3.4 秒后,发送了一个 TCP Keep-Alive 报文,也就是窗口大小探测报文;</li><li>当接收方收到窗口探测报文后,就立马回一个窗口通知,但是窗口大小还是 0;</li><li>发送方发现窗口还是 0,于是继续等待了 6.8(翻倍) 秒后,又发送了窗口探测报文,接收方依然还是回了窗口为 0 的通知;</li><li>发送方发现窗口还是 0,于是继续等待了 13.5(翻倍) 秒后,又发送了窗口探测报文,接收方依然还是回了窗口为 0 的通知;</li></ul>
可以发现,这些窗口探测报文以 3.4s、6.5s、13.5s 的间隔出现,说明 <b>超时时间会翻倍递增</b>。
在 Wireshark 看到的 Windows size 也就是 " win = ",这个值表示发送窗口吗?
这不是发送窗口,而是在向对方声明自己的接收窗口。
你可能会好奇,抓包文件里有「Window size scaling factor」,它其实是算出实际窗口大小的乘法因子,<br>「Window size value」实际上并不是真实的窗口大小,真实窗口大小的计算公式如下:<br><ul><li>「Window size value」 * 「Window size scaling factor」 = 「Caculated window size 」</li></ul><br>对应的下图案例,也就是 32 * 2048 = 65536。
实际上是 Caculated window size 的值是 Wireshark 工具帮我们算好的,Window size scaling factor 和 Windos size value 的值是在 TCP 头部中,其中 Window size scaling factor 是在三次握手过程中确定的,如果你抓包的数据没有 TCP 三次握手,那可能就无法算出真实的窗口大小的值,如下图:
如何在包里看出发送窗口的大小?<br><br><ul><li>很遗憾,没有简单的办法,发送窗口虽然是由接收窗口决定,但是它又可以被网络因素影响,<br>也就是拥塞窗口,实际上发送窗口是值是 min(拥塞窗口,接收窗口)。</li></ul>
发送窗口和 MSS 有什么关系?<br><br><ul><li>发送窗口决定了一口气能发多少字节,而 MSS 决定了这些字节要分多少包才能发完。举个例子,<br>如果发送窗口为 16000 字节的情况下,如果 MSS 是 1000 字节,那就需要发送 1600/1000 = 16 个包。</li></ul>
发送方在一个窗口发出 n 个包,是不是需要 n 个 ACK 确认报文?<br><br><ul><li>不一定,因为 TCP 有累计确认机制,所以当收到多个数据包时,只需要应答最后一个数据包的 ACK 报文就可以了。</li></ul>
TCP 延迟确认与 Nagle 算法
当我们 TCP 报文的承载的数据非常小的时候,例如几个字节,那么整个网络的效率是很低的,因为每个 TCP 报文中都会有 20 <br>个字节的 TCP 头部,也会有 20 个字节的 IP 头部,而数据只有几个字节,所以在整个报文中有效数据占有的比重就会非常低。
这就好像快递员开着大货车送一个小包裹一样浪费。<br>
那么就出现了常见的两种策略,来减少小报文的传输,分别是:<br><ul><li>Nagle 算法</li><li>延迟确认</li></ul>
Nagle 算法是如何避免大量 TCP 小数据报文的传输?<br>(Nagle 用于发送方)
Nagle 算法做了一些策略来 <b><font color="#0000ff">避免过多的小数据报文发送</font></b>,这可提高传输效率。
Nagle 伪代码如下:
使用 Nagle 算法,该算法的思路是延时处理,<b><font color="#0000ff">只有满足下面两个条件中的任意一个条件,才能可以发送数据</font></b>:<br><ul><li>条件一:要等到 <b><font color="#0000ff">可用窗口大小 >= MSS</font></b> 并且 <b><font color="#0000ff">可发送数据大小 >= MSS</font></b>;</li><li>条件二:<b><font color="#0000ff">收到之前发送数据的 ack 回包</font></b>;</li></ul>
只要上面两个条件都不满足,发送方一直在囤积数据,直到满足上面的发送条件。<br>
上图右侧启用了 Nagle 算法,它的发送数据的过程:<br><ul><li>一开始由于没有已发送未确认的报文,所以就立刻发了 H 字符;</li><li>接着,在还没收到对 H 字符的确认报文时,发送方就一直在囤积数据,直到收到了确认报文后,<br>此时没有已发送未确认的报文,于是就把囤积后的 ELL 字符一起发给了接收方;</li><li>待收到对 ELL 字符的确认报文后,于是把最后一个 O 字符发送了出去</li></ul>
可以看出,<b><font color="#0000ff">Nagle 算法一定会有一个小报文,也就是在最开始的时候</font>。</b><br>
另外,Nagle 算法默认是打开的,如果对于一些需要小数据包交互的场景的程序,<br>比如,telnet 或 ssh 这样的交互性比较强的程序,则需要关闭 Nagle 算法。
可以在 Socket 设置 TCP_NODELAY 选项来关闭这个算法(关闭 Nagle 算法没有全局参数,需要根据每个应用自己的特点来关闭)。
那延迟确认又是什么?<br>(用于接收方)
事实上当 <b><font color="#0000ff">没有携带数据的 ACK,它的网络效率也是很低的</font></b>,因为它也有 40 个字节的 IP 头 和 TCP 头,但却没有携带数据报文。
为了解决 <b><font color="#0000ff">ACK 传输效率低问题</font></b>,所以就衍生出了 <b><font color="#0000ff">TCP 延迟确认</font>。</b>
TCP 延迟确认的策略:<br><ul><li>当有响应数据要发送时,ACK 会随着响应数据一起立刻发送给对方</li><li>当没有响应数据要发送时,ACK 将会延迟一段时间,以等待是否有响应数据可以一起发送</li><li>如果 <b><font color="#0000ff">在延迟等待发送 ACK 期间,对方的第二个数据报文又到达了,这时就会立刻发送 ACK</font></b></li></ul>
延迟等待的时间是在 Linux 内核中定义的,如下图:<br>
关键就需要 HZ 这个数值大小,HZ 是跟系统的时钟频率有关,每个操作系统都不一样,在我的 Linux 系统中 HZ 大小是 1000,如下图:
知道了 HZ 的大小,那么就可以算出:<br><ul><li><span style="font-size: inherit;">最大延迟确认时间是 200 ms (1000/5)</span></li><li>最短延迟确认时间是 40 ms (1000/25)</li></ul>
TCP 延迟确认可以在 Socket 设置 TCP_QUICKACK 选项来关闭这个算法。<br>
延迟确认 和 Nagle 算法混合使用时,会产生新的问题<br>
当 TCP 延迟确认 和 Nagle 算法混合使用时,<b><font color="#0000ff">会导致时耗增长</font></b>,如下图:<br>
发送方使用了 Nagle 算法,接收方使用了 TCP 延迟确认会发生如下的过程:<br><ul><li>发送方先发出一个小报文,接收方收到后,<b><font color="#0000ff">由于延迟确认机制,自己又没有要发送的数据,只能干等着发送方的下一个报文到达</font></b>;</li><li>而 <b><font color="#0000ff">发送方由于 Nagle 算法机制,在未收到第一个报文的确认前,是不会发送后续的数据</font></b>;</li><li>所以 <b><font color="#0000ff">接收方只能等待最大时间 200 ms 后,才回 ACK 报文</font></b>,发送方收到第一个报文的确认报文后,也才可以发送后续的数据。</li></ul>
很明显,这两个同时使用会造成额外的时延,这就会使得网络"很慢"的感觉。<br>
要解决这个问题,只有两个办法:<br><ul><li>发送方 <b><font color="#0000ff">关闭 Nagle 算法</font></b></li><li>或者接收方 <b><font color="#0000ff">关闭 TCP 延迟确认</font></b></li></ul>
相关问题
tcp_retries1 参数,是什么场景下生效 ? tcp_retries2 是不是只受限于规定的次数,还是受限于次数和时间限制的最小值 ?<br><br>tcp_retries1(tcp_syn_retries) 和 tcp_retries2 都是在TCP三次握手之后的场景。<br><ul><li>当重传次数超过 tcp_retries1就会指示 IP 层进行 MTU 探测、刷新路由等过程,并不会断开TCP连接,当重传次数超过 tcp_retries2 才会断开TCP流。</li><li>tcp_retries1 和 tcp_retries2 两个重传次数都是受一个 timeout 值限制的,timeout 的值是根据它俩的值计算出来的,<br>当重传时间超过 timeout,就不会继续重传了,即使次数还没到达。</li></ul>
tcp_orphan_retries 也是控制 tcp 连接的关闭。这个跟 tcp_retries1、tcp_retries2 有什么区别吗?<br><br>主动方发送 FIN 报文后,连接就处于 FIN_WAIT1 状态下,该状态通常应在数十毫秒内转为 FIN_WAIT2。<br>如果迟迟收不到对方返回的 ACK 时,此时,内核会定时重发 FIN 报文,其中重发次数由 tcp_orphan_retries 参数控制。<br>
为什么连续两个报文的seq会是一样的呢,比如三次握手之后的那个报文?还是说,序号相同的是同一个报文,只是拆开显示了?<br><br><ol><li>三次握手中的前两次,是 seq+1;</li><li>三次握手中的最后一个 ack,实际上是可以携带数据的,由文中的例子是没有发送数据的,可以看到第三次握手的 len=0 ,<br>在数据传输阶段「下一个 seq=seq+len 」,所以第三次握手的 seq 和下一个数据报的 seq 是一样的,因为 len 为 0;</li></ol>
TCP 半连接队列和全连接队列
什么是 TCP 半连接队列和全连接队列?
在 TCP 三次握手的时候,Linux 内核会维护两个队列,分别是:<br><ul><li>半连接队列,也称 SYN 队列;</li><li>全连接队列,也称 accept 队列;</li></ul>
服务端收到 <font color="#0000ff"><b>客户端发起的 SYN 请求后</b>,<b>内核会把该连接存储到半连接队列</b></font>,并向客户端响应 SYN+ACK,<br>接着客户端会返回 ACK,<b><font color="#0000ff">服务端收到第三次握手的 ACK 后,内核会把连接从半连接队列移除,然后创建<br>新的完全的连接,并将其添加到 accept 队列,等待进程调用 accept 函数时把连接取出来</font></b>。
不管是半连接队列还是全连接队列,<b><font color="#0000ff">都有最大长度限制,超过限制时,内核会直接丢弃,或返回 RST 包</font></b>。
实战 - TCP 全连接队列溢出
如何知道应用程序的 TCP 全连接队列大小?<br>
在服务端可以使用 ss 命令,来查看 TCP 全连接队列的情况。<br>
但需要注意的是 ss 命令获取的 Recv-Q/Send-Q 在「LISTEN 状态」和「非 LISTEN 状态」<br>所表达的含义是不同的。从下面的内核代码可以看出区别:
在「LISTEN 状态」时,Recv-Q/Send-Q 表示的含义如下:<br><ul><li>Recv-Q:当前全连接队列的大小,也就是当前已完成三次握手并等待服务端 accept() 的 TCP 连接;</li><li>Send-Q:当前全连接最大队列长度,下面的输出结果说明监听 8088 端口的 TCP 服务,最大全连接长度为 128;</li></ul>
在「非 LISTEN 状态」时,Recv-Q/Send-Q 表示的含义如下:<br><ul><li>Recv-Q:已收到但未被应用进程读取的字节数;</li><li>Send-Q:已发送但未收到确认的字节数;</li></ul>
如何模拟 TCP 全连接队列溢出的场景?<br>
实验环境:<br><ul><li><span style="font-size: inherit;">客户端和服务端都是 CentOs 6.5 ,Linux 内核版本 2.6.32</span></li><li>服务端 IP 192.168.3.200,客户端 IP 192.168.3.100</li><li>服务端是 Nginx 服务,端口为 8088</li></ul>
先介绍下 wrk 工具,它是一款简单的 HTTP 压测工具,它能够在单机多核 CPU 的条件下,<br>使用系统自带的高性能 I/O 机制,通过多线程和事件模式,对目标机器产生大量的负载。
本次模拟实验就使用 wrk 工具来压力测试服务端,发起大量的请求,<br>一起看看服务端 TCP 全连接队列满了会发生什么?有什么观察指标?
客户端执行 wrk 命令对服务端发起压力测试,并发 3 万个连接:
在服务端可以使用 ss 命令,来查看当前 TCP 全连接队列的情况:
其间共执行了两次 ss 命令,从上面的输出结果,可以发现当前 TCP <br>全连接队列上升到了 129 大小,超过了最大 TCP 全连接队列。
当超过了 TCP 最大全连接队列,服务端则会丢掉后续进来的 TCP 连接,<br>丢掉的 TCP 连接的个数会被统计起来,我们可以使用 netstat -s 命令来查看:
上面看到的 41150 times ,表示全连接队列溢出的次数,注意这个是累计值。<br>可以隔几秒钟执行下,如果这个数字一直在增加的话肯定全连接队列偶尔满了。
从上面的模拟结果,可以得知,<b><font color="#0000ff">当服务端并发处理大量请求时,如果 TCP 全连接队列过小,就容易溢出。<br>发生 TCP 全连接队溢出的时候,后续的请求就会被丢弃,这样就会出现服务端请求数量上不去的现象</font>。</b><br>
当 TCP 全连接队列满了会使用什么策略来回应客户端?<br>
Linux 有个参数可以指定当 TCP 全连接队列满了会使用什么策略来回应客户端。<br>
实际上,丢弃连接只是 Linux 的默认行为,我们还可以选择向客户端发送 RST 复位报文,告诉客户端连接已经建立失败。
tcp_abort_on_overflow 共有两个值分别是 0 和 1,其分别表示:<br><ul><li>0 :如果全连接队列满了,那么 server 扔掉 client 发过来的 ack ;</li><li>1 :如果全连接队列满了,server 发送一个 reset 包给 client,表示废掉这个握手过程和这个连接;</li></ul>
如果要想知道客户端连接不上服务端,是不是服务端 TCP 全连接队列满的原因,那么可以把 tcp_abort_on_overflow 设置为 1,<br>这时如果在客户端异常中可以看到很多 connection reset by peer 的错误,那么就可以证明是由于服务端 TCP 全连接队列溢出的问题。
通常情况下,应当把 tcp_abort_on_overflow 设置为 0,因为这样更有利于应对突发流量。
举个例子,当 TCP 全连接队列满导致服务器丢掉第三次握手的 ACK,与此同时,客户端的连接状态却是 ESTABLISHED,进程就在建立好的<br>连接上发送请求。只要服务器没有为请求回复 ACK,<b><font color="#0000ff">请求就会被多次重发</font></b>。如果服务器上的进程 <b><font color="#0000ff">只是短暂的繁忙造成 accept 队列满,那么<br>当 TCP 全连接队列有空位时,再次接收到客户端的请求报文,由于该报文中含有 ACK,仍然会触发服务器端成功建立连接</font>。<font color="#0000ff">而如果把 tcp_<br>abort_on_overflow 设置为 1,则在第一次全连接队列满时,服务端就会发送一个 reset 包给客户端,表示废除掉这个握手过程和这个连接了</font>。</b>
所以,tcp_abort_on_overflow <b><font color="#0000ff">设为 0 可以提高连接建立的成功率</font></b>,只有你非常肯定 TCP 全连接队列会长期溢出时,才能设置为 1 以尽快通知客户端。
如何增大 TCP 全连接队列呢?<br>
当发现 TCP 全连接队列发生溢出的时候,我们就需要增大该队列的大小,以便可以应对客户端大量的请求。
<b><font color="#0000ff">TCP 全连接队列的最大值取决于 somaxconn 和 backlog 之间的最小值,也就是 min(somaxconn, backlog)</font></b>。<br>从下面的 Linux 内核代码可以得知:<br><ul><li>somaxconn 是 Linux 内核的参数,默认值是 128,可以通过 /proc/sys/net/core/somaxconn 来设置其值;</li><li>backlog 是 listen(int sockfd, int backlog) 函数中的 backlog 大小,Nginx 默认值是 511,可以通过修改配置文件设置其长度;</li></ul>
前面模拟测试中,我的测试环境:<br><ul><li>somaxconn 是默认值 128;</li><li>Nginx 的 backlog 是默认值 511</li></ul>
所以测试环境的 TCP 全连接队列最大值为 min(128, 511),也就是 128,可以执行 ss 命令查看:
现在我们重新压测,把 TCP 全连接队列搞大,把 somaxconn 设置成 5000:
接着把 Nginx 的 backlog 也同样设置成 5000:<br>
最后要重启 Nginx 服务,因为只有重新调用 listen() 函数 TCP 全连接队列才会重新初始化。
重启完后 Nginx 服务后,服务端执行 ss 命令,查看 TCP 全连接队列大小:<br><ul><li>从执行结果,可以发现 TCP 全连接最大值为 5000。</li></ul>
增大 TCP 全连接队列后,继续压测,客户端同样以 3 万个连接并发发送请求给服务端:<br>
服务端执行 ss 命令,查看 TCP 全连接队列使用情况:<br>
从上面的执行结果,可以发现全连接队列使用增长的很快,但是一直都没有超过最大值,<br>所以就不会溢出,那么 netstat -s 就不会有 TCP 全连接队列溢出个数的显示:
说明 TCP 全连接队列最大值从 128 增大到 5000 后,服务端抗住了 3 万连接并发请求,也没有发生全连接队列溢出的现象了。
<font color="#0000ff">如果持续不断地有连接因为 TCP 全连接队列溢出被丢弃,就应该调大 backlog 以及 somaxconn 参数</font>。
实战 - TCP 半连接队列溢出
如何查看 TCP 半连接队列长度?<br>
很遗憾,TCP 半连接队列长度的长度,没有像全连接队列那样可以用 ss 命令查看。
但是我们可以抓住 TCP 半连接的特点,就是 <b><font color="#0000ff">服务端处于 SYN_RECV 状态的 TCP 连接,就是 TCP 半连接队列</font></b>。
于是,我们可以使用如下命令计算当前 TCP 半连接队列长度:<br>
如何模拟 TCP 半连接队列溢出场景?<br>
模拟 TCP 半连接溢出场景不难,实际上就是对服务端一直发送 TCP SYN 包,但是不回<br>第三次握手 ACK,这样就会使得服务端有大量的处于 SYN_RECV 状态的 TCP 连接。
这其实也就是所谓的 SYN 洪泛、SYN 攻击、DDos 攻击。
实验环境:<br><ul><li><span style="font-size: inherit;">客户端和服务端都是 CentOs 6.5 ,Linux 内核版本 2.6.32</span></li><li>服务端 IP 192.168.3.200,客户端 IP 192.168.3.100</li><li>服务端是 Nginx 服务,端口为 8088</li></ul>
注意:本次模拟实验是没有开启 tcp_syncookies,关于 tcp_syncookies 的作用,后续会说明。
本次实验使用 hping3 工具模拟 SYN 攻击:<br>
当服务端受到 SYN 攻击后,连接服务端 ssh 就会断开了,无法再连上。<br>只能在服务端主机上执行查看当前 TCP 半连接队列大小:
同时,还可以通过 netstat -s 观察半连接队列溢出的情况:<br>
上面输出的数值是 <b><font color="#0000ff">累计值</font></b>,表示共有多少个 TCP 连接因为半连接队列溢出而被丢弃。<br><b><font color="#0000ff">隔几秒执行几次,如果有上升的趋势,说明当前存在半连接队列溢出的现象</font>。</b>
许多人说 tcp_max_syn_backlog 是指定半连接队列的大小,正确吗?<br>
很遗憾,半连接队列的大小并不单单只跟 tcp_max_syn_backlog 有关系。
上面模拟 SYN 攻击场景时,服务端的 tcp_max_syn_backlog 的默认值如下:
但是在测试的时候发现,服务端最多只有 256 个半连接队列,而不是 512,<br>所以 <b>半连接队列的最大长度不一定由 tcp_max_syn_backlog 值决定的。</b>
走进 Linux 内核源码,分析 TCP 半连接队列的最大值是如何决定的?
TCP 第一次握手(收到 SYN 包)的 Linux 内核代码如下,其中缩减<br>了大量的代码,只需要重点关注 TCP 半连接队列溢出的处理逻辑:
从源码中,可以得出共有三个条件因队列长度的关系而被丢弃的:<br><ol><li><b><font color="#0000ff">如果半连接队列满了,并且没有开启 tcp_syncookies,则会丢弃;</font></b></li><li><b><font color="#0000ff">若全连接队列满了,且没有重传 SYN+ACK 包的连接请求多于 1 个,则会丢弃;</font></b></li><li><b><font color="#0000ff">如果没有开启 tcp_syncookies,并且 max_syn_backlog 减去 当前半连接队列长度小于 (max_syn_backlog >> 2),则会丢弃;</font></b></li></ol>
关于 tcp_syncookies 的设置,后面在详细说明,可以先说一下,开启 tcp_syncookies 是缓解 SYN 攻击其中一个手段。
接下来,我们继续看一下检测半连接队列是否满的函数 inet_csk_reqsk_queue_is_full 和 检测全连接队列是否满的函数 sk_acceptq_is_full :
从上面源码,可以得知:<br><ul><li><b><font color="#0000ff">全连接队列</font> </b>的最大值是 sk_max_ack_backlog 变量,该变量在 listen() 源码里指定(上面分析了),也就是 <b>min(somaxconn, backlog)</b>;</li><li><b><font color="#0000ff">半连接队列</font> </b>的最大值是 max_qlen_log 变量,max_qlen_log 是在哪指定的呢?现在暂时还不知道,我们继续跟进;</li></ul>
我们继续跟进代码,看一下是哪里初始化了半连接队列的最大值 max_qlen_log:
从上面的代码中,我们可以算出 max_qlen_log 是 8,于是代入到 检测半连接队列是否满的函数 reqsk_queue_is_full :
也就是 qlen >> 8 什么时候为 1 就代表半连接队列满了。很明显是当 qlen 为 256 时,256 >> 8 = 1。
至此,总算知道为什么上面模拟测试 SYN 攻击的时候,服务端处于 SYN_RECV 连接最大只有 256 个。
可见,<b>半连接队列最大值不是单单由 max_syn_backlog 决定,还跟 somaxconn 和 backlog 有关系。</b>
在 Linux 2.6.32 内核版本,它们之间的关系,总体可以概况为:<br><ul><li>当 max_syn_backlog > min(somaxconn, backlog) 时, 半连接队列最大值 max_qlen_log = min(somaxconn, backlog) * 2;</li><li>当 max_syn_backlog < min(somaxconn, backlog) 时, 半连接队列最大值 max_qlen_log = max_syn_backlog * 2;</li></ul>
半连接队列最大值 max_qlen_log 就表示服务端处于 SYN_REVC 状态的最大个数吗?<br>
依然很遗憾,并不是。max_qlen_log 是 <b>理论 </b>半连接队列最大值,<br>并不一定代表服务端处于 SYN_REVC 状态的最大个数。
在前面我们在分析 TCP 第一次握手(收到 SYN 包)时会被丢弃的三种条件:<br><ul><li>如果半连接队列满了,并且没有开启 tcp_syncookies,则会丢弃;</li><li>若全连接队列满了,且没有重传 SYN+ACK 包的连接请求多于 1 个,则会丢弃;</li><li><b>如果没有开启 tcp_syncookies,并且 max_syn_backlog 减去 当前半连接队列长度 小于 (max_syn_backlog >> 2),则会丢弃;</b></li></ul>
假设条件 1 当前半连接队列的长度 「没有超过」理论的半连接队列最大值 max_qlen_log,那么如果条件 3 成立,<br>则依然会丢弃 SYN 包,也就会使得服务端处于 SYN_REVC 状态的最大个数不会是理论值 max_qlen_log。
似乎很难理解,我们继续接着做实验,实验见真知。
服务端环境如下:
配置完后,服务端要重启 Nginx,因为全连接队列最大值和半连接队列最大值是在 listen() 函数初始化。
根据前面的源码分析,我们可以计算出半连接队列 max_qlen_log 的最大值为 256:
客户端执行 hping3 发起 SYN 攻击:
服务端执行如下命令,查看处于 SYN_RECV 状态的最大个数:
可以发现,服务端处于 SYN_RECV 状态的最大个数并不是 max_qlen_log 变量的值。
这就是前面所说的原因:<b>如果当前半连接队列的长度 「没有超过」理论半连接队列最大值 max_qlen_log,那么如果<br>条件 3 成立,则依然会丢弃 SYN 包,也就会使得服务端处于 SYN_REVC 状态的最大个数不会是理论值 max_qlen_log。</b>
我们来分析一波条件 3 :<br>
从上面的分析,可以得知如果触发「当前半连接队列长度 > 192」条件,TCP 第一次握手的 SYN 包是会被丢弃的。
在前面我们测试的结果,服务端处于 SYN_RECV 状态的最大个数是 193,正好是触发了条件 3,<br>所以处于 SYN_RECV 状态的个数还没到「理论半连接队列最大值 256」,就已经把 SYN 包丢弃了。
所以,服务端处于 SYN_RECV 状态的最大个数分为如下两种情况:<br><ul><li>如果「当前半连接队列」没超过「理论半连接队列最大值」,但是超过 max_syn_backlog - (max_syn_backlog >> 2),<br>那么处于 SYN_RECV 状态的最大个数就是 max_syn_backlog - (max_syn_backlog >> 2);</li><li>如果「当前半连接队列」超过「理论半连接队列最大值」,那么处于 SYN_RECV 状态的最大个数就是「理论半连接队列最大值」;</li></ul>
每个 Linux 内核版本「理论」半连接最大值计算方式会不同。<br>
在上面我们是针对 Linux 2.6.32 版本分析的「理论」半连接最大值的算法,可能每个版本有些不同。
比如在 Linux 5.0.0 的时候,「理论」半连接最大值就是全连接队列最大值,但依然还是有队列溢出的三个条件:
如果 SYN 半连接队列已满,只能丢弃连接吗?<br>
并不是这样,<b><font color="#0000ff">开启 syncookies 功能就可以在不使用 SYN 半连接队列的情况下成功建立连接</font></b>,<br>在前面我们源码分析也可以看到这点,当开启了 syncookies 功能就不会丢弃连接。
syncookies 是这么做的:<b><font color="#0000ff">服务器根据当前状态计算出一个值,放在己方发出的 SYN+ACK 报文中发出,<br>当客户端返回 ACK 报文时,取出该值验证,如果合法,就认为连接建立成功</font></b>,如下图所示。
syncookies 参数主要有以下三个值:<br><ul><li>0 值,表示关闭该功能;</li><li>1 值,表示仅当 SYN 半连接队列放不下时,再启用它;</li><li>2 值,表示无条件开启功能;</li></ul>
那么在应对 SYN 攻击时,只需要设置为 1 即可:
如何防御 SYN 攻击?<br>
这里给出几种防御 SYN 攻击的方法:<br><ul><li>增大半连接队列;</li><li>开启 tcp_syncookies 功能</li><li>减少 SYN+ACK 重传次数</li></ul>
方式一:增大半连接队列<br>
在前面源码和实验中,得知 <b>要想增大半连接队列,我们得知不能只单纯增大 tcp_max_syn_backlog 的值,还需<br>一同增大 somaxconn 和 backlog,也就是增大全连接队列</b>。否则,只单纯增大 tcp_max_syn_backlog 是无效的。
增大 tcp_max_syn_backlog 和 somaxconn 的方法是修改 Linux 内核参数:
增大 backlog 的方式,每个 Web 服务都不同,比如 Nginx 增大 backlog 的方法如下:<br>
最后,改变了如上这些参数后,要重启 Nginx 服务,因为半连接队列和全连接队列都是在 listen() 初始化的。
方式二:开启 tcp_syncookies 功能<br>
开启 tcp_syncookies 功能的方式也很简单,修改 Linux 内核参数:<br>
syncookies 启用后就不需要半链接了?那请求的数据会存在哪里?<br><br><b><font color="#0000ff">syncookies = 1 时,半连接队列满后,后续的请求就不会存放到半连接队列了</font></b>,而是在第二次握手的时候,服务端会计算一个 cookie 值,<br>放入到 SYN +ACK 包中的序列号发给客户端,客户端收到后并回 ack ,服务端就会校验连接是否合法,合法就直接把连接放入到全连接队列。<br>
方式三:减少 SYN+ACK 重传次数<br>
当服务端受到 SYN 攻击时,就会有大量处于 SYN_REVC 状态的 TCP 连接,<br>处于这个状态的 TCP 会重传 SYN+ACK ,当重传超过次数达到上限后,就会断开连接。
那么针对 SYN 攻击的场景,我们可以 <b><font color="#0000ff">减少 SYN+ACK 的重传次数,以加快处于 SYN_REVC 状态的 TCP 连接断开</font></b>。
如何优化 TCP
TCP 三次握手的性能提升
握手的性能提升介绍
TCP 是面向连接的、可靠的、双向传输的传输层通信协议,所以在传输数据之前需要经过三次握手才能建立连接。
那么,三次握手的过程在一个 HTTP 请求的平均时间占比 10% 以上,在网络状态不佳、高并发或者<br>遭遇 SYN 攻击等场景中,如果不能有效正确的调节三次握手中的参数,就会对性能产生很多的影响。
如何正确有效的使用这些参数,来提高 TCP 三次握手的性能,这就需要理解「三次握手的状态变迁」,<br>这样当出现问题时,先用 netstat 命令查看是哪个握手阶段出现了问题,再来对症下药,而不是病急乱投医。
客户端和服务端都可以针对三次握手优化性能。主动发起连接的客户端优化相对简单些,<br>而服务端需要监听端口,属于被动连接方,其间保持许多的中间状态,优化方法相对复杂一些。
所以,客户端(主动发起连接方)和服务端(被动连接方)优化的方式是不同的,接下来分别针对客户端和服务端优化。
客户端优化<br>
三次握手建立连接的首要目的是「同步序列号」。
只有同步了序列号才有可靠传输,TCP 许多特性都依赖于序列号实现,比如流量控制、丢包重传等,<br>这也是三次握手中的报文称为 SYN 的原因,SYN 的全称就叫 Synchronize Sequence Numbers(同步序列号)。
SYN_SENT 状态的优化<br>
客户端作为主动发起连接方,首先它将发送 SYN 包,于是客户端的连接就会处于 SYN_SENT 状态。
客户端在等待服务端回复的 ACK 报文,正常情况下,服务器会在几毫秒内返回 SYN+ACK ,但如果客户端<br>长时间没有收到 SYN+ACK 报文,则会重发 SYN 包,重发的次数由 tcp_syn_retries 参数控制,默认是 5 次:
通常,第一次超时重传是在 1 秒后,第二次超时重传是在 2 秒,第三次超时重传是在 4 秒后,<br>第四次超时重传是在 8 秒后,第五次是在超时重传 16 秒后。<b>每次超时的时间是上一次的 2 倍</b>。
当第五次超时重传后,会继续等待 32 秒,如果服务端仍然没有回应 ACK,客户端就会终止三次握手。
所以,总耗时是 1+2+4+8+16+32=63 秒,大约 1 分钟左右。
可以根据网络的稳定性和目标服务器的繁忙程度修改 SYN 的重传次数,调整客户端的三次握手时间上限。<br>比如内网中通讯时,就可以适当调低重试次数,尽快把错误暴露给应用程序。
服务端优化<br>
当服务端收到 SYN 包后,服务端会立马回复 SYN+ACK 包,表明确认收到了客户端的序列号,同时也把自己的序列号发给对方。
此时,服务端出现了新连接,状态是 SYN_RCV。在这个状态下,Linux 内核就会建立一个<br>「半连接队列」来维护「未完成」的握手信息,当半连接队列溢出后,服务端就无法再建立新的连接。
SYN 攻击,攻击的是就是这个半连接队列。
如何查看由于 SYN 半连接队列已满,而被丢弃连接的情况?<br>
我们可以通过该 netstat -s 命令给出的统计结果中, 可以得到由于半连接队列已满,引发的失败次数:
上面输出的数值是 <b>累计值</b>,表示共有多少个 TCP 连接因为半连接队列溢出而被丢弃。<br><b>隔几秒执行几次,如果有上升的趋势,说明当前存在半连接队列溢出的现象。</b>
如何调整 SYN 半连接队列大小?<br>
要想增大半连接队列,<b>不能只单纯增大 tcp_max_syn_backlog 的值,还需一同增大 somaxconn <br>和 backlog,也就是增大 accept 队列。否则,只单纯增大 tcp_max_syn_backlog 是无效的。</b>
增大 tcp_max_syn_backlog 和 somaxconn 的方法是修改 Linux 内核参数:
增大 backlog 的方式,每个 Web 服务都不同,比如 Nginx 增大 backlog 的方法如下:
最后,改变了如上这些参数后,要重启 Nginx 服务,因为 SYN 半连接队列和 accept 队列都是在 listen() 初始化的。
如果 SYN 半连接队列已满,只能丢弃连接吗?
并不是这样,<b>开启 syncookies 功能就可以在不使用 SYN 半连接队列的情况下成功建立连接。</b><br>
syncookies 的工作原理:服务器根据当前状态计算出一个值,放在己方发出的 SYN+ACK 报文中发出,<br>当客户端返回 ACK 报文时,取出该值验证,如果合法,就认为连接建立成功,如下图所示。<br>
syncookies 参数主要有以下三个值:<br><ul><li>0 值,表示关闭该功能;</li><li>1 值,表示仅当 SYN 半连接队列放不下时,再启用它;</li><li>2 值,表示无条件开启功能;</li></ul>
那么在应对 SYN 攻击时,只需要设置为 1 即可:<br>
SYN_RCV 状态的优化<br>
当客户端接收到服务器发来的 SYN+ACK 报文后,就会回复 ACK 给服务器,<br>同时客户端连接状态从 SYN_SENT 转换为 ESTABLISHED,表示连接建立成功。
服务器端连接成功建立的时间还要再往后,等到服务端收到客户端的 ACK 后,服务端的连接状态才变为 ESTABLISHED。
如果服务器没有收到 ACK,就会重发 SYN+ACK 报文,同时一直处于 SYN_RCV 状态。
当网络繁忙、不稳定时,报文丢失就会变严重,此时应该调大重发次数。<br>反之则可以调小重发次数。<b>修改重发次数的方法是,调整 tcp_synack_retries 参数:</b>
tcp_synack_retries 的默认重试次数是 5 次,与客户端重传 SYN 类似,它的重传会经历 1、2、4、8、16 秒,<br>最后一次重传后会继续等待 32 秒,如果服务端仍然没有收到 ACK,才会关闭连接,故共需要等待 63 秒。
服务器收到 ACK 后连接建立成功,此时,内核会把连接从半连接队列移除,然后创建新<br>的完全的连接,并将其添加到 accept 队列,等待进程调用 accept 函数时把连接取出来。
如果进程不能及时地调用 accept 函数,就会造成 accept 队列(也称全连接队列)溢出,最终导致建立好的 TCP 连接被丢弃。
accept 队列已满,只能丢弃连接吗?<br>
丢弃连接只是 Linux 的默认行为,我们还可以选择向客户端发送 RST 复位报文,告诉<br>客户端连接已经建立失败。打开这一功能需要将 tcp_abort_on_overflow 参数设置为 1。
tcp_abort_on_overflow 共有两个值分别是 0 和 1,其分别表示:<br><ul><li>0 :如果 accept 队列满了,那么 server 扔掉 client 发过来的 ack ;</li><li>1 :如果 accept 队列满了,server 发送一个 RST 包给 client,表示废掉这个握手过程和这个连接;</li></ul>
如果要想知道客户端连接不上服务端,是不是服务端 TCP 全连接队列满的原因,那么可以把 tcp_abort_on_overflow 设置为 1,<br>这时如果在客户端异常中可以看到很多 connection reset by peer 的错误,那么就可以证明是由于服务端 TCP 全连接队列溢出的问题。
通常情况下,应当把 tcp_abort_on_overflow 设置为 0,因为这样更有利于应对突发流量。
举个例子,当 TCP 全连接队列满导致服务器丢掉第三次握手的 ACK,与此同时,客户端的连接状态却是 ESTABLISHED,进程就在建立好的<br>连接上发送请求。只要服务器没有为请求回复 ACK,<b>请求就会被多次重发</b>。如果服务器上的进程 <b>只是短暂的繁忙造成 accept 队列满,那么<br>当 TCP 全连接队列有空位时,再次接收到客户端的请求报文,由于该保温中含有 ACK,仍然会触发服务器端成功建立连接。而如果把 <br>tcp_abort_on_overflow 设置为1,则在第一次全连接队列满时,服务端就会发送一个 reset 包给客户端,表示废除掉这个握手过程和这个连接了。</b><br>
所以,tcp_abort_on_overflow 设为 0 可以提高连接建立的成功率,只有你非常肯定 TCP 全连接队列会长期溢出时,才能设置为 1 以尽快通知客户端。
如何调整 accept 队列的长度呢?<br>
accept 队列的长度取决于 somaxconn 和 backlog 之间的最小值,也就是 min(somaxconn, backlog),其中:<br><ul><li>somaxconn 是 Linux 内核的参数,默认值是 128,可以通过 net.core.somaxconn 来设置其值;</li><li>backlog 是 listen(int sockfd, int backlog) 函数中的 backlog 大小;</li></ul>
Tomcat、Nginx、Apache 常见的 Web 服务的 backlog 默认值都是 511。<br>
如何查看服务端进程 accept 队列的长度?<br>
可以通过 ss -ltn 命令查看:<br><ul><li>Recv-Q:当前 accept 队列的大小,也就是当前已完成三次握手并等待服务端 accept() 的 TCP 连接;</li><li>Send-Q:accept 队列最大长度,上面的输出结果说明监听 8088 端口的 TCP 服务,accept 队列的最大长度为 128;</li></ul>
如何查看由于 accept 连接队列已满,而被丢弃的连接?<br>
当超过了 accept 连接队列,服务端则会丢掉后续进来的 TCP 连接,<br>丢掉的 TCP 连接的个数会被统计起来,我们可以使用 netstat -s 命令来查看:
上面看到的 41150 times ,表示 accept 队列溢出的次数,注意这个是累计值。<br>可以隔几秒钟执行下,如果这个数字一直在增加的话,说明 accept 连接队列偶尔满了。
如果持续不断地有连接因为 accept 队列溢出被丢弃,就应该调大 backlog 以及 somaxconn 参数。
如何绕过三次握手?<br>
以上我们只是在对三次握手的过程进行优化,接下来我们看看如何绕过三次握手发送数据。
三次握手建立连接造成的后果就是,HTTP 请求必须在一个 RTT(从客户端到服务器一个往返的时间)后才能发送。
在 Linux 3.7 内核版本之后,提供了 TCP Fast Open 功能,这个功能可以减少 TCP 连接建立的时延。<br>
TCP Fast Open 功能的工作方式
在客户端首次建立连接时的过程:<br><ul><li>客户端发送 SYN 报文,该报文包含 Fast Open 选项,且该选项的 Cookie 为空,这表明客户端请求 Fast Open Cookie;</li><li>支持 TCP Fast Open 的服务器生成 Cookie,并将其置于 SYN-ACK 数据包中的 Fast Open 选项以发回客户端;</li><li>客户端收到 SYN-ACK 后,本地缓存 Fast Open 选项中的 Cookie。</li></ul>
所以,第一次发起 HTTP GET 请求的时候,还是需要正常的三次握手流程。<br><br>之后,如果客户端再次向服务器建立连接时的过程:<br><ol><li>客户端发送 SYN 报文,该报文包含「数据」(对于非 TFO(TCP Fast Open) 的普通 TCP 握手过程,<br>SYN 报文中不包含「数据」)以及此前记录的 Cookie;</li><li>支持 TCP Fast Open 的服务器会对收到 Cookie 进行校验:如果 Cookie 有效,服务器将在 SYN-ACK 报文中对 SYN <br>和「数据」进行确认,服务器随后将「数据」递送至相应的应用程序;如果 Cookie 无效,服务器将丢弃 SYN 报文中<br>包含的「数据」,且其随后发出的 SYN-ACK 报文将只确认 SYN 的对应序列号;</li><li>如果服务器接受了 SYN 报文中的「数据」,服务器可在握手完成之前发送「数据」,<b>这就减少了握手带来的 1 个 RTT 的时间消耗</b>;</li><li>客户端将发送 ACK 确认服务器发回的 SYN 以及「数据」,但如果客户端在初始的 SYN 报文中发送的「数据」没有被确认,<br>则客户端将重新发送「数据」;</li><li>此后的 TCP 连接的数据传输过程和非 TFO 的正常情况一致。</li></ol>
所以,之后发起 HTTP GET 请求的时候,可以绕过三次握手,这就减少了握手带来的 1 个 RTT 的时间消耗。
开启了 TFO 功能,cookie 的值是存放到 TCP option 字段里的:
注:客户端在请求并存储了 Fast Open Cookie 之后,可以不断重复 TCP Fast Open 直至服务器认为 Cookie 无效(通常为过期)。
Linux 下怎么打开 TCP Fast Open 功能?
在 Linux 系统中,可以通过 <b>设置 tcp_fastopn 内核参数,来打开 Fast Open 功能</b>:<br>
tcp_fastopn 各个值的意义:<br><ul><li>0 关闭</li><li>1 作为客户端使用 Fast Open 功能</li><li>2 作为服务端使用 Fast Open 功能</li><li>3 无论作为客户端还是服务器,都可以使用 Fast Open 功能</li></ul>
<b>TCP Fast Open 功能需要客户端和服务端同时支持,才有效果。</b><br>
小结
关于优化 TCP 三次握手的几个 TCP 参数:
客户端的优化<br>
当客户端发起 SYN 包时,可以通过 tcp_syn_retries 控制其重传的次数。<br>
服务端的优化<br>
当服务端 SYN 半连接队列溢出后,会导致后续连接被丢弃,可以通过 netstat -s 观察半连接队列溢出的情况,如果 SYN 半连接<br>队列溢出情况比较严重,可以通过 tcp_max_syn_backlog、somaxconn、backlog 参数来调整 SYN 半连接队列的大小。
服务端回复 SYN+ACK 的重传次数由 tcp_synack_retries 参数控制。如果遭受 SYN 攻击,应把 tcp_syncookies <br>参数设置为 1,表示仅在 SYN 队列满后开启 syncookie 功能,可以保证正常的连接成功建立。
服务端收到客户端返回的 ACK,会把连接移入 accpet 队列,等待进行调用 accpet() 函数取出连接。
可以通过 ss -lnt 查看服务端进程的 accept 队列长度,如果 accept 队列溢出,系统默认丢弃 ACK,<br>如果可以把 tcp_abort_on_overflow 设置为 1 ,表示用 RST 通知客户端连接建立失败。
如果 accpet 队列溢出严重,可以通过 listen 函数的 backlog 参数和 somaxconn <br>系统参数提高队列大小,accept 队列长度取决于 min(backlog, somaxconn)。
绕过三次握手<br>
TCP Fast Open 功能可以绕过三次握手,使得 HTTP 请求减少了 1 个 RTT 的时间,<br>Linux 下可以通过 tcp_fastopen 开启该功能,同时必须保证服务端和客户端同时支持。
TCP 四次挥手的性能提升
概述
先了解四次挥手状态变迁的过程。客户端和服务端双方都可以主动断开连接,<br><b>通常先关闭连接的一方称为主动方,后关闭连接的一方称为被动方。</b>
可以看到,<b>四次挥手过程只涉及了两种报文,分别是 FIN 和 ACK</b>:<br><ul><li><span style="font-size: inherit;">FIN 就是结束连接的意思,谁发出 FIN 报文,就表示它将不会再发送任何数据,关闭这一方向上的传输通道;</span></li><li>ACK 就是确认的意思,用来通知对方:你方的发送通道已经关闭;</li></ul>
四次挥手的过程:<br><ul><li>当主动方关闭连接时,会发送 FIN 报文,此时发送方的 TCP 连接将从 ESTABLISHED 变成 FIN_WAIT1。</li><li>当被动方收到 FIN 报文后,内核会自动回复 ACK 报文,连接状态将从 ESTABLISHED 变成 CLOSE_WAIT,<br>表示被动方在等待进程调用 close 函数关闭连接。</li><li>当主动方收到这个 ACK 后,连接状态由 FIN_WAIT1 变为 FIN_WAIT2,<b>也就是表示主动方的发送通道就关闭了</b>。</li><li>当<b>被动方进入 CLOSE_WAIT 时</b>,<b>被动方还会继续处理数据</b>,等到进程的 <b>read 函数返回 0 后</b>,<b>应用程序就会调用 <br>close 函数</b>,<b>进而触发内核发送 FIN 报文</b>,此时<b>被动方的连接状态变为 LAST_ACK</b>。</li><li>当主动方收到这个 FIN 报文后,内核会回复 ACK 报文给被动方,同时主动方的连接状态由 FIN_WAIT2 变为 <br>TIME_WAIT,<b>在 Linux 系统下大约等待 1 分钟后,TIME_WAIT 状态的连接才会彻底关闭</b>。</li><li>当被动方收到最后的 ACK 报文后,<b>被动方的连接就会关闭</b>。</li></ul>
可以看到,<b>每个方向都需要一个 FIN 和一个 ACK,</b>因此通常被称为 <b>四次挥手</b>。
这里一点需要注意是:<b>主动关闭连接的,才有 TIME_WAIT 状态。</b>
主动方的优化<br>
概述
关闭连接的方式通常有两种,分别是 <b>RST 报文关闭 </b>和 <b>FIN 报文关闭</b>。
如果进程收到 <b>RST 报文</b>,就直接关闭连接了,<b>不需要走四次挥手流程</b>,是一个暴力关闭连接的方式。
<b>安全关闭连接 </b>的方式 <b>必须通过四次挥手</b>,它由 <b>进程调用 close 和 shutdown 函数发起 <br>FIN 报文</b>(shutdown 参数须传入 SHUT_WR 或者 SHUT_RDWR 才会发送 FIN)。
调用 close 函数和 shutdown 函数有什么区别?
调用了 close 函数意味着完全断开连接,<b>完全断开不仅指无法传输数据,而且也不能发送数据。 此时,<br>调用了 close 函数的一方的连接叫做「孤儿连接」,如果你用 netstat -p 命令,会发现连接对应的进程名为空。</b>
使用 close 函数关闭连接是不优雅的。于是,就出现了一种优雅关闭连接的 shutdown 函数,它可以控制只关闭一个方向的连接:
第二个参数决定断开连接的方式,主要有以下三种方式:<br><ul><li>SHUT_RD(0):<b>关闭连接的「读」这个方向</b>,如果接收缓冲区有已接收的数据,则将会被丢弃,并且后续再收到新的数据,<br>会对数据进行 ACK,然后悄悄地丢弃。也就是说,对端还是会接收到 ACK,在这种情况下根本不知道数据已经被丢弃了。</li><li>SHUT_WR(1):<b>关闭连接的「写」这个方向</b>,这就是常被称为「半关闭」的连接。如果发送缓冲区还有未发送的数据,<br>将被立即发送出去,并发送一个 FIN 报文给对端。</li><li>SHUT_RDWR(2):相当于 SHUT_RD 和 SHUT_WR 操作各一次,<b>关闭套接字的读和写两个方向</b>。</li></ul>
close 和 shutdown 函数都可以关闭连接,但这两种方式关闭的连接,不只功能上有差异,控制它们的 Linux 参数也不相同。
FIN_WAIT1 状态的优化<br>
主动方发送 FIN 报文后,连接就处于 FIN_WAIT1 状态,正常情况下,<br>如果能及时收到被动方的 ACK,则会很快变为 FIN_WAIT2 状态。
但是当迟迟收不到对方返回的 ACK 时,连接就会一直处于 FIN_WAIT1 状态。此时,<b>内核会定时重发 FIN 报文,<br>其中重发次数由 tcp_orphan_retries 参数控制</b>(注意,orphan 虽然是孤儿的意思,该参数却不只对孤儿连接有效,<br>事实上,它对所有 FIN_WAIT1 状态下的连接都有效),默认值是 0。
这 0 表示几次?实际上当为 0 时,特指 8 次,从下面的内核源码可知:
如果 FIN_WAIT1 状态连接很多,我们就需要考虑降低 tcp_orphan_retries 的值,<br>当重传次数超过 tcp_orphan_retries 时,连接就会直接关闭掉。
对于普遍正常情况时,调低 tcp_orphan_retries 就已经可以了。如果遇到恶意攻击,<br>FIN 报文根本无法发送出去,这是由 TCP 两个特性导致的:<br><ul><li>首先,TCP 必须保证报文是有序发送的,FIN 报文也不例外,当发送缓冲区还有数据没有发送时,FIN 报文也不能提前发送。</li><li>其次,TCP 有流量控制功能,当接收方接收窗口为 0 时,发送方就不能再发送数据。所以,当攻击者下载大文件时,<br>就可以通过接收窗口设为 0 ,这就会使得 FIN 报文都无法发送出去,那么连接会一直处于 FIN_WAIT1 状态。(此情况<br>是由于调用 close 的时候, 发现接收窗口为 0,导致 FIN 报文发送不出去,但是状态已经转换为 FIN_WAIT1 状态)</li></ul>
解决这种问题的方法,是 <b>调整 tcp_max_orphans 参数,它定义了「孤儿连接」的最大数量</b>:<br><ul><li>注意:此参数只作用于「孤儿连接」;</li></ul>
当进程调用了 close 函数关闭连接,此时连接就会是「孤儿连接」,因为它无法再发送和接收数据。<br>Linux 系统为了防止孤儿连接过多,导致系统资源长时间被占用,就提供了 tcp_max_orphans 参数。<br>如果孤儿连接数量大于它,新增的孤儿连接将不再走四次挥手,而是直接发送 RST 复位报文强制关闭。
FIN_WAIT2 状态的优化<br>
当主动方收到 ACK 报文后,会处于 FIN_WAIT2 状态,就表示 <b>主动方的发送通道已经关闭</b>,<br>接下来将等待对方发送 FIN 报文,关闭对方的发送通道。
这时,<b>如果连接是用 shutdown 函数关闭的,连接可以一直处于 FIN_WAIT2 状态,因为它可能还可以发送或接收数据<br>(主动方只能接收,被动方还能收发)。但对于 close 函数关闭的孤儿连接,由于无法再发送和接收数据,所以这个状态<br>不可以持续太久,而 tcp_fin_timeout 控制了这个状态下连接的持续时长</b>,默认值是 60 秒:
它意味着对于孤儿连接(调用 close 关闭的连接),如果在 60 秒后还没有收到 FIN 报文,连接就会直接关闭。
这个 60 秒不是随便决定的,它与 TIME_WAIT 状态持续的时间是相同的,后面我们再来说说为什么是 60 秒。
TIME_WAIT 状态的优化<br>
TIME_WAIT 是主动方四次挥手的最后一个状态,也是最常遇见的状态。
当收到被动方发来的 FIN 报文后,主动方会立刻回复 ACK,表示确认对方的发送通道已经关闭,<br>接着就处于 TIME_WAIT 状态。在 Linux 系统,TIME_WAIT 状态会持续 60 秒后才会进入关闭状态。
TIME_WAIT 状态的连接,在主动方看来确实快已经关闭了。然后,被动方没有收到 ACK 报文前,还是处于 LAST_ACK 状态。<br>如果这个 ACK 报文没有到达被动方,被动方就会重发 FIN 报文。重发次数仍然由前面介绍过的 tcp_orphan_retries 参数控制。
TIME-WAIT 的状态尤其重要,主要是两个原因:<br><ul><li><span style="font-size: inherit;">防止历史连接中的数据,被后面相同四元组的连接错误的接收;</span></li><li>保证「被动关闭连接」的一方,能被正确的关闭;</li></ul>
原因一:防止历史连接中的数据,被后面相同四元组的连接错误的接收<br>
TIME-WAIT 的一个作用是 <b>防止收到历史数据,从而导致数据错乱的问题</b>。<br>
假设 TIME-WAIT 没有等待时间或时间过短,被延迟的数据包抵达后会发生什么呢?<br>
如下图:<br><ul><li>服务端在关闭连接之前发送的 SEQ = 301 报文,被网络延迟了。</li><li>接着,服务端以相同的四元组重新打开了新连接,前面被延迟的 SEQ = 301 这时抵达了客户端,而且该数据报文的序列号刚好在客户端<br>接收窗口内,因此客户端会正常接收这个数据报文,但是这个数据报文是上一个连接残留下来的,这样就产生数据错乱等严重的问题。</li></ul>
为了防止历史连接中的数据,被后面相同四元组的连接错误的接收,因此 TCP 设计了 TIME_WAIT 状态,状态会持续 2MSL 时长,<br><b>这个时间足以让两个方向上的数据包都被丢弃,使得原来连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的。</b>
原因二:保证「被动关闭连接」的一方,能被正确的关闭<br>
在 RFC 793 指出 TIME-WAIT 另一个重要的作用是:<br><ul><li><span style="font-size: inherit;">TIME-WAIT - represents waiting for enough time to pass to be sure the remote <br></span>TCP received the acknowledgment of its connection termination request.</li></ul><ul><li><span style="font-size: inherit;">也就是说,TIME-WAIT 作用是 <b>等待足够的时间以确保最后的 ACK 能让被动关闭方接收,从而帮助其正常关闭。</b></span></li></ul>
如果客户端(主动关闭方)最后一次 ACK 报文(第四次挥手)在网络中丢失了,<br>那么按照 TCP 可靠性原则,服务端(被动关闭方)会重发 FIN 报文。
假设客户端没有 TIME_WAIT 状态,而是在发完最后一次回 ACK 报文就直接进入 CLOSED 状态,如果该 ACK 报文丢失了,<br>服务端则重传的 FIN 报文,而这时客户端已经进入到关闭状态了,在收到服务端重传的 FIN 报文后,就会回 RST 报文。
服务端收到这个 RST 并将其解释为一个错误(Connection reset by peer),这对于一个可靠的协议来说不是一个优雅的终止方式。
为了防止这种情况出现,客户端必须等待足够长的时间,确保服务端能够收到 ACK,如果服务端没有收到 ACK,<br>那么就会触发 TCP 重传机制,服务端会重新发送一个 FIN,这样一去一来刚好 2 个 MSL 的时间。
客户端在收到服务端重传的 FIN 报文时,TIME_WAIT 状态的等待时间,会重置回 2MSL。
我们再回过头来看看,为什么 TIME_WAIT 状态要保持 60 秒呢?
这与孤儿连接 FIN_WAIT2 状态默认保留 60 秒的原理是一样的,<b>因为这两个状态都需要保持 2MSL 时长。<br>MSL 全称是 Maximum Segment Lifetime,它定义了一个报文在网络中的最长生存时间</b>(报文每经过一次<br>路由器的转发,IP 头部的 TTL 字段就会减 1,减到 0 时报文就被丢弃,这就限制了报文的最长存活时间)。
为什么是 2 MSL 的时长呢?这其实是相当于 <b>至少允许报文丢失一次</b>。比如,若 ACK 在一个 MSL 内丢失,<br>这样被动方重发的 FIN 会在第 2 个 MSL 内到达,TIME_WAIT 状态的连接可以应对。
为什么不是 4 或者 8 MSL 的时长呢?你可以想象一个丢包率达到百分之一的糟糕网络,<br>连续两次丢包的概率只有万分之一,这个概率实在是太小了,忽略它比解决它更具性价比。
因此,TIME_WAIT 和 FIN_WAIT2 状态的最大时长都是 2 MSL,由于在 Linux 系统中,MSL 的值固定为 30 秒,所以它们都是 60 秒。
TMIE_WAIT 状态优化方式一
<b>Linux 提供了 tcp_max_tw_buckets 参数,当 TIME_WAIT 的连接数量超过该参数时,<br>新关闭的连接就不再经历 TIME_WAIT 而直接关闭:</b>
当服务器的并发连接增多时,相应地,同时处于 TIME_WAIT 状态的连接数量也会变多,此时就应当调大 tcp_max_tw_buckets <br>参数,减少不同连接间数据错乱的概率。tcp_max_tw_buckets 也不是越大越好,毕竟系统资源是有限的。
TIME_WAIT 状态优化方式二<br>
有一种方式可以在建立新连接时,复用处于 TIME_WAIT 状态的连接,那就是打开 tcp_tw_reuse 参数。但是需要注意,<br>该参数是只用于客户端(建立连接的发起方),因为是在调用 connect() 时起作用的,而对于服务端(被动连接方)是没有用的。
tcp_tw_reuse 只作用在 connect 函数,也就是客户端,跟服务端一毛关系的没有。
tcp_tw_reuse 从协议角度理解是安全可控的,可以复用处于 TIME_WAIT 的端口为新的连接所用。
什么是协议角度理解的安全可控呢?主要有两点:<br><ul><li>只适用于连接发起方,也就是 C/S 模型中的客户端;</li><li>对应的 TIME_WAIT 状态的连接创建时间超过 1 秒才可以被复用。</li></ul>
使用这个选项,还有一个前提,需要打开对 TCP 时间戳的支持(对方也要打开 ):
由于引入了时间戳,它能带来了些好处:<br><ul><li>我们在前面提到的 2MSL(TIME_WAIT状态的持续时间) 问题就不复存在了,因为重复的数据包会因为时间戳过期被自然丢弃;</li><li>同时,它还可以防止序列号绕回,也是因为重复的数据包会由于时间戳过期被自然丢弃;</li></ul>
时间戳是在 TCP 的选项字段里定义的,开启了时间戳功能,在 TCP 报文传输的时候会带上发送报文的时间戳。
TIME_WAIT 状态优化方式三<br>
我们可以在程序中设置 socket 选项,来设置调用 close 关闭连接行为。
如果 l_onoff 为非 0, 且 l_linger 值为 0,<b>那么调用 close 后,会立该发送一个 RST 标志给对端,<br>该 TCP 连接将跳过四次挥手,也就跳过了 TIME_WAIT 状态,直接关闭。</b>
这种方式只推荐在客户端使用,服务端千万不要使用。因为服务端一调用 close,<br>就发送 RST 报文的话,客户端就总是看到 TCP 连接错误 “connnection reset by peer”。
被动方的优化
概述
当被动方收到 FIN 报文时,内核会自动回复 ACK,同时连接处于 CLOSE_WAIT 状态,<br>顾名思义,它表示等待应用进程调用 close 函数关闭连接。
内核没有权利替代进程去关闭连接,因为如果主动方是通过 shutdown 关闭连接,那么它就是想<br>在半关闭连接上接收数据或发送数据。因此,Linux 并没有限制 CLOSE_WAIT 状态的持续时间。
当然,大多数应用程序并不使用 shutdown 函数关闭连接。所以,<b>当你用 netstat 命令发现大量 CLOSE_WAIT 状态。<br>就需要排查你的应用程序,因为可能因为应用程序出现了 Bug,read 函数返回 0 时,没有调用 close 函数</b>。
处于 CLOSE_WAIT 状态时,调用了 close 函数,内核就会发出 FIN 报文关闭发送通道,<br>同时连接进入 LAST_ACK 状态,等待主动方返回 ACK 来确认连接关闭。
如果迟迟收不到这个 ACK,内核就会重发 FIN 报文,重发次数仍然由 <br>tcp_orphan_retries 参数控制,<b>这与主动方重发 FIN 报文的优化策略一致</b>。
还有一点我们需要注意的,<b>如果被动方迅速调用 close 函数,那么被动方的 ACK 和 FIN 有可能<br>在一个报文中发送,这样看起来,四次挥手会变成三次挥手,这只是一种特殊情况,不用在意</b>。
如果连接双方同时关闭连接,会怎么样?<br>
由于 TCP 是全双工的协议,所以是会出现两方同时关闭连接的现象,也就是同时发送了 FIN 报文。
此时,上面介绍的优化策略仍然适用。两方发送 FIN 报文时,都认为自己是主动方,<br>所以都进入了 FIN_WAIT1 状态,FIN 报文的重发次数仍由 tcp_orphan_retries 参数控制。
接下来,<b>双方在等待 ACK 报文的过程中,都等来了 FIN 报文。这是一种新情况,所以连接会进入一种叫做 CLOSING 的新状态,它替代了 <br>FIN_WAIT2 状态</b>。接着,双方内核回复 ACK 确认对方发送通道的关闭后,进入 TIME_WAIT 状态,等待 2MSL 的时间后,连接自动关闭。
小结
针对 TCP 四次挥手的优化,我们需要根据主动方和被动方四次挥手状态变化来调整系统 TCP 内核参数。
主动方的优化<br>
主动发起 FIN 报文断开连接的一方,如果迟迟没收到对方的 ACK 回复,<br>则会重传 FIN 报文,重传的次数由 tcp_orphan_retries 参数决定。
当主动方收到 ACK 报文后,连接就进入 FIN_WAIT2 状态,根据关闭的方式不同,优化的方式也不同:<br><ul><li>如果这是 close 函数关闭的连接,那么它就是孤儿连接。如果 tcp_fin_timeout 秒内没有收到对方的 FIN 报文,连接就直接关闭。<br>同时,为了应对孤儿连接占用太多的资源,tcp_max_orphans 定义了最大孤儿连接的数量,超过时连接就会直接释放。</li><li>反之是 shutdown 函数关闭的连接,则不受此参数限制;</li></ul>
当主动方接收到 FIN 报文,并返回 ACK 后,主动方的连接进入 TIME_WAIT 状态。这一状态会持续 1 分钟,<br>为了防止 TIME_WAIT 状态占用太多的资源,tcp_max_tw_buckets 定义了最大数量,超过时连接也会直接释放。
当 TIME_WAIT 状态过多时,还可以通过设置 tcp_tw_reuse 和 tcp_timestamps 为 1 ,<br>将 TIME_WAIT 状态的端口复用于作为客户端的新连接,注意该参数只适用于客户端。
被动方的优化<br>
被动关闭的连接方应对非常简单,它在回复 ACK 后就进入了 CLOSE_WAIT 状态,<br>等待进程调用 close 函数关闭连接。因此,出现大量 CLOSE_WAIT 状态的连接时,应当从应用程序中找问题。
当被动方发送 FIN 报文后,连接就进入 LAST_ACK 状态,在未等到 ACK 时,<br>会在 tcp_orphan_retries 参数的控制下重发 FIN 报文。
TCP 传输数据的性能提升
概述
在前面介绍的是三次握手和四次挥手的优化策略,接下来主要介绍的是 TCP 传输数据时的优化策略。<br>
TCP 连接是由内核维护的,内核会为每个连接建立内存缓冲区:<br><ul><li>如果连接的内存配置过小,就无法充分使用网络带宽,TCP 传输效率就会降低;</li><li>如果连接的内存配置过大,很容易把服务器资源耗尽,这样就会导致新连接无法建立;</li></ul>
因此,我们必须理解 Linux 下 TCP 内存的用途,才能正确地配置内存大小。<br>
滑动窗口是如何影响传输速度的?<br>
TCP 会保证每一个报文都能够抵达对方,它的机制是这样:报文发出去后,必须接收到对方<br>返回的确认报文 ACK,如果迟迟未收到,就会超时重发该报文,直到收到对方的 ACK 为止。
所以,TCP 报文发出去后,并不会立马从内存中删除,因为重传时还需要用到它。<br>
由于 TCP 是内核维护的,所以报文存放在内核缓冲区。如果连接非常多,<br>我们可以通过 free 命令观察到 buff/cache 内存是会增大。
如果 TCP 是每发送一个数据,都要进行一次确认应答。当上一个数据包收到了应答了, 再发送下一个。<br>这个模式就有点像我和你面对面聊天,你一句我一句,但这种方式的缺点是效率比较低的。
所以,这样的传输方式有一个缺点:数据包的 <b>往返时间越长,通信的效率就越低</b>。<br>
要解决这一问题不难,<b>并行批量发送报文,再批量确认报文即可</b>。
然而,这引出了另一个问题,发送方可以随心所欲的发送报文吗?<br>当然这不现实,我们 <b>还得考虑接收方的处理能力</b>。
当接收方硬件不如发送方,或者系统繁忙、资源紧张时,是无法瞬间处理这么多报文的。<br>于是,这些报文只能被丢掉,使得网络效率非常低。
为了解决这种现象发生,TCP 提供一种机制可以让「发送方」根据「接收方」<br>的实际接收能力控制发送的数据量,这就是滑动窗口的由来。
接收方根据它的缓冲区,可以计算出后续能够接收多少字节的报文,这个数字叫做接收窗口。当内核接收到报文时,<br>必须用缓冲区存放它们,这样剩余缓冲区空间变小,接收窗口也就变小了;当进程调用 read 函数后,数据被读入了<br>用户空间,内核缓冲区就被清空,这意味着主机可以接收更多的报文,接收窗口就会变大。
因此,接收窗口并不是恒定不变的,接收方会把当前可接收的大小放在 TCP 报文头部中的 <b>窗口字段</b>,这样就可以起到窗口大小通知的作用。
发送方的窗口等价于接收方的窗口吗?如果不考虑拥塞控制,发送方的窗口大小「约等于」<br>接收方的窗口大小,因为窗口通知报文在网络传输是存在时延的,所以是约等于的关系。<br>
从上图中可以看到,窗口字段只有 2 个字节,因此它最多能表达 65535 字节大小的窗口,也就是 64KB 大小。
这个窗口大小最大值,在当今高速网络下,很明显是不够用的。所以后续有了扩充窗口的方法:<b>在 TCP 选项字段定义了窗口扩大因子,用于扩大 TCP <br>通告窗口,其值大小是 2^14,这样就使 TCP 的窗口大小从 16 位扩大为 30 位(2^16 * 2^ 14 = 2^30),所以此时窗口的最大值可以达到 1GB</b>。
Linux 中打开这一功能,需要把 tcp_window_scaling 配置设为 1(默认打开):<br>
要使用窗口扩大选项,通讯双方必须在各自的 SYN 报文中发送这个选项:<br><ul><li>主动建立连接的一方在 SYN 报文中发送这个选项;</li><li>而被动建立连接的一方只有在收到带窗口扩大选项的 SYN 报文之后才能发送这个选项。</li></ul>
这样看来,只要进程能及时地调用 read 函数读取数据,并且接收缓冲区配置得足够大,<br>那么接收窗口就可以无限地放大,发送方也就无限地提升发送速度。真的是这样吗??
这是不可能的,因为网络的传输能力是有限的,当发送方依据发送窗口,发送超过网络处理能力的报文时,<br>路由器会直接丢弃这些报文。因此,缓冲区的内存并不是越大越好。
如何确定最大传输速度?<br>
在前面我们知道了 TCP 的传输速度,受制于发送窗口与接收窗口,以及网络设备传输能力。其中,<br>窗口大小由内核缓冲区大小决定。<b>如果缓冲区与网络传输能力匹配,那么缓冲区的利用率就达到了最大化</b>。
问题来了,如何计算网络的传输能力呢?<br><br>相信大家都知道网络是有「带宽」限制的,带宽描述的是网络传输能力,它与内核缓冲区的计量单位不同:<br><ul><li>带宽是单位时间内的流量,表达是「速度」,比如常见的带宽 100 MB/s;</li><li>缓冲区单位是字节,网络速度乘以时间才能得到字节数;</li></ul>
这里需要说一个概念,就是 <b>带宽时延积</b>,它决定 <b>网络中飞行报文的大小</b>,它的计算方式:
比如最大带宽是 100 MB/s,网络时延(RTT)是 10ms 时,意味着<br>客户端到服务端的网络一共可以存放 100MB/s * 0.01s = 1MB 的字节。
这个 1MB 是带宽和时延的乘积,所以它就叫「带宽时延积」(缩写为 BDP,Bandwidth Delay Product)。同时,这 1MB 也表示<br>「飞行中」的 TCP 报文大小,它们就在网络线路、路由器等网络设备上。如果飞行报文超过了 1 MB,就会导致网络过载,容易丢包。
由于发送缓冲区大小决定了发送窗口的上限,而发送窗口又决定了「已发送未确认」<br>的飞行报文的上限。因此,发送缓冲区不能超过「带宽时延积」。
发送缓冲区与带宽时延积的关系:<br><ul><li><span style="font-size: inherit;">如果发送缓冲区「超过」带宽时延积,超出的部分就没办法有效的网络传输,同时导致网络过载,容易丢包;</span></li><li>如果发送缓冲区「小于」带宽时延积,就不能很好的发挥出网络的传输效率。</li></ul>
所以,发送缓冲区的大小最好是往带宽时延积靠近。<br>
怎样调整缓冲区大小?
在 Linux 中发送缓冲区和接收缓冲都是可以用参数调节的。设置完后,Linux 会根据你设置的缓冲区进行 <b>动态调节</b>。
调节发送缓冲区范围<br>
先来看看发送缓冲区,它的范围通过 tcp_wmem 参数配置:
上面三个数字单位都是字节,它们分别表示:<br><ul><li>第一个数值是动态范围的最小值,4096 byte = 4K;</li><li>第二个数值是初始默认值,16384 byte ≈ 16K;</li><li>第三个数值是动态范围的最大值,4194304 byte = 4096K(4M);</li></ul>
发送缓冲区是自行调节的,当发送方发送的数据被确认后,并且没有新的数据要发送,就会把发送缓冲区的内存释放掉。
调节接收缓冲区范围<br>
而接收缓冲区的调整就比较复杂一些,先来看看设置接收缓冲区范围的 tcp_rmem 参数:
上面三个数字单位都是字节,它们分别表示:<br><ul><li><span style="font-size: inherit;">第一个数值是动态范围的最小值,表示即使在内存压力下也可以保证的最小接收缓冲区大小,4096 byte = 4K;</span></li><li>第二个数值是初始默认值,87380 byte ≈ 86K;</li><li>第三个数值是动态范围的最大值,6291456 byte = 6144K(6M);</li></ul>
<b>接收缓冲区可以根据系统空闲内存的大小来调节接收窗口</b>:<br><ul><li>如果系统的空闲内存很多,就可以自动把缓冲区增大一些,这样传给对方的接收窗口也会变大,因而提升发送方发送的传输数据数量;</li><li>反之,如果系统的内存很紧张,就会减少缓冲区,这虽然会降低传输效率,可以保证更多的并发连接正常工作;</li></ul>
发送缓冲区的调节功能是自动开启的,<b>而接收缓冲区则需要配置 tcp_moderate_rcvbuf 为 1 来开启调节功能</b>:
调节 TCP 内存范围<br>
接收缓冲区调节时,怎么知道当前内存是否紧张或充分呢?这是通过 tcp_mem 配置完成的:
上面三个数字单位不是字节,而是「页面大小」,1 页表示 4KB,它们分别表示:<br><ul><li>当 TCP 内存小于第 1 个值时,不需要进行自动调节;</li><li>在第 1 和第 2 个值之间时,内核开始调节接收缓冲区的大小;</li><li>大于第 3 个值时,内核不再为 TCP 分配新内存,此时新连接是无法建立的;</li></ul>
一般情况下这些值是在系统启动时根据系统内存数量计算得到的。根据当前 tcp_mem 最大内存页面数是 177120,<br>当内存为 (177120 * 4) / 1024K ≈ 692M 时,系统将无法为新的 TCP 连接分配内存,即 TCP 连接将被拒绝。
根据实际场景调节的策略<br>
在高并发服务器中,为了兼顾网速与大量的并发连接,<b>我们应当保证缓冲区的动态调整的最大值达到带宽时延积,<br>而最小值保持默认的 4K 不变即可。而对于内存紧张的服务而言,调低默认值是提高并发的有效手段。</b>
同时,如果这是网络 IO 型服务器,那么,<b>调大 tcp_mem 的上限可以让 TCP 连接使用更多的系统内存,这有利于提升并发能力</b>。<br>需要注意的是,tcp_wmem 和 tcp_rmem 的单位是字节,而 tcp_mem 的单位是页面大小。而且,<b>千万不要在 socket 上直接设置 <br>SO_SNDBUF 或者 SO_RCVBUF,这样会关闭缓冲区的动态调整功能</b>。
小结<br>
本节针对 TCP 优化数据传输的方式,做了一些介绍。<br>
TCP 可靠性是通过 ACK 确认报文实现的,又依赖滑动窗口提升了发送速度也兼顾了接收方的处理能力。
可是,默认的滑动窗口最大值只有 64 KB,不满足当今的高速网络的要求,要想提升发送速度必须提升滑动窗口的上限,<br>在 Linux 下是通过设置 tcp_window_scaling 为 1 做到的,此时最大值可高达 1GB。
滑动窗口定义了网络中飞行报文的最大字节数,当它超过带宽时延积时,网络过载,就会发生丢包。<br>而当它小于带宽时延积时,就无法充分利用网络带宽。因此,滑动窗口的设置,必须参考带宽时延积。
内核缓冲区决定了滑动窗口的上限,缓冲区可分为:发送缓冲区 tcp_wmem 和接收缓冲区 tcp_rmem。
Linux 会对缓冲区动态调节,我们应该把缓冲区的上限设置为带宽时延积。发送缓冲区的调节功能是自动打开的,<br>而接收缓冲区需要把 tcp_moderate_rcvbuf 设置为 1 来开启。其中,调节的依据是 TCP 内存范围 tcp_mem。
但需要注意的是,如果程序中的 socket 设置 SO_SNDBUF 和 SO_RCVBUF,则会关闭<br>缓冲区的动态整功能,所以不建议在程序设置它俩,而是交给内核自动调整比较好。
如何理解是 TCP 面向字节流协议?
如何理解字节流?
TCP 是面向字节流的协议,UDP 是面向报文的协议?这里的「面向字节流」和「面向报文」该如何理解。
之所以会说 TCP 是面向字节流的协议,UDP 是面向报文的协议,是因为操作<br>系统对 TCP 和 UDP 协议的 <b>发送方的机制不同</b>,也就是问题原因在发送方。
先来说说为什么 UDP 是面向报文的协议?<br>
当用户消息通过 UDP 协议传输时,<b>操作系统不会对消息进行拆分</b>,在组装好 UDP 头部后就交给网络层来处理,所以发出去的 UDP 报文中的数据部分就是<br>完整的用户消息,也就是 <b>每个 UDP 报文就是一个用户消息的边界</b>,这样接收方在接收到 UDP 报文后,读一个 UDP 报文就能读取到完整的用户消息。
如果收到了两个 UDP 报文,操作系统是怎么区分开的?
操作系统在收到 UDP 报文后,会将其插入到队列里,<b>队列里的每一个元素就是一个 UDP 报文</b>,这样当用户<br>调用 recvfrom() 系统调用读数据的时候,就会从队列里取出一个数据,然后从内核里拷贝给用户缓冲区。
再来说说为什么 TCP 是面向字节流的协议?<br>
当用户消息通过 TCP 协议传输时,<b>消息可能会被操作系统分组成多个的 TCP 报文</b>,<br>也就是一个完整的用户消息被拆分成多个 TCP 报文进行传输。
这时,接收方的程序如果不知道发送方发送的消息的长度,也就是不知道消息的边界时,是无法读出一个有效的用户消息的,<br>因为用户消息被拆分成多个 TCP 报文后,并不能像 UDP 那样,一个 UDP 报文就能代表一个完整的用户消息。
举个实际的例子:发送方准备发送 「Hi.」和「I am Xiaolin」这两个消息。<br>
在发送端,当我们调用 send 函数完成数据“发送”以后,<br>数据并没有被真正从网络上发送出去,只是从应用程序拷贝到了操作系统内核协议栈中。
至于什么时候真正被发送,<b>取决于发送窗口、拥塞窗口以及当前发送缓冲区的大小等条件</b>。<br>也就是说,我们不能认为每次调用 send 发送的数据,都会作为一个整体完整地消息被发送出去。
如果我们考虑实际网络传输过程中的各种影响,假设发送端陆续调用 send 函数<br>先后发送 「Hi.」和「I am Xiaolin」 报文,那么实际的发送很有可能是这几种情况。
第一种情况,这两个消息被分到同一个 TCP 报文,像这样:<br>
第二种情况,「I am Xiaolin」的部分随 「Hi」 在一个 TCP 报文中发送出去,像这样:<br>
第三种情况,「Hi.」 的一部分随 TCP 报文被发送出去,另一部分和 「I am Xiaolin」<br> 一起随另一个 TCP 报文发送出去,像这样:
类似的情况还能举例很多种,这里主要是想说明,我们不知道 <br>「Hi.」和 「I am Xiaolin」 这两个用户消息是如何进行 TCP 分组传输的。
因此,我们 <b><font color="#0000ff">不能认为一个用户消息对应一个 TCP 报文,正因为这样,所以 TCP 是面向字节流的协议</font></b>。
当两个消息的某个部分内容被分到同一个 TCP 报文时,就是我们常说的 TCP 粘包问题,<br>这时接收方不知道消息的边界的话,是无法读出有效的消息。
要解决这个问题,要交给 <b>应用程序</b>。<br>
如何解决粘包
什么是 TCP 粘包
TCP粘包就是指发送方 <b><font color="#0000ff">发送的若干包数据到达接收方时粘成了一个包</font></b>,从接收缓冲区来看,后一包数据的头<br>紧接着前一包数据的尾,出现粘包的原因是多方面的,可能是来自发送方,也可能是来自接收方。
与粘包类似的问题,还有拆包,拆包意思就是一个完整的数据包在发送时被拆开了,到达接收方时需要组合。
造成 TCP 粘包的原因
发送方原因:<br><br><ul><li>TCP 默认使用 Nagle 算法(避免发送小数据),会累计一定数据量的数据包后才发送数据。</li></ul>
接收方原因:<br><br><ul><li>TCP 接收到数据包时,并不会马上交到应用层处理,而是会保存在接收缓存里,然后由应用程序主动从缓存中读取。</li><li>这样一来,如果应用程序从缓存中读取数据包的速度较慢,多个包就可能在一个缓存里,应用程序就可能读取到首尾相接粘在一起的包。</li></ul>
如何解决?
粘包的问题出现是因为不知道一个用户消息的边界在哪,如果知道<br>了边界在哪,接收方就可以通过边界来划分出有效的用户消息。
一般有三种分包的方式:<br><ul><li>固定长度的消息;</li><li>特殊字符作为边界;</li><li>自定义消息结构。</li></ul>
固定长度的消息<br>
这种是最简单方法,即每个用户消息都是固定长度的,比如规定一个消息的长度是 64 个字节,<br>当接收方接满 64 个字节,就认为这个内容是一个完整且有效的消息。
但是这种方式灵活性不高,实际中很少用。<br>
特殊字符作为边界
我们可以在两个用户消息之间插入一个特殊的字符串,这样接收方在<br>接收数据时,读到了这个特殊字符,就把认为已经读完一个完整的消息。
HTTP 是一个非常好的例子:<br>
HTTP 通过设置回车符、换行符作为 HTTP 报文协议的边界。<br><br>HTTP body 部分靠 content-length 来做边界。<br>
有一点要注意,这个作为边界点的特殊字符,如果刚好消息内容里有这个特殊字符,<br>我们要对这个字符转义,避免被接收方当作消息的边界点而解析到无效的数据。
自定义消息结构
可以自定义一个消息结构,由包头和数据组成,其中包头是固定<br>大小的,而且包头里有一个字段来说明紧随其后的数据有多大。
比如这个消息结构体,首先 4 个字节大小的变量来表示数据长度,真正的数据则在后面。
当接收方接收到包头的大小(比如 4 个字节)后,就解析包头的内容,于是就可以知道数据的长度,<br>然后接下来就继续读取数据,直到读满数据的长度,就可以组装成一个完整到用户消息来处理了。
UDP 为什么没有粘包问题?
TCP 为了保证可靠传输并减少额外的开销(避免每次发包都要验证),采用了 <b><font color="#0000ff">基于流的传输</font></b>,基于流的传输不认为消息是一条一条的,<br>是无消息边界的(消息边界:指传输协议把数据当做一条独立的消息在网上传输,接收端一次只能接受一条独立的消息)。
而 UDP 是面向消息传输的,在传输时自带消息边界,接收方一次只接收一条独立的消息,不存在粘包问题。
举个例子:有三个数据包,大小分别为 2k、4k、6k,如果采用 UDP 发送的话,不管接受方的接收缓存有多大,我们必须要进行至少三次以上的<br>发送才能把数据包发送完;但是使用 TCP 协议发送的话,我们只需要接受方的接收缓存有 12k 的大小,就可以一次把这 3 个数据包全部发送完毕。
为什么 TCP 每次建立连接时,初始化序列号都要不一样?
主要原因是为了 <b><font color="#0000ff">防止历史报文被下一个相同四元组的连接接收</font></b>。<br>
TCP 四次挥手中的 TIME_WAIT 状态不是会持续 2 MSL 时长,<br>那这样的话,历史报文不是早就在网络中消失了吗?
是的,如果能正常四次挥手,由于 TIME_WAIT 状态会持续 2 MSL 时长,历史报文会在下一个连接之前就会自然消失。
但是我们并 <b><font color="#0000ff">不能保证每次连接都能通过四次挥手来正常关闭连接</font></b>。<br>
假设每次建立连接,客户端和服务端的初始化序列号都是从 0 开始:
过程如下:<br><ul><li>客户端和服务端建立一个 TCP 连接,在客户端发送数据包被网络阻塞了,然后超时重传了这个数据包,而此时<br>服务端设备断电重启了,之前与客户端建立的连接就消失了,于是在收到客户端的数据包的时候就会发送 RST 报文。</li><li>紧接着,客户端又与服务端建立了与上一个连接相同四元组的连接;</li><li>在新连接建立完成后,上一个连接中被网络阻塞的数据包正好抵达了服务端,刚好该数据包的<br>序列号正好是在服务端的接收窗口内,所以该数据包会被服务端正常接收,就会造成数据错乱。</li></ul>
客户端和服务端的初始化序列号不一样不是也会发生这样的事情吗?<br>
是的,即使客户端和服务端的初始化序列号不一样,也会存在收到历史报文的可能。
但是我们要清楚一点,历史报文能否被对方接收,还要看该历史报文的<br>序列号是否正好在对方接收窗口内,如果不在就会丢弃,如果在才会接收。
如果每次建立连接客户端和服务端的初始化序列号都「不一样」,就有大概率因为历史<br>报文的序列号「不在」对方接收窗口,从而很大程度上避免了历史报文,比如下图:
相反,如果每次建立连接客户端和服务端的初始化序列号都「一样」,就有大概率遇到<br>历史报文的序列号刚「好在」对方的接收窗口内,从而导致历史报文被新连接成功接收。
所以,每次初始化序列号不一样能够很大程度上避免历史报文被下一个<br>相同四元组的连接接收,注意是 <b><font color="#0000ff">很大程度上,并不是完全避免了</font></b>。
客户端和服务端的初始化序列号都是随机的,那还是有可能随机成一样的呀?
RFC793 提到初始化序列号 ISN 随机生成算法:ISN = M + F(localhost, localport, remotehost, remoteport)。<br><ul><li>M是一个计时器,这个计时器每隔 4 微秒加1。</li><li>F 是一个 Hash 算法,根据源IP、目的IP、源端口、目的端口生成一个随机数值,要保证 hash 算法不能被外部轻易推算得出。</li></ul>
可以看到,随机数是会基于时钟计时器递增的,基本不可能会随机成一样的初始化序列号。<br>
懂了,客户端和服务端初始化序列号都是随机生成的话,就能大概率避免连接接收历史报文了。
是的,不是完全避免,而是大概率避免。<br>
为了能更好的理解这个原因,先来了解序列号(SEQ)和初始序列号(ISN)。<br><br><ul><li><b><font color="#0000ff">序列号</font></b>,是 TCP 一个头部字段,标识了 TCP 发送端到 TCP 接收端的数据流的一个字节,因为 TCP 是面向字节流的可靠协议,<br>为了保证消息的顺序性和可靠性,TCP 为每个传输方向上的每个字节都赋予了一个编号,以便于传输成功后确认、丢失后重传<br>以及在接收端保证不会乱序。<b><font color="#0000ff">序列号是一个 32 位的无符号数,因此在到达 4G 之后再循环回到 0</font></b>。</li><li><b><font color="#0000ff">初始序列号</font></b>,在 TCP 建立连接的时候,客户端和服务端都会各自生成一个初始序列号,它是基于时钟生成的一个随机数,来保证<br>每个连接都拥有不同的初始序列号。<b><font color="#0000ff">初始化序列号可被视为一个 32 位的计数器,该计数器的数值每 4 微秒加 1,循环一次需要 4.55 小时</font></b>。</li></ul>
下图中的 Seq 就是序列号,其中红色框住的分别是客户端和服务端各自生成的初始序列号。
通过前面我们知道,<b><font color="#0000ff">序列号和初始化序列号并不是无限递增的,<br>会发生回绕为初始值的情况,这意味着无法根据序列号来判断新老数据</font></b>。
不要以为序列号的上限值是 4GB,就以为很大,很难发生回绕。在一个速度足够快的网络中传输大量数据时,<br>序列号的回绕时间就会变短。如果序列号回绕的时间极短,我们就会再次面临之前延迟的报文抵达后序列号依然有效的问题。
引入时间戳机制,完全避免历史报文被接收的问题。
使用时间戳选项能够有效的防止上述 SEQ 和 ISN 回绕问题,如果丢失的报文会在时刻 F 重新出现,由于<br>它的时间戳为 2,小于最近的有效时间戳(5 或 6),因此防回绕序列号算法(PAWS)会将其丢弃。
防回绕序列号算法要求连接双方维护最近一次收到的数据包的时间戳(Recent TSval),每收到一个新数据包都会读取数据包中的<br>时间戳值跟 Recent TSval 值做比较,<b><font color="#0000ff">如果发现收到的数据包中时间戳不是递增的,则表示该数据包是过期的,就会直接丢弃这个数据包</font></b>。
为了解决这个问题,就需要有 TCP 时间戳。tcp_timestamps 参数是默认开启的,开启了 tcp_timestamps 参数,<br>TCP 头部就会使用时间戳选项,它有两个好处,<b><font color="#0000ff">一个是便于精确计算 RTT ,另一个是能防止序列号回绕(PAWS)</font></b>。
试看下面的示例,假设 TCP 的发送窗口是 1 GB,并且使用了时间戳选项,发送方会为每个 TCP 报文<br>分配时间戳数值,我们假设每个报文时间加 1,然后使用这个连接传输一个 6GB 大小的数据流。
32 位的序列号在时刻 D 和 E 之间回绕。假设在时刻B有一个报文丢失并被重传,又假设这个报文段在<br>网络上绕了远路并在时刻 F 重新出现。如果 TCP 无法识别这个绕回的报文,那么数据完整性就会遭到破坏。
客户端和服务端的初始化序列号都是随机生成,能很大程度上避免历史报文被下一个<br>相同四元组的连接接收,然后又引入时间戳的机制,从而完全避免了历史报文被接收的问题。
如果时间戳也回绕了怎么办?<br>
时间戳的大小是 32 bit,所以理论上也是有回绕的可能性的。<br><br>时间戳回绕的速度只与对端主机时钟频率有关。
Linux 以本地时钟计数(jiffies)作为时间戳的值,不同的增长时间会有不同的问题:<br><br><ul><li>如果时钟计数加 1 需要1ms,则需要约 24.8 天才能回绕一半,只要报文的生存时间小于这个值的话判断新旧数据就不会出错。</li><li>如果时钟计数提高到 1us 加1,则回绕需要约71.58分钟才能回绕,这时问题也不大,因为网络中旧报文几乎不可能生存超过70分钟,<br>只是如果70分钟没有报文收发则会有一个包越过PAWS(这种情况会比较多见,相比之下 24 天没有数据传输的TCP连接少之又少),<br>但除非这个包碰巧是序列号回绕的旧数据包而被放入接收队列(太巧了吧),否则也不会有问题;</li><li>如果时钟计数提高到 0.1 us 加 1 回绕需要 7 分钟多一点,这时就可能会有问题了,连接如果 7 分钟没有数据收发就会有一个报文越过 PAWS,<br>对于TCP连接而言这么短的时间内没有数据交互太常见了吧!这样的话会频繁有包越过 PAWS 检查,从而使得旧包混入数据中的概率大大增加;</li></ul>
Linux 在 PAWS 检查做了一个特殊处理,如果一个 TCP 连接连续 24 天不收发数据则在接收第一个包时基于<br>时间戳的 PAWS 会失效,也就是可以 PAWS 函数会放过这个特殊的情况,认为是合法的,可以接收该数据包。
要解决时间戳回绕的问题,可以考虑以下解决方案:<br><br>1)增加时间戳的大小,由32 bit扩大到64bit:<br><ul><li><span style="font-size: inherit;">这样虽然可以在能够预见的未来解决时间戳回绕的问题,但会导致新旧协议兼容性问题,像现在的IPv4与IPv6一样</span></li></ul><br style="font-size: inherit;">2)将一个与时钟频率无关的值作为时间戳,时钟频率可以增加但时间戳的增速不变:<br><ul><li>随着时钟频率的提高,TCP在相同时间内能够收发的包也会越来越多。如果时间戳的增速不变,则会有越来越多的<br>报文使用相同的时间戳。这种趋势到达一定程度则时间戳就会失去意义,除非在可预见的未来这种情况不会发生。</li></ul>
服务器端产生了大量 TIME_WAIT 状态的连接?
模拟高并发的场景,可能会出现批量的 TIME_WAIT 的 TCP 连接:
短时间后,所有的 TIME_WAIT 全都消失,被回收,端口包括服务,均正常。即,在高并发的场景下,TIME_WAIT 连接存在,属于正常现象。
线上场景中,持续的高并发场景:<br><br><ul><li>一部分 TIME_WAIT 连接被回收,但新的 TIME_WAIT 连接产生;</li><li>一些极端情况下,会出现大量的 TIME_WAIT 连接。</li></ul>
上述大量的 TIME_WAIT 状态 TCP 连接,<b><font color="#0000ff">有什么业务上的影响吗</font></b>?<br><br>每一个 time_wait 状态,都会占用一个本地端口,当大量的连接处于 time_wait 时,<br><b><font color="#0000ff">新建立 TCP 连接会出错</font></b>,出现 address already in use : connect 异常<br><br>Tips:TCP 本地端口数量,上限为 65535(6.5w),这是因为 TCP 头部使用 16 bit,<br>存储「端口号」,因此约束上限为 65535。<br>
大量的 TIME_WAIT 状态 TCP 连接存在,其<b><font color="#0000ff"> 本质原因 </font></b>是什么?<br><br><ul><li>存在大量 <b><font color="#0000ff">短连接</font></b>(HTTP 请求中,connection 头部被设为 close),此时 <b><font color="#0000ff">关闭连接基本都由服务端发起</font></b>,<br>在高并发场景下,如果有许多短连接,则会在服务端产生大量的 TIME_WAIT 状态的连接。</li></ul>
<b><font color="#0000ff">解决办法 </font></b>是什么?<br><br><ul><li><span style="font-size: inherit;">客户端在请求时,将 HTTP 请求头部的 connection 设置为 keep-alive(建立长连接)。现在的浏览器一般都是这样。</span></li><li><span style="font-size: inherit;">服务器端可以将 TIME_WAIT 状态的连接设置为可被重用,通过配置 tcp_tw_reuse。</span></li><li><span style="font-size: inherit;">服务器端还可以将 TIME_WAIT 维持的时间调小,以便尽快结束 TIME_WAIT 状态,关闭连接。</span></li></ul>
SYN 报文什么时候情况下会被丢弃?
坑爹的 tcp_tw_recycle<br>
TCP 四次挥手过程中,主动断开连接方会有一个 TIME_WAIT 的状态,这个状态会持续 2 MSL 后才会转变为 CLOSED 状态。
在 Linux 操作系统下,TIME_WAIT 状态的持续时间是 60 秒,这意味着这 60 秒内,客户端一直会占用着这个端口。<br>要知道,端口资源也是有限的,一般可以开启的端口为 32768~61000 ,也可以通过如下参数设置指定范围:
<b><font color="#0000ff">如果客户端(发起连接方)的 TIME_WAIT 状态过多</font></b>,占满了所有端口资源,那么就无法对「目的 IP + 目的 PORT」<br>都一样的服务器发起连接了,但是被使用的端口,还是可以继续对另外一个服务器发起连接的。后面会具体讲解。
因此,客户端(发起连接方)都是和「目的 IP+ 目的 PORT 」都一样的服务器建立连接的话,当客户端的 TIME_WAIT 状态连接<br>过多的话,就会受端口资源限制,如果占满了所有端口资源,那么就无法再跟「目的 IP+ 目的 PORT」都一样的服务器建立连接了。
不过,即使是在这种场景下,只要连接的是不同的服务器,端口是可以重复使用的,所以客户端还是可以向其他服务器发起连接的,这是因为内核<br>在定位一个连接的时候,是通过四元组(源IP、源端口、目的IP、目的端口)信息来定位的,并不会因为客户端的端口一样,而导致连接冲突。
但是 TIME_WAIT 状态也不是摆设作用,它的作用有两个:<br><ul><li>防止具有相同四元组的旧数据包被收到,也就是防止历史连接中的数据,被后面的连接接受,否则就会导致后面的连接收到一个无效的数据;</li><li>保证「被动关闭连接」的一方能被正确的关闭,即保证最后的 ACK 能让被动关闭方接收,从而帮助其正常关闭;</li></ul>
不过,Linux 操作系统提供了两个系统参数可以快速回收处于 TIME_WAIT 状态的连接,这两个参数都是默认关闭的:<br><ul><li>net.ipv4.tcp_tw_reuse,如果开启该选项的话,客户端(连接发起方) 在调用 connect() 函数时,<b>如果内核选择到的端口,<br>已经被相同四元组的连接占用的时候,就会判断该连接是否处于 TIME_WAIT 状态,如果该连接处于 TIME_WAIT 状态<br><font color="#0000ff">并且 TIME_WAIT 状态持续的时间超过了 1 秒,那么就会重用这个连接</font>,然后就可以正常使用该端口了</b>。所以该选项只适用于连接发起方。<br><br></li><li>net.ipv4.tcp_tw_recycle,如果开启该选项的话,允许处于 TIME_WAIT 状态的连接 <b><font color="#0000ff">会被快速回收</font></b>;</li></ul>
要使得这两个选项生效,有一个前提条件,就是要打开 TCP 时间戳,即 net.ipv4.tcp_timestamps=1(默认即为 1))。
tcp_tw_recycle 在使用了 NAT 的网络下是不安全的!<br>
对于服务器来说,如果同时开启了 recycle 和 timestamps 选项,则会开启一种称之为「 per-host 的 PAWS 机制」。
什么是 PAWS 机制?
tcp_timestamps 选项开启之后, PAWS 机制会自动开启,它的作用是防止 TCP 包中的序列号发生绕回。
正常来说每个 TCP 包都会有自己唯一的 SEQ,出现 TCP 数据包重传的时候会复用 SEQ 号,这样接收方能通过 SEQ 号来判断数据包的唯一性,也能在<br>重复收到某个数据包的时候判断数据是不是重传的。<b>但是 TCP 这个 SEQ 号是有限的,一共 32 bit,SEQ 开始是递增,溢出之后从 0 开始再次依次递增</b>。
所以当 SEQ 号出现溢出后单纯通过 SEQ 号无法标识数据包的唯一性,某个数据包延迟或因重发而延迟时可能导致连接传递的数据被破坏,比如:
上图 A 数据包出现了重传,并在 SEQ 号耗尽再次从 A 递增时,第一次发的 A 数据包延迟到达了 Server,这种情况下如果没有别的机制<br>来保证,Server 会认为延迟到达的 A 数据包是正确的而接收,反而是将正常的第三次发的 SEQ 为 A 的数据包丢弃,造成数据传输错误。
PAWS 就是为了避免这个问题而产生的,在开启 tcp_timestamps 选项情况下,一台机器发的所有 TCP 包都会带上发送时的时间戳,<br>PAWS 要求连接双方维护最近一次收到的数据包的时间戳(Recent TSval),每收到一个新数据包都会读取数据包中的时间戳值跟 <br>Recent TSval 值做比较,<b>如果发现收到的数据包中时间戳不是递增的,则表示该数据包是过期的,就会直接丢弃这个数据包</b>。
对于上面图中的例子有了 PAWS 机制就能做到在收到 Delay 到达的 A 号数据包时,识别出它是个过期的数据包而将其丢掉。
那什么是 per-host 的 PAWS 机制呢?
开启了 recycle 和 timestamps 选项,就会开启一种叫 per-host 的 PAWS 机制。<br><b>per-host 是对「对端 IP 做 PAWS 检查」</b>,而非对「IP + 端口」四元组做 PAWS 检查。
但是如果客户端网络环境是用了 NAT 网关,<b>那么客户端环境的每一台机器通过 NAT 网关后,<br>都会是相同的 IP 地址</b>,在服务端看来,就好像 <b>只是在跟一个客户端打交道一样,无法区分出来</b>。
Per-host PAWS 机制利用 TCP option 里的 timestamp 字段的增长来判断串扰数据,而 timestamp 是根据客户端各自的 CPU tick 得出的值。
当客户端 A 通过 NAT 网关和服务器建立 TCP 连接,然后服务器主动关闭并且快速回收 TIME-WAIT 状态的连接后,<b>客户端 B 也通过 NAT 网关和<br>服务器建立 TCP 连接,注意客户端 A 和 客户端 B 因为经过相同的 NAT 网关,所以是用相同的 IP 地址与服务端建立 TCP 连接,如果客户端 B 的 <br>timestamp 比 客户端 A 的 timestamp 小,那么由于服务端的 per-host 的 PAWS 机制的作用,服务端就会丢弃客户端主机 B 发来的 SYN 包</b>。
因此,tcp_tw_recycle 在使用了 NAT 的网络下是存在问题的,如果它是对 TCP 四元组做 PAWS 检查,而不是对<br>「相同的 IP 做 PAWS 检查」,那么就不会存在这个问题了。tcp_tw_recycle 在 Linux 4.12 版本后,直接取消了这一参数。<br>
accpet 队列满了
详细的 SYN 队列和 accept 队列,在前面 "TCP 半连接队列和全连接队列",下面只做简单介绍。
概念
在 TCP 三次握手的时候,Linux 内核会维护两个队列,分别是:<br><ul><li>半连接队列,也称 SYN 队列;</li><li>全连接队列,也称 accepet 队列;</li></ul>
服务端收到客户端发起的 SYN 请求后,<b>内核会把该连接存储到半连接队列</b>,并向客户端响应 SYN+ACK,接着客户端会返回 ACK,服务端收到第三次<br>握手的 ACK 后,<b>内核会把连接从半连接队列移除,然后创建新的完全的连接,并将其添加到 accept 队列,等待进程调用 accept 函数时把连接取出来</b>。
半连接队列满了
当服务器造成syn攻击,就有可能导致 <b>TCP 半连接队列满了,这时后面来的 syn 包都会被丢弃。</b><br>
但是,<b>如果开启了 syncookies 功能,即使半连接队列满了,也不会丢弃 syn 包。</b>
syncookies 是这么做的:服务器根据当前状态计算出一个值,放在己方发出的 SYN+ACK 报文中发出,<br>当客户端返回 ACK 报文时,取出该值验证,如果合法,就认为连接建立成功,如下图所示:<br>
syncookies 参数主要有以下三个值:<br><ul><li>0 值,表示关闭该功能;</li><li><span style="font-size: inherit;">1 值,表示仅当 SYN 半连接队列放不下时,再启用它;</span></li><li>2 值,表示无条件开启功能;</li></ul>
那么在应对 SYN 攻击时,只需要设置为 1 即可:<br>
这里给出几种防御 SYN 攻击的方法:<br><ul><li>增大半连接队列;</li><li>开启 tcp_syncookies 功能</li><li>减少 SYN+ACK 重传次数</li></ul>
方式一:增大半连接队列<br>
<b>要想增大半连接队列,我们得知不能只单纯增大 tcp_max_syn_backlog 的值,还需一同增大 somaxconn 和 backlog,<br>也就是增大全连接队列</b>。否则,只单纯增大 tcp_max_syn_backlog 是无效的。
增大 tcp_max_syn_backlog 和 somaxconn 的方法是修改 Linux 内核参数:
增大 backlog 的方式,每个 Web 服务都不同,比如 Nginx 增大 backlog 的方法如下:<br>
最后,改变了如上这些参数后,要重启 Nginx 服务,因为半连接队列和全连接队列都是在 listen() 初始化的。<br>
方式二:开启 tcp_syncookies 功能<br>
开启 tcp_syncookies 功能的方式也很简单,修改 Linux 内核参数:<br>
方式三:减少 SYN+ACK 重传次数<br>
当服务端受到 SYN 攻击时,就会有大量处于 SYN_REVC 状态的 TCP 连接,处于<br>这个状态的 TCP 会重传 SYN+ACK ,当重传超过次数达到上限后,就会断开连接。
那么针对 SYN 攻击的场景,我们可以减少 SYN+ACK 的重传次数,以加快处于 SYN_REVC 状态的 TCP 连接断开。
全连接队列满了
在服务端并发处理大量请求时,如果 TCP accpet 队列过小,或者应用程序调用 accept() 不及时,<br>就会造成 accpet 队列满了 ,这时后续的连接就会被丢弃,这样就会出现服务端请求数量上不去的现象。
我们可以通过 ss 命令来看 accpet 队列大小,在「LISTEN 状态」时,Recv-Q/Send-Q 表示的含义如下:<br><ul><li>Recv-Q:当前 accpet 队列的大小,也就是当前已完成三次握手并等待服务端 accept() 的 TCP 连接个数;</li><li>Send-Q:当前 accpet 最大队列长度,上面的输出结果说明监听 8088 端口的 TCP 服务进程,accpet 队列的最大长度为 128;</li></ul>
如果 Recv-Q 的大小超过 Send-Q,就说明发生了 accpet 队列满的情况。
要解决这个问题,我们可以:<br><ul><li><span style="font-size: inherit;">调大 accpet 队列的最大长度,调大的方式是通过 <b>调大 backlog 以及 somaxconn 参数</b>。</span></li><li>检查系统或者代码为什么调用 accept() 不及时;</li></ul>
已建立连接的 TCP,收到 SYN 会发生什么?
概述
一个已经建立的 TCP 连接,<b><font color="#0000ff">客户端中途宕机了,而服务端此时也没有数据要发送</font></b>,<br>一直处于 establish 状态,<b><font color="#0000ff">客户端恢复后,向服务端建立连接</font></b>,此时服务端会怎么处理?
TCP 连接是由「四元组」唯一确认的。这个场景中,客户端的 IP、服务端 IP、目的端口并没有变化,<br>所以这个问题关键要看客户端发送的 SYN 报文中的源端口是否和上一次连接的源端口相同。
<b>1. 客户端的 SYN 报文里的端口号与历史连接不相同</b><br>
如果客户端恢复后发送的 SYN 报文中的源端口号跟上一次连接的源端口号不一样,<br>此时服务端会认为是 <b><font color="#0000ff">新的连接要建立,于是就会通过三次握手来建立新的连接</font></b>。
那旧连接里处于 establish 状态的服务端最后会怎么样呢?
如果服务端发送了数据包给客户端,由于客户端的连接已经被关闭了,<br>此时客户的内核就会回 RST 报文,服务端收到后就会释放连接。
如果服务端一直没有发送数据包给客户端,在超过一段时间后, TCP 保活<br>机制就会启动,检测到客户端没有存活后,接着服务端就会释放掉该连接。
2. 客户端的 SYN 报文里的端口号与历史连接相同<br>
如果客户端恢复后,发送的 SYN 报文中的源端口号跟上一次连接的源端口号一样,<br>也就是 <b><font color="#0000ff">处于 established 状态的服务端收到了这个 SYN 报文</font></b>。
大家觉得服务端此时会做什么处理呢?<br><ul><li>丢掉 SYN 报文?</li><li>回复 RST 报文?</li><li>回复 ACK 报文?</li></ul>
先直接说答案:<br><br><b><font color="#0000ff">处于 established 状态的服务端如果收到了客户端的 SYN 报文(注意此时的 SYN 报文其实是乱序的,因为 SYN 报文的初始<br>化序列号其实是一个随机数),会回复一个携带了正确序列号和确认号的 ACK 报文,这个 ACK 被称之为 Challenge ACK</font>。</b><br><br><b><font color="#0000ff">接着,客户端收到这个 Challenge ACK,发现序列号并不是自己期望收到的,<br>于是就会回 RST 报文,服务端收到后,就会释放掉该连接</font>。</b><br>
RFC 文档解释
rfc793 文档里的第 34 页里,有说到这个例子。
原文的解释:<br><ul><li>When the SYN arrives at line 3, TCP B, being in a synchronized state, and the incoming segment outside the window, <br>responds with an acknowledgment indicating what sequence it next expects to hear (ACK 100).</li><li>TCP A sees that this segment does not acknowledge anything it sent and, being unsynchronized, sends a reset (RST) <br>because it has detected a half-open connection.</li><li>TCP B aborts at line 5.</li><li>TCP A willcontinue to try to establish the connection;</li></ul>
源码分析
处于 establish 状态的服务端如果收到了客户端的 SYN 报文时,内核会调用这些函数:
我们只关注 tcp_validate_incoming 函数是怎么处理 SYN 报文的,精简后的代码如下:
从上面的代码实现可以看到,处于 establish 状态的服务端,在收到报文后,首先会判断序列号是否在窗口内,<br>如果不在,则看看 RST 标记有没有被设置,如果有就会丢掉。然后如果没有 RST 标志,就会判断是否有 SYN <br>标记,如果有 SYN 标记就会跳转到 syn_challenge 标签,然后执行 tcp_send_challenge_ack() 函数。
tcp_send_challenge_ack() 函数里就会调用 tcp_send_ack() 函数来回复一个携带了正确序列号和确认号的 ACK 报文。
如何关闭一个 TCP 连接?
如何关闭一个 TCP 连接?可能大家第一反应是「杀掉进程」不就行了吗?
是的,这个是最粗暴的方式,杀掉客户端进程和服务端进程影响的范围会有所不同:<br><ul><li>在客户端杀掉进程的话,就会发送 FIN 报文,来断开这个客户端进程与服务端建立的所有 TCP 连接,<br>这种方式影响范围只有这个客户端进程所建立的连接,而其他客户端或进程不会受影响。</li><li>而在服务端杀掉进程影响就大了,此时所有的 TCP 连接都会被关闭,服务端无法继续提供访问服务。</li></ul>
所以,关闭进程的方式并不可取,最好的方式要精细到关闭某一条 TCP 连接。
那伪造一个四元组相同的 RST 报文不就行了?
这个思路很好,但是不要忘了还有个序列号的问题,你伪造的 RST 报文的序列号一定能被对方接受吗?
如果 RST 报文的序列号不能落在对方的滑动窗口内,这个 RST 报文会被对方丢弃的,就达不到关闭的连接的效果。
所以,<b><font color="#0000ff">要伪造一个能关闭 TCP 连接的 RST 报文,必须同时满足「四元组相同」和「序列号正好落在对方的滑动窗口内」这两个条件</font>。</b>
直接伪造符合预期的序列号是比较困难,因为如果一个正在传输数据的 TCP 连接,滑动窗口<br>时刻都在变化,因此很难刚好伪造一个刚好落在对方滑动窗口内的序列号的 RST 报文。
办法还是有的,我们可以 <b><font color="#0000ff">伪造一个四元组相同的 SYN 报文,来拿到“合法”的序列号</font></b>!
正如我们最开始学到的,如果处于 establish 状态的服务端,收到四元组相同的 SYN 报文后,<b><font color="#0000ff">会回复一个 Challenge ACK,这个 ACK <br>报文里的「确认号」,正好是服务端下一次想要接收的序列号,说白了,就是可以通过这一步拿到服务端下一次预期接收的序列号</font>。</b>
<font color="#0000ff">然后用这个确认号作为 RST 报文的序列号,发送给服务端,此时服务端会认为这个 RST 报文里的序列号是合法的,于是就会释放连接!</font>
在 Linux 上有个叫 killcx 的工具,就是基于上面这样的方式实现的,它会主动发送 SYN 包获取 SEQ/ACK 号,然后利用 SEQ/ACK 号<br>伪造两个 RST 报文分别发给客户端和服务端,这样 <b><font color="#0000ff">双方的 TCP 连接都会被释放</font></b>,这种方式活跃和非活跃的 TCP 连接都可以杀掉。
使用方式也很简单,只需指明客户端的 IP 和端口号。
killcx 工具的工作原理,如下图:
它伪造客户端发送 SYN 报文,服务端收到后就会回复一个携带了正确「序列号和确认号」的 ACK <br>报文(Challenge ACK),然后就可以利用这个 ACK 报文里面的信息,伪造两个 RST 报文:<br><ul><li>用 Challenge ACK 里的确认号伪造 RST 报文发送给服务端,服务端收到 RST 报文后就会释放连接。</li><li>用 Challenge ACK 里的序列号伪造 RST 报文发送给客户端,客户端收到 RST 也会释放连接。</li></ul>
正是通过这样的方式,成功将一个 TCP 连接关闭了!
给大家贴一个使用 killcx 工具关闭连接的抓包图,大家多看看序列号和确认号的变化。
所以,以后抓包中,<font color="#0000ff"><b>如果莫名奇妙出现一个 SYN 包,有可能对方接下来想要对你发起的 RST 攻击,直接将你的 TCP 连接断开</b></font>!
四次挥手中收到乱序的 FIN 包会如何处理?
概述
如果 <b><font color="#0000ff">FIN 报文比数据包先抵达客户端</font></b>,此时 <b><font color="#0000ff">FIN 报文其实是一个乱序的报文</font></b>,<br>此时客户端的 TCP 连接并不会从 FIN_WAIT_2 状态转换到 TIME_WAIT 状态。
因此,我们的关注点是<b>「在 FIN_WAIT_2 状态下,是如何处理收到的乱序 FIN 报文,然后 TCP 连接又是什么时候才进入到 TIME_WAIT 状态?」</b>。
结论:<br><ul><li><b>在 FIN_WAIT_2 状态时,如果收到乱序的 FIN 报文,那么就被会加入到「乱序队列」,并不会进入到 TIME_WAIT 状态。</b></li></ul><br><ul><li><b>等再次收到前面被网络延迟的数据包时,会判断<font color="#f44336">乱序队列</font>有没有数据,然后会检测乱序队列中是否有可用的数据,如果能在乱序队列中<br>找到与当前报文的序列号保持的顺序的报文,就会看该报文是否有 FIN 标志,如果发现有 FIN 标志,这时才会进入 TIME_WAIT 状态。</b></li></ul>
TCP 源码分析
这次我们重点分析的是,在 FIN_WAIT_2 状态下,收到 FIN 报文是如何处理的。
在 Linux 内核里,当 IP 层处理完消息后,会通过回调 tcp_v4_rcv 函数将消息转给 TCP 层,<br>所以这个函数就是 TCP 层收到消息的入口。
处于 FIN_WAIT_2 状态下的客户端,在收到服务端的报文后,最终会调用 tcp_v4_do_rcv 函数。
接下来,tcp_v4_do_rcv 方法会调用 tcp_rcv_state_process,在这里会根据 TCP 状态做对应的处理,这里我们只关注 FIN_WAIT_2 状态。
在上面这个代码里,可以看到如果 shutdown 关闭了读方向,那么在收到对方发来的数据包,则会回复 RST 报文。
而我们这次的题目里, shutdown 只关闭了写方向,所以会继续往下调用 tcp_data_queue <br>函数(因为 case TCP_FIN_WAIT2 代码块里并没有 break 语句,所以会走到该函数)。
在上面的 tcp_data_queue 函数里,如果收到的报文的序列号是我们预期的,也就是有序的话:<br><ul><li>会判断该报文有没有 FIN 标志,如果有的话就会调用 tcp_fin 函数,这个函数负责将 FIN_WAIT_2 状态转换为 TIME_WAIT。</li><li>接着还会看乱序队列有没有数据,如果有的话会调用 tcp_ofo_queue 函数,这个函数负责检查乱序队列中是否有数据包可用,<br>即能不能在乱序队列找到与当前数据包保持序列号连续的数据包。</li></ul>
而当收到的报文的序列号不是我们预期的,也就是乱序的话,则调用 tcp_data_queue_ofo 函数,<br>将报文加入到乱序队列,这个队列的数据结构是红黑树。
我们的题目里,客户端收到的 FIN 报文实际上是一个乱序的报文,因此此时并不会调用 tcp_fin <br>函数进行状态转换,而是将报文通过 tcp_data_queue_ofo 函数加入到乱序队列。
然后当客户端收到被网络延迟的数据包后,此时因为该数据包的序列号是期望的,然后又因为上一次收到<br>的乱序 FIN 报文被加入到了乱序队列,表明乱序队列是有数据的,于是就会调用 tcp_ofo_queue 函数。
来看看 tcp_ofo_queue 函数。
在上面的 tcp_ofo_queue 函数里,在乱序队列中找到能与当前报文的序列号保持的顺序的报文后,<br>会看该报文是否有 FIN 标志,如果有的话,就会调用 tcp_fin() 函数。
最后,我们来看看 tcp_fin 函数的处理。
可以看到,如果当前的 TCP 状态为 TCP_FIN_WAIT2,就会发送第四次挥手 ack,然后调用 tcp_time_wait 函数,<br>这个函数里会将 TCP 状态变更为 TIME_WAIT,并启动 TIME_WAIT 的定时器。
怎么看 TCP 源码?
其实看 TCP 源码,并不是直接打开 Linux 源码直接看,因为 Linux 源码实在太庞大了,<br>如果不知道 TCP 入口函数在哪,那简直就是大海捞针。
所以,在看 TCP 源码,我们可以去网上搜索下别人的源码分析,网上已经有很多<br>前辈帮我们分析了 TCP 源码了,而且各个函数的调用链路,他们都有写出来了。
比如,你想了解 TCP 三次握手/四次挥手的源码实现,你就可以以「TCP 三次握手/四次挥手的源码分析」<br>这样关键字来搜索,大部分文章的注释写的还是很清晰。
网上的文章一般只会将重点的部分,很多代码细节没有贴出来,如果你想完整的看到函数的所有代码,那就得看内核代码了。
Linux 内核代码的在线网站:https://elixir.bootlin.com/linux/latest/source<br>
所以,小林看 TCP 源码的经验就是,先在网上找找前辈写的 TCP 源码分析,然后知道整个函数的调用链路后,<br>如果想具体了解某个函数的具体实现,可以在那个看 Linux 内核代码的在线网站上搜索该函数,就可以看到完整<br>的函数的实现。如果中途遇到看不懂的代码,也可以将这个代码复制到百度或者谷歌搜索,一般也能找到别人分析的过程。
在 TIME_WAIT 状态的 TCP 连接,收到 SYN 后会发生什么?
概述
《Linux 服务器高性能编程》一书中说到:处于 TIME_WAIT 状态的连接,<br>在收到相同四元组的 SYN 后,会回 RST 报文,对方收到后就会断开连接。
起初,我看到也觉得这个逻辑也挺符合常理的,但是当我自己去啃了 TCP 源码后,发现并不是这样的。
所以,今天就来讨论下「<b><font color="#0000ff">在 TCP 正常挥手过程中,处于 TIME_WAIT 状态的连接,收到相同四元组的 SYN 后会发生什么?</font></b>」
问题现象如下图,左边是服务端,右边是客户端:
先说结论
针对这个问题,<b><font color="#0000ff">关键是要看 SYN 的「序列号和时间戳」是否合法</font></b>,因为处于 TIME_WAIT 状态的连接收到 SYN 后,<br>会判断 SYN 的「序列号和时间戳」是否合法,然后根据判断结果的不同做不同的处理。
什么是「合法」的 SYN?<br><ul><li><b>合法 SYN</b>:客户端的 SYN 的「序列号」比服务端「期望下一个收到的序列号」要 <b><font color="#0000ff">大</font></b>,<br><b><font color="#0000ff">并且</font></b> SYN 的「时间戳」比服务端「最后收到的报文的时间戳」要 <b><font color="#0000ff">大</font></b>。</li><li><b style="font-size: inherit;">非法 SYN</b><span style="font-size: inherit;">:客户端的 SYN 的「序列号」比服务端「期望下一个收到的序列号」要 </span><b style="font-size: inherit;"><font color="#0000ff">小</font></b><span style="font-size: inherit;">,</span></li></ul><b style="font-size: inherit;"><font color="#0000ff"> 或者</font></b><span style="font-size: inherit;"> SYN 的「时间戳」比服务端「最后收到的报文的时间戳」要 </span><b style="font-size: inherit;"><font color="#0000ff">小</font></b><span style="font-size: inherit;">。</span><br style="font-size: inherit;"><br>若双方都没有开启 TCP 时间戳机制,则省略时间戳条件。
<font color="#0000ff">收到合法 SYN</font><br>
如果处于 TIME_WAIT 状态的连接收到「合法的 SYN 」后,<b><font color="#0000ff">就会重用此四元组连接,<br>跳过 2MSL 而转变为 SYN_RECV 状态,接着就能进行建立连接过程</font></b>。
<font color="#ff0000"><b>注意</b></font>:服务端能直接重用连接,不需要像客户端那样配置 tcp_tw_reuse 参数来启动。
用下图作为例子,双方都启用了 TCP 时间戳机制,TSval 是发送报文时的时间戳:
上图中,在收到第三次挥手的 FIN 报文时,会记录该报文的 TSval (21),用 ts_recent 变量保存。<br>然后会计算下一次期望收到的序列号,本次例子下一次期望收到的序列号就是 301,用 rcv_nxt 变量保存。
处于 TIME_WAIT 状态的连接收到 SYN 后,<b><font color="#0000ff">因为 SYN 的 seq(400) 大于 rcv_nxt(301),并且 SYN 的 TSval(30) 大于 ts_recent(21),<br>所以是一个「合法的 SYN」,于是就会重用此四元组连接,跳过 2MSL 而转变为 SYN_RECV 状态,接着就能进行建立连接过程</font></b>。
<b><font color="#ff0000">注意</font></b>:这里重用连接是默认行为,不受任何参数控制。与 tcp_tw_reuse 无关,<br>而且 tcp_tw_reuse 只能用在客户端(连接发起方)。
<b><font color="#0000ff">收到非法的 SYN</font></b><br>
如果处于 TIME_WAIT 状态的连接收到「非法的 SYN 」后,就会<b><font color="#0000ff"> 再回复一个第四次挥手的 ACK 报文,<br>客户端收到后,发现并不是自己期望收到确认号(ack num),就回 RST 报文给服务端</font></b>。
用下图作为例子,双方都启用了 TCP 时间戳机制,TSval 是发送报文时的时间戳:
上图中,在收到第三次挥手的 FIN 报文时,会记录该报文的 TSval (21),用 ts_recent 变量保存。<br>然后会计算下一次期望收到的序列号,本次例子下一次期望收到的序列号就是 301,用 rcv_nxt 变量保存。
处于 TIME_WAIT 状态的连接收到 SYN 后,<b><font color="#0000ff">因为 SYN 的 seq(200) 小于 rcv_nxt(301),所以是一个「非法的 SYN」,<br>就会再回复一个与第四次挥手一样的 ACK 报文,客户端收到后,发现并不是自己期望收到确认号,就回 RST 报文给服务端</font></b>。
PS:这里先埋一个疑问,处于 TIME_WAIT 状态的连接,收到 RST 会断开连接吗?<br>
源码分析
下面源码分析是基于 Linux 4.2 版本的内核代码。<br>
Linux 内核在收到 TCP 报文后,会执行 tcp_v4_rcv 函数,在该函数和 TIME_WAIT 状态相关的主要代码如下:<br><br>该代码的过程:<br><ol><li>接收到报文后,会调用 __inet_lookup_skb() 函数查找对应的 sock 结构;</li><li>如果连接的状态是 TIME_WAIT,会跳转到 do_time_wait 处理;</li><li>由 tcp_timewait_state_process() 函数来处理收到的报文,处理后根据返回值来做相应的处理。</li></ol>
如果收到的 SYN 是合法的,tcp_timewait_state_process() 函数就会返回 TCP_TW_SYN,然后重用此连接。<br>如果收到的 SYN 是非法的,tcp_timewait_state_process() 函数就会返回 TCP_TW_ACK,然后会回上次发过的 ACK。
接下来,看 tcp_timewait_state_process() 函数是如何判断 SYN 包的。
如果双方启用了 TCP 时间戳机制,就会通过 tcp_paws_reject() 函数来判断时间戳是否发生了回绕,<br>也就是「当前收到的报文的时间戳」是否大于「上一次收到的报文的时间戳」:<br><ul><li>如果大于,就说明没有发生时间戳绕回,函数返回 false。</li><li>如果小于,就说明发生了时间戳回绕,函数返回 true。</li></ul>
从源码可以看到,当收到 SYN 包后,如果该 SYN 包的时间戳没有发生回绕,也就是时间戳是递增的,并且 SYN 包的序列号<br>也没有发生回绕,也就是 SYN 的序列号「大于」下一次期望收到的序列号。就会初始化一个序列号,然后返回 TCP_TW_SYN,<br>接着就重用该连接,也就跳过 2MSL 而转变为 SYN_RECV 状态,接着就能进行建立连接过程。<br>
如果双方都没有启用 TCP 时间戳机制,就只需要判断 SYN 包的序列号有没有发生回绕,<br>如果 SYN 的序列号大于下一次期望收到的序列号,就可以跳过 2MSL,重用该连接。
如果 SYN 包是非法的,就会返回 TCP_TW_ACK,接着就会发送与上一次一样的 ACK 给对方。
在 TIME_WAIT 状态,收到 RST 会断开连接吗?
在前面留了一个疑问,处于 TIME_WAIT 状态的连接,收到 RST 会断开连接吗?
会不会断开,关键看 <font color="#ff00ff"><i>net.ipv4.tcp_rfc1337</i></font> 这个内核参数(默认情况是为 0):<br><ul><li>如果这个参数设置为<font color="#ff00ff"><b> 0</b></font>, 收到 RST 报文会 <b><font color="#0000ff">提前结束 TIME_WAIT 状态,释放连接</font></b>。</li><li>如果这个参数设置为<font color="#ff00ff"><b> 1</b></font>, 就会 <b><font color="#0000ff">丢掉 RST 报文</font></b>。</li></ul>
源码处理如下:
TIME_WAIT 状态 <b><font color="#0000ff">收到 RST 报文而释放连接,这样等于跳过 2MSL 时间,这么做还是有风险</font></b>。
sysctl_tcp_rfc1337 这个参数是在 rfc 1337 文档提出来的,目的是避免因为 TIME_WAIT <br>状态收到 RST 报文而跳过 2MSL 的时间,文档里也给出跳过 2MSL 时间会有什么潜在问题。
TIME_WAIT 状态之所以要持续 2MSL 时间,主要有两个目的:<br><ul><li>防止历史连接中的数据,被后面相同四元组的连接错误的接收;</li><li>保证「被动关闭连接」的一方,能被正确的关闭;</li></ul>
虽然 TIME_WAIT 状态持续的时间是有一点长,显得很不友好,但是它被设计来就是用来避免发生乱七八糟的事情。
所以,<b><font color="#0000ff">我个人觉得将 net.ipv4.tcp_rfc1337 设置为 1 会比较安全</font></b>。
总结
在 TCP 正常挥手过程中,处于 TIME_WAIT 状态的连接,收到相同四元组的 SYN 后会发生什么?
如果双方开启了时间戳机制:<br><br><ul><li>如果客户端的 SYN 的「序列号」比服务端「期望下一个收到的序列号」要 <b><font color="#0000ff">大</font></b>,<b><font color="#0000ff">并且 </font></b>SYN 的「时间戳」比服务端「最后收到的报文的时间戳」要 <b><font color="#0000ff">大</font></b>。<br>那么就会 <b><font color="#0000ff">重用该四元组连接,跳过 2MSL 而转变为 SYN_RECV 状态</font></b>,接着就能进行建立连接过程。</li><li>如果客户端的 SYN 的「序列号」比服务端「期望下一个收到的序列号」要 <b><font color="#0000ff">小</font></b>,<b><font color="#0000ff">或者 </font></b>SYN 的「时间戳」比服务端「最后收到的报文的时间戳」要 <b><font color="#0000ff">小</font></b>。<br>那么就会 <b><font color="#0000ff">再回复一个第四次挥手的 ACK 报文</font></b>,客户端收到后,发现并 <b><font color="#0000ff">不是自己期望收到确认号,就回 RST 报文给服务端</font></b>。</li></ul>
在 TIME_WAIT 状态,收到 RST 会断开连接吗?<br><br><ul><li>如果 net.ipv4.tcp_rfc1337 参数为 0,则提前结束 TIME_WAIT 状态,释放连接。</li><li>如果 net.ipv4.tcp_rfc1337 参数为 1,则会丢掉该 RST 报文。</li></ul>
TCP 连接,一端断电和进程崩溃有什么区别?
概述
问题:一个 TCP 连接,没有打开 keepalive 选项,没有数据交互,<br>现在一端突然断电和一端的进程 crash (崩溃) 了,这两种情况有什么区别?
这个问题有几个关键词:<br><ul><li>没有开启 keepalive;</li><li><span style="font-size: inherit;">一直没有数据交互;</span></li><li><span style="font-size: inherit;">主机崩溃;</span></li><li>进程崩溃;</li></ul>
什么是 TCP keepalive ?
TCP keepalive 其实就是 <b><font color="#0000ff">TCP 的保活机制</font>。</b>
TCP 有一个机制是 <b><font color="#0000ff">保活机制</font></b>。这个机制的原理是这样的:<br>定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用,每隔一个时间间隔,发送一个探测报文,该探测<br>报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的 TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。<br>
在 Linux 内核可以有对应的参数可以设置保活时间、保活探测的次数、保活探测的时间间隔,以下都为默认值:<br><ul><li>tcp_keepalive_time=7200:表示保活时间是 7200 秒(2小时),也就 2 小时内如果没有任何连接相关的活动,则会启动保活机制;</li><li>tcp_keepalive_intvl=75:表示每次检测间隔 75 秒;</li><li>tcp_keepalive_probes=9:表示检测 9 次无响应,认为对方是不可达的,从而中断本次的连接。</li></ul>
也就是说在 Linux 系统中,最少需要经过 2 小时 11 分 15 秒才可以发现一个「死亡」连接。
注意,应用程序若想使用 TCP 保活机制需要通过 socket 接口设置 SO_KEEPALIVE <br>选项才能够生效,如果没有设置,那么就无法使用 TCP 保活机制。
如果两端的 TCP 连接一直没有数据交互,达到了触发 TCP 保活机制的条件,那么内核里的 TCP 协议栈就会发送探测报文。<br><br><ul><li>如果对端程序是正常工作的。当 TCP 保活的探测报文发送给对端, 对端会正常响应,<br>这样 <b><font color="#0000ff">TCP 保活时间会被重置</font></b>,等待下一个 TCP 保活时间的到来。</li><li>如果对端主机崩溃,或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,<br>石沉大海,没有响应,连续几次,达到保活探测次数后,<b><font color="#0000ff">TCP 会报告该 TCP 连接已经死亡</font></b>。</li></ul>
所以,TCP 保活机制可以在双方没有数据交互的情况,通过探测报文,来确定对方的 TCP 连接是否存活。<br>
主机崩溃<br>
知道了 TCP keepalive 作用,我们再回过头看题目中的「主机崩溃」这种情况。
在没有开启 TCP keepalive,且双方一直没有数据交互的情况下,如果客户端的「主机崩溃」了,会发生什么。
客户端主机崩溃了,<b><font color="#0000ff">服务端是无法感知到的</font></b>,在加上服务端没有开启 TCP keepalive,又没有数据交互的情况下,<br><b><font color="#0000ff">服务端的 TCP 连接将会一直处于 ESTABLISHED 连接状态</font></b>,直到服务端重启进程。
所以,我们可以得知一个点,在 <b><font color="#0000ff">没有使用 TCP 保活机制且双方不传输数据</font> </b>的情况下,<br><b><font color="#0000ff">一方的 TCP 连接处在 ESTABLISHED 状态,并不代表另一方的连接还一定正常</font></b>。
进程崩溃<br>
TCP 的连接信息是由内核维护的,所以当服务端的进程崩溃后,内核需要回收该进程的所有 TCP 连接资源,于是内核会发送第一次挥手 FIN 报文,<br>后续的挥手过程也都是 <font color="#0000ff">在内核完成</font>,并不需要进程的参与,所以即使服务端的进程退出了,还是能与客户端完成 TCP四次挥手的过程。
实验:使用 kill -9 来模拟进程崩溃的情况,发现 <b><font color="#0000ff">在 kill 掉进程后,服务端会发送 FIN 报文,与客户端进行四次挥手</font></b>。
所以,即使没有开启 TCP keepalive,且双方也没有数据交互的情况下,如果 <b><font color="#0000ff">其中一方的进程发生了崩溃,<br>这个过程操作系统是可以感知的到的,于是就会发送 FIN 报文给对方,然后与对方进行 TCP 四次挥手</font></b>。
有数据传输的场景<br>
以上就是对这个问题的回答,接下来我们看看在「<b><font color="#0000ff">有数据传输</font></b>」的场景下的一些异常情况:<br><br><ul><li>第一种,客户端主机宕机,又迅速重启,会发生什么?</li><li>第二种,客户端主机宕机,一直没有重启,会发生什么?</li></ul>
客户端主机宕机,又迅速重启<br>
在客户端主机宕机后,服务端向客户端发送的报文会得不到任何的响应,在一定时长后,<br>服务端就会触发 <b><font color="#0000ff">超时重传机制</font></b>,重传未得到响应的报文。
服务端重传报文的过程中,客户端主机重启完成后,客户端的内核就会接收重传的报文,然后根据报文的信息传递给对应的进程:<br><br><ul><li>如果客户端主机上 <b><font color="#0000ff">没有进程绑定该 TCP 报文的目标端口号</font></b>,那么客户端内核就会 <b><font color="#0000ff">回复 RST 报文,重置该 TCP 连接</font></b>;</li><li>如果客户端主机上 <b><font color="#0000ff">有进程绑定该 TCP 报文的目标端口号</font></b>,由于客户端主机重启后,之前的 TCP 连接的数据结构已经丢失了,<br>客户端内核里协议栈会发现找不到该 TCP 连接的 socket 结构体,于是就会 <b><font color="#0000ff">回复 RST 报文,重置该 TCP 连接</font></b>。</li></ul>
所以,<b><font color="#0000ff">只要有一方重启完成后,收到之前 TCP 连接的报文,都会回复 RST 报文,以断开连接</font></b>。
客户端主机宕机,一直没有重启<br>
这种情况,服务端超时重传报文的次数达到一定阈值后,内核就会判定出该 TCP 有问题,<br>然后通过 Socket 接口告诉应用程序该 TCP 连接出问题了,于是服务端的 TCP 连接就会断开。
那 TCP 的数据报文具体重传几次呢?<br>
在 Linux 系统中,提供一个叫 tcp_retries2 配置项,默认值是 15,如下图:
这个内核参数是控制,在 TCP 连接建立的情况下,超时重传的最大次数。
不过 tcp_retries2 设置了 15 次,并不代表 TCP 超时重传了 15 次才会通知应用程序终止该 TCP 连接,<b><font color="#0000ff">内核会根据 tcp_retries2 <br>设置的值,计算出一个 timeout(</font><font color="#ff00ff">如果 tcp_retries2 =15,那么计算得到的 timeout = 924600 ms</font><font color="#0000ff">),如果重传间隔超过这个 <br>timeout,则认为超过了阈值,就会停止重传,然后就会断开 TCP 连接</font></b>。
在发生超时重传的过程中,每一轮的超时时间(RTO)都是 <b><font color="#0000ff">倍数增长</font> </b>的,比如如果第一轮 <br>RTO 是 200 毫秒,那么第二轮 RTO 是 400 毫秒,第三轮 RTO 是 800 毫秒,以此类推。
而 RTO 是基于 RTT(一个包的往返时间) 来计算的,如果 RTT 较大,那么计算<br>出来的 RTO 就越大,那么经过几轮重传后,很快就达到了上面的 timeout 值了。
举个例子,如果 tcp_retries2 =15,那么计算得到的 timeout = 924600 ms,<br>如果重传总间隔时长达到了 timeout 就会停止重传,然后就会断开 TCP 连接:<br><br><ul><li>如果 RTT 比较小,那么 RTO 初始值就约等于下限 200ms,也就是第一轮的超时时间是 200 毫秒,由于 timeout <br>总时长是 924600 ms,表现出来的现象刚好就是重传了 15 次,超过了 timeout 值,从而断开 TCP 连接</li><li>如果 RTT 比较大,假设 RTO 初始值计算得到的是 1000 ms,也就是第一轮的超时时间是 1 秒,<br>那么根本不需要重传 15 次,重传总间隔就会超过 924600 ms。</li></ul>
最小 RTO 和最大 RTO 是在 Linux 内核中定义好了:
Linux 2.6+ 使用 1000 毫秒的 HZ,因此TCP_RTO_MIN约为 200 毫秒,TCP_RTO_MAX约为 120 秒。
如果 tcp_retries2 设置为15,且 RTT 比较小,那么 RTO 初始值就约等于下限 200ms,这意味着它 <font color="#0000ff"><b>需要 <br>924.6 秒 </b></font>才能将断开的 TCP 连接通知给上层(即应用程序),每一轮的 RTO 增长关系如下表格:
总结
如果「<b><font color="#0000ff">客户端进程崩溃</font></b>」,客户端的进程在发生崩溃的时候,<font color="#0000ff"><b>内核会发送 FIN 报文</b></font>,与服务端进行四次挥手。
但是,「<b><font color="#0000ff">客户端主机宕机</font></b>」,那么是不会发生四次挥手的,<br>具体后续会发生什么,还要看服务端会不会发送数据。
如果 <b><font color="#0000ff">服务端会发送数据</font></b>,由于客户端已经不存在,收不到数据报文的响应报文,服务端的数据报文会 <b><font color="#0000ff">超时重传</font></b>,<br>当重传总间隔时长达到一定阈值(内核会根据 tcp_retries2 设置的值计算出一个阈值)后,会断开 TCP 连接;
如果服务端一直 <b><font color="#0000ff">不会发送数据</font></b>,再看服务端有没有开启 <b><font color="#0000ff">TCP keepalive</font></b> 机制?<br><ul><li>如果 <b><font color="#0000ff">有开启</font></b>,服务端在一段时间没有进行数据交互时,会 <b><font color="#0000ff">触发 TCP keepalive 机制</font></b>,<br>探测对方是否存在,如果探测到对方已经消亡,则会断开自身的 TCP 连接;</li><li>如果<b><font color="#0000ff"> 没有开启</font></b>,服务端的 <b><font color="#0000ff">TCP 连接会一直存在</font></b>,并且一直 <font color="#0000ff"><b>保持在 ESTABLISHED 状态</b></font>。</li></ul>
最后说句,TCP 牛逼,啥异常都考虑到了。
拔掉网线后, 原本的 TCP 连接还存在吗?
概述
今天,聊一个有趣的问题:<b><font color="#0000ff">拔掉网线几秒,再插回去,原本的 TCP 连接还存在吗?</font></b>
可能有的同学会说,网线都被拔掉了,那说明物理层被断开了,那在上层的传输层理应也会断开,所以原本的 TCP <br>连接就不会存在的了。就好像, 我们拨打有线电话的时候,如果某一方的电话线被拔了,那么本次通话就彻底断了。
真的是这样吗?
上面这个逻辑就有问题。问题在于,<b><font color="#0000ff">错误的认为拔掉网线这个动作会影响传输层</font></b>,事实上并不会影响。
实际上,TCP 连接在 Linux 内核中是一个名为 <b><font color="#0000ff">struct socket 的结构体</font></b>,该结构体的内容包含 TCP 连接的状态等信息。<br>当拔掉网线的时候,<b><font color="#0000ff">操作系统并不会变更该结构体的任何内容,所以 TCP 连接的状态也不会发生改变</font></b>。
我在我的电脑上做了个小实验,我用 ssh 终端连接了我的云服务器,然后我通过断开 wifi 的方式<br>来模拟拔掉网线的场景,此时查看 TCP 连接的状态没有发生变化,还是处于 ESTABLISHED 状态。
通过上面这个实验结果,我们知道了,<b><font color="#0000ff">拔掉网线这个动作并不会影响 TCP 连接的状态</font></b>。
接下来,要看拔掉网线后,双方做了什么动作。针对这个问题,要分场景来讨论:<br><ul><li>拔掉网线后,有数据传输;</li><li>拔掉网线后,没有数据传输;</li></ul>
拔掉网线后,有数据传输
在客户端拔掉网线后,服务端向客户端发送的数据报文会得不到任何的响应,<br>在等待一定时长后,服务端就会触发 <b><font color="#0000ff">超时重传机制</font></b>,重传未得到响应的数据报文。
<b><font color="#0000ff">如果在服务端重传报文的过程中,客户端刚好把网线插回去了</font></b>,由于拔掉网线并不会改变客户端的 TCP 连接状态,<br>并且还是处于 ESTABLISHED 状态,所以这时客户端是可以正常接收服务端发来的数据报文的,然后客户端就会回 ACK 响应报文。
此时,客户端和服务端的 TCP 连接依然存在的,就感觉什么事情都没有发生。<br>
但是,<b><font color="#0000ff">如果如果在服务端重传报文的过程中,客户端一直没有将网线插回去</font></b>,服务端超时重传报文的次数达到一定阈值后,<br>内核就会判定出该 TCP 有问题,然后通过 Socket 接口告诉应用程序该 TCP 连接出问题了,于是服务端的 TCP 连接就会断开。
而等客户端插回网线后,如果客户端向服务端发送了数据,由于服务端已经没有与客户端相同<br>四元祖的 TCP 连接了,因此服务端内核就会回复 RST 报文,客户端收到后就会释放该 TCP 连接。
此时,客户端和服务端的 TCP 连接都已经断开了。<br>
那 TCP 的数据报文具体重传几次呢?
在 Linux 系统中,提供一个叫 tcp_retries2 配置项,默认值是 15,如下图:
这个内核参数是控制,在 TCP 连接建立的情况下,超时重传的最大次数。
不过 tcp_retries2 设置了 15 次,并不代表 TCP 超时重传了 15 次才会通知应用程序终止该 TCP 连接,<b><font color="#0000ff">内核会根据 tcp_retries2 <br>设置的值,计算出一个 timeout(</font><font color="#ff00ff">如果 tcp_retries2 =15,那么计算得到的 timeout = 924600 ms</font><font color="#0000ff">),如果重传间隔超过这个 <br>timeout,则认为超过了阈值,就会停止重传,然后就会断开 TCP 连接</font></b>。
在发生超时重传的过程中,每一轮的超时时间(RTO)都是 <b><font color="#0000ff">倍数增长</font> </b>的,比如如果第一轮 <br>RTO 是 200 毫秒,那么第二轮 RTO 是 400 毫秒,第三轮 RTO 是 800 毫秒,以此类推。
而 RTO 是基于 RTT(一个包的往返时间) 来计算的,如果 RTT 较大,那么计算<br>出来的 RTO 就越大,那么经过几轮重传后,很快就达到了上面的 timeout 值了。
举个例子,如果 tcp_retries2 =15,那么计算得到的 timeout = 924600 ms,<br>如果重传总间隔时长达到了 timeout 就会停止重传,然后就会断开 TCP 连接:<br><br><ul><li>如果 RTT 比较小,那么 RTO 初始值就约等于下限 200ms,也就是第一轮的超时时间是 200 毫秒,由于 timeout <br>总时长是 924600 ms,表现出来的现象刚好就是重传了 15 次,超过了 timeout 值,从而断开 TCP 连接</li><li>如果 RTT 比较大,假设 RTO 初始值计算得到的是 1000 ms,也就是第一轮的超时时间是 1 秒,<br>那么根本不需要重传 15 次,重传总间隔就会超过 924600 ms。</li></ul>
最小 RTO 和最大 RTO 是在 Linux 内核中定义好了:
Linux 2.6+ 使用 1000 毫秒的 HZ,因此TCP_RTO_MIN约为 200 毫秒,TCP_RTO_MAX约为 120 秒。
如果 tcp_retries2 设置为15,且 RTT 比较小,那么 RTO 初始值就约等于下限 200ms,这意味着它 <font color="#0000ff"><b>需要 <br>924.6 秒 </b></font>才能将断开的 TCP 连接通知给上层(即应用程序),每一轮的 RTO 增长关系如下表格:
拔掉网线后,没有数据传输
针对拔掉网线后,没有数据传输的场景,还得看是否开启了 TCP keepalive 机制 (TCP 保活机制)。
如果 <b><font color="#0000ff">没有开启 TCP keepalive 机制</font></b>,在客户端拔掉网线后,并且双方都<br>没有进行数据传输,那么客户端和服务端的 TCP 连接将会一直保持存在。
而如果 <b><font color="#0000ff">开启了 TCP keepalive 机制</font></b>,在客户端拔掉网线后,即使双方<br>都没有进行数据传输,在持续一段时间后,TCP 就会发送探测报文:<br><ul><li>如果 <b><font color="#0000ff">对端是正常工作的</font></b>。当 TCP 保活的探测报文发送给对端, 对端会正常响应,<br>这样 <b><font color="#0000ff">TCP 保活时间会被重置,等待下一个 TCP 保活时间的到来</font></b>。</li><li>如果 <b><font color="#0000ff">对端主机崩溃,或对端由于其他原因导致报文不可达</font></b>。当 TCP 保活的探测报文发送给对端后,<br>石沉大海,没有响应,连续几次,达到保活探测次数后,<b><font color="#0000ff">TCP 会报告该 TCP 连接已经死亡</font></b>。</li></ul>
所以,TCP 保活机制可以在双方没有数据交互的情况,通过探测报文,来确定对方的 TCP 连接是否存活。
TCP keepalive 机制具体是怎么样的?<br>
TCP keepalive 其实就是 <b><font color="#0000ff">TCP 的保活机制</font>。</b>
TCP 有一个机制是 <b><font color="#0000ff">保活机制</font></b>。这个机制的原理是这样的:<br>定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用,每隔一个时间间隔,发送一个探测报文,该探测<br>报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的 TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。<br>
在 Linux 内核可以有对应的参数可以设置保活时间、保活探测的次数、保活探测的时间间隔,以下都为默认值:<br><ul><li>tcp_keepalive_time=7200:表示保活时间是 7200 秒(2小时),也就 2 小时内如果没有任何连接相关的活动,则会启动保活机制;</li><li>tcp_keepalive_intvl=75:表示每次检测间隔 75 秒;</li><li>tcp_keepalive_probes=9:表示检测 9 次无响应,认为对方是不可达的,从而中断本次的连接。</li></ul>
也就是说在 Linux 系统中,最少需要经过 2 小时 11 分 15 秒才可以发现一个「死亡」连接。
注意,应用程序若想使用 TCP 保活机制需要通过 socket 接口设置 SO_KEEPALIVE <br>选项才能够生效,如果没有设置,那么就无法使用 TCP 保活机制。
如果两端的 TCP 连接一直没有数据交互,达到了触发 TCP 保活机制的条件,那么内核里的 TCP 协议栈就会发送探测报文。<br><br><ul><li>如果对端程序是正常工作的。当 TCP 保活的探测报文发送给对端, 对端会正常响应,<br>这样 <b><font color="#0000ff">TCP 保活时间会被重置</font></b>,等待下一个 TCP 保活时间的到来。</li><li>如果对端主机崩溃,或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,<br>石沉大海,没有响应,连续几次,达到保活探测次数后,<b><font color="#0000ff">TCP 会报告该 TCP 连接已经死亡</font></b>。</li></ul>
所以,TCP 保活机制可以在双方没有数据交互的情况,通过探测报文,来确定对方的 TCP 连接是否存活。<br>
TCP keepalive 机制探测的时间也太长了吧?<br>
对的,是有点长。
TCP keepalive 是 <b><font color="#0000ff">TCP 层(内核态)</font></b> 实现的,它是给所有基于 TCP 传输协议的程序一个兜底的方案。
实际上,我们应用层可以自己实现一套探测机制,可以在较短的时间内,探测到对方是否存活。
比如,web 服务软件一般都会提供 keepalive_timeout 参数,用来指定 HTTP 长连接的超时时间。如果设置了 <br>HTTP 长连接的超时时间是 60 秒,web 服务软件就会启动一个定时器,如果客户端在完后一个 HTTP 请求后,<br>在 60 秒内都没有再发起新的请求,<b><font color="#0000ff">定时器的时间一到,就会触发回调函数来释放该连接</font></b>。
总结
客户端拔掉网线后,并不会直接影响 TCP 连接状态。所以,拔掉网线后,<br>TCP 连接是否还会存在,关键要看拔掉网线之后,有没有进行数据传输。
有数据传输的情况:<br><br><ul><li>在客户端拔掉网线后,如果服务端发送了数据报文,那么在服务端重传次数没有达到最大值之前,<br>客户端就插回了网线,那么双方原本的 TCP 连接还是能正常存在,就好像什么事情都没有发生。</li><li>在客户端拔掉网线后,如果服务端发送了数据报文,在客户端插回网线之前,服务端重传次数达到了最大值时,<br>服务端就会断开 TCP 连接。等到客户端插回网线后,向服务端发送了数据,因为服务端已经断开了与客户端<br>相同四元组的 TCP 连接,所以就会回 RST 报文,客户端收到后就会断开 TCP 连接。至此, 双方的 TCP 连接都断开了。</li></ul>
没有数据传输的情况:<br><br><ul><li>如果双方都没有开启 TCP keepalive 机制,那么在客户端拔掉网线后,如果客户端<br>一直不插回网线,那么客户端和服务端的 TCP 连接状态将会一直保持存在。</li><li>如果双方都开启了 TCP keepalive 机制,那么在客户端拔掉网线后,如果客户端一直不插回网线,<br>TCP keepalive 机制会探测到对方的 TCP 连接没有存活,于是就会断开 TCP 连接。而如果在 TCP <br>探测期间,客户端插回了网线,那么双方原本的 TCP 连接还是能正常存在。</li></ul>
除了客户端拔掉网线的场景,还有客户端「主机宕机和进程崩溃」的两种场景,上面有讲到。
第一个场景,客户端宕机这件事跟拔掉网线是一样无法被服务端的感知的,所以如果在没有数据传输,并且没有<br>开启 TCP keepalive 机制时,<b><font color="#0000ff">服务端的 TCP 连接将会一直处于 ESTABLISHED 连接状态</font></b>,直到服务端重启进程。
所以,我们可以得知一个点。在没有使用 TCP 保活机制,且双方不传输数据的情况下,<br>一方的 TCP 连接处在 ESTABLISHED 状态时,并不代表另一方的 TCP 连接还一定是正常的。
第二个场景,客户端的进程崩溃后,客户端的内核就会向服务端发送 FIN 报文,<b><font color="#0000ff">与服务端进行四次挥手</font></b>。
所以,即使没有开启 TCP keepalive,且双方也没有数据交互的情况下,如果其中一方的进程发生了崩溃,<br>这个过程操作系统是可以感知的到的,于是就会发送 FIN 报文给对方,然后与对方进行 TCP 四次挥手。
tcp_tw_reuse 为什么默认是关闭的?
概述
既然打开 net.ipv4.tcp_tw_reuse 参数可以快速复用处于 TIME_WAIT 状态的 TCP 连接,那为什么 Linux 默认是关闭状态呢?
其实这是在变相问「<b><font color="#0000ff">如果 TIME_WAIT 状态持续时间过短或者没有,会有什么问题?</font></b>」
因为开启 tcp_tw_reuse 参数可以快速复用处于 TIME_WAIT 状态的 TCP 连接时,相当于缩短了 TIME_WAIT 状态的持续时间。
使用 tcp_tw_reuse 快速复用处于 TIME_WAIT 状态的 TCP 连接时,是需要保证 net.ipv4.tcp_timestamps 参数是开启的(默认是开启的),<br>而 tcp_timestamps 参数可以避免旧连接的延迟报文,这不是解决了没有 TIME_WAIT 状态时的问题了吗?
是解决部分问题,但是不能完全解决,接下来详细聊聊这个问题。
什么是 TIME_WAIT 状态?
TCP 四次挥手过程,如下图:<br><ul><li>客户端打算关闭连接,此时会发送一个 TCP 首部 FIN 标志位被置为 1的报文,也即 FIN 报文,之后客户端进入 FIN_WAIT_1 状态。</li><li>服务端收到该报文后,就向客户端发送 ACK 应答报文,接着服务端进入 CLOSED_WAIT 状态。</li><li>客户端收到服务端的 ACK 应答报文后,之后进入 FIN_WAIT_2 状态。</li><li>等待服务端处理完数据后,也向客户端发送 FIN 报文,之后服务端进入 LAST_ACK 状态。</li><li>客户端收到服务端的 FIN 报文后,回一个 ACK 应答报文,之后进入 TIME_WAIT 状态</li><li>服务器收到了 ACK 应答报文后,就进入了 CLOSE 状态,至此服务端已经完成连接的关闭。</li><li>客户端在经过 2MSL 一段时间后,自动进入 CLOSE 状态,至此客户端也完成连接的关闭。</li></ul>
可以看到,<b><font color="#0000ff">两个方向都需要一个 FIN 和一个 ACK,因此通常被称为四次挥手。</font></b>
这里一点需要注意是:<b><font color="#0000ff">主动关闭连接的,才有 TIME_WAIT 状态。</font></b><br>
可以看到,TIME_WAIT 是「主动关闭方」断开连接时的最后一个状态,该状态<br>会持续 <b><font color="#0000ff">2MSL(Maximum Segment Lifetime)</font></b> 时长,之后进入CLOSED 状态。
MSL 指的是 TCP 协议中任何报文在网络上最大的生存时间,任何超过这个时间的数据都将被丢弃。虽然 RFC 793 <br>规定 MSL 为 2 分钟,但是在实际实现的时候会有所不同,比如 Linux 默认为 30 秒,那么 2MSL 就是 60 秒。
MSL 是由网络层的 IP 包中的 TTL 来保证的,TTL 是 IP 头部的一个字段,用于设置一个数据报可经过的<br>路由器的数量上限。报文每经过一次路由器的转发,IP 头部的 TTL 字段就会减 1,减到 0 时报文就被丢弃。
MSL 与 TTL 的区别:MSL 的单位是时间,而 TTL 是经过路由跳数。<br>所以 <b><font color="#0000ff">MSL 应该要大于等于 TTL 消耗为 0 的时间,以确保报文已被自然消亡</font></b>。
TTL 的值一般是 64,Linux 将 MSL 设置为 30 秒,意味着 Linux 认为数据报文经过 <br>64 个路由器的时间不会超过 30 秒,如果超过了,就认为报文已经消失在网络中了。
为什么要设计 TIME_WAIT 状态?
设计 TIME_WAIT 状态,主要有两个原因:<br><ul><li>防止历史连接中的数据,被后面相同四元组的连接错误的接收;</li><li>保证「被动关闭连接」的一方,能被正确的关闭;</li></ul>
原因一<br>
为了能更好的理解这个原因,先来了解序列号(SEQ)和初始序列号(ISN):<br><ul><li><b>序列号</b>,是 TCP 一个头部字段,标识了 TCP 发送端到 TCP 接收端的数据流的一个字节,因为 TCP 是面向字节流的</li></ul> 可靠协议,为了保证消息的顺序性和可靠性,TCP 为每个传输方向上的每个字节都赋予了一个编号,以便于传输成功<br> 后确认、丢失后重传以及在接收端保证不会乱序。<b>序列号是一个 32 位的无符号数,因此在到达 4G 之后再循环回到 0</b>。<br><ul><li><b>初始序列号</b>,在 TCP 建立连接的时候,客户端和服务端都会各自生成一个初始序列号,它是基于时钟生成的一个随机数,来保证每个</li></ul> 连接都拥有不同的初始序列号。<b>初始化序列号可被视为一个 32 位的计数器,该计数器的数值每 4 微秒加 1,循环一次需要 4.55 小时</b>。<br>
通过前面我们知道,<b>序列号和初始化序列号并不是无限递增的,会发生回绕为初始值的情况,这意味着无法根据序列号来判断新老数据。</b>
假设 TIME-WAIT 没有等待时间或时间过短,被延迟的数据包抵达后会发生什么呢?
如上图:<br><ul><li>服务端在关闭连接之前发送的 SEQ = 301 报文,被网络延迟了。</li><li>接着,服务端以相同的四元组重新打开了新连接,前面被延迟的 SEQ = 301 这时抵达了客户端,而且该数据报文的序列号刚好在客户端<br>接收窗口内,因此客户端会正常接收这个数据报文,但是这个数据报文是上一个连接残留下来的,这样就产生数据错乱等严重的问题。</li></ul>
为了防止历史连接中的数据,被后面相同四元组的连接错误的接收,因此 TCP 设计了 TIME_WAIT 状态,状态会持续 2MSL 时长,<br><b>这个时间足以让两个方向上的数据包都被丢弃,使得原来连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的。<br><br></b>MSL 是 Maximum Segment Lifetime的英文缩写,可译为“最长报文段寿命”,<br>它是任何报文在网络上存在的最长的最长时间,超过这个时间报文将被丢弃。<b><br></b>
原因二
在 RFC 793 指出 TIME-WAIT 另一个重要的作用是:<br>TIME-WAIT - represents waiting for enough time to pass to be sure the remote <br>TCP received the acknowledgment of its connection termination request.<br>
也就是说,<b>TIME-WAIT 作用是等待足够的时间以确保最后的 ACK 能让被动关闭方接收,从而帮助其正常关闭。</b>
如果客户端(主动关闭方)最后一次 ACK 报文(第四次挥手)在网络中丢失了,<br>那么按照 TCP 可靠性原则,服务端(被动关闭方)会重发 FIN 报文。
假设客户端没有 TIME_WAIT 状态,而是在发完最后一次回 ACK 报文就直接进入 CLOSED 状态,如果该 ACK 报文丢失了,<br>服务端则重传的 FIN 报文,而这时客户端已经进入到关闭状态了,在收到服务端重传的 FIN 报文后,就会回 RST 报文。
服务端收到这个 RST 并将其解释为一个错误(Connection reset by peer),这对于一个可靠的协议来说不是一个优雅的终止方式。
为了防止这种情况出现,客户端必须等待足够长的时间,确保服务端能够收到 ACK,如果服务端没有收到 ACK,<br>那么就会触发 TCP 重传机制,服务端会重新发送一个 FIN,这样一去一来刚好两个 MSL 的时间。
客户端在收到服务端重传的 FIN 报文时,TIME_WAIT 状态的等待时间,会重置回 2MSL。
tcp_tw_reuse 是什么?
在 Linux 操作系统下,TIME_WAIT 状态的持续时间是 60 秒,这意味着这 60 秒内,客户端一直会占用着这个端口。<br>要知道,端口资源也是有限的,一般可以开启的端口为 32768~61000 ,也可以通过如下参数设置指定范围:
<b><font color="#0000ff">如果客户端(发起连接方)的 TIME_WAIT 状态过多</font></b>,占满了所有端口资源,那么就无法对「目的 IP+ 目的 PORT」都一样的<br>服务器发起连接了,但是被使用的端口,还是可以继续对另外一个服务器发起连接的。具体看 「客户端的端口可以重复使用吗?」
因此,客户端(发起连接方)都是和「目的 IP+ 目的 PORT 」都一样的服务器建立连接的话,当客户端的 TIME_WAIT 状态连接<br>过多的话,就会受端口资源限制,如果占满了所有端口资源,那么就无法再跟「目的 IP+ 目的 PORT」都一样的服务器建立连接了。
不过,即使是在这种场景下,只要连接的是不同的服务器,端口是可以重复使用的,所以客户端还是可以向其他服务器发起连接的,这是因为内核<br>在定位一个连接的时候,是通过四元组(源IP、源端口、目的IP、目的端口)信息来定位的,并不会因为客户端的端口一样,而导致连接冲突。
好在,Linux 操作系统提供了两个可以系统参数来快速回收处于 TIME_WAIT 状态的连接,这两个参数都是默认关闭的:<br><br><ul><li><span style="font-size: inherit;">net.ipv4.tcp_tw_reuse,如果开启该选项的话,客户端(连接发起方) 在调用 connect() 函数时,</span><b style="font-size: inherit;"><font color="#0000ff">如果内核选择到的端口,<br>已经被相同四元组的连接占用的时候,就会判断该连接是否处于 TIME_WAIT 状态,如果该连接处于 TIME_WAIT 状态并且 <br>TIME_WAIT 状态持续的时间超过了 1 秒,那么就会重用这个连接,然后就可以正常使用该端口了。</font></b>所以该选项只适用于连接发起方。</li></ul><ul><li><span style="font-size: inherit;">net.ipv4.tcp_tw_recycle,如果开启该选项的话,允许处于 TIME_WAIT 状态的连接被快速回收,<b><font color="#0000ff">该参数在 NAT 的网络下是不安全的</font></b>!<br>详细看「SYN 报文什么时候情况下会被丢弃?」</span></li></ul>
要使得上面这两个参数生效,有一个前提条件,就是要打开 TCP 时间戳,即 net.ipv4.tcp_timestamps=1(默认即为 1)。
开启了 tcp_timestamps 参数,TCP 头部就会使用时间戳选项,它有两个好处,<br><b><font color="#0000ff">一个是便于精确计算 RTT ,另一个是能防止序列号回绕(PAWS)</font></b>。
序列号是一个 32 位的无符号整型,上限值是 4GB,超过 4GB 后就需要将序列号回绕进行重用。这在以前<br>网速慢的年代不会造成什么问题,但在一个速度足够快的网络中传输大量数据时,序列号的回绕时间就会变短。<br>如果序列号回绕的时间极短,我们就会再次面临之前延迟的报文抵达后序列号依然有效的问题。
为了解决这个问题,就需要有 TCP 时间戳。
试看下面的示例,假设 TCP 的发送窗口是 1 GB,并且使用了时间戳选项,发送方会为每个 TCP <br>报文分配时间戳数值,我们假设每个报文时间加 1,然后使用这个连接传输一个 6GB 大小的数据流。
32 位的序列号在时刻 D 和 E 之间回绕。假设在时刻B有一个报文丢失并被重传,又假设这个报文段在<br>网络上绕了远路并在时刻 F 重新出现。如果 TCP 无法识别这个绕回的报文,那么数据完整性就会遭到破坏。
使用时间戳选项能够有效的防止上述问题,如果丢失的报文会在时刻 F 重新出现,由于它的<br>时间戳为 2,小于最近的有效时间戳(5 或 6),因此防回绕序列号算法(PAWS)会将其丢弃。
防回绕序列号算法要求连接双方维护最近一次收到的数据包的时间戳(Recent TSval),每收到一个新数据包都会读取数据包中的时间<br>戳值跟 Recent TSval 值做比较,<b><font color="#0000ff">如果发现收到的数据包中时间戳不是递增的,则表示该数据包是过期的,就会直接丢弃这个数据包</font></b>。
为什么 tcp_tw_reuse 默认是关闭的?
通过前面这么多铺垫,终于可以说这个问题了。开启 tcp_tw_reuse 会有什么风险呢?
风险一
开启 tcp_tw_reuse 来快速复用 TIME_WAIT 状态的连接,如果第四次挥手的 ACK 报文丢失了,<br>有可能会导致被动关闭连接的一方不能被正常的关闭,如下图:<br>
风险二
我们知道开启 tcp_tw_reuse 的同时,也需要开启 tcp_timestamps,意味着可以用时间戳的方式有效的判断回绕序列号的历史报文。
但是,在看了防回绕序列号函数的源码后,发现 <b><font color="#0000ff">对于 RST 报文的时间戳即使过期了,只要 RST 报文的序列号在对方的接收窗口内,也是能被接受的</font></b>。
下面 tcp_validate_incoming 函数就是验证接收到的 TCP 报文是否合格的函数,其中第一步就会进行 PAWS 检查,由 tcp_paws_discard 函数负责。
当 tcp_paws_discard 返回 true,就代表报文是一个历史报文,于是就要丢弃这个报文。但是在丢掉这个报文的时候,会先<br>判断是不是 RST 报文,如果不是 RST 报文,才会将报文丢掉。也就是说,即使 RST 报文是一个历史报文,并不会被丢弃。
假设有这样的场景,如下图:
过程如下:<br><br><ul><li>客户端向一个还没有被服务端监听的端口发起了 HTTP 请求,接着服务端就会回 RST 报文给对方,很可惜的是 <b><font color="#0000ff">RST 报文被网络阻塞了</font></b>。</li><li>由于客户端迟迟没有收到 TCP 第二次握手,于是重发了 SYN 包,与此同时服务端已经开启了服务,监听了对应的端口。<br>于是接下来,客户端和服务端就进行了 TCP 三次握手、数据传输(HTTP应答-响应)、四次挥手。</li><li><b><font color="#0000ff">因为客户端开启了 tcp_tw_reuse,于是快速复用 TIME_WAIT 状态的端口,又与服务端建立了一个与刚才相同的四元组的连接。</font></b></li><li><b><font color="#0000ff">接着,前面被网络延迟 RST 报文这时抵达了客户端,而且 RST 报文的序列号在客户端的接收窗口内,<br>由于防回绕序列号算法不会防止过期的 RST,所以 RST 报文会被客户端接受了,于是客户端的连接就断开了。</font></b></li></ul>
上面这个场景就是开启 tcp_tw_reuse 风险,<b><font color="#0000ff">因为快速复用 TIME_WAIT 状态的端口,导致新连接可能被回绕序列号的 RST <br>报文断开了;而如果不跳过 TIME_WAIT 状态,而是停留 2MSL 时长,那么这个 RST 报文就不会出现下一个新的连接。</font></b>
为什么 PAWS 检查要放过过期的 RST 报文?
RFC 1323 ,里面有一句提到:<br>It is recommended that RST segments NOT carry timestamps, and that RST segments be acceptable regardless of their timestamp. <br>Old duplicate RST segments should be exceedingly unlikely, and their cleanup function should take precedence over timestamps.<br>
大概的意思:<font color="#ff00ff">建议 RST 不携带时间戳,并且无论其时间戳如何,RST 都是可接受的。<br>老的重复的 RST 应该是极不可能的,并且它们的清除功能应优先于时间戳。</font>
RFC 1323 提到说收历史的 RST 报文是极不可能,之所以有这样的想法是因为 TIME_WAIT 状态持续的 2MSL 时间,<br>足以让连接中的报文在网络中自然消失,所以认为按正常操作来说是不会发生的,因此认为清除连接优先于时间戳。
而前面提到的案例,是因为开启了 tcp_tw_reuse 状态,跳过了 TIME_WAIT 状态,才发生的事情。
都经过一个 HTTP 请求了,延迟的 RST 报文竟然还会存活?<br><br>一个 HTTP 请求其实很快的,比如下面这个抓包,只需要 0.2 秒就完成了,<br>远小于 MSL,所以延迟的 RST 报文存活是有可能的。<br>
总结
tcp_tw_reuse 的作用是让客户端快速复用处于 TIME_WAIT 状态的端口,<br>相当于跳过了 TIME_WAIT 状态,这可能会出现这样的两个问题:<br><ul><li>历史 RST 报文可能会终止后面相同四元组的连接,因为 PAWS 检查到即使 RST 是过期的,也不会丢弃。</li><li>如果第四次挥手的 ACK 报文丢失了,有可能被动关闭连接的一方不能被正常的关闭;</li></ul>
虽然 TIME_WAIT 状态持续的时间是有一点长,显得很不友好,但是它被设计来就是用来避免发生乱七八糟的事情。
HTTPS 中 TLS 和 TCP 能同时握手吗?
概述
我们先不管 HTTPS 中的 TLS 握手过程,能否同时进行三次握手,有一点可以确定,<br>就是 「<b><font color="#ff00ff">HTTPS 建立连接的过程,先进行 TCP 三次握手,再进行 TLS 四次握手</font></b>」
下面这个 TLSv1.2 的 基于 RSA 算法的四次握手过程,<br>可以明显的看到是先进行 TCP 三次握手,再进行 TLS 四次握手:
不过 TLS 握手过程的次数还得看版本。
TLSv1.2 握手过程基本都是需要四次,也就是需要经过 2-RTT 才能完成握手,<br>然后才能发送请求,而 TLSv1.3 只需要 1-RTT 就能完成 TLS 握手,如下图:
<b><font color="#0000ff">一般情况下,不管 TLS 握手次数如何,都得先经过 TCP 三次握手后才能进行</font></b>,因为 HTTPS <br>都是基于 TCP 传输协议实现的,得先建立完可靠的 TCP 连接才能做 TLS 握手的事情。
那「HTTPS 中的 TLS 握手过程可以同时进行三次握手」对不对呢?
这个场景是可能发生的,但是需要在特定的条件下才可能发生,<b><font color="#0000ff">如果没有说任何前提条件,说这句话就是在耍流氓</font></b>。
那到底什么条件下,这个场景才能发生呢?需要下面这两个条件<font color="#ff00ff"><i>同时满足</i></font>才可以:<br><ul><li><b><font color="#0000ff">客户端和服务端都开启了 TCP Fast Open 功能,且 TLS 版本是 1.3;</font></b></li><li><b><font color="#0000ff">客户端和服务端已经完成过一次通信。</font></b></li></ul>
那具体怎么做到的呢?我们先了解些 TCP Fast Open 功能和 TLSv1.3 的特性。<br>
TCP Fast Open
常规的情况下,如果要使用 TCP 传输协议进行通信,则客户端和服务端通信之前,先要<br>经过 TCP 三次握手后,建立完可靠的 TCP 连接后,客户端才能将数据发送给服务端。
其中,TCP 的第一次和第二次握手是不能够携带数据的,而 TCP 的第三次握手是可以携带数据的,<br>因为这时候客户端的 TCP 连接状态已经是 ESTABLISHED,表明客户端这一方已经完成了 TCP 连接建立。
就算客户端携带数据的第三次握手在网络中丢失了,客户端在一定时间内没有收到服务端对该数据的应答报文,就会触发超时<br>重传机制,然后客户端重传该携带数据的第三次握手的报文,直到重传次数达到系统的阈值,客户端就会销毁该 TCP 连接。
以上是常规的 TCP 连接后,下面再来看看 TCP Fast Open。<br>
TCP Fast Open 是为了绕过 TCP 三次握手发送数据,在 Linux 3.7 内核版本之后,<br>提供了 TCP Fast Open 功能,这个功能可以减少 TCP 连接建立的时延。
要使用 TCP Fast Open 功能,客户端和服务端都要同时支持才会生效。<br>
<b><font color="#0000ff">不过,开启了 TCP Fast Open 功能,想要绕过 TCP 三次握手发送数据,得建立第二次以后的通信过程。</font></b>
在客户端 <b><font color="#0000ff">首次</font> </b>建立连接时的过程,如下图:
具体介绍:<br><ul><li><span style="font-size: inherit;">客户端发送 SYN 报文,该报文包含 Fast Open 选项,且该选项的 Cookie 为空,这表明客户端请求 Fast Open Cookie;</span></li><li>支持 TCP Fast Open 的服务器生成 Cookie,并将其置于 SYN-ACK 报文中的 Fast Open 选项以发回客户端;</li><li>客户端收到 SYN-ACK 后,本地缓存 Fast Open 选项中的 Cookie。</li></ul>
所以,第一次客户端和服务端通信的时候,还是需要正常的三次握手流程。随后,客户端就有了 <br>Cookie 这个东西,它可以用来向服务器 TCP 证明先前与客户端 IP 地址的三向握手已成功完成。
对于客户端与服务端的后续通信,客户端可以在第一次握手的时候携带应用数据,<br>从而达到绕过三次握手发送数据的效果,整个过程如下图:
详细介绍下这个过程:<br><ul><li>客户端发送 SYN 报文,该报文可以携带「应用数据」以及此前记录的 Cookie;</li><li>支持 TCP Fast Open 的服务器会对收到 Cookie 进行校验:如果 Cookie 有效,服务器将在 SYN-ACK 报文中对 SYN <br>和「数据」进行确认,服务器随后将「应用数据」递送给对应的应用程序;如果 Cookie 无效,服务器将丢弃 SYN 报文<br>中包含的「应用数据」,且其随后发出的 SYN-ACK 报文将只确认 SYN 的对应序列号;</li><li><b><font color="#0000ff">如果服务器接受了 SYN 报文中的「应用数据」,服务器可在握手完成之前发送「响应数据」,这就减少了握手带来的 1 个 RTT 的时间消耗;</font></b></li><li>客户端将发送 ACK 确认服务器发回的 SYN 以及「应用数据」,但如果客户端在初始的 SYN 报文中发送的「应用数据」<br>没有被确认,则客户端将重新发送「应用数据」;</li><li>此后的 TCP 连接的数据传输过程和非 TCP Fast Open 的正常情况一致。</li></ul>
所以,如果客户端和服务端同时支持 TCP Fast Open 功能,那么在完成首次通信过程后,<br>后续客户端与服务端的通信则可以绕过三次握手发送数据,这就减少了握手带来的 1 个 RTT 的时间消耗。
TLSv1.3
在最开始的时候,也提到 TLSv1.3 握手过程只需 1-RTT 的时间,它到整个握手过程,如下图:
TCP 连接的第三次握手是可以携带数据的,如果客户端在第三次握手发送了 TLSv1.3 <br>第一次握手数据,是不是就表示「<font color="#ff00ff"><b>HTTPS 中的 TLS 握手过程可以同时进行三次握手</b></font>」?
不是的,因为 <b><font color="#0000ff">服务端只有在收到客户端的 TCP 的第三次握手后,才能和客户端进行后续 TLSv1.3 握手</font></b>。
TLSv1.3 还有个更厉害到地方在于 <b><font color="#0000ff">会话恢复机制</font></b>,<b><font color="#0000ff">在重连 TLvS1.3 只需要 0-RTT</font></b>,用“pre_shared_key”<br>和“early_data”扩展,在 TCP 连接后立即就建立安全连接发送加密消息,过程如下图:
TCP Fast Open + TLSv1.3
在前面我们知道,客户端和服务端同时支持 TCP Fast Open 功能的情况下,<b><font color="#0000ff">在第二次以后到通信过程中,<br>客户端可以绕过三次握手直接发送数据,而且服务端也不需要等收到第三次握手后才发送数据</font></b>。
如果 HTTPS 的 TLS 版本是 1.3,那么 TLS 过程只需要 1-RTT。
<b><font color="#0000ff">因此如果「TCP Fast Open + TLSv1.3」情况下,在第二次以后的通信过程中,TLS 和 TCP 的握手过程是可以同时进行的。</font></b>
<b><font color="#0000ff">如果基于 TCP Fast Open 场景下的 TLSv1.3 0-RTT 会话恢复过程,不仅 TLS 和 TCP <br>的握手过程是可以同时进行的,而且 HTTP 请求也可以在这期间内一同完成。</font></b>
总结
「HTTPS 是先进行 TCP 三次握手,再进行 TLSv1.2 四次握手」,这句话一点问题都没有。
「HTTPS 中的 TLS 握手过程可以同时进行三次握手」,这个场景是可能存在到,但是在<br>没有说任何前提条件,而说这句话就等于耍流氓。需要下面这两个条件同时满足才可以:<br><ul><li><b><font color="#0000ff">客户端和服务端都开启了 TCP Fast Open 功能,且 TLS 版本是 1.3;</font></b></li><li><b><font color="#0000ff">客户端和服务端已经完成过一次通信;</font></b></li></ul>
TCP Keepalive 和 HTTP Keep-Alive 是一个东西吗?
概述
<b><font color="#0000ff">TCP 的 Keepalive 和 HTTP 的 Keep-Alive 完全是两样不同东西</font></b>,实现的层面也不同:<br><ul><li>HTTP 的 Keep-Alive,是由 <b><font color="#0000ff">应用层(用户态)</font></b>实现的,称为 HTTP 长连接;</li><li>TCP 的 Keepalive,是由 <b><font color="#0000ff">TCP 层(内核态)</font></b>实现的,称为 TCP 保活机制;</li></ul>
HTTP 的 Keep-Alive
HTTP 协议采用的是「请求-应答」的模式,也就是客户端发起了请求,服务端才会返回响应,一来一回这样子。
由于 HTTP 是基于 TCP 传输协议实现的,客户端与服务端要进行 HTTP 通信前,需要先建立 TCP 连接,然后客户端<br>发送 HTTP 请求,服务端收到后就返回响应,至此「请求-应答」的模式就完成了,随后就会释放 TCP 连接。
如果每次请求都要经历这样的过程:建立 TCP -> 请求资源 -> 响应资源 -> 释放连接,<br>那么此方式就是 <b><font color="#0000ff">HTTP 短连接</font></b>,如下图:
这样实在太累人了,一次连接只能请求一次资源。
能不能在第一个 HTTP 请求完后,先不断开 TCP 连接,让后续的 HTTP 请求继续使用此连接?<br>
当然可以,HTTP 的 Keep-Alive 就是实现了这个功能,可以使用同一个 TCP 连接来发送和<br>接收多个 HTTP 请求/应答,避免了连接建立和释放的开销,这个方法称为 <b><font color="#0000ff">HTTP 长连接</font></b>。
HTTP 长连接的特点是,只要任意一端没有明确提出断开连接,则保持 TCP 连接状态。
怎么才能使用 HTTP 的 Keep-Alive 功能?
在 HTTP 1.0 中默认是关闭的,如果浏览器要开启 Keep-Alive,它必须在请求的包头中添加:
然后当服务器收到请求,作出回应的时候,它也添加一个头在响应中:
这样做,连接就不会中断,而是保持连接。当客户端发送另一个请求时,<br>它会使用同一个连接。这一直继续到客户端或服务器端提出断开连接。
<b><font color="#0000ff">从 HTTP 1.1 开始, 就默认是开启了 Keep-Alive</font></b>,如果要关闭 Keep-Alive,需要在 HTTP 请求的包头里添加:<br>
现在大多数浏览器都默认是使用 HTTP/1.1,所以 Keep-Alive 都是默认打开的。<br>一旦客户端和服务端达成协议,那么长连接就建立好了。
HTTP 长连接不仅仅减少了 TCP 连接资源的开销,而且这给 <b><font color="#0000ff">HTTP 流水线技术 </font></b>提供了可实现的基础。
所谓的 <b><font color="#0000ff">HTTP 流水线,是客户端可以先一次性发送多个请求,而在发送过程中不需先等待服务器的回应</font></b>,可以减少整体的响应时间。<br>
举例来说,客户端需要请求两个资源。以前的做法是,在同一个 TCP 连接里面,先发送 A 请求,<br>然后等待服务器做出回应,收到后再发出 B 请求。HTTP 流水线机制则允许客户端同时发出 A 请求和 B 请求。
但是 <b><font color="#0000ff">服务器还是按照顺序响应</font></b>,先回应 A 请求,完成后再回应 B 请求。
而且要等服务器响应完客户端第一批发送的请求后,客户端才能发出下一批的请求,也就说如果服务器<br>响应的过程发生了阻塞,那么客户端就无法发出下一批的请求,此时就造成了「队头阻塞」的问题。
可能有的同学会问,如果使用了 HTTP 长连接,如果客户端完成一个 HTTP 请求后,<br>就不再发起新的请求,此时这个 TCP 连接一直占用着不是挺浪费资源的吗?
对没错,所以为了避免资源浪费的情况,web 服务软件一般都会提供 <br>keepalive_timeout 参数,用来指定 HTTP 长连接的超时时间。
比如设置了 HTTP 长连接的超时时间是 60 秒,web 服务软件就会 <b><font color="#0000ff">启动一个定时器</font></b>,如果客户端在完后一个 <br>HTTP 请求后,在 60 秒内都没有再发起新的请求,<b><font color="#0000ff">定时器的时间一到,就会触发回调函数来释放该连接</font></b>。
TCP 的 Keepalive
TCP 的 Keepalive 这东西其实就是 TCP 的 <b><font color="#0000ff">保活机制</font></b>,它的工作原理在前面有写到。
如果两端的 TCP 连接一直没有数据交互,达到了触发 TCP 保活机制的条件,那么内核里的 TCP 协议栈就会发送探测报文。
<ul><li>如果对端程序是正常工作的。当 TCP 保活的探测报文发送给对端, 对端会正常响应,<br>这样 <b><font color="#0000ff">TCP 保活时间会被重置</font></b>,等待下一个 TCP 保活时间的到来。</li><li>如果对端主机崩溃,或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,<br>石沉大海,没有响应,连续几次,达到保活探测次数后,<b><font color="#0000ff">TCP 会报告该 TCP 连接已经死亡</font></b>。</li></ul>
所以,TCP 保活机制可以在双方没有数据交互的情况,通过探测报文,<br>来确定对方的 TCP 连接是否存活,这个工作是在内核完成的。
注意,应用程序若想使用 TCP 保活机制需要通过 socket 接口设置 SO_KEEPALIVE <br>选项才能够生效,如果没有设置,那么就无法使用 TCP 保活机制。
总结
HTTP 的 Keep-Alive 也叫 HTTP 长连接,该功能是由「应用程序」实现的,可以使得用同一个 TCP <br>连接来发送和接收多个 HTTP 请求/应答,减少了 HTTP 短连接带来的多次 TCP 连接建立和释放的开销。
TCP 的 Keepalive 也叫 TCP 保活机制,该功能是由「内核」实现的,当客户端和服务端长达一定时间没有进行数据交互时,<br>内核为了确保该连接是否还有效,就会发送探测报文,来检测对方是否还在线,然后来决定是否要关闭该连接。
TCP 协议有什么缺陷?
概述
TCP 通过序列号、确认应答、超时重传、流量控制、拥塞控制等方式实现了可靠传输,<br>看起来它很完美,事实真的是这样吗?TCP 就没什么缺陷吗?
所以,今天就跟大家聊聊,TCP 协议有哪些缺陷?主要有四个方面:<br><ul><li>升级 TCP 的工作很困难;</li><li>TCP 建立连接的延迟;</li><li>TCP 存在队头阻塞问题;</li><li>网络迁移需要重新建立 TCP 连接;</li></ul>
升级 TCP 的工作很困难
TCP 协议是诞生在 1973 年,至今 TCP 协议依然还在实现更多的新特性。
但是 TCP 协议是在内核中实现的,应用程序只能使用不能修改,如果要想升级 TCP 协议,那么只能升级内核。
而升级内核这个工作是很麻烦的事情,麻烦的事情不是说升级内核这个操作很麻烦,而是由于内核升级涉及到底层软件<br>和运行库的更新,我们的服务程序就需要回归测试是否兼容新的内核版本,所以服务器的内核升级也比较保守和缓慢。
很多 TCP 协议的新特性,都是需要客户端和服务端同时支持才能生效的,比如 TCP Fast Open 这个特性,虽然在2013 年就被提出了,但是 Windows <br>很多系统版本依然不支持它,这是因为 PC 端的系统升级滞后很严重,Windows Xp 现在还有大量用户在使用,尽管它已经存在快 20 年。
所以,即使 TCP 有比较好的特性更新,也很难快速推广,用户往往要几年或者十年才能体验到。
TCP 建立连接的延迟
基于 TCP 实现的应用协议,都是需要先建立三次握手才能进行数据传输,比如 HTTP 1.0/1.1、HTTP/2、HTTPS。
现在大多数网站都是使用 HTTPS 的,这意味着在 TCP 三次握手之后,还需要经过 TLS <br>四次握手后,才能进行 HTTP 数据的传输,这在一定程序上增加了数据传输的延迟。
TCP 三次握手和 TLS 握手延迟,如图:
TCP 三次握手的延迟被 TCP Fast Open (快速打开)这个特性解决了,<br>这个特性可以在「第二次建立连接」时减少 TCP 连接建立的时延。
过程如下:<br><ul><li>在第一次建立连接的时候,服务端在第二次握手产生一个 Cookie (已加密)并通过 SYN、ACK 包一起发给客户端,<br>于是客户端就会缓存这个 Cookie,所以第一次发起 HTTP Get 请求的时候,还是需要 2 个 RTT 的时延;</li><li>在下次请求的时候,客户端在 SYN 包带上 Cookie 发给服务端,就提前可以跳过三次握手的过程,因为 Cookie 中维护<br>了一些信息,服务端可以从 Cookie 获取 TCP 相关的信息,这时发起的 HTTP GET 请求就只需要 1 个 RTT 的时延;</li></ul>
TCP Fast Open 这个特性是不错,但是它需要服务端和客户端的操作系统同时支持才能体验到,而 TCP Fast Open 是在 2013 年<br>提出的,所以市面上依然有很多老式的操作系统不支持,而升级操作系统是很麻烦的事情,因此 TCP Fast Open 很难被普及开来。
还有一点,针对 HTTPS 来说,TLS 是在应用层实现的握手,而 TCP 是在内核实现的握手,<br>这两个握手过程是无法结合在一起的,总是得先完成 TCP 握手,才能进行 TLS 握手。
也正是 TCP 是在内核实现的,所以 TLS 是无法对 TCP 头部加密的,<br>这意味着 TCP 的序列号都是明文传输,所以就存安全的问题。
一个典型的例子就是攻击者伪造一个的 RST 报文强制关闭一条 TCP 连接,而攻击<br>成功的关键则是 TCP 字段里的序列号位于接收方的滑动窗口内,该报文就是合法的。
为此 TCP 也不得不进行三次握手来同步各自的序列号,而且初始化序列号时是采用随机的方式(不完全随机,<br>而是随着时间流逝而线性增长,到了 2^32 尽头再回滚)来提升攻击者猜测序列号的难度,以增加安全性。
但是这种方式只能避免攻击者预测出合法的 RST 报文,而无法避免攻击者截获客户端的报文,然后中途伪造出合法 RST 报文的攻击的方式。
大胆想一下,如果 TCP 的序列号也能被加密,或许真的不需要三次握手了,客户端和服务端的初始序列号都从 0 开始,也就不用<br>做同步序列号的工作了,但是要实现这个要改造整个协议栈,太过于麻烦,即使实现出来了,很多老的网络设备未必能兼容。
TCP 存在队头阻塞问题
TCP 是字节流协议,<b><font color="#0000ff">TCP 层必须保证收到的字节数据是完整且有序的</font></b>,如果序列号较低的 TCP 段在网络传输中丢失了,<br>即使序列号较高的 TCP 段已经被接收了,应用层也无法从内核中读取到这部分数据。如下图:
图中发送方发送了很多个 packet,每个 packet 都有自己的序号,你可以认为是 TCP 的序列号,其中 packet #3 <br>在网络中丢失了,即使 packet #4-6 被接收方收到后,由于内核中的 TCP 数据不是连续的,于是接收方的应用层<br>就无法从内核中读取到,只有等到 packet #3 重传后,接收方的应用层才可以从内核中读取到数据。
这就是 TCP 队头阻塞问题,但这也不能怪 TCP ,因为只有这样做才能保证数据的有序性。
HTTP/2 多个请求是跑在一个 TCP 连接中的,那么当 TCP 丢包时,整个 TCP 都要等待重传,<br>那么就会阻塞该 TCP 连接中的所有请求,所以 HTTP/2 队头阻塞问题就是因为 TCP 协议导致的。
网络迁移需要重新建立 TCP 连接
基于 TCP 传输协议的 HTTP 协议,由于是通过四元组(源 IP、源端口、目的 IP、目的端口)确定一条 TCP 连接。
那么 <b><font color="#0000ff">当移动设备的网络从 4G 切换到 WIFI 时,意味着 IP 地址变化了,那么就必须要断开连接,然后重新建立 TCP 连接。</font></b><br>
而建立连接的过程包含 TCP 三次握手和 TLS 四次握手的时延,以及 TCP 慢启动的减速过程,<br>给用户的感觉就是网络突然卡顿了一下,因此连接的迁移成本是很高的。
如何基于 UDP 协议实现可靠传输?
概述
如何基于 UDP 协议实现可靠传输?
很多同学第一反应就会说把 TCP 可靠传输的特性(序列号、确认应答、<br>超时重传、流量控制、拥塞控制)在应用层实现一遍。
实现的思路确实这样没错,但是有没有想过,<b><font color="#0000ff">既然 TCP 天然支持可靠传输,<br>为什么还需要基于 UDP 实现可靠传输呢?这不是重复造轮子吗?</font></b>
所以,我们要先弄清楚 TCP 协议有哪些痛点?而这些痛点是否可以在基于 UDP 协议实现的可靠传输协议中得到改进?
在上面的谈了「TCP 的缺陷」,TCP 协议有以下四个方面的缺陷:<br><ul><li>升级 TCP 的工作很困难;</li><li>TCP 建立连接的延迟;</li><li>TCP 存在队头阻塞问题;</li><li>网络迁移需要重新建立 TCP 连接;</li></ul>
现在市面上已经有基于 UDP 协议实现的可靠传输协议的成熟方案了,那就是 QUIC 协议,已经应用在了 HTTP/3。
所以聊聊 <b><font color="#0000ff">QUIC 是如何实现可靠传输的?又是如何解决上面 TCP 协议四个方面的缺陷?</font></b>
QUIC 是如何实现可靠传输的?
要基于 UDP 实现的可靠传输协议,那么就要在应用层下功夫,也就是要设计好协议的头部字段。
拿 HTTP/3 举例子,在 UDP 报文头部与 HTTP 消息之间,共有 3 层头部:<br>
整体看的视角是这样的:
接下来,分别对每一个 Header 做个介绍。
<b>Packet Header</b><br>
Packet Header 首次建立连接时和日常传输数据时使用的 Header 是不同的。<br>如下图(<font color="#ff00ff">注意我没有把 Header 所有字段都画出来,只是画出了重要的字段</font>):
Packet Header 细分这两种:<br><ul><li>Long Packet Header 用于首次建立连接。</li><li>Short Packet Header 用于日常传输数据。</li></ul>
QUIC 也是需要三次握手来建立连接的,主要目的是为了协商连接 ID。协商出连接 ID 后,后续传输时,双方只需要固定住连接 ID,从而实现连接迁移功能。所以,你可以看到日常传输数据的 Short Packet Header 不需要在传输 Source Connection ID 字段了,只需要传输 Destination Connection ID。
Short Packet Header 中的 Packet Number 是每个报文独一无二的编号,它是 <b>严格递增</b> 的,<br>就算 Packet N 丢失了,重传的 Packet N 的 Packet Number 已经不是 N,而是一个比 N 大的值。
为什么要这么设计呢?
先来看看 TCP 的问题,TCP 在重传报文时的序列号和原始报文的序列号<br>是一样的,也正是由于这个特性,引入了 TCP 重传的歧义问题。<br>
比如上图,当 TCP 发生超时重传后,客户端发起重传,然后接收到了服务端确认 ACK 。<br>由于客户端原始报文和重传报文序列号都是一样的,那么服务端针对这两个报文回复的都是相同的 ACK。
这样的话,客户端就无法判断出是「原始报文的响应」还是「重传报文的响应」,这样在计算 <br>RTT(往返时间) 时应该选择从发送原始报文开始计算,还是重传原始报文开始计算呢?<br><br><ul><li>如果算成原始报文的响应,但实际上是重传报文的响应(上图左),会导致采样 RTT 变大;</li><li>如果算成重传报文的响应,但实际上是原始报文的响应(上图右),又很容易导致采样 RTT 过小;</li></ul>
RTO (超时时间)是基于 RTT 来计算的,那么如果 RTT 计算不精准,<br>那么 RTO (超时时间)也会不精确,这样可能导致重传的概率事件增大。
QUIC 报文中的 Pakcet Number 是严格递增的, 即使是重传报文,它的 <br>Pakcet Number 也是递增的,这样就能更加精确计算出报文的 RTT。
如果 ACK 的 Packet Number 是 N+M,就根据重传报文计算采样 RTT。如果 ACK 的 <br>Pakcet Number 是 N,就根据原始报文的时间计算采样 RTT,没有歧义性的问题。<br>
另外,还有一个好处,<b><font color="#0000ff">QUIC 使用的 Packet Number 单调递增的设计,可以让数据包不再像 TCP 那样必须有序确认,QUIC 支持乱序确认,<br>当数据包 Packet N 丢失后,只要有新的已接收数据包确认,当前窗口就会继续向右滑动</font></b>(后面讲流量控制的时候,会举例子)。
待发送端获知数据包Packet N 丢失后,会将需要重传的数据包放到待发送队列,重新编号比如数据包 Packet N+M 后重新发送给接收端,<br>对重传数据包的处理跟发送新的数据包类似,这样就不会因为丢包重传将当前窗口阻塞在原地,从而解决了队头阻塞问题。
所以,Packet Number 单调递增的两个好处:<br><ul><li>可以更加精确计算 RTT,没有 TCP 重传的歧义性问题;</li><li>可以支持乱序确认,因为丢包重传将当前窗口阻塞在原地,而 TCP 必须是顺序确认的,丢包时会导致窗口不滑动;</li></ul>
QUIC Frame Header<br>
一个 Packet 报文中可以存放多个 QUIC Frame。
每一个 Frame 都有明确的类型,针对类型的不同,功能也不同,自然格式也不同。
我这里只举例 Stream 类型的 Frame 格式,Stream 可以认为就是一条 HTTP 请求,它长这样:<br><br><ul><li>Stream ID 作用:多个并发传输的 HTTP 消息,通过不同的 Stream ID 加以区别,类似于 HTTP2 的 Stream ID;</li><li>Offset 作用:类似于 TCP 协议中的 Seq 序号,<b><font color="#0000ff">保证数据的顺序性和可靠性</font></b>;</li><li>Length 作用:指明了 Frame 数据的长度。</li></ul>
在前面介绍 Packet Header 时,说到 Packet Number 是严格递增,即使重传报文的 Packet Number 也是递增的,<br>既然重传数据包的 Packet N+M 与丢失数据包的 Packet N 编号并不一致,我们怎么确定这两个数据包的内容一样呢?
所以引入 Frame Header 这一层,<b><font color="#0000ff">通过 Stream ID + Offset 字段信息实现数据的有序性</font></b>,<br>通过比较两个数据包的 Stream ID 与 Stream Offset ,如果都是一致,就说明这两个数据包的内容一致。
举个例子,下图中,数据包 Packet N 丢失了,后面重传该数据包的编号为 Packet N+2,<b><font color="#0000ff">丢失的数据包和重传的数据包 <br>Stream ID 与 Offset 都一致,说明这两个数据包的内容一致</font></b>。这些数据包传输到接收端后,接收端能根据 Stream ID 与 <br>Offset 字段信息将 Stream x 和 Stream x+y 按照顺序组织起来,然后交给应用程序处理。<br>
总的来说,<b><font color="#0000ff">QUIC 通过单向递增的 Packet Number,配合 Stream ID 与 Offset 字段信息,可以支持乱序确认而不影响数据包的<br>正确组装</font></b>,摆脱了TCP 必须按顺序确认应答 ACK 的限制,解决了 TCP 因某个数据包重传而阻塞后续所有待发送数据包的问题。
QUIC 是如何解决 TCP 队头阻塞问题的?
什么是 TCP 队头阻塞问题?<br>
TCP 队头阻塞的问题要从两个角度看,一个是发送窗口的队头阻塞,另外一个是接收窗口的队头阻塞。
<font color="#ff00ff">1、发送窗口的队头阻塞</font><br>
TCP 发送出去的数据,都是需要按序确认的,只有在数据都被按顺序确认完后,发送窗口才会往前滑动。
举个例子,比如下图的发送方把发送窗口内的数据全部都发出去了,可用窗口的大小就为 0 了,<br>表明可用窗口耗尽,在没收到 ACK 确认之前是无法继续发送数据了。
接着,当发送方收到对第 32~36 字节的 ACK 确认应答后,<b><font color="#0000ff">则滑动窗口往右边移动 5 个字节,因为有 5 个字节的<br>数据被应答确认</font></b>,接下来第 52~56 字节又变成了可用窗口,那么后续也就可以发送 52~56 这 5 个字节的数据了。
<b><font color="#0000ff">但是如果某个数据报文丢失或者其对应的 ACK 报文在网络中丢失,会导致发送方无法移动发送窗口,这时就无法再发送<br>新的数据</font></b>,只能超时重传这个数据报文,直到收到这个重传报文的 ACK,发送窗口才会移动,继续后面的发送行为。
举个例子,比如下图,客户端是发送方,服务器是接收方。
客户端发送了第 5~9 字节的数据,但是第 5 字节的 ACK 确认报文在网络中丢失了,<br>那么即使客户端收到第 6~9 字节的 ACK 确认报文,发送窗口也不会往前移动。
<b><font color="#0000ff">此时的第 5 字节相当于“队头”,因为没有收到“队头”的 ACK 确认报文,导致发送窗口无法往前移动,<br>此时发送方就无法继续发送后面的数据,相当于按下了发送行为的暂停键,这就是发送窗口的队头阻塞问题。</font></b>
<font color="#ff00ff">2、接收窗口的队头阻塞</font>
接收方收到的数据范围必须在接收窗口范围内,如果收到超过接收窗口范围的数据,就会丢弃该数据,<br>比如下图接收窗口的范围是 32 ~ 51 字节,如果收到第 52 字节以上数据都会被丢弃。
接收窗口什么时候才能滑动?当接收窗口收到有序数据时,接收窗口才能往前滑动,<br>然后那些已经接收并且被确认的「有序」数据就可以被应用层读取。
但是,<b><font color="#0000ff">当接收窗口收到的数据不是有序的,比如收到第 33~40 字节的数据,由于第 32 字节数据没有收到, 接收窗口<br>无法向前滑动,那么即使先收到第 33~40 字节的数据,这些数据也无法被应用层读取的</font></b>。只有当发送方重传了第 32 <br>字节数据并且被接收方收到后,接收窗口才会往前滑动,然后应用层才能从内核读取第 32~40 字节的数据。
至此发送窗口和接收窗口的队头阻塞问题都说完了,这两个问题的原因都是因为 TCP 必须按序处理数据,<br>也就是 TCP 层为了保证数据的有序性,只有在处理完有序的数据后,滑动窗口才能往前滑动,否则就停留。<br><br><ul><li>停留「发送窗口」会使得发送方无法继续发送数据。</li><li>停留「接收窗口」会使得应用层无法读取新的数据。</li></ul>
其实也不能怪 TCP 协议,它本来设计目的就是为了保证数据的有序性。<br>
HTTP/2 的队头阻塞<br>
HTTP/2 通过抽象出 Stream 的概念,实现了 HTTP 并发传输,一个 Stream 就代表 HTTP/1.1 里的请求和响应。
在 HTTP/2 连接上,不同 Stream 的帧是可以乱序发送的(因此可以并发不同的 Stream ),因为每个帧的头部会携带 <br>Stream ID 信息,所以接收端可以通过 Stream ID 有序组装成 HTTP 消息,而同一 Stream 内部的帧必须是严格有序的。
<b><font color="#0000ff">但是 HTTP/2 多个 Stream 请求都是在一条 TCP 连接上传输,这意味着多个 Stream 共用同一个 TCP 滑动窗口,<br>那么当发生数据丢失,滑动窗口是无法往前移动的,此时就会阻塞住所有的 HTTP 请求,这属于 TCP 层队头阻塞。</font></b>
没有队头阻塞的 QUIC<br>
QUIC 也借鉴 HTTP/2 里的 Stream 的概念,在一条 QUIC 连接上可以并发发送多个 HTTP 请求 (Stream)。
<b><font color="#0000ff">但是 QUIC 给每一个 Stream 都分配了一个独立的滑动窗口,这样使得一个连接<br>上的多个 Stream 之间没有依赖关系,都是相互独立的,各自控制的滑动窗口。</font></b>
假如 Stream2 丢了一个 UDP 包,也只会影响 Stream2 的处理,不会影响其他 Stream,<br>与 HTTP/2 不同,HTTP/2 只要某个流中的数据包丢失了,其他流也会因此受影响。<br>
QUIC 是如何做流量控制的?
TCP 流量控制是通过让「接收方」告诉「发送方」,它(接收方)的接收窗口有多大,<br>从而让「发送方」根据「接收方」的实际接收能力控制发送的数据量。
QUIC 实现流量控制的方式:<br><ul><li>通过 window_update 帧告诉对端自己可以接收的字节数,这样发送方就不会发送超过这个数量的数据。</li><li>通过 BlockFrame 告诉对端由于流量控制被阻塞了,无法发送数据。</li></ul>
在前面说到,TCP 的接收窗口在收到有序的数据后,接收窗口才能往前滑动,否则停止滑动;<br>TCP 的发送窗口在收到对已发送数据的顺序确认 ACK后,发送窗口才能往前滑动,否则停止滑动。
QUIC 是基于 UDP 传输的,而 UDP 没有流量控制,因此 QUIC 实现了自己的流量控制机制,QUIC 的滑动窗口滑动的条件跟 TCP 有一点差别,<br>但是同一个 Stream 的数据也是要保证顺序的,不然无法实现可靠传输,因此同一个 Stream 的数据包丢失了,也会造成窗口无法滑动。
<b><font color="#0000ff">QUIC 的 每个 Stream 都有各自的滑动窗口,不同 Stream 互相独立,队头的 Stream A 被阻塞后,不妨碍 StreamB、C的读取</font></b>。而对于 HTTP/2 而言,<br>所有的 Stream 都跑在一条 TCP 连接上,而这些 Stream 共享一个滑动窗口,因此同一个 Connection 内,Stream A 被阻塞后,StreamB、C 必须等待。
QUIC 实现了两种级别的流量控制,分别为 Stream 和 Connection 两种级别:<br><br><ul><li><b><font color="#0000ff">Stream 级别的流量控制</font></b>:Stream 可以认为就是一条 HTTP 请求,每个 Stream 都有独立的滑动窗口,<br>所以每个 Stream 都可以做流量控制,防止单个 Stream 消耗连接(Connection)的全部接收缓冲。</li><li><b><font color="#0000ff">Connection 流量控制</font></b>:限制连接中所有 Stream 相加起来的总字节数,防止发送方超过连接的缓冲容量。</li></ul>
Stream 级别的流量控制<br>
最开始,接收方的接收窗口初始状态如下(参考 Google 文档的 Flow control in QUIC):
接着,接收方收到了发送方发送过来的数据,有的数据被上层读取了,有的数据丢包了,此时的接收窗口状况如下:
可以看到,<b><font color="#0000ff">接收窗口的左边界取决于接收到的最大偏移字节数</font></b>,此时 接收窗口 = 最大窗口数 - 接收到的最大偏移数。
这里就可以看出 QUIC 的流量控制和 TCP 有点区别了:<br><ul><li>TCP 的接收窗口只有在前面所有的 Segment 都接收的情况下才会移动左边界,<br>当在前面还有字节未接收但收到后面字节的情况下,窗口也不会移动。</li><li>QUIC 的接收窗口的左边界滑动条件取决于接收到的最大偏移字节数。</li></ul>
那接收窗口右边界触发的滑动条件是什么呢?看下图:
当图中的绿色部分数据超过最大接收窗口的一半后,最大接收窗口向右移动,接收窗口的右边界也向右扩展,同时给对端<br>发送「窗口更新帧」,当发送方收到接收方的窗口更新帧后,发送窗口的右边界也会往右扩展,以此达到窗口滑动的效果。
绿色部分的数据是已收到的顺序的数据,<b><font color="#0000ff">如果中途丢失了数据包,导致绿色部分的数据没有超过最大接收窗口的一半,<br>那接收窗口就无法滑动了</font></b>,这个只影响同一个 Stream,其他 Stream 是不会影响的,因为每个 Stream 都有各自的滑动窗口。<br>
在前面我们说过 QUIC 支持乱序确认,具体是怎么做到的呢?
接下来,举个例子(例子来源于:QUIC——快速UDP网络连接协议):<br><ul><li>当前发送方的缓冲区大小为8,发送方 QUIC 按序(offset顺序)发送 29-36 的数据包。</li></ul>
31、32、34 数据包先到达,基于 offset 被优先乱序确认,但 30 数据包没有确认,<br>所以当前已提交的字节偏移量不变,发送方的缓存区不变。
30 到达并确认,发送方的缓存区收缩到阈值,接收方发送 MAX_STREAM_DATA Frame<br>(协商缓存大小的特定帧)给发送方,请求增长最大绝对字节偏移量。
协商完毕后最大绝对字节偏移量右移,发送方的缓存区变大,同时发送方发现数据包 33 超时
发送方将超时数据包重新编号为 42 继续发送
以上就是最基本的数据包发送-接收过程,控制数据发送的唯一限制就是最大绝对字节偏移量,<br>该值是接收方基于当前已经提交的偏移量(连续已确认并向上层应用提交的数据包offset)和发送方协商得出。
Connection 流量控制<br>
而对于 Connection 级别的流量窗口,其接收窗口大小就是各个 Stream 接收窗口大小之和。
上图所示的例子,所有 Streams 的最大窗口数为 120,其中:<br><ul><li>Stream 1 的最大接收偏移为 100,可用窗口 = 120 - 100 = 20</li><li>Stream 2 的最大接收偏移为 90,可用窗口 = 120 - 90 = 30</li><li>Stream 3 的最大接收偏移为 110,可用窗口 = 120 - 110 = 10</li></ul>
那么整个 Connection 的可用窗口 = 20 + 30 + 10 = 60
QUIC 对拥塞控制的改进
QUIC 协议当前默认使用了 TCP 的 Cubic 拥塞控制算法(我们熟知的慢开始、拥塞避免、快重传、快恢复策略),<br>同时也支持 CubicBytes、Reno、RenoBytes、BBR、PCC 等拥塞控制算法,相当于将 TCP 的拥塞控制算法照搬过来了。
QUIC 是如何改进 TCP 的拥塞控制算法的呢?
QUIC 是处于应用层的,应用程序层面就能实现不同的拥塞控制算法,不需要操作系统,不需要内核支持。这是一个飞跃,<br>因为传统的 TCP 拥塞控制,必须要端到端的网络协议栈支持,才能实现控制效果。而内核和操作系统的部署成本非常高,<br>升级周期很长,所以 TCP 拥塞控制算法迭代速度是很慢的。<b><font color="#0000ff">而 QUIC 可以随浏览器更新,QUIC 的拥塞控制算法就可以有较快的迭代速度。</font></b>
TCP 更改拥塞控制算法是对系统中所有应用都生效,无法根据不同应用设定不同的拥塞控制策略。<br>但是因为 QUIC 处于应用层,<b><font color="#0000ff">所以就可以针对不同的应用设置不同的拥塞控制算法</font></b>,这样灵活性就很高了。
QUIC 更快的连接建立
对于 HTTP/1 和 HTTP/2 协议,TCP 和 TLS 是分层的,分别属于内核实现的传输层、openssl 库实现的表示层,因此它们难以合并在一起,需要分<br>批次来握手,先 TCP 握手(1RTT),再 TLS 握手(2RTT),所以需要 3RTT 的延迟才能传输数据,就算 Session 会话服用,也需要至少 2 个 RTT。
HTTP/3 在传输数据前虽然需要 QUIC 协议握手,这个握手过程只需要 1 RTT,握手的目的是为确认双方的「连接 ID」,连接迁移就是基于连接 ID 实现的。
但是 HTTP/3 的 QUIC 协议并不是与 TLS 分层,<b><font color="#0000ff">而是 QUIC 内部包含了 TLS,它在自己的帧会携带 TLS 里的“记录”,<br>再加上 QUIC 使用的是 TLS1.3,因此仅需 1 个 RTT 就可以「同时」完成建立连接与密钥协商,甚至在第二次连接的时候,<br>应用数据包可以和 QUIC 握手信息(连接信息 + TLS 信息)一起发送,达到 0-RTT 的效果。</font></b>
如下图右边部分,HTTP/3 当会话恢复时,有效负载数据与第一个数据包一起发送,可以做到 0-RTT(下图的右下角):
QUIC 是如何迁移连接的?
基于 TCP 传输协议的 HTTP 协议,由于是通过四元组(源 IP、源端口、目的 IP、目的端口)确定一条 TCP 连接。
那么<b><font color="#0000ff">当移动设备的网络从 4G 切换到 WIFI 时,意味着 IP 地址变化了,那么就必须要断开连接,然后重新建立 TCP 连接。</font></b>
而建立连接的过程包含 TCP 三次握手和 TLS 四次握手的时延,以及 TCP 慢启动的减速过程,<br>给用户的感觉就是网络突然卡顿了一下,因此连接的迁移成本是很高的。
QUIC 协议没有用四元组的方式来“绑定”连接,而是通过 <b><font color="#0000ff">连接 ID</font></b> 来标记通信的两个端点,客户端和服务器可以各自选择<br>一组 ID 来标记自己,因此即使移动设备的网络变化后,导致 IP 地址变化了,只要仍保有上下文信息(比如连接 ID、TLS 密钥等),<br>就可以“无缝”地复用原连接,消除重连的成本,没有丝毫卡顿感,达到了 <b><font color="#0000ff">连接迁移</font> </b>的功能。
TCP 和 UDP 可以使用同一个端口吗?
TCP 和 UDP 可以同时绑定相同的端口吗?<br>
「TCP 和 UDP 可以同时 <b style=""><font color="#0000ff">监听</font><font color="#0d47a1"> </font></b>相同的端口吗?」表述有问题,这个问题应该表述成「TCP 和 UDP 可以同时 <b><font color="#0000ff">绑定</font><font color="#0d47a1"> </font></b>相同的端口吗?」
因为「监听」这个动作是在 TCP 服务端网络编程中才具有的,而 UDP 服务端网络编程中是没有「监听」这个动作的。
TCP 和 UDP 服务端网络相似的一个地方,就是会调用 bind 绑定端口。<br>给大家贴一下 TCP 和 UDP 网络编程的区别就知道了。
TCP 网络编程如下,服务端执行 listen() 系统调用就是监听端口的动作。
UDP 网络编程如下,服务端是没有监听这个动作的,只有执行 bind() 系统调用来绑定端口的动作。
那 TCP 和 UDP 可以同时绑定相同的端口吗? 答案是:<b><font color="#0000ff">可以的</font></b>。
在数据链路层中,通过 MAC 地址来寻找局域网中的主机。在网际层中,通过 IP 地址来寻找网络中互连<br>的主机或路由器。在传输层中,需要通过端口进行寻址,来识别同一计算机中同时通信的不同应用程序。
所以,传输层的「端口号」的作用,是为了区分同一个主机上不同应用程序的数据包。
传输层有两个传输协议分别是 TCP 和 UDP,在内核中是两个完全独立的软件模块。
当主机收到数据包后,可以在 IP 包头的「协议号」字段知道该数据包是 TCP/UDP,所以可以根据这个信息<br>确定送给哪个模块(TCP/UDP)处理,送给 TCP/UDP 模块的报文根据「端口号」确定送给哪个应用程序处理。
因此, TCP/UDP 各自的端口号也相互独立,如 TCP 有一个 80 号端口,UDP 也可以有一个 80 号端口,二者并不冲突。
多个 TCP 服务进程可以绑定同一个端口吗?<br>
默认情况下,针对「多个 TCP 服务进程可以绑定同一个端口吗?」这个问题的答案是:<b><font color="#0000ff">如果两个 TCP 服务<br>进程同时绑定的 IP 地址和端口都相同,那么执行 bind() 时候就会出错,错误是“Address already in use”</font></b>。
注意,如果 TCP 服务进程 A 绑定的地址是 0.0.0.0 和端口 8888,而如果 TCP 服务进程 B 绑定<br>的地址是 192.168.1.100 地址(或者其他地址)和端口 8888,那么执行 bind() 时候也会出错。
这是因为 0.0.0.0 地址比较特殊,代表任意地址,意味着绑定了 0.0.0.0 地址,相当于把主机上的所有 IP 地址都绑定了。
重启 TCP 服务进程时,为什么会有“Address already in use”的报错信息?
TCP 服务进程需要绑定一个 IP 地址和一个端口,然后就监听在这个地址和端口上,等待客户端连接的到来。
然后在实践中,我们可能会经常碰到一个问题,当 TCP 服务进程重启之后,总是碰到<br>“Address already in use”的报错信息,TCP 服务进程不能很快地重启,而是要过一会才能重启成功。
这是为什么呢?
当我们重启 TCP 服务进程的时候,意味着通过服务器端发起了关闭连接操作,于是就会经过四次挥手,<br>而对于主动关闭方,会在 TIME_WAIT 这个状态里停留一段时间,这个时间大约为 2MSL。
<b><font color="#0000ff">当 TCP 服务进程重启时,服务端会出现 TIME_WAIT 状态的连接,TIME_WAIT 状态的连接使用的 IP+PORT 仍然被认为是一个有效的 IP+PORT <br>组合,相同机器上不能够在该 IP+PORT 组合上进行绑定,那么执行 bind() 函数的时候,就会返回了 Address already in use 的错误。</font></b>
而等 TIME_WAIT 状态的连接结束后,重启 TCP 服务进程就能成功。
重启 TCP 服务进程时,如何避免“Address already in use”的报错信息?<br>
我们可以在调用 bind 前,对 socket 设置 SO_REUSEADDR 属性,可以解决这个问题。
因为 SO_REUSEADDR 作用是:<b><font color="#0000ff">如果当前启动进程绑定的 IP+PORT 与处于TIME_WAIT 状态的连接占用<br>的 IP+PORT 存在冲突,但是新启动的进程使用了 SO_REUSEADDR 选项,那么该进程就可以绑定成功</font></b>。
因此,在所有 TCP 服务器程序中,调用 bind 之前最好对 socket 设置 SO_REUSEADDR 属性,<br>这不会产生危害,相反,它会帮助我们在很快时间内重启服务端程序。
<b><font color="#0000ff">前面提到过这个问题</font></b>:如果 TCP 服务进程 A 绑定的地址是 0.0.0.0 和端口 8888,而如果 TCP 服务进程 B <br>绑定的地址是 192.168.1.100 地址(或者其他地址)和端口 8888,那么执行 bind() 时候也会出错。
这个问题也可以由 SO_REUSEADDR 解决,因为它的另外一个作用为:绑定的 IP地址 + 端口,只要 IP 地址不是正好(exactly)相同,那么允许绑定。
比如,0.0.0.0:8888 和192.168.1.100:8888,虽然逻辑意义上前者包含了后者,但是 0.0.0.0 泛指所有本地 IP,而 192.168.1.100 <br>特指某一IP,两者并不是完全相同,所以在对 socket 设置 SO_REUSEADDR 属性后,那么执行 bind() 时候就会绑定成功。
客户端的端口可以重复使用吗?<br>
客户端在执行 connect 函数的时候,会在内核里随机选择一个端口,然后向服务端发起 SYN 报文,然后与服务端进行三次握手。
所以,客户端的端口选择的发生在 connect 函数,内核在选择端口的时候,会从 <br>net.ipv4.ip_local_port_range 这个内核参数指定的范围来选取一个端口作为客户端端口。
该参数的默认值是 32768 61000,意味着端口总可用的数量是 61000 - 32768 = 28232 个。
当客户端与服务端完成 TCP 连接建立后,我们可以通过 netstat 命令查看 TCP 连接。
那问题来了,上面客户端已经用了 64992 端口,那么还可以继续使用该端口发起连接吗?
这个问题,很多同学都会说不可以继续使用该端口了,如果按这个理解的话, 默认情况下客户端可以选择的端口是 28232 个,<br>那么意味着客户端只能最多建立 28232 个 TCP 连接,如果真是这样的话,那么这个客户端并发连接也太少了吧,所以这是错误理解。
正确的理解是,<b><font color="#0000ff">TCP 连接是由四元组(源IP地址,源端口,目的IP地址,目的端口)唯一确认的,那么只要四元组中其中一个元素发生了变化,<br>那么就表示不同的 TCP 连接的。所以如果客户端已使用端口 64992 与服务端 A 建立了连接,那么客户端要与服务端 B 建立连接,还是可以<br>使用端口 64992 的,因为内核是通过四元祖信息来定位一个 TCP 连接的,并不会因为客户端的端口号相同,而导致连接冲突的问题。</font></b>
比如下面这张图,有 2 个 TCP 连接,左边是客户端,右边是服务端,客户端使用了相同的端口 50004 与两个服务端建立了 TCP 连接。
仔细看,上面这两条 TCP 连接的四元组信息中的「目的 IP 地址」是不同的,一个是 180.101.49.12 ,另外一个是 180.101.49.11。
多个客户端可以 bind 同一个端口吗?<br>
bind 函数虽然常用于服务端网络编程中,但是它也是用于客户端的。
前面我们知道,客户端是在调用 connect 函数的时候,由内核随机选取一个端口作为连接的端口。
而如果我们想自己指定连接的端口,就可以用 bind 函数来实现:客户端先通过 bind 函数绑定一个端口,<br>然后调用 connect 函数就会跳过端口选择的过程了,转而使用 bind 时确定的端口。
针对这个问题:多个客户端可以 bind 同一个端口吗?
要看多个客户端绑定的 IP + PORT 是否都相同,如果都是相同的,那么在执行 bind() 时候就会出错,错误是“Address already in use”。
如果一个绑定在 192.168.1.100:6666,一个绑定在 192.168.1.200:6666,因为 IP 不相同,所以执行 bind() 的时候,能正常绑定。
所以, 如果多个客户端同时绑定的 IP 地址和端口都是相同的,那么执行 bind() 时候就会出错,错误是“Address already in use”。
一般而言,客户端不建议使用 bind 函数,应该交由 connect 函数来选择端口会比较好,因为客户端的端口通常都没什么意义。
客户端 TCP 连接 TIME_WAIT 状态过多,会导致端口资源耗尽而无法建立新的连接吗?
针对这个问题要看,客户端是否都是与同一个服务器(目标地址和目标端口一样)建立连接。
如果客户端都是与同一个服务器(目标地址和目标端口一样)建立连接,那么如果客户端 <br>TIME_WAIT 状态的连接过多,当端口资源被耗尽,就无法与这个服务器再建立连接了。
但是,<b><font color="#0000ff">只要客户端连接的服务器不同,端口资源可以重复使用的</font></b>。
所以,如果客户端都是与不同的服务器建立连接,即使客户端端口资源只有几万个, 客户端发起<br>百万级连接也是没问题的(当然这个过程还会受限于其他资源,比如文件描述符、内存、CPU 等)。
如何解决客户端 TCP 连接 TIME_WAIT 过多,导致无法与同一个服务器建立连接的问题?
前面我们提到,如果客户端都是与同一个服务器(目标地址和目标端口一样)建立连接,<br>那么如果客户端 TIME_WAIT 状态的连接过多,当端口资源被耗尽,就无法与这个服务器再建立连接了。
针对这个问题,也是有解决办法的,那就是打开 net.ipv4.tcp_tw_reuse 这个内核参数。<br>
<b><font color="#0000ff">因为开启了这个内核参数后,客户端调用 connect 函数时,如果选择到的端口,已经被相同四元组的连接占用的时候,就会判断该连接是否处于 TIME_WAIT 状态,如果该连接处于 TIME_WAIT 状态并且 TIME_WAIT 状态持续的时间超过了 1 秒,那么就会重用这个连接,然后就可以正常使用该端口了。</font></b>
再次提醒一次,开启了 net.ipv4.tcp_tw_reuse 内核参数,是客户端(连接发起方) <br>在调用 connect() 函数时才起作用,所以在服务端开启这个参数是没有效果的。
至此,我们已经把客户端在执行 connect 函数时,内核选择端口的情况大致说了一遍,流程图如下:
总结
TCP 和 UDP 可以同时绑定相同的端口吗?
可以的。
TCP 和 UDP 传输协议,在内核中是由两个完全独立的软件模块实现的。
当主机收到数据包后,可以在 IP 包头的「协议号」字段知道该数据包是 TCP/UDP,所以可以根据这个信息<br>确定送给哪个模块(TCP/UDP)处理,送给 TCP/UDP 模块的报文根据「端口号」确定送给哪个应用程序处理。
因此, TCP/UDP 各自的端口号也相互独立,互不影响。<br>
多个 TCP 服务进程可以同时绑定同一个端口吗?<br>
如果两个 TCP 服务进程同时绑定的 IP 地址和端口都相同,那么执行 bind() 时候就会出错,错误是“Address already in use”。
如果两个 TCP 服务进程绑定的端口都相同,而 IP 地址不同,那么执行 bind() 不会出错。<br>
如何解决服务端重启时,报错“Address already in use”的问题?<br>
当我们重启 TCP 服务进程的时候,意味着通过服务器端发起了关闭连接操作,于是就会经过四次挥手,<br>而对于主动关闭方,会在 TIME_WAIT 这个状态里停留一段时间,这个时间大约为 2MSL。<br>
当 TCP 服务进程重启时,服务端会出现 TIME_WAIT 状态的连接,TIME_WAIT 状态的连接使用的 IP+PORT 仍然被认为是一个有效的 IP+PORT <br>组合,相同机器上不能够在该 IP+PORT 组合上进行绑定,那么执行 bind() 函数的时候,就会返回了 Address already in use 的错误。<br>
要解决这个问题,我们可以对 socket 设置 SO_REUSEADDR 属性。
这样即使存在一个和绑定 IP+PORT 一样的 TIME_WAIT 状态的连接,依然可以正常绑定成功,因此可以正常重启成功。
客户端的端口可以重复使用吗?<br>
在客户端执行 connect 函数的时候,只要客户端连接的服务器不是同一个,内核允许端口重复使用。
TCP 连接是由四元组(源IP地址,源端口,目的IP地址,目的端口)唯一确认的,<br>那么只要四元组中其中一个元素发生了变化,那么就表示不同的 TCP 连接的。
所以,如果客户端已使用端口 64992 与服务端 A 建立了连接,那么客户端要与服务端 B 建立连接,还是可以使用端口 64992 的,<br>因为内核是通过四元祖信息来定位一个 TCP 连接的,并不会因为客户端的端口号相同,而导致连接冲突的问题。<br>
客户端 TCP 连接 TIME_WAIT 状态过多,会导致端口资源耗尽而无法建立新的连接吗?<br>
要看客户端是否都是与同一个服务器(目标地址和目标端口一样)建立连接。
如果客户端都是与同一个服务器(目标地址和目标端口一样)建立连接,那么如果客户端 TIME_WAIT 状态的连接过多,当端口资源被耗尽,就无法<br>与这个服务器再建立连接了。即使在这种状态下,还是可以与其他服务器建立连接的,只要客户端连接的服务器不是同一个,那么端口是重复使用的。<br>
如何解决客户端 TCP 连接 TIME_WAIT 过多,导致无法与同一个服务器建立连接的问题?<br>
打开 net.ipv4.tcp_tw_reuse 这个内核参数。<br>
因为开启了这个内核参数后,客户端调用 connect 函数时,如果选择到的端口,<br>已经被相同四元组的连接占用的时候,就会判断该连接是否处于 TIME_WAIT 状态。<br>
如果该连接处于 TIME_WAIT 状态并且 TIME_WAIT 状态持续的时间超过了 1 秒,<br>那么就会重用这个连接,然后就可以正常使用该端口了。
服务端没有 listen,客户端发起连接建立,会发生什么?
概述
服务端如果只 bind 了 IP 地址和端口,而没有调用 listen 的话,<br>然后客户端对服务端发起了 TCP 连接建立,此时那么会发生什么呢?
做个实验
用下面这个程序作为例子,绑定了 IP 地址 + 端口,而没有调用 listen。
然后,用浏览器访问这个地址:http://121.43.173.240:8888/
报错连接服务器失败。<br><br>同时,用抓包工具抓取这个过程。
可以看到,客户端对服务端发起 SYN 报文后,服务端回了 RST 报文。
所以,这个问题就有了答案,<b><font color="#0000ff">服务端如果只 bind 了 IP 地址和端口,<br>而没有调用 listen 的话,然后客户端对服务端发起了连接建立,服务端会回 RST 报文</font></b>。
源码分析
Linux 内核处理收到 TCP 报文的入口函数是 tcp_v4_rcv,在收到 TCP <br>报文后,会调用 __inet_lookup_skb 函数找到 TCP 报文所属 socket 。
__inet_lookup_skb 函数首先查找连接建立状态的 socket(__inet_lookup_established),<br>在没有命中的情况下,才会查找监听套接口(__inet_lookup_listener)。
查找监听套接口(__inet_lookup_listener)这个函数的实现是,根据目地址<br>和目的端口算出一个哈希值,然后在哈希表找到对应监听该端口的 socket。
本次的案例中,服务端是没有调用 listen 函数的,所以自然也是找不到监听该端口的 socket。
所以,__inet_lookup_skb 函数最终找不到对应的 socket,于是跳转到 no_tcp_socket。
在这个错误处理中,只要收到的报文(skb)的「校验和」没问题的话,<br>内核就会调用 tcp_v4_send_reset 发送 RST 中止这个连接。
没有 listen,能建立 TCP 连接吗?
标题的问题在前面已经解答,现在我们看另外一个相似的问题:<br><ul><li>不使用 listen ,可以建立 TCP 连接吗?</li></ul>
答案是:<b><font color="#0000ff">可以的,客户端是可以自己连自己的形成连接(TCP自连接),也可以两个客户端同时向对方发出请求<br>建立连接(TCP同时打开),这两个情况都有个共同点,就是没有服务端参与,也就是没有listen,就能建立连接。</font></b>
那没有 listen,为什么还能建立连接?
我们知道 <b><font color="#0000ff">执行 listen 方法时,会创建半连接队列和全连接队列</font></b>。
三次握手的过程中会在这两个队列中暂存连接信息。
所以形成连接,前提是你得有个地方存放着,方便握手的时候能根据 IP + 端口等信息找到对应的 socket。
那么客户端会有半连接队列吗?<br>
显然没有,因为客户端没有执行 listen,因为半连接队列和全连接队列都是在执行 listen 方法时,内核自动创建的。
但 <b><font color="#0000ff">内核还有个全局 hash 表,可以用于存放 sock 连接的信息</font></b>。
这个全局 hash 表其实还细分为 ehash,bhash 和 listen_hash 等,但因为过于细节,大家理解成有一个全局 hash 就够了。
<b><font color="#0000ff">在 TCP 自连接的情况中,客户端在 connect 方法时,最后会将自己的连接信息放入到这个全局 hash 表中,然后将信息发出,消息在经过回环<br>地址重新回到 TCP 传输层的时候,就会根据 IP + 端口信息,再一次从这个全局 hash 中取出信息。于是握手包一来一回,最后成功建立连接。</font></b>
TCP 同时打开的情况也类似,只不过从一个客户端变成了两个客户端而已。
没有 accept,能建立 TCP 连接吗?
概述
下面这个动图,是我们平时客户端和服务端建立连接时的代码流程。
对应的是下面一段简化过的服务端伪代码。
需要注意的是,在执行listen()方法之后还会执行一个accept()方法。
<b><font color="#0000ff">一般情况下</font></b>,如果启动服务器,会发现最后程序 <b><font color="#0000ff">会阻塞在 accept() 里</font></b>,因为此时在等待可使用的连接。
此时服务端就算ok了,就等客户端了。
那么,再看下简化过的客户端伪代码。
客户端比较简单,创建好 socket 之后,直接就发起 connect 方法。
此时回到服务端,<b><font color="#0000ff">会发现之前一直阻塞的 accept 方法,返回结果了</font></b>。
这就算两端成功建立好了一条连接。之后就可以愉快的进行读写操作了。
那么,我们今天的问题是,<b><font color="#0000ff">如果没有这个 accept 方法,TCP 连接还能建立起来吗?</font></b>
其实只要在执行accept() 之前执行一个 sleep(20),然后立刻执行客户端相关的方法,同时抓个包,就能得出结论。<br>
从抓包结果看来,<b><font color="#0000ff">就算不执行 accept() 方法,三次握手照常进行,并顺利建立连接</font></b>。
更骚气的是,<b><font color="#0000ff">在服务端执行 accept() 前,如果客户端发送消息给服务端,服务端是能够正常回复ack确认包的。</font></b>
并且,sleep(20) 结束后,服务端正常执行 accept(),客户端前面发送的消息,还是能正常收到的。
这是为什么呢?我们需要从三次握手的细节中寻找答案。
三次握手的细节分析
先回顾一下三次握手的过程:
服务端代码,对 socket 执行 bind 方法可以绑定监听端口,然后执行 listen 方法后,就会进入监听(LISTEN)状态。<br>内核会为每一个处于 LISTEN 状态的 socket 分配两个队列,分别叫 <b><font color="#0000ff">半连接队列和全连接队列</font></b>。
半连接/全连接队列是什么
<ul><li><b><font color="#0000ff">半连接队列(SYN队列)</font></b>,服务端收到 <b><font color="#0000ff">第一次握手后</font></b>,会将 sock 加入到这个队列中,队列内的 sock 都处于 SYN_RECV 状态。</li><li><b><font color="#0000ff">全连接队列(ACCEPT队列)</font></b>,在服务端收到 <b><font color="#0000ff">第三次握手后</font></b>,会将半连接队列的 sock 取出,放到全连接队列中。</li></ul>队列里的 sock 都处于 ESTABLISHED 状态。这里面的连接,就 <b><font color="#0000ff">等着服务端执行 accept() 后被取出了</font></b>。
看到这里,文章开头的问题就有了答案,<b><font color="#0000ff">建立连接的过程中根本不需要 accept() 参与, 执行 accept() 只是为了从全连接队列里取出一条连接</font></b>。<br>所以没有了 accept(),连接依旧能建立,知识暂时没法从全连接队列中获取连接而已。
把话题再重新回到这两个队列上。
虽然都叫队列,但其实 <b><font color="#0000ff">全连接队列(icsk_accept_queue)是个链表</font></b>,而 <b><font color="#0000ff">半连接队列(syn_table)是个哈希表</font></b>。
为什么半连接队列要设计成哈希表
先对比下 <b><font color="#0000ff">全连接里队列</font></b>,他本质是个链表,因为也是线性结构,说它是个队列也没毛病。它里面放的都是已经建立完成的连接,这些连接正等待<br>被取走。而服务端取走连接的过程中,并不关心具体是哪个连接,只要是个连接就行,所以直接从队列头取就行了。这个过程算法复杂度为 O(1)。
而 <b><font color="#0000ff">半连接队列</font> </b>却不太一样,因为队列里的都是不完整的连接,嗷嗷等待着第三次握手的到来。那么现在有一个第三次握手来了,则需要从队列<br>里把相应IP端口的连接取出,<b><font color="#0000ff">如果半连接队列还是个链表,那我们就需要依次遍历,才能拿到我们想要的那个连接,算法复杂度就是 O(n)</font></b>。<br><br>而 <b><font color="#0000ff">如果将半连接队列设计成哈希表,那么查找半连接的算法复杂度就回到 O(1) 了。</font></b><br>
因此出于效率考虑,全连接队列被设计成链表,而半连接队列被设计为哈希表。
怎么观察两个队列的大小
查看全连接队列
通过 ss -lnt 命令,可以看到全连接队列的大小,其中 :<br><ul><li>Send-Q 是指全连接队列的最大值,可以看到我这上面的最大值是 128;</li><li>Recv-Q 是指当前的全连接队列的使用值,我这边用了 0 个,也就是全连接队列里为空,连接都被取出来了。</li></ul>
当上面 Send-Q 和 Recv-Q 数值很接近的时候,那么全连接队列可能已经满了。可以通过下面的命令查看是否发生过队列溢出:
上面说明发生过 4343 次全连接队列溢出的情况。这个查看到的是历史发生过的次数。
如果配合使用 watch -d 命令,可以自动每2s间隔执行相同命令,还能高亮<br>显示变化的数字部分,如果溢出的数字不断变多,说明 <b><font color="#0000ff">正在发生溢出</font> </b>的行为。<br>
查看半连接队列<br>
半连接队列没有命令可以直接查看到,但因为半连接队列里,放的都是 SYN_RECV 状态的连接,<br>那可以通过统计处于这个状态的连接的数量,间接获得半连接队列的长度。
注意半连接队列和全连接队列都是挂在某个 Listen socket 上的。
可以看到这台机器上的半连接队列长度为 0,这个很正常,正经连接谁会没事老待在半连接队列里。
当队列里的半连接不断增多,最终也是会发生溢出,可以通过下面的命令查看。
可以看到,我的机器上一共发生了 26395 次半连接队列溢出。同样建议配合 watch -d 命令使用。
全连接队列满了会怎么样
如果队列满了,服务端还收到客户端的第三次握手 ACK,默认当然会丢弃这个 ACK。
但除了丢弃之外,还有一些附带行为,这会受 tcp_abort_on_overflow 参数的影响。
tcp_abort_on_overflow 设置为 0 时:<br><ul><li>全连接队列满了之后,会丢弃这个第三次握手 ACK 包,<b><font color="#0000ff">并且开启定时器,重传第二次握手的 SYN+ACK,<br>如果重传超过一定限制次数,还会把对应的半连接队列里的连接给删掉</font></b>。</li></ul>
tcp_abort_on_overflow 设置为 1 时:<br><ul><li>全连接队列满了之后,就直接发 RST 给客户端,效果上看就是连接断了。</li></ul>
这个现象是不是很熟悉,<b><font color="#0000ff">服务端端口未监听时</font></b>,客户端尝试去连接,服务端也会回一个 RST。<br>这两个情况长一样,所以客户端这时候收到 RST 之后,其实无法区分到底是 <b><font color="#0000ff">端口未监听</font></b>,还是 <b><font color="#0000ff">全连接队列满了</font></b>。
半连接队列满了会怎么样
<b><font color="#0000ff">一般是丢弃</font></b>,但这个行为可以通过 tcp_syncookies 参数去控制。<br>但比起这个,更重要的是先了解下半连接队列为什么会被打满。
首先我们需要明白,一般情况下,半连接的"生存"时间其实很短,只有在第一次和第三次握手间,如果半连接都满了,说明服务端疯狂收到<br>第一次握手请求,如果是线上游戏应用,能有这么多请求进来,那说明你可能要富了。但现实往往比较骨感,你可能遇到了 <b><font color="#0000ff">SYN Flood 攻击</font></b>。
所谓 SYN Flood 攻击,可以简单理解为,攻击方模拟客户端疯狂发第一次握手请求过来,在服务端憨憨地回复第二次握手<br>过去之后,客户端死活不发第三次握手过来,这样做,可以把服务端半连接队列打满,从而导致正常连接不能正常进来。
那这种情况怎么处理?有没有一种方法可以绕过半连接队列?
有,上面提到的tcp_syncookies派上用场了。<br>
当它被设置为 1 的时候,客户端发来 <b><font color="#0000ff">第一次握手 SYN 时</font></b>,服务端 <b><font color="#0000ff">不会将其放入半连接队列中</font></b>,而是直接 <b><font color="#0000ff">生成一个 cookies</font></b>,<br>这个 cookies 会跟着 <b><font color="#0000ff">第二次握手</font></b>,发回客户端。客户端在发 <b><font color="#0000ff">第三次握手的时候带上这个 cookies</font></b>,服务端验证到它就是当初<br>发出去的那个,<b><font color="#0000ff">就会建立连接并放入到全连接队列中</font></b>。可以看出整个过程 <b><font color="#0000ff">不再需要半连接队列的参与</font></b>。
会有一个 cookies 队列吗<br>
生成是 cookies,保存在哪呢?<b><font color="#0000ff">是不是会有一个队列保存这些 cookies?</font></b>
我们可以反过来想一下,如果有 cookies 队列,那它会跟<br>半连接队列一样,到头来,<b><font color="#0000ff">还是会被 SYN Flood 攻击打满</font></b>。
实际上 cookies 并不会有一个专门的队列保存,它是通过<b><font color="#0000ff">通信双方的IP地址端口、<br>时间戳、MSS</font></b> 等信息进行 <b><font color="#0000ff">实时计算</font></b> 的,保存在 <b><font color="#0000ff">TCP 报头的 seq 里</font></b>。
当服务端收到客户端发来的第三次握手包时,会通过 seq 还原出 <br><b><font color="#0000ff">通信双方的IP地址端口、时间戳、MSS</font></b>,验证通过则建立连接。
cookies 方案为什么不直接取代半连接队列?<br>
目前看下来 syn cookies 方案省下了半连接队列所需要的队列内存,<br>还能解决 SYN Flood 攻击,那为什么不直接取代半连接队列?
凡事皆有利弊,cookies 方案虽然能防 SYN Flood 攻击,但是也有一些问题。因为 <b><font color="#0000ff">服务端<br>并不会保存连接信息</font></b>,所以如果传输过程中数据包丢了,<b><font color="#0000ff">也不会重发第二次握手的信息</font></b>。
另外,编码解码 cookies,都是比较 <b><font color="#0000ff">耗 CPU</font></b> 的,利用这一点,如果此时 <b><font color="#0000ff">攻击者构造大量的第三次握手包(ACK包)</font></b>,同时带上各种<br>瞎编的 cookies 信息,服务端收到 ACK 包后 <b><font color="#0000ff">以为是正经 cookies</font></b>,憨憨地跑去解码(耗CPU),最后发现不是正经数据包后才丢弃。
这种通过构造大量 ACK 包去消耗服务端资源的攻击,叫 <b><font color="#0000ff">ACK 攻击</font></b>,受到攻击的服务器可能会因为 <b><font color="#0000ff">CPU资源耗尽 </font></b>导致没能响应正经请求。
没有 listen,为什么还能建立连接
那既然没有 accept 方法能建立连接,那是不是没有 listen 方法,也能建立连接?<br>是的,之前提到过客户端是可以自己连自己的形成连接(<b><font color="#0000ff">TCP 自连接</font></b>),也可以两个客户端同时向对方发出请求建立连接(<b><font color="#0000ff">TCP 同时打开</font></b>),<br>这两个情况都有个共同点,就是 <b><font color="#0000ff">没有服务端参与,也就是没有 listen,就能建立连接</font></b>。
我们知道执行 listen 方法时,会创建半连接队列和全连接队列。
三次握手的过程中会在这两个队列中暂存连接信息。
所以形成连接,前提是你得 <b><font color="#0000ff">有个地方存放着</font></b>,方便握手的时候能根据 IP 端口等信息找到 socket 信息。
<b><font color="#0000ff">那么客户端会有半连接队列吗?</font></b>
<b><font color="#0000ff">显然没有</font></b>,因为客户端没有执行 listen,因为半连接队列和全连接队列都是在执行 listen 方法时,内核自动创建的。
但内核还有个 <b><font color="#0000ff">全局 hash 表</font></b>,可以用于存放 sock 连接的信息。这个全局 hash 表其实还细分<br>为 ehash,bhash 和 listen_hash 等,但因为过于细节,大家理解成有一个全局 hash 就够了。
在 TCP 自连接的情况中,客户端在 connect 方法时,最后会将自己的连接信息放入到这个 <b><font color="#0000ff">全局 hash 表</font> </b>中,然后将信息发出,消息在经过<br>回环地址重新回到 TCP传输层的时候,就会根据IP端口信息,再一次从这个全局 hash 中取出信息。于是握手包一来一回,最后成功建立连接。
TCP 同时打开的情况也类似,只不过从一个客户端变成了两个客户端而已。<br>
总结
每一个 socket 执行 listen 时,内核都会自动创建一个半连接队列和全连接队列。<br>
第三次握手前,TCP 连接会放在半连接队列中,直到第三次握手到来,才会被放到全连接队列中。<br>
accept 方法只是为了从全连接队列中拿出一条连接,本身跟三次握手几乎毫无关系。<br>
所以即使没有 accpet 方法,TCP 连接照样能建立完成,只是暂时没有去获取连接。
出于效率考虑,虽然都叫队列,但半连接队列其实被设计成了哈希表,而全连接队列本质是链表。<br>
全连接队列满了,再来第三次握手也会丢弃,此时如果 tcp_abort_on_overflow=1,还会直接发 RST 给客户端。<br>
半连接队列满了,可能是因为受到了 SYN Flood 攻击,可以设置 tcp_syncookies,绕开半连接队列。
客户端没有半连接队列和全连接队列,但有一个全局 hash,可以通过它实现自连接或 TCP 同时打开。<br>
用了 TCP 协议,数据一定不会丢吗?
概述
TCP 是一个可靠的传输协议,那它一定能保证数据不丢失吗?
这个数据丢失的原因有很多,我们接下来逐一探讨。
数据包的发送流程
首先,我们两个手机的聊天软件客户端要通信,中间会通过它们家的服务器。大概长这样。
但为了 <b><font color="#0000ff">简化模型</font></b>,我们把中间的服务器给省略掉,假设这是个端到端的通信。<br>且为了保证消息的可靠性,我们盲猜它们之间用的是 <b><font color="#0000ff">TCP 协议</font></b> 进行通信。
为了发送数据包,两端首先会通过 <b><font color="#0000ff">三次握手</font></b>,建立TCP连接。
一个数据包,从聊天框里发出,消息会从 <b><font color="#0000ff">聊天软件</font></b> 所在的 <b><font color="#0000ff">用户空间 </font></b>拷贝到 <b><font color="#0000ff">内核空间的发送缓冲区(send buffer)</font></b>,数据包就这样顺着 <br><b><font color="#0000ff">传输层、网络层,进入到数据链路层,在这里数据包会经过流量控制(qdisc),再通过RingBuffer发到物理层的网卡</font></b>。数据就这样顺着 <br><b><font color="#0000ff">网卡 </font></b>发到了 <b><font color="#0000ff">纷繁复杂 </font></b>的网络世界里。这里头数据会经过 n 多个 <b><font color="#0000ff">路由器和交换机 </font></b>之间的跳转,最后到达 <b><font color="#0000ff">目的机器的网卡 </font></b>处。
此时目的机器的网卡会通知 <b><font color="#0000ff">DMA</font></b> 将数据包信息放到 RingBuffer 中,再触发一个 <b><font color="#0000ff">硬中断 </font></b>给 CPU,CPU 触发 <b><font color="#0000ff">软中断 </font></b>让 ksoftirqd 去 RingBuffer 收包,<br>于是一个数据包就这样顺着 <b><font color="#0000ff">物理层,数据链路层,网络层,传输层</font></b>,最后从内核空间拷贝到用户空间里的 <b><font color="#0000ff">聊天软件 </font></b>里。
到这里,抛开一些细节,大家大概知道了一个数据包 <b><font color="#0000ff">从发送到接收</font> </b>的宏观过程。<br><br>可以看到,这上面全是密密麻麻的名词。
整条链路下来,有不少地方可能会发生丢包。这边只重点讲下几个 <b><font color="#0000ff">常见容易发生丢包的场景</font></b>。
建立连接时丢包
TCP协议会通过 <b><font color="#0000ff">三次握手 </font></b>建立连接,大概长下面这样。
在服务端,第一次握手之后,会先建立个 <b><font color="#0000ff">半连接</font></b>,然后再发出第二次握手。<br>这时候需要有个地方可以 <b><font color="#0000ff">暂存 </font></b>这些半连接。这个地方就叫 <b><font color="#0000ff">半连接队列</font></b>。
如果之后第三次握手来了,半连接就会升级为全连接,然后暂存到另外<br>一个叫 <b><font color="#0000ff">全连接队列 </font></b>的地方,坐等程序执行 accept() 方法将其取走使用。
是队列就有长度,有长度就有可能会满,如果它们 <b><font color="#0000ff">满了</font></b>,那新来的包就会被 <b><font color="#0000ff">丢弃</font></b>。
可以通过下面的方式查看是否存在这种丢包行为。
从现象来看就是连接建立失败。
流量控制丢包
应用层能发网络数据包的软件有那么多,如果所有数据不加控制一股脑冲入到网卡,网卡会吃不消,那怎么办?<br>让数据按一定的规则排个队依次处理,也就是所谓的 <b><font color="#0000ff">qdisc(Queueing Disciplines,排队规则)</font></b>,这也是我们常说的 <b><font color="#0000ff">流量控制机制</font></b>。
排队,得先有个 <b><font color="#0000ff">队列</font></b>,而队列有个 <b><font color="#0000ff">长度</font></b>。
我们可以通过下面的 ifconfig 命令查看到,里面涉及到的 txqueuelen 后面的数字 1000,其实就是流控队列的长度。
当发送数据过快,流控队列长度 txqueuelen 又不够大时,就容易出现 <b><font color="#0000ff">丢包</font></b> 现象。
可以通过下面的 ifconfig 命令,查看 TX 下的 dropped 字段,当它大于 0 时,则 <b><font color="#0000ff">有可能是发生了流控丢包</font></b>。
当遇到这种情况时,我们可以尝试修改下流控队列的长度。比如像下面这样将 eth0 网卡的流控队列长度从 1000 提升为 1500.
网卡丢包
网卡和它的驱动导致丢包的场景也比较常见,原因很多,比如 <br><b><font color="#0000ff">网线质量差,接触不良</font></b>。除此之外,我们来聊几个常见的场景。
RingBuffer 过小导致丢包
上面提到,在接收数据时,会将数据暂存到RingBuffer接收缓冲区中,然后等着内核触发软中断慢慢收走。<br>如果这个 <b><font color="#0000ff">缓冲区过小</font></b>,而这时候 <b><font color="#0000ff">发送的数据又过快</font></b>,就有可能发生溢出,此时也会产生 <b><font color="#0000ff">丢包</font></b>。
我们可以通过下面的命令去查看是否发生过这样的事情。<br><ul><li>查看 overruns 指标,它记录了由于 RingBuffer 长度不足导致的溢出次数。</li></ul>
当然,用 ethtool 命令也能查看。
但这里需要注意的是,因为一个网卡里是可以有 <b><font color="#0000ff">多个 RingBuffer</font></b> 的,所以上面的 rx_queue_0_drops <br>里的 0 代表的是 <b><font color="#0000ff">第 0 个 RingBuffer</font></b> 的丢包数,对于多队列的网卡,这个0还可以改成其他数字。
当发现有这类型丢包的时候,可以通过下面的命令查看当前网卡的配置。
上面的输出内容,含义是 <b><font color="#0000ff">RingBuffer 最大支持 4096 的长度,但现在实际只用了 1024</font></b>。
想要修改这个长度可以执行 ethtool -G eth1 rx 4096 tx 4096 将发送和接收 RingBuffer 的长度都改为 4096。
RingBuffer 增大之后,可以减少因为容量小而导致的丢包情况。<br>
网卡性能不足
网卡作为硬件,<b><font color="#0000ff">传输速度是有上限的</font></b>。当网络传输速度过大,<br>达到网卡上限时,就会发生丢包。这种情况一般常见于压测场景。
我们可以通过 ethtool 加网卡名,获得当前网卡支持的最大速度。
可以看到,上面机器用的网卡能支持的最大传输速度 speed=1000Mb/s。
也就是俗称的千兆网卡,但注意这里的单位是 Mb,这里的 b 是指 bit,而不是 Byte。1Byte=8bit。<br>所以 10000Mb/s 还要除以 8,也就是理论上网卡最大传输速度是 1000/8 = 125MB/s。
我们可以通过 sar 命令从网络接口层面来分析数据包的收发情况。
其中 txkB/s 是指当前每秒发送的字节(byte)总数,rxkB/s 是指每秒接收的字节(byte)总数。
当两者加起来的值约等于 12~13w 字节的时候,也就对应大概 125MB/s 的传输速度。此时达到网卡性能极限,就会开始丢包。
遇到这个问题,优先看下你的服务是不是真有这么大的 <b><font color="#0000ff">真实流量</font></b>,如果是的话可以考虑下拆分服务,或者就忍痛充钱升级下配置吧。
接收缓冲区丢包
我们一般使用 TCP socket 进行网络编程的时候,内核都会分配一个 <b><font color="#0000ff">发送缓冲区</font></b> 和一个 <b><font color="#0000ff">接收缓冲区</font></b>。
当我们想要发一个数据包,会在代码里执行 send(msg),这时候数据包并不是一把梭直接就走网卡飞出去的。<br>而是将数据拷贝到内核 <b><font color="#0000ff">发送缓冲区 </font></b>就完事 <b><font color="#0000ff">返回 </font></b>了,至于 <b><font color="#0000ff">什么时候发数据,发多少数据</font></b>,这个后续由 <b><font color="#0000ff">内核自己做决定</font></b>。
而 <b><font color="#0000ff">接收缓冲区</font></b> 作用也类似,从外部网络收到的数据包就暂存在这个地方,然后坐等用户空间的应用程序将数据包取走。
这两个缓冲区是有大小限制的,可以通过下面的命令去查看。
不管是接收缓冲区还是发送缓冲区,都能看到三个数值,分别对应缓冲区的 <b><font color="#0000ff">最小值,<br>默认值和最大值 (min、default、max)。缓冲区会在min和max之间动态调整。</font></b>
<b><font color="#0000ff">那么问题来了,如果缓冲区设置过小会怎么样?</font></b><br>
对于 <b><font color="#0000ff">发送缓冲区</font></b>,执行 send 的时候,如果是 <b><font color="#0000ff">阻塞调用</font></b>,那就会等,等到缓冲区有空位可以发数据。
如果是 <b><font color="#0000ff">非阻塞调用</font></b>,就会 <b><font color="#0000ff">立刻返回</font> </b>一个 EAGAIN 错误信息,意思是 Try again。<br>让应用程序下次再重试。这种情况下一般不会发生丢包。
当 <b><font color="#0000ff">接受缓冲区满了</font></b>,事情就不一样了,它的 <b><font color="#0000ff">TCP 接收窗口会变为 0</font></b>,也就是所谓的 <b><font color="#0000ff">零窗口</font></b>,并且会通过数据包里的 win=0,告诉发送端,<br>"球球了,顶不住了,别发了"。一般这种情况下,发送端就该停止发消息了,但如果这时候确实还有数据发来,就会发生 <b><font color="#0000ff">丢包</font></b>。
我们可以通过下面的命令里的 TCPRcvQDrop 查看到有没有发生过这种丢包现象。
但是说个伤心的事情,我们一般也看不到这个 TCPRcvQDrop,因为这个是 5.9 版本里引入的打点,<br>而我们的服务器用的一般是 2.x~3.x 左右版本。你可以通过下面的命令查看下你用的是什么版本的 linux 内核。
两端之间的网络丢包
前面提到的是两端机器内部的网络丢包,除此之外,两端之间那么长的一条链路都属于<br>外部网络,这中间有各种路由器和交换机还有光缆啥的,丢包也是很经常发生的。
这些丢包行为发生在中间链路的某些个机器上,我们当然是没权限去<br>登录这些机器。但我们可以通过一些命令观察整个链路的连通情况。
<b>ping 命令查看丢包</b>
比如我们知道目的地的域名是 baidu.com。想知道你的机器到 baidu 服务器之间,有没有产生丢包行为,可以使用 ping 命令。
倒数第二行里有个 100% packet loss,意思是丢包率 100%。
但这样其实你只能知道 <b><font color="#0000ff">你的机器和目的机器之间有没有丢包</font></b>。
那如果你想知道你和目的机器之间的这条链路,<b><font color="#0000ff">哪个节点丢包了</font></b>,有没有办法呢?
当然有,那就是使用下面的 mtr 命令。
<b>mtr 命令</b>
mtr 命令可以查看到你的机器和目的机器之间的每个节点的丢包情况。<br><ul><li>其中 -r 是指 report,以报告的形式打印结果。</li></ul>
可以看到 Host 那一列,出现的都是链路中间每一跳的机器,Loss 的那一列就是指这一跳对应的丢包率。
需要注意的是,中间有一些是 host 是 ???,那个是因为 <b><font color="#0000ff">mtr 默认用的是 ICMP 包</font></b>,有些 <b><font color="#0000ff">节点限制了 ICMP 包</font></b>,导致不能正常展示。
我们可以在 mtr 命令里加个 -u,也就是使用 <b><font color="#0000ff">udp 包</font></b>,就能看到部分 ??? 对应的 IP。
把 <b><font color="#0000ff">ICMP 包和 UDP 包 </font></b>的结果拼在一起看,就是 <b><font color="#0000ff">比较完整 </font></b>的链路图了。
还有个小细节,Loss 那一列,我们 <b><font color="#0000ff">在 icmp 的场景下</font></b>,<b><font color="#0000ff">关注最后一行</font></b>,如果是 <b><font color="#0000ff">0%</font></b>,<br>那不管前面 loss 是 100% 还是 80% 都无所谓,那些都是 <b><font color="#0000ff">节点限制导致的虚报</font></b>。
但如果 <b><font color="#0000ff">最后一行是 20%,再往前几行都是 20% 左右</font></b>,那说明丢包就是从最接近的那一行开始产生的,长时间是这样,那很可能这一跳出了<br>点问题。如果是公司内网的话,你可以带着这条线索去找对应的网络同事。如果是外网的话,那耐心点等等吧,别人家的开发会比你更着急。
发生了丢包怎么办?
说了这么多。只是想告诉大家,<b><font color="#0000ff">丢包是很常见的,几乎不可避免的一件事情。</font></b>
但问题来了,发生丢包了怎么办?
这个好办,用 <b><font color="#0000ff">TCP 协议</font></b> 去做传输。
建立了 TCP 连接的两端,发送端在发出数据后会等待接收端回复 ack 包,<b><font color="#0000ff">ack 包的目的是为了告诉对方自己确实收到了数据</font></b>,<br>但如果中间链路发生了丢包,那发送端会迟迟收不到确认 ack,于是就会进行<b><font color="#0000ff"> 重传</font></b>。以此来保证每个数据包都确确实实到达了接收端。
假设现在网断了,我们还用聊天软件发消息,聊天软件会使用TCP不断尝试重传数据,<b><font color="#0000ff">如果重传期间网络恢复了</font></b>,<br>那数据就能正常发过去。但如果多次重试直到超时都还是失败,这时候你将收获一个 <b><font color="#0000ff">红色感叹号</font></b>。
这时候问题又来了。
假设某聊天软件用的就是 <b><font color="#0000ff">TCP 协议</font></b>。
那为什么有时候回消息时自己明明看到消息已经发送了,但是对方却没有收到,还是会丢包?<br>明明丢包了会 <b><font color="#0000ff">重试</font></b>,重试失败了还会出现 <b><font color="#0000ff">红色感叹号</font></b>。
于是乎,问题就变成了,<b><font color="#0000ff">用了TCP协议,就一定不会丢包吗?</font></b><br>
用了 TCP 协议就一定不会丢包吗?
我们知道 <b><font color="#0000ff">TCP 位于传输层</font></b>,在它的 <b><font color="#0000ff">上面 </font></b>还有各种 <b><font color="#0000ff">应用层协议</font></b>,比如常见的 HTTP 或者各类 RPC 协议。
TCP 保证的可靠性,<b><font color="#0000ff">是传输层的可靠性</font></b>。也就是说,<b><font color="#0000ff">TCP 只保证数据从 A 机器的传输层可靠地发到 B 机器的传输层。</font></b>
至于数据到了接收端的传输层之后,能不能保证到应用层,TCP 并不管。
假设现在,我们输入一条消息,从聊天框发出,<b><font color="#0000ff">走到传输层 TCP 协议的发送缓冲区</font></b>,不管中间有没有丢包,最后通过 <b><font color="#0000ff">重传 </font></b>都保证 <b><font color="#0000ff">发到了对方的传输层 <br>TCP 接收缓冲区</font></b>,此时接收端回复了一个 ack,发送端 <b><font color="#0000ff">收到这个 ack 后 </font></b>就会将自己<font color="#0000ff"> <b>发送缓冲区里的消息给扔掉</b></font>。到这里 TCP 的任务就结束了。
TCP 任务是结束了,但聊天软件的任务没结束。
<b><font color="#0000ff">聊天软件还需要将数据从TCP的接收缓冲区里读出来,如果在读出来这一刻,手机由于内存不足或其他各种原因,导致软件崩溃闪退了。</font></b>
发送端以为自己发的消息已经发给对方了,但接收端却并没有收到这条消息。于是乎,<b><font color="#0000ff">消息就丢了</font></b>。
<b><font color="#0000ff">虽然概率很小,但它就是发生了</font></b>。合情合理,逻辑自洽。
这类丢包问题怎么解决?
大家应该还记得我们开头提到过,<b><font color="#0000ff">为了简单</font></b>,就将 <b><font color="#0000ff">服务器那一方给省略 </font></b>了,<br>从三端通信变成了两端通信,所以 <b><font color="#0000ff">才有了这个丢包问题</font></b>。
<b><font color="#0000ff">现在我们重新将服务器加回来。</font></b><br>
大家有没有发现,有时候我们在手机里聊了一大堆内容,然后登录电脑版,它能将最近的聊天记录都同步到电脑版上。也就是说服务器 <b><font color="#0000ff">可能</font></b> 记录了<br>我们最近发过什么数据,假设每条消息都有个 id,服务器和聊天软件每次都拿 <b><font color="#0000ff">最新消息的 id</font></b> 进行对比,就能知道 <b><font color="#0000ff">两端消息是否一致</font></b>,就像对账一样。
对于 <b><font color="#0000ff">发送方</font></b>,只要定时跟服务端的内容对账一下,就知道哪条消息没发送成功,直接重发就好了。
如果 <b><font color="#0000ff">接收方</font></b> 的聊天软件崩溃了,重启后跟服务器稍微通信一下就知道少了哪条数据,<b><font color="#0000ff">同步上来 </font></b>就是了,所以也不存在上面提到的丢包情况。
可以看出,<b><font color="#0000ff">TCP 只保证传输层的消息可靠性,并不保证应用层的消息可靠性。如果我们还想保证应用层的消息可靠性,就需要应用层自己去实现逻辑做保证。</font></b>
那么问题叒来了,<b><font color="#0000ff">两端通信的时候也能对账,为什么还要引入第三端服务器?</font></b>
主要有三个原因:<br><ul><li>第一,如果是两端通信,你聊天软件里有 1000 个好友,你就得建立 1000 个连接。但如果引入服务端,<br>你 <b><font color="#0000ff">只需要跟服务器建立 1 个连接就够了</font></b>,<b><font color="#0000ff">聊天软件消耗的资源越少,手机就越省电</font></b>。</li><li>第二,就是 <b><font color="#0000ff">安全问题</font></b>,如果还是两端通信,随便一个人找你对账一下,你就把聊天记录给同步过去了,<br>这并不合适吧。如果对方别有用心,信息就泄露了。引入第三方服务端就可以很方便的做各种 <b><font color="#0000ff">鉴权校验</font></b>。</li><li>第三,是 <b><font color="#0000ff">软件版本问题</font></b>。软件装到用户手机之后,软件更不更新就是由用户说了算了。如果还是两端通信,<br>且两端的 <b><font color="#0000ff">软件版本跨度太大</font></b>,很容易产生各种兼容性问题,但引入第三端服务器,就可以强制部分过低版本<br>升级,否则不能使用软件。<b><font color="#0000ff">但对于大部分兼容性问题,给服务端加兼容逻辑就好了</font></b>,不需要强制用户更新软件。</li></ul>
总结
数据从发送端到接收端,链路很长,任何一个地方都可能发生丢包,几乎可以说丢包不可避免。
平时没事也不用关注丢包,大部分时候 TCP 的重传机制保证了消息可靠性。
当你发现服务异常的时候,比如接口延时很高,总是失败的时候,可以用 ping 或者 mtr 命令看下是不是中间链路发生了丢包。
TCP只保证传输层的消息可靠性,并不保证应用层的消息可靠性。如果我们还想保证应用层的消息可靠性,就需要应用层自己去实现逻辑做保证。
TCP 四次挥手,可以变成三次吗?
概述
<b><font color="#0000ff">TCP 四次挥手中,能不能把第二次的 ACK 报文, 放到第三次 FIN 报文一起发送?</font></b>
虽然我们在学习 TCP 挥手时,学到的是需要四次来完成 TCP 挥手,<br>但是 <b><font color="#0000ff">在一些情况下, TCP 四次挥手是可以变成 TCP 三次挥手的。</font></b>
而且在用 wireshark 工具抓包的时候,我们也会常看到 TCP 挥手过程是三次,而不是四次,如下图:
先来回答为什么 RFC 文档里定义 TCP 挥手过程是要四次?再来回答什么情况下,什么情况会出现三次挥手?
TCP 四次挥手<br>
概述
TCP 四次挥手的过程如下:
具体过程:<br><ul><li>客户端主动调用关闭连接的函数,于是就会发送 FIN 报文,这个 FIN 报文代表客户端不会再发送数据了,进入 FIN_WAIT_1 状态;</li><li>服务端收到了 FIN 报文,然后马上回复一个 ACK 确认报文,此时服务端进入 CLOSE_WAIT 状态。在收到 FIN 报文的时候,<br><b><font color="#0000ff">TCP 协议栈会为 FIN 包插入一个文件结束符 EOF 到接收缓冲区中</font></b>,服务端应用程序可以通过 read 调用来感知这个 FIN 包,<br>这个 <b><font color="#0000ff">EOF 会被放在已排队等候的其他已接收的数据之后</font></b>,所以必须要得 <b><font color="#0000ff">继续 read 接收缓冲区已接收的数据</font></b>;</li><li>接着,当服务端在 read 数据的时候,最后自然就会 <b><font color="#0000ff">读到 EOF</font></b>,接着 <b><font color="#0000ff">read() 就会返回 0</font></b>,<b><font color="#0000ff">这时服务端应用程序如果有数据要发送的话,<br>就发完数据后才调用关闭连接的函数,如果服务端应用程序没有数据要发送的话,可以直接调用关闭连接的函数</font></b>,这时服务端就会发<br>一个 FIN 包,这个 FIN 报文代表服务端不会再发送数据了,之后处于 LAST_ACK 状态;</li><li>客户端接收到服务端的 FIN 包,并发送 ACK 确认包给服务端,此时客户端将进入 TIME_WAIT 状态;</li><li>服务端收到 ACK 确认包后,就进入了最后的 CLOSE 状态;</li><li>客户端经过 2MSL 时间之后,也进入 CLOSE 状态;</li></ul>
可以看到,每个方向都需要 <b><font color="#0000ff">一个 FIN 和一个 ACK</font></b>,因此通常被称为 <b><font color="#0000ff">四次挥手</font></b>。<br>
为什么 TCP 挥手需要四次?
服务器收到客户端的 FIN 报文时,内核会马上回一个 ACK 应答报文,<b><font color="#0000ff">但是服务端应用程序可能还有<br>数据要发送,所以并不能马上发送 FIN 报文,而是将发送 FIN 报文的控制权交给服务端应用程序</font></b>:<br><ul><li>如果服务端应用程序有数据要发送的话,就发完数据后,才调用关闭连接的函数;</li><li>如果服务端应用程序没有数据要发送的话,可以直接调用关闭连接的函数,</li></ul>
从上面过程可知,<b><font color="#0000ff">是否要发送第三次挥手的控制权不在内核,而是在被动关闭方(上图的服务端)的应用程序,因为应用程序可能还有数据要发送,<br>由应用程序决定什么时候调用关闭连接的函数,当调用了关闭连接的函数,内核就会发送 FIN 报文了</font></b>,所以服务端的 ACK 和 FIN 一般都会分开发送。
FIN 报文一定得调用关闭连接的函数,才会发送吗?
不一定。如果进程退出了,不管是不是正常退出,还是异常退出<br>(如进程崩溃),内核都会发送 FIN 报文,与对方完成四次挥手。
粗暴关闭 vs 优雅关闭
前面介绍 TCP 四次挥手的时候,并没有详细介绍关闭连接的函数,其实关闭的连接的函数有两种函数:<br><br><ul><li><b><font color="#0000ff">close 函数</font></b>,同时 socket <b><font color="#0000ff">关闭发送方向和读取方向</font></b>,也就是 socket 不再有发送和接收数据的能力。如果有 <b><font color="#0000ff">多进程/多线程 </font></b>共享<br>同一个 socket,如果有一个进程调用了 close 关闭 <b><font color="#0000ff">只是让 socket 引用计数 -1</font></b>,并不会导致 socket 不可用,同时也 <b><font color="#0000ff">不会发出 <br>FIN 报文</font></b>,其他进程还是可以正常读写该 socket,<b><font color="#0000ff">直到引用计数变为 0,才会发出 FIN 报文</font></b>。</li><li><b><font color="#0000ff">shutdown 函数</font></b>,可以指定 socket <b><font color="#0000ff">只关闭发送方向而不关闭读取方向</font></b>,也就是 socket 不再有发送数据的能力,但是还是具有<br>接收数据的能力。如果有 <b><font color="#0000ff">多进程/多线程</font></b> 共享同一个 socket,<b><font color="#0000ff">shutdown 则不管引用计数,直接使得该 socket 不可用,然后<br>发出 FIN 报文</font></b>,如果有别的进程企图使用该 socket,将会受到影响。</li></ul>
如果客户端是用 close 函数来关闭连接,那么在 TCP 四次挥手过程中,如果收到了服务端发送的数据,由于客户端已经不再具有发送和接收数据的能力,<br>所以 <b><font color="#0000ff">客户端 </font></b>的内核会回 <b><font color="#0000ff">RST 报文 </font></b>给服务端,然后内核会 <b><font color="#0000ff">释放连接</font></b>,这时就不会经历完成的 TCP 四次挥手,所以我们常说,调用 <b><font color="#0000ff">close 是粗暴的关闭</font></b>。
当服务端收到 RST 后,内核就会释放连接,当服务端应用程序再次发起读操作或者写操作时,就能感知到连接已经被释放了:<br><br><ul><li>如果是读操作,则会返回 RST 的报错,也就是我们常见的 Connection reset by peer。</li><li>如果是写操作,那么程序会产生 SIGPIPE 信号,应用层代码可以捕获并处理信号,如果不处理,则默认情况下进程会终止,异常退出。</li></ul>
相对的,shutdown 函数因为可以指定只关闭发送方向而不关闭读取方向,所以即使在 TCP 四次挥手过程中,如果收到了服务端发送<br>的数据,客户端也是可以正常读取到该数据的,然后就会经历完整的 TCP 四次挥手,所以我们常说,调用 <b><font color="#0000ff">shutdown 是优雅的关闭</font></b>。
但是注意,shutdown 函数也可以指定「只关闭读取方向,而不关闭发送方向」,但是这时候内核是不会发送 FIN 报文的,因为 <b><font color="#0000ff">发送 FIN 报文是<br>意味着我方将不再发送任何数据</font></b>,而 shutdown 如果指定「不关闭发送方向」,就意味着 socket 还有发送数据的能力,所以内核就不会发送 FIN。
什么情况会出现三次挥手?
当被动关闭方(上图的服务端)在 TCP 挥手过程中,<b><font color="#0000ff">「没有数据要发送」并且「</font><font color="#ff0000">开启</font><font color="#0000ff">了 <br>TCP 延迟确认机制」,那么第二和第三次挥手就会合并传输,这样就出现了三次挥手。</font></b>
当被动关闭方(上图的服务端)在 TCP 挥手过程中,<b><font color="#0000ff">「没有数据要发送」,<br>同时「</font><font color="#ff0000">关闭</font><font color="#0000ff">了 TCP 延迟确认机制」,那么就会是四次挥手。</font></b>
然后因为 TCP 延迟确认机制是默认开启的,所以导致我们抓包时,看见三次挥手的次数比四次挥手还多。
什么是 TCP 延迟确认机制?<br>
当发送没有携带数据的 ACK,它的网络效率也是很低的,因为它也有 40 个字节的 IP 头 和 TCP 头,<br>但却没有携带数据报文。 为了解决 ACK 传输效率低问题,所以就衍生出了 <b><font color="#0000ff">TCP 延迟确认</font></b>。 <br><br>TCP 延迟确认的策略:<br><ul><li>当有响应数据要发送时,ACK 会随着响应数据一起立刻发送给对方</li><li>当没有响应数据要发送时,ACK 将会延迟一段时间,以等待是否有响应数据可以一起发送</li><li>如果在延迟等待发送 ACK 期间,对方的第二个数据报文又到达了,这时就会立刻发送 ACK</li></ul>
延迟等待的时间是在 Linux 内核中定义的,如下图:<br>
关键就需要 HZ 这个数值大小,HZ 是跟系统的时钟频率有关,每个<br>操作系统都不一样,在我的 Linux 系统中 HZ 大小是 1000,如下图:
知道了 HZ 的大小,那么就可以算出:<br><ul><li><span style="font-size: inherit;">最大延迟确认时间是 200 ms (1000/5)</span></li><li>最短延迟确认时间是 40 ms (1000/25)</li></ul>
因此,因为<b><font color="#0000ff">延迟机制的存在,服务端收到 FIN 后,不会立马回 ACK</font></b>,因为服务端收到了 FIN,<br>这时候应用程序的 read 就会返回 0,<b><font color="#0000ff">如果没用数据要发送,一般就是直接调用 close 了</font></b>,如果<br><b><font color="#0000ff">在 ACK 延迟的这段时间,刚好马上调用了close,于是就可以和 FIN 一起发送,也就成了三次挥手了</font></b>。
怎么关闭 TCP 延迟确认机制?<br>
如果要关闭 TCP 延迟确认机制,可以在 Socket 设置里 <b><font color="#0000ff">启用 TCP_QUICKACK</font></b>。
TCP_QUICKACK 表示立即给对端回复 ACK,就意味着服务器 <b><font color="#0000ff">接收到 SYN 后马上就要给客户端恢复 ACK(破坏 TCP 延迟确认机制)</font></b>,<br>这时服务器还没来得及判断是否还有数据要发送,当然不可以冒然地发送 FIN,那自然就需要四次挥手了。
总结
当被动关闭方在 TCP 挥手过程中,如果<b><font color="#0000ff">「没有数据要发送」</font></b>,同时「<b><font color="#0000ff">没有开启 TCP_QUICKACK</font></b>(默认情况就是没有开启,没有<br>开启 TCP_QUICKACK,等于就是 <b><font color="#0000ff">在使用 TCP 延迟确认机制</font></b>)」,那么第二和第三次挥手就会合并传输,这样就出现了 <b><font color="#0000ff">三次挥手</font></b>。
<b><font color="#0000ff">所以,出现三次挥手现象,是因为 TCP 延迟确认机制导致的。</font></b>
TCP 序列号和确认号是如何变化的?
子主题
0 条评论
下一页