TCP(Transmission Control Protocol,传输控制协议),是一种面向连接的可靠传输协议,提供可靠(无差错、不丢失、不重复、按顺序)的字节流数据传输服务。在传输效率和可靠性之间选择了后者,所以有开销大、传输速度慢的缺点。
TCP 的可靠性传输具有非常复杂的实现细节,包括但不限于:
数据段首部:
其中的 TCP Flags 字段,是非常重要的功能标识,占 8 位,分别为:
下面以三次握手和四次挥手为例,看看数据段首部是怎么进行传输标识并以此来保存可靠性的。
Client IP:172.18.128.204
Server TCP Socket:(10.0.0.128, 80)
172.18.128.204.62534 > 10.0.0.128.80: Flags [S], cksum 0x8523 (correct), seq 3401804541, win 65535, options [mss 1460,nop,wscale 5,nop,nop,TS val 960632545 ecr 0,sackOK,eol], length 0
客户端执行系统调用 connect()
发出 SYN 请求建立 TCP 连接,此时客户端的 TCP 端口状态为 SYN_SENT(表示请求连接)。
10.0.0.128.80 > 172.18.128.204.62534: Flags [S.], cksum 0x378d (incorrect -> 0xf136), seq 2666924726, ack 3401804542, win 27960, options [mss 1410,sackOK,TS val 19959035 ecr 960632545,nop,wscale 7], length 0
服务端执行系统调用 listen()
监听到 SYN 请求,TCP 端口状态从 LISTEN 转为 SYN_RCVD 并第一次响应 ACK。
172.18.128.204.62534 > 10.0.0.128.80: Flags [.], cksum 0x7cfb (correct), seq 3401804542, ack 2666924727, win 4106, options [nop,nop,TS val 960632549 ecr 19959035], length 0
客户端接收到 ACK 响应之后,TCP 端口状态变成 ESTABLISHED(已建立连接,表示通信双方正在通信),再给服务端发送 ACK。服务端接收到 ACK 之后,服务端的 TCP 端口状态为 ESTABLISHED。
NOTE:Flags are some combination of S (SYN), F (FIN), P (PUSH), R (RST), W (ECN CWR) or E (ECN-Echo), or a single ‘.’ (no flags)
为什么要使用三次握手来保证数据传输的可靠性?
“3 次握手” 的作用就是双方都能明确自己和对方的收、发能力是正常的,并告知收发双方自己的 ISN(Initial Sequence Number,初始化序号),如上文 Client:seq=996980318
或 Server:seq=2180032179
,这个 ISN 会被作为建立连接后进行「顺序」数据传输的依据。我们可以从数据段首部的 Sequence Number 和 Acknowledgment Number 都占 32 位得知,seq 和 ack 的取值范围均是 [0, 2^32-1],所以 ISN 实际上会被顺序循环使用。而且 seq 并非每次都是从 0 开始的,TCP 协议会以 4μs 一次的频率进行 ISN+=1 操作,以此来避免 TCP 重连时出现在同一条连接中存在两个及以上 seq number 相同的数据包,而最终导致顺序错乱。所以,三次握手实际上就是初始化通信双方的 seq ISN,保证数据包的有序传输。
双方建立通信之后,Client 向 Server 正式发出 HTTP 请求:
IP (tos 0x0, ttl 60, id 0, offset 0, flags [DF], proto TCP (6), length 129)
172.18.128.204.62534 > 10.0.0.128.80: Flags [P.], cksum 0xe98f (correct), seq 3401804542:3401804619, ack 2666924727, win 4106, options [nop,nop,TS val 960632549 ecr 19959035], length 77: HTTP, length: 77
GET / HTTP/1.1
Host: 172.18.22.208
User-Agent: curl/7.54.0
Accept: */*
IP (tos 0x0, ttl 64, id 34743, offset 0, flags [DF], proto TCP (6), length 52)
10.0.0.128.80 > 172.18.128.204.62534: Flags [.], cksum 0x3785 (incorrect -> 0x8bd8), seq 2666924727, ack 3401804619, win 219, options [nop,nop,TS val 19959040 ecr 960632549], length 0
seq 3401804542:3401804619
== seq 3401804542:[3401804542+length]
Server 处理请求并响应:
IP (tos 0x0, ttl 64, id 34744, offset 0, flags [DF], proto TCP (6), length 295)
10.0.0.128.80 > 172.18.128.204.62534: Flags [P.], cksum 0x3878 (incorrect -> 0x528d), seq 2666924727:2666924970, ack 3401804619, win 219, options [nop,nop,TS val 19959044 ecr 960632549], length 243: HTTP, length: 243
HTTP/1.1 200 OK
Date: Thu, 24 Jan 2019 10:08:31 GMT
Server: Apache/2.4.6 (CentOS)
Last-Modified: Thu, 24 Jan 2019 08:26:02 GMT
ETag: "4-5802ff5f8b6b4"
Accept-Ranges: bytes
Content-Length: 4
Content-Type: text/html; charset=UTF-8
123
IP (tos 0x0, ttl 60, id 0, offset 0, flags [DF], proto TCP (6), length 52)
172.18.128.204.62534 > 10.0.0.128.80: Flags [.], cksum 0x7bae (correct), seq 3401804619, ack 2666924970, win 4099, options [nop,nop,TS val 960632560 ecr 19959044], length 0
NOTE 1:在经过了三次握手之后(Client 和 Server 都确定了对方的 seq ISN),正式的 HTTP 数据传输是在有序进行的。
NOTE 2:可以看见每一个数据包的发出都有相应的 ACK 响应,确保接收方有确切的接收到数据包,否则发送方会启用超时重发。
TCP 协议规定,对于已经建立的连接,收发双方要进行四次挥手才能成功断开连接,如果缺少了其中某个步骤,都会使连接处于假死状态,连接本身所占用的资源不会被释放。
172.18.128.204.62534 > 10.0.0.128.80: Flags [F.], cksum 0x7bad (correct), seq 3401804619, ack 2666924970, win 4099, options [nop,nop,TS val 960632560 ecr 19959044], length 0
由 Client 提出断开连接请求 FIN(Flags [F.],
),Client 的 TCP 端口进入 FIN-WAIT-1 状态。
10.0.0.128.80 > 172.18.128.204.62534: Flags [F.], cksum 0x3785 (incorrect -> 0x8acb), seq 2666924970, ack 3401804620, win 219, options [nop,nop,TS val 19959053 ecr 960632560], length 0
Server 发送 ACK 应答,Server 的 TCP 端口进入 CLOSE_WAIT 状态,Client 的 TCP 端口进入 FIN-WAIT-2 状态。几乎同时 Server 还发送了一个 FIN 端口连接请求,进入 LAST-ACK 状态。
172.18.128.204.62534 > 10.0.0.128.80: Flags [.], cksum 0x7b9a (correct), seq 3401804620, ack 2666924971, win 4099, options [nop,nop,TS val 960632569 ecr 19959053], length 0
Client 进行 ACK 应答(LAST ACK),进入 TIME-WAIT(两倍的分段最大生存期),并最终变成 CLOSED 状态。
为什么需要四次挥手?
TCP 连接是双向传输的对等的模式,就是说双方都可以同时向对方发送或接收数据。当有一方要关闭连接时,会发送指令告知对方,我要关闭连接了。这时对方会回一个ACK,此时一个方向的连接关闭。但是另一个方向仍然可以继续传输数据,也就是说,服务端收到客户端的 FIN 标志,知道客户端想要断开这次连接了,但是,服务端还可能希望发数据,等到发送完了所有的数据后,会发送一个 FIN 段来关闭此方向上的连接。接收方发送 ACK确认关闭连接。客户端发送了 FIN 连接释放报文之后,服务器收到了这个报文,就进入了 CLOSE-WAIT 状态。这个状态是为了让服务器端发送还未传送完毕的数据,传送完毕之后,服务器会发送 FIN 连接释放报文。因为服务端在 LISTEN 状态下,收到建立连接请求的 SYN 报文后,把 ACK 和 SYN 放在一个报文里发送给客户端。而关闭连接时,当收到对方的 FIN 报文时,仅仅表示对方不再发送数据了但是还能接收数据,己方是否现在关闭发送数据通道,需要上层应用来决定,因此,己方 ACK 和 FIN 一般都会分开发。
短连接:Client 向 Server 发送消息,Server 回应 Client,然后一次读写就完成了,这时候双方任何一个都可以发起 close 操作,不过一般都是 Client 先发起 close 操作。短连接一般只会在 Client/Server 间传递一次读写操作。优点:管理起来比较简单,建立存在的连接都是有用的连接,不需要额外的控制手段。
长连接:Client 与 Server 完成一次读写之后,它们之间的连接并不会主动关闭,后续的读写操作会继续使用这个连接。
在长连接的应用场景下,Client 端一般不会主动关闭它们之间的连接,Client 与 Server 之间的连接如果一直不关闭的话,随着客户端连接越来越多,Server 压力也越来越大,这时候 Server 端需要采取一些策略,如关闭一些长时间没有读写事件发生的连接,这样可以避免一些恶意连接导致 Server 端服务受损;如果条件再允许可以以客户端为颗粒度,限制每个客户端的最大长连接数,从而避免某个客户端连累后端的服务。
长连接和短连接的产生在于 Client 和 Server 采取的关闭策略,具体的应用场景采用具体的策略。
常见的重传机制有:
重传机制的其中一个方式,就是在发送数据时,设定一个定时器,当超过指定的时间后,没有收到对方的 ACK 确认应答报文,就会重发该数据,也就是我们常说的超时重传。
TCP 会在以下两种情况发生超时重传:
超时时间应该设置为多少呢?我们先来了解一下什么是 RTT(Round-Trip Time 往返时延),从下图我们就可以知道:
RTT 就是数据从网络一端传送到另一端所需的时间,也就是包的往返时间。超时重传时间是以 RTO (Retransmission Timeout 超时重传时间)表示。假设在重传的情况下,超时时间 RTO 「较长或较短」时,会发生什么事情呢?
精确的测量超时时间 RTO 的值是非常重要的,这可让我们的重传机制更高效。根据上述的两种情况,我们可以得知,超时重传时间 RTO 的值应该略大于报文往返 RTT 的值。
实际上「报文往返 RTT 的值」是经常变化的,因为我们的网络也是时常变化的。也就因为「报文往返 RTT 的值」 是经常波动变化的,所以「超时重传时间 RTO 的值」应该是一个动态变化的值。
我们来看看 Linux 是如何计算 RTO 的呢?估计往返时间,通常需要采样以下两个:
RFC6289 建议使用以下的公式计算 RTO:
其中 SRTT 是计算平滑的RTT ,DevRTR 是计算平滑的RTT 与 最新 RTT 的差距。在 Linux 下,α = 0.125,β = 0.25, μ = 1,∂ = 4。
如果超时重发的数据,再次超时的时候,又需要重传的时候,TCP 的策略是超时间隔加倍。也就是每当遇到一次超时重传的时候,都会将下一次超时时间间隔设为先前值的两倍。两次超时,就说明网络环境差,不宜频繁反复发送。
超时触发重传存在的问题是,超时周期可能相对较长。那是不是可以有更快的方式呢?于是就可以用「快速重传」机制来解决超时重发的时间等待。
TCP 还有另外一种快速重传(Fast Retransmit)机制,它不以时间为驱动,而是以数据驱动重传。
发送方发出了 1,2,3,4,5 份数据:
所以,快速重传的工作方式是当收到三个相同的 ACK 报文时,会在定时器过期之前,重传丢失的报文段。快速重传机制只解决了一个问题,就是超时时间的问题,但是它依然面临着另外一个问题。就是重传的时候,是重传之前的一个,还是重传所有的问题。
比如对于上面的例子,是重传 Seq2 呢?还是重传 Seq2、Seq3、Seq4、Seq5 呢?因为发送端并不清楚这连续的三个 Ack 2 是谁传回来的。根据 TCP 不同的实现,以上两种情况都是有可能的。可见,这是一把双刃剑。为了解决不知道该重传哪些 TCP 报文,于是就有 SACK 方法。
SACK( Selective Acknowledgment,选择性确认)这种方式需要在 TCP 头部「选项」字段里加一个 SACK 的东西,它可以将缓存的地图发送给发送方,这样发送方就可以知道哪些数据收到了,哪些数据没收到,知道了这些信息,就可以只重传丢失的数据。
如下图,发送方收到了三次同样的 ACK 确认报文,于是就会触发快速重发机制,通过 SACK 信息发现只有 200~299 这段数据丢失,则重发时,就只选择了这个 TCP 段进行重复。
如果要支持 SACK,必须双方都要支持。在 Linux 下,可以通过 net.ipv4.tcp_sack 参数打开这个功能(Linux 2.4 后默认打开)。
Duplicate SACK 又称 D-SACK,其主要使用了 SACK 来告诉「发送方」有哪些数据被重复接收了。
可见,D-SACK 有这么几个好处:
在 Linux 下可以通过 net.ipv4.tcp_dsack 参数开启/关闭这个功能(Linux 2.4 后默认打开)。
TCP 的 ACK 机制就像两个人面对面聊天,你一句我一句,可见这种方式的缺点是效率比较低的。数据包的往返时间越长,通信的效率就越低。
为解决这个问题,TCP 引入了窗口这个概念。即使在往返时间较长的情况下,它也不会降低网络通信的效率。有了窗口,就可以指定窗口大小,窗口大小就是指无需等待确认应答,而可以继续发送数据的最大值。
窗口的实现实际上是操作系统开辟的一个缓存空间,发送方主机在等到确认应答返回之前,必须在缓冲区中保留已发送的数据。如果按期收到确认应答,此时数据就可以从缓存区清除。
假设窗口大小为 3 个 TCP 段,那么发送方就可以「连续发送」 3 个 TCP 段,并且中途若有 ACK 丢失,可以通过「下一个确认应答进行确认」。如下图:
图中的 ACK 600 确认应答报文丢失,也没关系,因为可以通话下一个确认应答进行确认,只要发送方收到了 ACK 700 确认应答,就意味着 700 之前的所有数据「接收方」都收到了。这个模式就叫累计确认或者累计应答。
TCP 头里有一个字段叫 Window,也就是窗口大小。这个字段是接收端告诉发送端自己还有多少缓冲区可以接收数据。于是发送端就可以根据这个接收端的处理能力来发送数据,而不会导致接收端处理不过来。所以,通常窗口的大小是由接收方的决定的。发送方发送的数据大小不能超过接收方的窗口大小,否则接收方就无法正常接收到数据。
下图是发送方缓存的数据,根据处理的情况分成四个部分,其中深蓝色方框是发送窗口,紫色方框是可用窗口:
在下图,当发送方把数据「全部」都一下发送出去后,可用窗口的大小就为 0 了,表明可用窗口耗尽,在没收到 ACK 确认之前是无法继续发送数据了。
在下图,当收到之前发送的数据 32-36 字节的 ACK 确认应答后,如果发送窗口的大小没有变化,则滑动窗口往右边移动 5 个字节,因为有 5 个字节的数据被应答确认,接下来 52-56 字节又变成了可用窗口,那么后续也就可以发送 52-56 这 5 个字节的数据了。
程序是如何表示发送方的四个部分的呢?TCP 滑动窗口方案使用三个指针来跟踪在四个传输类别中的每一个类别中的字节。其中两个指针是绝对指针(指特定的序列号),一个是相对指针(需要做偏移)。
那么可用窗口大小的计算就可以是:可用窗口大 = SND.WND -(SND.NXT - SND.UNA)。
接收窗口相对简单一些,根据处理的情况划分成三个部分:
其中三个接收部分,使用两个指针进行划分:
注意,接收窗口和发送窗口的大小并不是完全相等,接收窗口的大小是约等于发送窗口的大小的。因为滑动窗口并不是一成不变的。比如,当接收方的应用进程读取数据的速度非常快的话,这样的话接收窗口可以很快的就空缺出来。那么新的接收窗口大小,是通过 TCP 报文中的 Windows 字段来告诉发送方。那么这个传输过程是存在时延的,所以接收窗口和发送窗口是约等于的关系。
发送方不能无脑的发数据给接收方,要考虑接收方处理能力。如果一直无脑的发数据给对方,但对方处理不过来,那么就会导致触发重发机制,从而导致网络流量的无端的浪费。为了解决这种现象发生,TCP 提供一种机制可以让「发送方」根据「接收方」的实际接收能力控制发送的数据量,这就是所谓的流量控制。
流量控制是为了控制发送方发送速率,保证接收方来得及接收。接收方发送的确认报文中的窗口字段可以用来控制发送方窗口大小,从而影响发送方的发送速率。将窗口字段设置为 0,则发送方不能发送数据。实际上,为了避免此问题的产生,发送端主机会时不时的发送一个叫做窗口探测的数据段,此数据段仅包含一个字节来获取最新的窗口大小信息。
假设以下场景:
操作系统缓冲区与滑动窗口的关系:前面的流量控制例子,我们假定了发送窗口和接收窗口是不变的,但是实际上,发送窗口和接收窗口中所存放的字节数,都是放在操作系统内存缓冲区中的,而操作系统的缓冲区,会被操作系统调整。当应用进程没办法及时读取缓冲区的内容时,也会对我们的缓冲区造成影响。
那操心系统的缓冲区,是如何影响发送窗口和接收窗口的呢?
考虑以下场景:
可见最后窗口都收缩为 0 了,也就是发生了窗口关闭。当发送方可用窗口变为 0 时,发送方实际上会定时发送窗口探测报文,以便知道接收方的窗口是否发生了改变,这个内容后面会说,这里先简单提一下。
当服务端系统资源非常紧张的时候,操心系统可能会直接减少了接收缓冲区大小,这时应用程序又无法及时读取缓存数据,那么这时候就有严重的事情发生了,会出现数据包丢失的现象。
所以,如果发生了先减少缓存,再收缩窗口,就会出现丢包的现象。为了防止这种情况发生,TCP 规定是不允许同时减少缓存又收缩窗口的,而是采用先收缩窗口,过段时间再减少缓存,这样就可以避免了丢包情况。
在前面我们都看到了,TCP 通过让接收方指明希望从发送方接收的数据大小(窗口大小)来进行流量控制。如果窗口大小为 0 时,就会阻止发送方给接收方传递数据,直到窗口变为非 0 为止,这就是窗口关闭。
窗口关闭潜在的危险:接收方向发送方通告窗口大小时,是通过 ACK 报文来通告的。那么,当发生窗口关闭时,接收方处理完数据后,会向发送方通告一个窗口非 0 的 ACK 报文,如果这个通告窗口的 ACK 报文在网络中丢失了,那麻烦就大了。
这会导致发送方一直等待接收方的非 0 窗口通知,接收方也一直等待发送方的数据,如不不采取措施,这种相互等待的过程,会造成了死锁的现象。
为了解决这个问题,TCP 为每个连接设有一个持续定时器,只要 TCP 连接一方收到对方的零窗口通知,就启动持续计时器。如果持续计时器超时,就会发送窗口探测 ( Windowprobe ) 报文,而对方在确认这个探测报文时,给出自己现在的接收窗口大小。
窗口探查探测的次数一般为 3 次,每次次大约 30-60 秒(不同的实现可能会不一样)。如果 3 次过后接收窗口还是 0 的话,有的 TCP 实现就会发 RST 报文来中断连接。
如果接收方太忙了,来不及取走接收窗口里的数据,那么就会导致发送方的发送窗口越来越小。到最后,如果接收方腾出几个字节并告诉发送方现在有几个字节的窗口,而发送方会义无反顾地发送这几个字节,这就是糊涂窗口综合症。
要知道,我们的 TCP + IP 头有 40 个字节,为了传输那几个字节的数据,要达上这么大的开销,这太不经济了。就好像一个可以承载 50 人的大巴车,每次来了一两个人,就直接发车。除非家里有矿的大巴司机,才敢这样玩,不然迟早破产。要解决这个问题也不难,大巴司机等乘客数量超过了 25 个,才认定可以发车。
考虑以下场景,接收方的窗口大小是 360 字节,但接收方由于某些原因陷入困境,假设接收方的应用层读取的能力如下:
每个过程的窗口大小的变化,在图中都描述的很清楚了,可以发现窗口不断减少了,并且发送的数据都是比较小的了。
所以,糊涂窗口综合症的现象是可以发生在发送方和接收方:
于是,要解决糊涂窗口综合症,就解决上面两个问题就可以了:
怎么让接收方不通告小窗口呢?接收方通常的策略如下:
怎么让发送方避免发送小数据呢?发送方通常的策略,使用 Nagle 算法,该算法的思路是延时处理,它满足以下两个条件中的一条才可以发送数据:
只要没满足上面条件中的一条,发送方一直在囤积数据,直到满足上面的发送条件。另外,Nagle 算法默认是打开的,如果对于一些需要小数据包交互的场景的程序,比如,telnet 或 ssh 这样的交互性比较强的程序,则需要关闭 Nagle 算法。
可以在 Socket 设置 TCP_NODELAY 选项来关闭这个算法(关闭 Nagle 算法没有全局参数,需要根据每个应用自己的特点来关闭)
setsockopt(sock_fd, IPPROTO_TCP, TCP_NODELAY, (char *)&value, sizeof(int));
前面的流量控制是避免「发送方」的数据填满「接收方」的缓存,但是并不知道网络的中发生了什么。一般来说,计算机网络都处在一个共享的环境。因此也有可能会因为其他主机之间的通信使得网络拥堵。
如果网络出现拥塞,分组将会丢失,此时发送方会继续重传,从而导致网络拥塞程度更高。因此当出现拥塞时,应当控制发送方的速率。这一点和流量控制很像,但是出发点不同。流量控制是为了让接收方能来得及接收,而拥塞控制是为了降低整个网络的拥塞程度。为了在「发送方」调节所要发送数据的量,定义了一个叫做「拥塞窗口」的概念。
拥塞窗口和发送窗口有什么关系呢?拥塞窗口 cwnd 是发送方维护的一个的状态变量,它会根据网络的拥塞程度动态变化的。在前面提到过发送窗口 swnd 和接收窗口 rwnd 是约等于的关系,那么由于入了拥塞窗口的概念后,此时发送窗口的值是swnd = min(cwnd, rwnd),也就是拥塞窗口和接收窗口中的最小值。
拥塞窗口 cwnd 变化的规则:
那么怎么知道当前网络是否出现了拥塞呢?其实只要「发送方」没有在规定时间内接收到 ACK 应答报文,也就是发生了超时重传,就会认为网络出现了用拥塞。
拥塞控制算法:
TCP 在刚建立连接完成后,首先是有个慢启动的过程,这个慢启动的意思就是一点一点的提高发送数据包的数量,如果一上来就发大量的数据,很容易给网络添堵。
慢启动的算法记住一个规则:当发送方每收到一个 ACK,就拥塞窗口 cwnd 的大小就会加 1。
这里假定拥塞窗口 cwnd 和发送窗口 swnd 相等:
可以看出慢启动算法,发包的个数是指数性的增长。
那慢启动涨到什么时候是个头呢?有一个叫慢启动门限 ssthresh (slow start threshold)状态变量。
前面说道,当拥塞窗口 cwnd 「超过」慢启动门限 ssthresh 就会进入拥塞避免算法。一般来说 ssthresh 的大小是 65535 字节。那么进入拥塞避免算法后,它的规则是:每当收到一个 ACK 时,cwnd 增加 1/cwnd。
接上前面的慢启动的例子,现假定 ssthresh 为 8:当 8 个 ACK 应答确认到来时,每个确认增加 1/8,8 个 ACK 确认 cwnd 一共增加 1,于是这一次能够发送 9 个 MSS 大小的数据,变成了线性增长。
所以,我们可以发现,拥塞避免算法就是将原本慢启动算法的指数增长变成了线性增长,还是增长阶段,但是增长速度缓慢了一些。就这么一直增长着后,网络就会慢慢进入了拥塞的状况了,于是就会出现丢包现象,这时就需要对丢失的数据包进行重传。
当触发了重传机制,也就进入了「拥塞发生算法」。
当网络出现拥塞,也就是会发生数据包重传,重传机制主要有两种:
这两种使用的拥塞发送算法是不同的,接下来分别来说说。
当发生了「超时重传」,则就会使用拥塞发生算法。这个时候,sshresh 和 cwnd 的值会发生变化:
接着,就重新开始慢启动,慢启动是会突然减少数据流的。这真是一旦「超时重传」,马上回到解放前。但是这种方式太激进了,反应也很强烈,会造成网络卡顿。
还有更好的方式,前面我们讲过「快速重传算法」。当接收方发现丢了一个中间包的时候,发送三次前一个包的 ACK,于是发送端就会快速地重传,不必等待超时再重传。
TCP 认为这种情况不严重,因为大部分没丢,只丢了一小部分,则 ssthresh 和 cwnd 变化如下
快速重传和快速恢复算法一般同时使用,快速恢复算法是认为,你还能收到 3 个重复 ACK 说明网络也不那么糟糕,所以没有必要像 RTO 超时那么强烈。
正如前面所说,进入快速恢复之前,cwnd 和 ssthresh 已被更新了:
然后,进入快速恢复算法如下:
也就是没有像「超时重传」一夜回到解放前,而是还在比较高的值,后续呈线性增长。
BBR 是谷歌在 2016 年提出的一种新的拥塞控制算法,已经在 Youtube 服务器和谷歌跨数据中心广域网上部署,据 Youtube 官方数据称,部署 BBR 后,在全球范围内访问 Youtube 的延迟降低了 53%,在时延较高的发展中国家,延迟降低了 80%。
BBR 算法不将出现丢包或时延增加作为拥塞的信号,而是认为当网络上的数据包总量大于瓶颈链路带宽和时延的乘积时才出现了拥塞,所以 BBR 也称为基于拥塞的拥塞控制算法(Congestion-Based Congestion Control),其适用网络为高带宽、高时延、有一定丢包率的长肥网络,可以有效降低传输时延,并保证较高的吞吐量,与其他两个常见算法发包速率对比如下:
BBR 算法周期性地探测网络的容量,交替测量一段时间内的带宽极大值和时延极小值,将其乘积作为作为拥塞窗口大小,使得拥塞窗口始的值始终与网络的容量保持一致。
所以 BBR 算法解决了两个比较主要的问题:
假设 Client 向 Server 连续发送了两个数据包,用 packet1 和 packet2 来表示,那么服务端收到的数据可以分为三种情况,现列举如下:
为什么会发生 TCP 粘包、拆包?
粘包、拆包解决办法:由于 TCP 本身是面向字节流的,无法理解上层的业务数据,所以在底层是无法保证数据包不被拆分和重组的,这个问题只能通过上层的应用协议栈设计来解决,根据业界的主流协议的解决方案,归纳如下:
TCP 协议规定,对于已经建立的连接,收发双方要进行四次挥手才能成功断开连接,如果缺少了其中某个步骤,都会使连接处于假死状态,连接本身所占用的资源不会被释放。实际上,一个网络服务器经常要同时管理大量的并发连接,所以需要保证无用的连接被完全断开,否则大量假死的连接会占用许多服务器资源。
对于这个问题,我们要关注 TCP 端口的:CLOSE_WAIT 和 TIME_WAIT 状态,与 TCP 四次挥手过程密切相关。
可以通过下面方法来查看 TCP 端口状态数量:
╭─mickeyfan@localhost ~
╰─$ netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}' 127 ↵
CLOSE_WAIT 1
TIME_WAIT 1
ESTABLISHED 17
LISTENING:网络服务启动后首先会处于监听的状态
SYN_SENT:表示收发方请求建立连接
ESTABLISHED:表示成功建立连接,当你要访问其它的计算机的网络服务时首先要发个 SYN 信号到特定端口,此时当前服务器的 TCP 端口状态为 SYN_SENT,如果连接成功了则会变为 ESTABLISHED。
NOTE:SYN_SENT 状态一般非常短暂,但如果发现 SYN_SENT 的数量非常多并且在向不同的网络服务器发出,那当前机器可能中了冲击波或震荡波之类的病毒。这类病毒为了感染别的计算机,它就要扫描别的计算机,在扫描的过程中对每个要扫描的计算机都要发出了 SYN 请求,这也是出现许多 SYN_SENT 的原因。
close()
断开连接,并且收到对方的 ACK 确认后,我方的 TCP 端口状态就会变为 TIME_WAIT。NOTE:TCP 协议规定 TIME_WAIT 状态会一直持续 2MSL(两倍的分段最大生存期,240s),以此来保证重新分配的 Socket 不会受到之前残留的延迟重发报文的影响(保证旧的连接状态不会对新连接产生影响)。处于 TIME_WAIT 状态的连接(Socket)占用的资源不会被内核释放,所以作为网络服务器,在可能的情况下,尽量不要主动断开连接。尤其对于要处理大量短连接的服务器,应该由客户端来主动提出断开,以减少 TIME_WAIT 状态造成的资源浪费。如果发现服务器存在大量的 TIME_WAIT,那么你应该检查是否有大量的自动断开连接动作存在服务器上。还有这样的情况,又我方提出断开连接,但对方一直不给 ACK 应答,我方就会卡在 FIN_WAIT_2 状态,此时我方默认等到 60 秒(可修改,参考 tcp_max_orphans)。所以这种情况下我方内存也会被大量无效数据报填满。
close()
来使得连接被正确关闭。NOTE:CLOSE_WAIT 表示我方被动断开连接,如果存在大量的 CLOSE_WAIT,表示我方只在第二次挥手时向对方应答 ACK,并没有完成第三次挥手,向对方发送 FIN 请求,这时就可能是因为在关闭连接之前网络服务器还有大量的数据要发送或者其他事要做,导致没有发送这个 FIN packet。一般是由网络服务器负载过高,或出现了不可预料的问题导致的。一个 CLOSE_WAIT 会维持至少 2 个小时,如果存在由于负载一直居高不下,生产了大量的 CLOSE_WAIT,就会造成资源极大的损耗,那么通常是等不到释放的那一刻,系统就已经解决崩溃了。
相关的内核参数:
vi /etc/sysctl.conf
net.ipv4.tcp_syncookies = 1
:表示开启 SYN Cookies。当出现 SYN 等待队列溢出时,启用 Cookies 来处理,可防范少量的 SYN 攻击,默认为 0,表示关闭.net.ipv4.tcp_tw_reuse = 1
:表示开启重用。允许将 TIME-WAIT Sockets 重新用于新的 TCP 连接,默认为 0,表示关闭。net.ipv4.tcp_tw_recycle = 1
:表示开启 TCP 连接中 TIME-WAIT Sockets 的快速回收,默认为 0,表示关闭。net.ipv4.tcp_fin_timeout
:系統等待 FIN_WAIT 超时时间。当客户端调用 connect() 时候就会发起三次握手,这时 Socket 唯一确定了这次通信,一旦握手完成后,服务端会在返回另一个 Socket 来专门用来后续的数据传输。所以我们使用 “监听 Socket” 和 “传输 Socket” 来区分两者。
提高连接常用套路有:
以 Nginx 为例:它的结构是 Master + Worker。Worker 会在 80、443端口上来监听请求,Worker 的数量一般设置为 CPU 的 cores 数,并且每个 Worker 都采用了 epoll 模型。处于监听状态的 Worker,会把所有的监听 Socket 加入到自己的 epoll 中。当这些 Socket 都在 epoll 中时,当某个 Socket 有事件发生就会立即被回调唤醒。可见,这种模式下大大增加了每个进程可以管理的 Socket 数量,上限直接可以上升到进程能够操作的最大文件描述符。而一般机器可以设置百万级别文件描述符,所以单机单进程就是百万连接,epoll 是解决 C10K 的利器,很多开源软件用到了它。
我们常说连接数受限于文件描述符,这是为什么?
因为在 Linux 上一切皆文件,故每一个 Socket 都是被当作一个文件看待,那么每个文件就会有一个文件描述符。在 Linux 中每一个进程中都有一个数组保存了该进程需要的所有文件描述符。这个文件描述符其实就是这个数组的 key ,它的 value 是一个指针,指向的就是打开的对应文件。
关于文件描述符有两点注意:
另外,单机设置 ulimit 的上限受制于系统的两个配置:
fs.nr_open 总是应该小于等于 fs.file-max,这两个值的设置也不是随意可以操作,因为设置的越大,系统资源消耗越多,所以需要根据真实情况来进行设置。
https://mp.weixin.qq.com/s/IxipRdFAgcmrqAagpWnPpQ
https://mp.weixin.qq.com/s/ZDg5Kvadc6jcEtZytfF7XA
https://mp.weixin.qq.com/s/pDgCn6bEczH45sYoiNU0IA