type
status
date
slug
summary
tags
category
icon
password
计算机网路系列(三):TCP篇
1、TCP是什么
TCP 工作在传输层,是面向连接的、可靠的、基于字节流的传输层通信协议。这次有三个重要概念:
- 面向连接:一定是「一对一」才能连接,不能像 UDP 协议可以一个主机同时向多个主机发送消息。
- 可靠的:无论的网络链路中出现了怎样的链路变化,TCP 都可以保证一个报文一定能够到达接收端;
- 字节流:消息是「没有边界」的,所以无论我们消息有多大都可以进行传输。并且消息是「有序的」,当 「前一个」消息没有收到的时候,即使它先收到了后面的字节,那么也不能扔给应用层去处理,同时对「重复」的报文会自动丢弃。
2、TCP格式
字段解析如下,其中粗体为重要字段:
源端口号
:16 位,数据发起者的端口号。
目标端口号
:16位,数据接收者的端口号。
序列号
:32 位,在建立连接时由计算机生成的随机数作为其初始值,然后为数据流中的每一个字节都编上一个序号,序列号字段的值则指的是本报文段所发送的数据的第一个字节的序号。每发送一次数据,就 「累加」一次该「数据字节数」的大小。用来解决网络包乱序问题。
确认应答号
:32 位,「期望」收到对方的下一个报文段的数据的第一个字节的序号,发送端收到这个确认应答以后可以认为在这个序号以前的数据都已经被正常接收。用来解决不丢包的问题。
数据偏移/首部长度
:4 位,指 TCP 报文段的数据起始处距离 TCP 报文段的起始处有多远,单位是 32 位字(以 4 字节为计算单位)。
保留位
:6 位,保留为今后使用,目前应置为 0。
控制位
:6 位,分别对应六个字段。URG
:该位为 1 时,告诉系统此报文段中有紧急数据,应尽快传送。ACK
:该位为 1 时,「确认应答」的字段变为有效,TCP 规定除了最初建立连接时的 SYN 包之外该位必须设置为 1 。只有当 ACK=1 时确认应答号字段才有效;当 ACK=0 时,确认应答号无效。PSH
:该值为 1 时,应尽快地交付接收应用进程,而不再等到整个缓存都填满了后再向上交付。RST
:该位为 1 时,表示 TCP 连接中出现异常必须强制断开连接。SYN
:该位为 1 时,表示希望建立连接,并在其「序列号」的字段进行序列号初始值的设定。FIN
:该位为 1 时,表示今后不会再有数据发送,希望断开连接。当通信结束希望断开连接时,通信双方的主机之间就可以相互交换 FIN 位为 1 的 TCP 段。
窗口大小
:16 位,服务器向客户端返回的滑动窗口大小,表示服务器当前可以接受的数据量大小,用于流量控制。
检验和
:16 位,用于检测发送过程中是否出现错误。
紧急指针
:16 位,指出在本报文段中紧急数据共有多少个字节(紧急数据放在本报文段数据的最前面)。
选项
:长度可变,TCP 最初只规定了一种选项,即最大报文段长度 MSS。MSS 告诉对方 TCP:“我的缓存所能接收的报文段的数据字段的最大长度是 MSS 个字节.” [MSS(Maximum Segment Size)是 TCP 报文段中的数据字段的最大长度,数据字段加上 TCP 首部才等于整个的 TCP 报文段]。
填充
:这是为了使整个首部长度是 4 字节的整数倍。
TCP 四元组可以唯一的确定一个连接,四元组包括如下:
- 源地址
- 源端口
- 目的地址
- 目的端口
其中源地址和目的地址的字段(32位)是在 IP 头部中,作用是通过 IP 协议发送报文给对方主机;源端口和目的端口的字段(16位)是在 TCP 头部中,作用是告诉 TCP 协议应该把报文发给哪个进程。所以 TCP 协议只包含端口号,不包含 IP 地址。
2.1 MTU和MSS
MTU
:一个网络包的最大⻓度,以太网中一般为 1500 字节;
MSS
:除去 IP 和 TCP 头部之后,一个网络包所能容纳的 TCP 数据的最大⻓度;
当 IP 层有一个超过 MTU 大小的数据要发送,那么 IP 层就要进行分片,把数据分片成若干片,保证每一个分片都小于 MTU。把一份 IP 数据报进行分片以后,由目标主机的 IP 层来进行重新组装后,再交给上一层 TCP 传输层。
但是注意
IP 层没有超时重传等纠错机制
,如果一个 IP 分片丢失,整个 IP 报文的所有分片都得重传。为了只重传丢失的数据,我们会在 TCP 层把超过 MSS 大小的数据提前进行数据分片,确保最后在 IP 层组装分片长度不会大于 MTU,自然也就不用 IP 分片了。经过 TCP 层分片后,如果一个 TCP 分片丢失后,进行重发时也是以 MSS 为单位,而不用重传所有的分片,大大增加了重传的效率。2.2 TCP和UDP的区别
UDP 不提供复杂的控制机制,利用 IP 提供面向「无连接」的通信服务。UDP 协议真的非常简,头部只有 8 个字节( 64 位)。
- 目标和源端口:主要是告诉 UDP 协议应该把报文发给哪个进程。
- 包⻓度:该字段保存了 UDP 首部的⻓度跟数据的⻓度之和。
- 校验和:校验和是为了提供可靠的 UDP 首部和数据而设计。
TCP 和 UDP 对比:
- 连接
- TCP 是面向连接的传输层协议,传输数据前先要建立连接。
- UDP 是不需要连接,即刻传输数据。
- 服务对象
- TCP 是一对一的两点服务,即一条连接只有两个端点。
- UDP 支持一对一、一对多、多对多的交互通信
- 可靠性
- TCP 是可靠交付数据的,数据可以无差错、不丢失、不重复、按需到达。
- UDP 是尽最大努力交付,不保证可靠交付数据。
- 拥塞控制、流量控制
- TCP 有拥塞控制和流量控制机制,保证数据传输的安全性。
- UDP 则没有,即使网络非常拥堵了,也不会影响 UDP 的发送速率。
- 首部开销
- TCP 首部⻓度较⻓,会有一定的开销,首部在没有使用「选项」字段时是20个字节,如果使用了「选项」字段则会变⻓的。
- UDP 首部只有 8 个字节,并且是固定不变的,开销较小。
- 传输方式
- TCP 是流式传输,没有边界,但保证顺序和可靠。
- UDP 是一个包一个包的发送,是有边界的,但可能会丢包和乱序。
- 分片不同
- TCP 的数据大小如果大于 MSS 大小,则会在传输层进行分片,目标主机收到后,也同样在传输层组装 TCP数据包,如果中途丢失了一个分片,只需要传输丢失的这个分片。
- UDP 的数据大小如果大于 MTU 大小,则会在 IP 层进行分片,目标主机收到后,在 IP 层组装完数据,接着再传给传输层,但是如果中途丢了一个分片,在实现可靠传输的 UDP 时则就需要重传所有的数据包,这样 传输效率非常差,所以通常 UDP 的报文应该小于 MTU。
2.3 TCP为什么是可靠的协议
- 三次握手和四次挥手保证连接是可靠的。
- 基于序列号的确认应答机制,保证数据包的有序性。
- 对于没有收到数据包,提供重传机制。
- 为了协调发送方的发送包和接收方的接收包的速度,提供流量控制机制。
- 为了维护网络环境的稳定,提供拥塞控制机制。
3、三次握手
- 一开始,客户端和服务端都处于
CLOSED
状态。先是服务端主动监听某个端口,处于LISTEN
状态。
- 客户端会随机初始化序号(
client_isn
),将此序号置于 TCP 首部的「序号」字段中,同时把SYN
标志 位置为1
,表示SYN
报文。接着把第一个 SYN 报文发送给服务端,表示向服务端发起连接,该报文不 包含应用层数据,之后客户端处于SYN-SENT
状态。
- 服务端收到客户端的
SYN
报文后,首先服务端也随机初始化自己的序号(server_isn
),将此序号填入 TCP 首部的「序号」字段中,其次把 TCP 首部的「确认应答号」字段填入client_isn + 1
, 接着把SYN
和ACK
标志位置为1
。最后把该报文发给客户端,该报文也不包含应用层数据,之后服务端处于SYN-RCVD
状态。
- 客户端收到服务端报文后,还要向服务端回应最后一个应答报文,首先该应答报文 TCP 首部
ACK
标志位 置为1
,其次「确认应答号」字段填入server_isn + 1
,最后把报文发送给服务端,这次报文可以携带客 户到服务器的数据,之后客户端处于ESTABLISHED
状态。
- 服务器收到客户端的应答报文后,也进入
ESTABLISHED
状态。
一旦完成三次握手,双方都处于 ESTABLISHED 状态,此时连接就已建立完成,客户端和服务端就可以相互发送 数据了。
注意:第三次握手是可以携带数据的,前两次握手是不可以携带数据的。
3.1 为什么握手是三次,不是两次,四次?
三次握手的主要原因:
为了防止旧的重复连接初始化造成混乱
。假如网络拥堵,客户端发送的
SYN
报文,或者服务器返回的 SYN+ACK
报文都有可能发生拥堵或者丢失,客户端特定时间内收不到报文触发超时重传,会再次发送SYN报文。假如客户端已经发送了多个
SYN
报文,目前收到了一个 SYN+ACK
报文。使用三次握手的机制,客户端有足够的上下文来判断这个报文是否属于历史连接:- 如果是历史连接(序列号过期或超时),则第三次握手发送的报文是
RST
报文,以此中止历史连接。
- 如果不是历史连接,则第三次发送的报文是
ACK
报文,通信双方就会成功建立连接。
为什么不是两次?
两次握手连接不能判断当前连接是否是历史连接。假如双方正常通信已经完成了,过了一段时间后客户端突然收到一个
SYN+ACK
旧报文。如果是两次握手的话这时候双方会建立起通信,但事实上双方通信已经完成了,这个报文属于脏数据,这次通信不应该被建立。为什么不是四次?
在第二次握手里面,服务器已经把
确认客户端序列号
和发送自己的序列号
合并为一个步骤,所以相当于四次握手简化为三次握手了。在第三次握手之后,就已经理论上最少可靠连接建立,所以不需要使用更多的通信次数。
3.2 半连接队列和全连接队列
在 TCP 三次握手的时候,Linux 内核会维护两个队列,分别是:
- 半连接队列,也称 SYN 队列
- 全连接队列,也称 accepet 队列
服务端收到客户端发起的 SYN 请求后,
内核会把该连接存储到半连接队列
,并向客户端响应 SYN+ACK,接着客户端会返回 ACK,服务端收到第三次握手的 ACK 后,内核会把连接从半连接队列移除,然后创建新的完全的连接,并将其添加到 accept 队列,等待进程调用 accept 函数时把连接取出来
。不管是半连接队列还是全连接队列,都有最大⻓度限制,超过限制时,内核会直接丢弃,或返回 RST 包。
3.3 SYN攻击
假设攻击者短时间伪造不同 IP 地址的 SYN 报文,服务端每接收到 一个 SYN 报文,就进入 SYN_RCVD 状态,但服务端发送出去的 ACK + SYN 报文,无法得到未知 IP 主机的 ACK 应答,久而久之就会占满服务端的 SYN 接收队列,使得服务器不能为正常用户服务。
避免SYN攻击的策略:启用
net.ipv4.tcp_syncookies
参数。- 当 「 SYN 队列」满之后,后续服务器收到 SYN 包,不进入「 SYN 队列」
- 计算出一个 cookie 值,再以 SYN + ACK 中的「序列号」返回客户端,
- 服务端接收到客户端的应答报文时,服务器会检查这个 ACK 包的合法性。如果合法,直接放入到「 Accept 队列」。
- 最后应用通过调用 accpet() socket 接口,从「 Accept 队列」取出的连接。
3.4 TCP的保活机制
已经建立好的 TCP 连接,如果长时间没有数据交互,就会触发
保活机制
。这个机制的原理是这样的:定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用,每隔一个时间间隔,发送一个探测报文,该探测报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的 TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。
在 Linux 内核可以有对应的参数可以设置保活时间、保活探测的次数、保活探测的时间间隔,以下都为默认值:
- tcp_keepalive_time=7200:表示保活时间是 7200 秒(2小时),也就 2 小时内如果没有任何连接相关的活动,则会启动保活机制
- tcp_keepalive_intvl=75:表示每次检测间隔 75 秒;
- tcp_keepalive_probes=9:表示检测 9 次无响应,认为对方是不可达的,从而中断本次的连接。
也就是说在 Linux 系统中,最少需要经过 2 小时 11 分 15 秒才可以发现一个「死亡」连接。
4、四次挥手
tcp连接断开通过四次挥手完成。注意和三次挥手不同,三次挥手只能由客户端发起,而四次挥手双方都可以主动断开连接,断开连接后主机中的「资源」将被释放。
- 客户端打算关闭连接,此时会发送一个 TCP 首部 FIN 标志位被置为 1 的报文,也即 FIN 报文,之后客户端进入 FIN_WAIT_1 状态。
- 服务端收到该报文后,就向客户端发送 ACK 应答报文,接着服务端进入 CLOSED_WAIT 状态。
- 客户端收到服务端的 ACK 应答报文后,之后进入 FIN_WAIT_2 状态。
- 等待服务端处理完数据后,也向客户端发送 FIN 报文,之后服务端进入 LAST_ACK 状态。
- 客户端收到服务端的 FIN 报文后,回一个 ACK 应答报文,之后进入 TIME_WAIT 状态。
- 服务器收到了 ACK 应答报文后,就进入了 CLOSED 状态,至此服务端已经完成连接的关闭。
- 客户端在经过 2MSL 一段时间后,自动进入 CLOSED 状态,至此客户端也完成连接的关闭。
为什么挥手是四次?
- 关闭连接时,客户端向服务端发送 FIN 时,仅仅表示客户端不再发送数据了但是还能接收数据。
- 服务器收到客户端的 FIN 报文时,先回一个 ACK 应答报文,而服务端可能还有数据需要处理和发送,等服务端不再发送数据时,才发送 FIN 报文给客户端来表示同意现在关闭连接。
所以每个方向都需要一个 FIN 和一个 ACK,而服务端的 ACK 和 FIN 一般都会分开发送,从而比三次握手导致多了一次。
为什么需要TIME_WAIT?
主动发起关闭连接的一方,才会有
TIME-WAIT
状态。需要
TIME-WAIT
状态的原因:- 防止旧的数据包被接收。如果 TIME-WAIT 过短或者没有,假如网络拥堵,关闭本次连接再重新打开连接之后,旧连接的数据包才到达,就会造成数据混乱。而足够长的 TIME-WAIT 足以
让两个方向上的数据包都被丢弃,使得原来连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的
。
- 防止数据包丢失导致被动关闭方不能正常关闭。四次挥手的最后一个 ACK 报文如果在网络中被丢失了,此时如果客户端 TIME-WAIT 过短或没有,则就直接进入了 CLOSED 状态了,那么服务端则会一直处在 LASE_ACK 状态。后面客户端和服务端就不能正常建立连接。而足够长的 TIME-WAIT 可以保证
服务端没有收到四次挥手的最后一个 ACK 报文时,则会重发 FIN 关闭连接报文并等待新的 ACK 报文,从而帮助双方都正确关闭
。
为什么TIME_WAIT的时间是2MSL?
MSL 是 Maximum Segment Lifetime,报文最大生存时间,它是
任何报文在网络上存在的最⻓时间,超过这个时 间报文将被丢弃
。在 Linux 系统里 2MSL 默认是 60 秒,那么一个 MSL 也就是 30 秒。Linux 系统停留在 TIME_WAIT 的时 间为固定的 60 秒。为什么TIME_WAIT的时间是2MSL?比较合理的解释是: 网络中可能存在来自发送方的数据包,当这些发送方的数据包被接收方处理后又会向对方发送响应,所以一来一回需要等待 2 倍的时间。设置2MSL的时间,可以保证发送方发出去的数据都收到响应。
2MSL 的时间是从客户端接收到 FIN 后发送 ACK 开始计时的。如果在 TIME-WAIT 时间内,因为客户端的 ACK 没有传输到服务端,客户端又接收到了服务端发的 FIN 报文,那么 2MSL 时间将重新计时。
TIME_WAIT过多的危害和优化
发送方过多的 TIME-WAIT 状态的 TCP 连接会占用有限的端口资源,一般可以开启的端口为
32768~61000
。如果发起连接一方的 TIME_WAIT 状态过多,占满了所有端口资源,则会导致无法创建新连接。优化 TIME_WAIT 的方法:
- 复用处于 TIME_WAIT 的 socket 为新的连接所用。客户端在调用 connect() 函数时,内核会随机找一个 time_wait 状态超过 1 秒的连接给新的连接复用。使用 net.ipv4.tcp_tw_reuse 和 net.ipv4.tcp_timestamps 参数。
重传机制
TCP 针对数据包丢失的情况,会用
重传机制
解决。常⻅的重传机制:
- 超时重传
- 快速重传
- 选择重传
超时重传
发送数据时,设定一个定时器,当超过指定的时间后,没有收到对方的 ACK 确认应答报文,就会重发该数据,这就是
超时重传
。超时重传有两个重要概念:
RTT
(Round-Trip Time):往返时间,也就是从数据发出去到接收响应所需的时间。
RTO
(Retransmission Timeout):超时重传时间。
RTO 的值会影响重传机制的效率:
- 当超时时间 RTO 较大时,发就慢,丢了老半天才重发,没有效率,性能差;
- 当超时时间 RTO 较小时,会导致可能并没有丢就重发,于是重发的就快,会增加网络拥塞,导致更多的超时,更多的超时导致更多的重发。
理论上
超时重传时间 RTO 的值应该略大于报文往返 RTT 的值
。实际上「报文往返 RTT 的值」是经常变化的,因为我们的网络也是时常变化的。也就因为「报文往返 RTT 的值」是经常波动变化的,所以「超时重传时间 RTO 的值」应该是一个动态变化的值。
快速重传
如果丢失的数据包都等超时时间到了才进行重传,有时候未免太慢了。所以还有另外一套重传机制:
快速重传
。快速重传机制的核心思想就是:
发送方当收到三个相同的 ACK 报文时,会在定时器过期之前,重传丢失的报文段
。选择重传
快速重传虽然解决了重传缓慢的问题,但是面临重传什么包的问题。例如收到三个同样的ACK,只能说明这个ACK对应的包丢失了,但是并不知道这三个ACK是哪些发出去的包返回的,也就是不知道服务器已经收到了哪些包。
在快速重传的基础上,返回最近收到的报文段的序列号范围,这样客户端就知道哪些数据包已经到达服务器了
,这就是选择重传
。SACK机制
选择重传基于
SACK(Selective Acknowledgment)
机制。SACK 机制需要双方支持。在 TCP 头部「选项」字段里加一个 SACK 的东⻄,它可以将缓存的地图发送给发送方,这样发送方就可以知道哪些数据收到了,哪些数据没收到,知道了这些信息,就可以只重传丢失的数据。
例如发送方收到了三次同样的 ACK 确认报文,于是就会触发快速重发机制,通过 SACK 信息发现只有 200~299 这段数据丢失,则重发时,就只选择了这个 TCP 段进行重复。
D-SACK机制
D-SACK,即重复SACK,对SACK机制进行扩展,使得扩展后的SACK可以告知发送方有哪些数据包自己重复接收了。引入D-SACK的目的是使TCP进行更好的流控:
- 让发送方知道,是发送的包丢了,还是返回的ACK包丢了;
- 网络上是否出现了包失序;
- 数据包是否被网络上的路由器复制并转发了
- 是不是自己的timeout太小了,导致重传
D-SACK的规则如下:
- 第一个段将包含重复收到的报文段的序号
- 跟在D-SACK之后的SACK将按照SACK的方式工作
- 如果有多个被重复接收的报文段,则D-SACK只包含其中第一个
如何判断是普通SACK还是D-SACK?D-SACK使用了
SACK的第一个段
来作为判断标准:- 如果SACK的第一个段的范围被ACK所覆盖,那么就是D-SACK
- 如果SACK的第一个段的范围被SACK的第二个段覆盖,那么就是D-SACK
例如:
1、满足第一个判断标准
由于ACK 4000大于[3000,3500],因此[4000, SACK=3000-3500]是D-SACK。发送端首先向接收端发送了3000-3499,3500-3999报文,接收端都收到了,但是接收端返回的ACK 3500及4000都丢失,导致发送端重传了3000-3499报文。接收端收到发送端重新发送的3000-3499报文,通过[4000,SACK=3000-3500]告知发送端,发送端就知道第一次的3000-3499报文接收端是收到了,由于当前ACK到了4000,那么4000之前的数据也都收到了。
2、满足第二个判断标准
由于第一个段[5000-5500]被第二个段[4500-5500]所覆盖,所以[4000, SACK=5000-5500, 4500-5500]是D-SACK,而前面的[4000, SACK=4500-5000]及[4000, SACK=4500-5500]都是普通的SACK。含义是4000前的包收到,5000-5499包重复收到,4500-5500的包都收到,4000-4499的包丢失。
流量控制
发送方不能无脑的发数据给接收方,要考虑接收方处理能力。
如果一直无脑的发数据给对方,但对方处理不过来,那么就会导致触发重发机制,从而导致网络流量的无端的浪费。
为了解决这种现象发生,
TCP 提供一种机制可以让「发送方」根据「接收方」的实际接收能力控制发送的数据量
,这就是所谓的流量控制
。流量控制基于
滑动窗口
。滑动窗口
如果 TCP 每发送一个数据,都要进行一次确认应答。当上一个数据包收到了应答了,再发送下一个,这种发送效率会很低。所以我们可以
定义一个窗口,窗口大小就是指无需等待确认应答,而可以继续发送数据的最大值
,这就是滑动窗口
。TCP 头里有一个字段叫
Window
,也就是窗口大小
。 这个字段是接收端告诉发送端自己还有多少缓冲区可以接收数据。于是发送端就可以根据这个接收端的处理能力来发送数据,而不会导致接收端处理不过来。所以,
通常窗口的大小是由接收方的窗口大小来决定的
。发送方发送的数据大小不能超过接收方的窗口大小,否则接收方就无法正常接收到数据。滑动窗口的实现
发送方和接收方分别有两个缓冲区,一个是 buffer,指的是内核缓冲区,另外一个是 application,指的是应用层的缓冲区。
对于发送方来说,当要发送数据的时候,需要从 application 拷贝数据到 buffer,而对于接收方来说,当接收到数据的时候,会先缓存到 buffer,然后再由应用程序从 buffer 读取数据。
发送方的滑动窗口
发送方的滑动窗口方案使用三个指针来跟踪在四个传输类别中的每一个类别中的字节。其中两个指针是绝对指针(指特定的序列号),一个是相对指针(需要做偏移)。
SND.WND
:表示发送窗口的大小(大小是由接收方指定的);
SND.UNA
:是一个绝对指针,它指向的是已发送但未收到确认的第一个字节的序列号,也就是 #2 的第一个字节。
SND.NXT
:也是一个绝对指针,它指向未发送但可发送范围的第一个字节的序列号,也就是 #3 的第一个 字节。
- 指向 #4 的第一个字节是个相对指针,它需要 SND.UNA 指针加上 SND.WND 大小的偏移,就可以指向 #4 的第一个字节了。
那么可用窗口大小的计算:
可用窗口大小 = SND.WND -(SND.NXT - SND.UNA)
接收方的滑动窗口
其中三个接收部分,使用两个指针进行划分:
RCV.WND
:表示接收窗口的大小,它会通告给发送方。
RCV.NXT
:是一个指针,它指向期望从发送方发送来的下一个数据字节的序列号,也就是 #3 的第一个字 节。
- 指向 #4 的第一个字节是个相对指针,它需要 RCV.NXT 指针加上 RCV.WND 大小的偏移,就可以指向 #4 的第一个字节了。
流量控制的实现
- 客户端是接收方,服务端是发送方
- 假设接收窗口和发送窗口相同,都为 200,整个传输过程中都保持相同的窗口大小,不受外界影响
- 客户端向服务端发送请求数据报文。这里要说明下,本次例子是把服务端作为发送方,所以没有画出服务端 的接收窗口。
- 服务端收到请求报文后,发送确认报文和 80 字节的数据,于是可用窗口 Usable 减少为 120 字节,同时 SND.NXT 指针也向右偏移 80 字节后,指向 321,这意味着下次发送数据的时候,序列号是 321。
- 客户端收到 80 字节数据后,于是接收窗口往右移动 80 字节, RCV.NXT 也就指向 321,这意味着客户端期 望的下一个报文的序列号是 321,接着发送确认报文给服务端。
- 服务端再次发送了 120 字节数据,于是可用窗口耗尽为 0,服务端无再继续发送数据。
- 客户端收到 120 字节的数据后,于是接收窗口往右移动 120 字节, RCV.NXT 也就指向 441,接着发送确认报文给服务端。
- 服务端收到对 80 字节数据的确认报文后, SND.UNA 指针往右偏移后指向 321,于是可用窗口 Usable 增大到 80。
- 服务端收到对 120 字节数据的确认报文后, SND.UNA 指针往右偏移后指向 441,于是可用窗口 Usable 增大到 200。
- 服务端可以继续发送了,于是发送了 160 字节的数据后, SND.NXT 指向 601,于是可用窗口 Usable 减少到 40。
- 客户端收到 160 字节后,接收窗口往右移动了 160 字节, RCV.NXT 也就是指向了 601,接着发送确认报文给服务端。
- 服务端收到对 160 字节数据的确认报文后,发送窗口往右移动了 160 字节,于是 SND.UNA 指针偏移了 160 后指向 601,可用窗口 Usable 也就增大至了 200。
零窗口和窗口探测
当接收方的 buffer 堆积了大量数据但 application 还没来得及读取,最终导致 buffer 满了,这时
接收方就会给发送方通报一个接收窗口为0的报文,表示不能再接收数据
,这就是零窗口
。当接收方处理完数据后,会向发送方通告一个窗口非 0 的 ACK 报文,如果这个通告窗口的 ACK 报文在网络中丢失了,那就会造成死锁:
- 发送方一直等待非 0 窗口通知。
- 接收方一直等待发送方发送数据。
为了解决死锁问题,TCP
为每个连接设有一个持续定时器,只要发送方收到对方的零窗口通知,就启动持续计时器
。如果持续计时器超时,就会发送窗口探测
(Window probe) 报文,而对方在确认这个探测报文时,给出自己现在的接收窗口大小。- 如果接收窗口仍然为 0,那么收到这个报文的一方就会重新启动持续计时器。
- 如果接收窗口不是 0,那么死锁的局面就可以被打破了。
延迟确认和Nagle算法
虽然滑动窗口可以发送多个数据包提高了通信效率,但是如果频繁的进行 TCP 小包通信, 通信效率还是是非常低下的。为了提高网络传输效率;
- 对于发送方来说可以使用
Nagle算法
- 对于接收方来说可以使用
延迟确认
。
Nagle算法
Nagle 算法的策略:
- 没有已发送未确认报文时,立刻发送数据。
- 存在未确认报文时,直到「收到所有未确认报文的ACK报文」或「数据⻓度达到 MSS 大小」时,再发送数据。
只有满足上面的其中一条发送方才会发送数据,否则发送方一直在囤积数据。
右侧启用了 Nagle 算法,它的发送数据的过程:
- 一开始由于没有已发送未确认的报文,所以就立刻发了 H 字符。
- 接着,在还没收到对 H 字符的确认报文时,发送方就一直在囤积数据,直到收到了确认报文后,此时没有已发送未确认的报文,于是就把囤积后的 ELL 字符一起发给了接收方。
- 待收到对 ELL 字符的确认报文后,于是把最后一个 O 字符发送了出去。
可以看出,Nagle 算法一定会有一个小报文,也就是在最开始的时候。
Nagle 算法默认是打开的,如果对于一些需要小数据包交互的场景的程序,比如,telnet 或 ssh 这样的交互 性比较强的程序,则需要关闭 Nagle 算法。在 Socket 设置
TCP_NODELAY
选项来关闭这个算法。延迟确认
如果接收方每次一收到数据就回复没有携带数据的 ACK,它的网络效率也是很低的,因为它也有 40 个字节的 IP 头 和 TCP 头,但却没有携带数据报文。为了提高效率就需要接收方
延迟确认
。TCP 延迟确认的策略:
- 当有响应数据要发送时,ACK 会随着响应数据一起立刻发送给对方。
- 当没有响应数据要发送时,ACK 将会延迟一段时间,以等待是否有响应数据可以一起发送
- 如果在延迟等待发送 ACK 期间,对方的第二个数据报文又到达了,这时就会立刻发送 ACK
注意:Nagle算法和延迟确认不能同时开启。当 TCP 延迟确认 和 Nagle 算法混合使用时,会导致时耗增⻓。
- 发送方先发出一个小报文,接收方收到后,由于延迟确认机制,自己又没有要发送的数据,只能干等着发送方的下一个报文到达;
- 而发送方由于 Nagle 算法机制,在未收到第一个报文的确认前,是不会发送后续的数据。所以接收方只能等待最大时间 200 ms 后,才回 ACK 报文,发送方收到第一个报文的确认报文后,也才可以发送后续的数据。
拥塞控制
流量控制是避免「发送方」的数据填满「接收方」的缓存
。但是在实际网络传输的过程中,网络传输还会受到网络环境的影响。在网络出现拥堵时,如果继续发送大量数据包,可能会导致数据包时延、丢失等,这时 TCP 就会重传数据,但是一重传就会导致网络的负担更重,于是会导致更大的延迟以及更多的丢包,这个情况就会进入恶性循环。所以就出现了拥塞控制,拥塞控制的目的就是避免「发送方」的数据填满整个网络
。为了在「发送方」调节所要发送数据的量,定义了一个叫做「
拥塞窗口
」的概念。拥塞窗口 cwnd
是发送方维护的一个的状态变量,它会根据网络的拥塞程度动态变化的。拥塞窗口 cwnd
变化的规则:- 只要网络中没有出现拥塞, cwnd 就会增大。
- 但网络中出现了拥塞, cwnd 就减少。
拥塞窗口
cwnd
、发送窗口 swnd
和接收窗口 rwnd
的关系:swnd = min(cwnd, rwnd)
,也就是说发送窗口是拥塞窗口和接收窗口中的最小值。但是 rwnd 是由对端确定的,网络环境对其没有影响,所以在考虑拥塞的时候我们一般不考虑 rwnd
的值。只要「发送方」没有在规定时间内接收到 ACK 应答报文,也就是发生了超时重传,就会认为网络出现了拥塞。TCP拥塞控制算法有三个版本(Tahoe/Reno/Newreno),Tahoe是TCP的最早版本,包含三个算法:
慢启动
(Slow Start)
拥塞避免
(Congestion Avoidance)
快速重传
(Fast Retransmit)
Reno 包含 Tahoe 的三个算法,还多了一个
快速恢复
(Fast Recovery)算法。Reno是目前使用最广泛的算法。NewReno 是基于 Reno 的改进版本,主要是改进了快速恢复算法。
慢启动
TCP 在刚建立连接完成后,首先是有个慢启动的过程,这个慢启动的意思就是一点一点的提高发送数据包的数量。慢启动的规则是:
当发送方每收到一个 ACK,拥塞窗口 cwnd 的大小就会加 1,直到 cwnd 大小达到 ssthresh
。慢启动⻔限
ssthresh
是拥塞控制算法定义的一个阈值变量:- 当
cwnd
<ssthresh
时,使用「慢启动算法」。
- 当
cwnd
>=ssthresh
时,就会使用「拥塞避免算法」。
连接建立完成后,一开始初始化 cwnd = 1 ,表示可以传一个 MSS 大小的数据。
- 当收到一个 ACK 确认应答后,cwnd 增加 1,于是一次能够发送 2 个
- 当收到 2 个的 ACK 确认应答后, cwnd 增加 2,于是就可以比之前多发2 个,所以这一次能够发送 4 个
- 当这 4 个的 ACK 确认到来的时候,每个确认 cwnd 增加 1, 4 个确认 cwnd 增加 4,于是就可以比之前多发 4 个,所以这一次能够发送 8 个。
由此可以看出
慢启动算法发包的个数是指数性的增⻓
。拥塞避免
当拥塞窗口
cwnd
「超过」慢启动⻔限 ssthresh
就会进入拥塞避免算法。一般来说 ssthresh
的大小是 65535 字节。拥塞避免算法的规则是:
每当收到一个 ACK 时,cwnd 增加 1/cwnd
。换而言之,当收到 cwnd 数量的 ACK 时,cwnd 的大小就会加1
。- 因为 ssthresh 为 8,当 8 个 ACK 应答确认到来时,每个确认增加 1/8,8 个 ACK 确认 cwnd 一共增加 1,于是这一次能够发送 9个 MSS 大小的数据,变成了线性增⻓。
拥塞避免算法就是将原本慢启动算法的指数增⻓变成了线性增⻓
。快速重传
拥塞避免算法虽然把发送包的增长速度变成了线性增长,但毕竟还在增⻓阶段。就这么一直增⻓着后,网络就会慢慢进入了拥塞的状况了,于是就会出现丢包现象,这时就需要对丢失的数据包进行重传。
重传机制主要有两种:
- 超时重传
- 快速重传
这两种重传机制里面
ssthresh
和 cwnd
的值的变化是不同的。超时重传对应的拥塞控制算法
ssthresh
和 cwnd
的变化:ssthresh = cwnd/2
。
cwnd
重置为 1。
- 进入慢启动算法。
慢启动会突然减少数据流,导致网络剧烈抖动,这种方式太激进了,并不推荐日常使用。所以Reno算法提出了更温和的快速重传+快速恢复算法。
快速重传对应的拥塞发生算法
快速重传算法下,当接收方发现丢了一个中间包的时候,发送三次前一个包的 ACK,于是发送端就会快速地重传,不必等待超时再重传。快速恢复算法是认为,你还能收到 3 个重复的 ACK 说明网络也不那么糟糕,没必要像超时重传那么激烈。
ssthresh
和 cwnd
的变化:ssthresh = cwnd/2
。
cwnd = ssthresh + 3
(3 的意思是确认有 3 个数据包被收到了)。
- 进入快速恢复算法。
快速恢复
快速重传和快速恢复算法一般同时使用
- 重传丢失的数据包。
- 如果再收到重复的 ACK,那么 cwnd 增加 1。
- 如果收到新数据的 ACK 后,把 cwnd 设置为 ssthresh 的值,然后再次进入拥塞避免状态。原因是该 ACK 确认了新的数据,说明从 duplicated ACK 时的数据都已收到,该恢复过程已经结束,可以回到恢复之前的拥塞避免状态了。
拥塞避免算法过程
- 当新建连接时,cwnd初始化为1个最大报文段(MSS)大小,发送端开始按照拥塞窗口大小发送数据,每当有一个报文段被确认,cwnd就增加1个MSS大小。这样cwnd的值就随着网络往返时间(Round Trip Time,RTT)呈指数级增长。
- 当cwnd超过慢启动门限(ssthresh)后,慢启动过程结束,进入拥塞避免算法。当窗口中所有的报文段都被确认时,cwnd的大小加1。cwnd的值就随着RTT开始线性增加。
- 当检测到拥塞状态(判断拥塞的依据是TCP对每一个报文段都有一个定时器,称为重传定时器(RTO),当RTO超时且还没有得到ack报文,或者连续三次收到相同的ack报文,就判断出现了拥塞情况。),进入快读重传阶段,把cwnd设置为ssthresh的值加3,然后重传丢失的报文段,进入快速恢复阶段。
- 再收到重复的ACK时,拥塞窗口增加1。当收到新的数据包的ACK时,把cwnd设置为第3步中的ssthresh的值。原因是因为该ACK确认了新的数据,说明从重复ACK时的数据都已收到,该恢复过程已经结束,可以回到恢复之前的状态了,也即再次进入拥塞避免状态。
常见问题
解决time_wait过多的问题
解决方案很简单,通过修改/etc/sysctl.conf文件,服务器能够快速回收和重用那些TIME_WAIT的资源
解决CLOSE_WAIT过多的问题
CLOSE_WAIT很多,表示说要么是你的应用程序写的有问题,没有合适的关闭socket;要么是说,你的服务器CPU处理不过来(CPU太忙)或者你的应用程序一直睡眠到其它地方(锁,或者文件I/O等等),你的应用程序获得不到合适的调度时间,造成你的程序没法真正的执行close操作。
泛洪攻击
- Author:mcbilla
- URL:http://mcbilla.com/article/18ff9b54-1aa4-40fb-8254-992e53bb3a68
- Copyright:All articles in this blog, except for special statements, adopt BY-NC-SA agreement. Please indicate the source!
Relate Posts