TCP协议概述
TCP协议的特点
TCP是面向连接的、可靠的、基于字节流的传输层通信协议。
- 面向连接:发送端和接收端建立一个连接通道,为了维护连接的可靠性,通过一定的数据结构来维护双方的交互状态
- 可靠性:无论网络链路出现了怎样变化,TCP都可以保证一个报文一定到达接收端
- 字节流:发送的时候发的是一个流,没头也没尾,所以无论消息有多大都可以进行传输,并且消息是[有序的],通过序列号保证报文的有序性和正确性

上面是TCP协议的三个主要特点,除此之外还具有以下一些重要的特点:
- 双全工服务:如果一台主机上的进程A与另一台主机的进程B存在一条TCP连接,那那么应用层数据就可以借助这条连接从A流向B,也可以从B流向A
- 流量控制:TCP可以根据接收方的接收速率来调整发送方的发送速率,从而使得发送方和接收方能达到一种流量的平衡,避免因为二者速率不平衡导致丢包的发送
- 拥塞控制:除了流量控制,TCP还能调控整体网络的拥塞程度,TCP有一些机制可以防止过多的数据注入到网络中,这样可以使网络中的路由器或链路不致过载
TCP连接简介
TCP协议是面向连接的,这是因为在一个应用程序可以开始向另外一个应用进程发送数据之前,这两个进程必须先“握手”,即必须先发送某些预备报文段,以建立确保数据传输的参数,而在不需要传输数据时,也需要“挥手”来关闭连接通道。TCP连接就是用于保证可靠性和流量控制维护的一些状态信息,这些信息的组合包括:Socket套接字,序列号和窗口大小。
一个TCP连接有一个四元组来确定:
在Linux系统中,可以使用“netstat -napt ”命令来查看服务器TCP连接情况:
TCP数据分组
如果 HTTP 数据报文比较长,超过了 MSS 的长度,这时 TCP 就需要把 HTTP 的数据拆解成一块块 的数据发送,而不是一次性发送所有数据。
注意: MTU :一个网络包的最大长度,以太网中一般为 1500 字节。 MSS :除去 IP 和 TCP 头部之后,一个网络包所能容纳的 TCP 数据的最大长度。
数据会被以 MSS 的长度为单位进行拆分,拆分出来的每一块数据都会被放进单独的网络包中。也就是在每个被拆分的数据加上 TCP 头信息,然后交给 IP 模块来发送数据。
TCP保活机制
TCP 有一个机制是保活机制。这个机制的原理是这样的:
定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用,每隔一个时间间隔,发送一个探测报文,该探测报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的 TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。 当然在 Linux 内核可以有对应的参数可以设置保活时间、保活探测的次数、保活探测的时间间隔。
TCP报文

TCP报文主要分为报文头部和应用层数据。其中源端口号和目标端口号都很好理解,校验和就是校验报文传输过程是否发送差错;首部长度自然就是TCP报文首部的字节数,保留顾名思义就是保留6位字节备用;紧急指针标志紧急数据在数据字段中的位置。下面是以HTTP报文为数据的TCP报文:
序列号
TCP协议中的一个特点就是面向字节流传输,而序列号是按顺序给要发送的数据(字节流)进行编号,接收端查询接收数据TCP首部中的序列号和数据的长度,将自己下一步应该接收的序号作为确认应答返送回去,这样子通过序列号和确认应答号,TCP可以实现可靠传输。
注意: 不难看出,序列号可以保证数据在网络中的有序性。
确认应答号
指接收方下一次期望收到的数据的序列号,一般指应答序列号之前的数据已经确定收到了,主要作用是确保网络包的可靠传输。
注意: 如果没有收到就应该重新发送,直到送达,这个是为了解决不丢包的问题。
控制位

- CWR:与后面ECE都用于IP首部的ECN字段,ECE标志为1 时,则通知对方已将拥塞窗口缩小
- ECE:标志为1会通知通信对方,从对方这边到这边的网络有拥塞,在收到数据包的IP首部中ECN为1时将TCP首部中的ECE置位1
- URG:为1 时表示有紧急数据要处理
- ACK:为1时,表示确认应答子段有效,TCP规定除了建立连接时的SYN包之外,其他的必须置为1
- PSH:为1时,需要将收到的数据立即传给上层协议,为0时,数据先进入缓存
- RST:为1时,TCP连接有异常,必须强制断开连接
- SYN:用于建立连接,为1时表示希望建立连接
- FIN:为1时,表示不再有数据发送,希望断开连接
注意: FIN为1时只是代表不再有数据发送,但是不代表不能发送TCP报文,只是不能发送有数据的TCP报文,发送FIN包后TCP连接是还没有断开TCP连接的,并且也能够接收数据。
窗口大小
表示现在运行对方发送的数据量,也就是告诉对方,从本报文段的确认号开始允许对方发送的数据量。该字段主要用于控制滑动窗口的大小,从而实现TCP的流量控制。
TCP连接

TCP连接主要分为三个阶段,即创建阶段、使用阶段、使用阶段、断开连接这三个阶段。其中使用阶段比较简单,就是数据的发送与确认,而创建阶段和断开阶段就会复杂一点点。断开阶段存在一个三次握手的逻辑,断开阶段存在一个四次挥手的逻辑,这二者都是TCP能提供可靠传输服务的一个重要基础。
注意: TCP 协议里面会有两个端口,一个是浏览器随机生成的监听(即客户端)的端口,一个是 Web 服务器监听的端口(HTTP 默认端口号是 80 , HTTPS 默认端口号是 443 )。
三次握手
TCP是面向连接的协议,所以使用TCP之前必须要先建立连接,而建立连接是通过三次握手来进行。刚开始时,客户端和服务端都处于closed状态,先是服务端主动去监听某个端口,进入listen状态。
握手步骤
SYN报文
客户端随机生成一个初始序列号client_isn,将该值放置于TCP首部的序列号字段,同时把SYN标志位改为1,表示SYN报文。接着把报文发送给服务端,表示向服务端发起连接请求,该报文不包含应用层数据,之后客户端处于SYN-SENT状态。
SYN+ACK报文
服务端收到SYN请求之后,随机生成一个初始序列号server_isn并放置到序列号字段中,然后再把收到的SYN请求包的序号字段client_isn+1并将其放置在确认应答号中,接着把SYN和ACK标志改为1,最后把该报文发给客户端。该报文称为SYN+ACK报文,报文也不包含应用层数据,之后服务端处于SYN-RCVD状态。
ACK报文
客户端收到服务端的报文后,还要向服务端回应最后一个应答报文,把报文的首部ACK标志为1,应答序列号字段填入server_isn+1,把报文发送给服务端。这次报文可以携带应用层发给服务端的数据,之后客户端进入established状态。
三次握手的原因
阻止重复历史连接的初始化
三次握手的首要原因是为了防止旧的重复连接初始化造成混乱。网络环境是错综复杂的,往往并不是如我们期望的一样,先发送的数据包,就先到达目标主机,反而它可能会由于网络拥堵等乱七八糟的原因,会使得旧的数据包,先到达目标主机,那么这种情况下 TCP 三次握手是如何避免的呢?
客户端连续发送多次 SYN 建立连接的报文,在网络拥堵情况下:
- 一个「旧 SYN 报文」比「最新的 SYN 」 报文早到达了服务端;
- 那么此时服务端就会回一个 SYN + ACK 报文给客户端;
- 客户端收到后可以根据自身的上下文,判断这是一个历史连接(序列号过期或超时),那么客户 端就会发送 RST 报文给服务端,表示中止这一次连接。
如果是两次握手连接,就不能判断当前连接是否是历史连接,三次握手则可以在客户端(发送方)准备发送第三次报文时,客户端有足够的上下文来判断当前连接是否是历史连接:如果是历史连接(序列号过期或超时),则第三次握手发送的报文是 RST 报文,以此中止历史连接;如果不是历史连接,则第三次发送的报文是 ACK 报文,通信双方就会成功建立连接; 所以,TCP 使用三次握手建立连接的最主要原因是防止历史连接初始化了连接。
建立可靠通信通道
TCP 协议的通信双方, 都必须维护一个「序列号」, 序列号是可靠传输的一个关键因素,它的作用:
- 接收方可以去除重复的数据;
- 接收方可以根据数据包的序列号按顺序接收;
- 可以标识发送出去的数据包中哪些是已经被对方收到的;
序列号在 TCP 连接中占据着非常重要的作用,所以当客户端发送携带「初始序列号」的 SYN 报文的时候,需要服务端回一个 ACK 应答报文,表示客户端的 SYN 报文已被服务端成功接收,那当服务端发送「初始序列号」给客户端的时候,依然也要得到客户端的应答回应,这样一来一回才能确保双方的初始序列号能被可靠的同步。 
四次握手其实也能够可靠的同步双方的初始化序号,但由于第二步和第三步可以优化成一步,所以就成了「三次握手」。 而两次握手只保证了一方(客户端)的初始序列号能被对方成功接收,没办法保证双方的初始序列号都能被确认接收。
避免资源浪费
如果只有「两次握手」,当客户端的 SYN 请求连接在网络中阻塞,客户端没有接收到 ACK 报文, 就会重新发送 SYN 。由于没有第三次握手,服务器不清楚客户端是否收到了自己发送的建立连接的 ACK 确认信号,所以每收到一个 SYN 就只能先主动建立一个连接 ,如果客户端的 SYN 阻塞了,重复发送多次 SYN 报文,那么服务器在收到请求后就会建立多个冗余的无效链接,造成不必要的资源浪费。 
注意: 即两次握手会造成消息滞留情况下,服务器重复接受无用的连接请求 SYN 报文,而造成重复分配资源。
只有经过三次握手才能确认双发的收发功能都正常,缺一不可:
- 第一次握手(客户端发送 SYN 报文给服务器,服务器接收该报文):客户端什么都不能确认;服务器确认了对方发送正常和自己接收正常
- 第二次握手(服务器响应 SYN 报文给客户端,客户端接收该报文):客户端确认了自己发送、接收正常,对方发送、接收都正常;服务器还是只是确认了对方发送正常和自己接收正常
第三次握手(客户端发送 ACK 报文给服务器):客户端确认了自己发送、接收正常,对方发送、接收正常;服务器确认了自己发送、接收正常,对方发送、接收正常
SYN攻击
TCP 连接建立是需要三次握手,假设攻击者短时间伪造不同 IP 地址的 SYN 报文,服务端每接收到一个 SYN 报文,就进入 SYN_RCVD 状态,但服务端发送出去的 ACK + SYN 报文,无法得到未知 IP 主机的 ACK 应答,久而久之就会占满服务端的 SYN 接收队列(未连接队列),使得服务器不能为正常用户服务,这就是SYN攻击。

下面是建立TCP连接的一个正常流程:
当服务端接收到客户端的 SYN 报文时,会将其加入到内核的「 SYN 队列」;
- 接着发送 SYN + ACK 给客户端,等待客户端回应 ACK 报文;
- 服务端接收到 ACK 报文后,从「 SYN 队列」移除放入到「 Accept 队列」;
- 应用通过调用 accpet() socket 接口,从「 Accept 队列」取出连接。
而当应用程序处理TCP连接过慢时,就会导致accept队列被占满:
如果恶意的SYN报文过多(即不回应ACK报文),那么这些恶意的SYN就会长时间霸占(假设服务器直到收到ACK报文为止),从而导致SYN被恶意报文大量占用。
一般,有两种方式防止SYN攻击:
设置Linux内核参数
其中一种解决方式是通过修改 Linux 内核参数,控制队列大小和当队列满时应做什么处理。 当网卡接收数据包的速度大于内核处理的速度时,会有一个队列保存这些数据包。控制该队列的最大值如下参数:
net.core.netdev_max_backlog
SYN_RCVD 状态连接的最大个数:
net.ipv4.tcp_max_syn_backlog
超出处理能时,对新的 SYN 直接回报 RST,丢弃连接:
net.ipv4.tcp_abort_on_overflow
启用cookie

- 当SYN 队列满之后,后续服务器收到 SYN 包,不进入SYN 队列
- 而在SYN队列中的就计算出一个 cookie 值,直接以cookie值作为 SYN + ACK 报文中的序列号返回客户端出队
- 服务端接收到客户端的应答报文时,服务器会根据上面计算的cookie检查这个 ACK 包的合法性,如果合法,直接放入到 Accept 队列
- 最后应用通过调用 accpet() socket 接口,从Accept 队列取出的连接
四次挥手
如果两台主机之间完成数据交互的话,就需要断开TCP连接,不能一直销毁主机资源,而断开连接时相互的,需要双方都知道并且安全的断开,而TCP断开连接是需要“四次挥手”,相互传送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 = 1),报文中会指定一个序列号 seq = u。并停止再发送数据,主动关闭 TCP 连接。此时客户端处于 FIN_WAIT1 状态,等待服务端的确认。
FIN-WAIT-1 - 等待远程TCP的连接中断请求,或先前的连接中断请求的确认
第二次挥手:服务端收到 FIN 之后,会发送 ACK 报文,且把客户端的序号值 +1 作为 ACK 报文的序列号值,表明已经收到客户端的报文了,此时服务端处于 CLOSE_WAIT 状态。
CLOSE-WAIT - 等待从本地用户发来的连接中断请求
此时的 TCP 处于半关闭状态,客户端到服务端的连接释放。客户端收到服务端的确认后,进入FIN_WAIT2(终止等待 2)状态,等待服务端发出的连接释放报文段。
FIN-WAIT-2 - 从远程TCP等待连接中断请求;
第三次挥手:如果服务端也想断开连接了(没有要向客户端发出的数据),和客户端的第一次挥手一样,发送 FIN 报文,且指定一个序列号。此时服务端处于 LAST_ACK 的状态,等待客户端的确认。
LAST-ACK - 等待原来发向远程TCP的连接中断请求的确认
第四次挥手:客户端收到 FIN 之后,一样发送一个 ACK 报文作为应答(ack = w+1),且把服务端的序列值 +1 作为自己 ACK 报文的序号值(seq=u+1),此时客户端处于 TIME_WAIT (时间等待)状态。
四次挥手的原因
TCP连接的断开需要四次挥手是因为服务器发送ACK报文和FIN报文不能一起发送,是因为服务器收到FIN报文之后,知道了客户端要断开连接,因此发送ACK报文告诉客户端我知道了,但是并不能立马发送FIN包(即服务器也要断开连接),因为这时服务端可能还有数据需要处理和发送,等服务端不再发送数据时,才发送 FIN 报文给客户端来表示同意现在关闭连接。
注意: 从上面过程可知,服务端通常需要等待完成数据的发送和处理,所以服务端的 ACK 和 FIN 一般都会分开发送,从而比三次握手导致多了一次。
TIME_WAIT状态
为什么主动断开的连接的一方需要TIME_WAIT状态,而不是直接关闭连接,主要有下面两个原因。
防止旧连接的数据包

假设没有WAIT_TIME或者WAIT_TIME时间过短:
如上图黄色框框服务端在关闭连接之前发送的 SEQ = 301 报文,被网络延迟了。 这时有相同端口的 TCP 连接被复用后,被延迟的 SEQ = 301 抵达了客户端,那么客户端是有可能正常接收这个过期的报文,这就会产生数据错乱等严重的问题。所以,TCP 就设计出了这么一个机制,经过 2MSL 这个时间,足以让两个方向上的数据包都被丢弃,使得原来连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的。
保证连接正确关闭
TIME-WAIT 的另一个作用是等待足够的时间以确保最后的 ACK 能让被动关闭方(图中的服务端)接收,从而帮助其正常关闭,否则将一直处于LAST_ACK阶段,从而消耗系统资源。
上图纠正一下: 服务器一直收不到ACK报文并不是一直会处在LAST_ACK状态,而是当发送一定数量的FIN报文但是没有收到过一次ACK报文的话服务器也会自定进入到CLOSE状态。
客户端四次挥手的最后一个 ACK 报文如果在网络中被丢失了,此时如果客户端 TIME-WAIT 过短或没有,则就直接进入了 CLOSED 状态了,那么服务端则会一直处在 LASE_ACK 状态。当客户端重新发起建立连接的 SYN 请求报文后,服务端会发送 RST 报文给客户端,连接建立的过程会被终止。
注意: 如果 TIME-WAIT 等待足够长的情况就会遇到两种情况:
- 服务端正常收到四次挥手的最后一个 ACK 报文,则服务端正常关闭连接。
- 服务端没有收到四次挥手的最后一个 ACK 报文时,则会重发 FIN 关闭连接报文并等待新的 ACK 报文。
所以客户端在 TIME-WAIT 状态等待 2MSL 时间后,就可以保证双方的连接都可以正常的关闭。但是TIME_WAIT如果远远大于2MSL的话,也会有危害,最主要的:
- 内存资源长时间被占用
- 是对端口资源的占用,一个 TCP 连接至少消耗一个本地端口
TCP重要机制
重传机制
超时重传
TCP 实现可靠传输的方式之一,是通过序列号与确认应答。在 TCP 中,当发送端的数据到达接收主机时,接收端主机会返回一个确认应答消息,表示已收到消 息。
然后网络是复杂的,没可能每次都正常的接收和应答。重传机制的其中一个方式,就是在发送数据时,设定一个定时器,当超过指定的时间还没有收到对方的 ACK 确认应答报文,就会重发该数据,也就是我们常说的超时重传。
那么现在的问题其实就是怎么确定这个“特定的时间间隔”。先引入一个RTT的概念:
超时重传时间以RTO表示,然后看一下如果RTO较长或较短有什么问题:
注意:
- 当超时时间 RTO 较大时,重发就慢,丢了老半天才重发,效率低且性能差;
- 当超时时间 RTO 较小时,会导致可能并没有丢就重发,于是重发的就快,会增加网络拥塞,导致更多的超时,更多的超时导致更多的重发。
因此,根据上面两种情况,RTO应当略大于RTT:
但是,RTT并不是一个固定的值,是随着网络情况动态变化的,具体怎么测量这里不展开了。如果超时重发的数据,再次超时的时候,又需要重传的时候,TCP 的策略是超时间隔加倍。也就是每当遇到一次超时重传的时候,都会将下一次超时时间间隔设为先前值的两倍。两次超时,就说明网络环境差,不宜频繁反复发送。超时触发重传存在的问题是,超时周期可能相对较长(有点类似串行的赶紧,必要要上一分组确定完成才能传输下一个分组),而TCP协议使用了快速重传解决问题。
快速重传
TCP 有另外一种快速重传机制,它不以时间为驱动,而是以数据驱动重传。如图所示:
- 第一份 Seq1 先送到了,于是就 Ack 回 2
- 结果 Seq2 因为某些原因没收到,Seq3 到达了,于是还是 Ack 回 2
- 后面的 Seq4 和 Seq5 都到了,但还是 Ack 回 2,因为 Seq2 还是没有收到
- 发送端收到了三个 Ack = 2 的确认,知道了 Seq2 还没有收到,就会在定时器过期之前,重传丢失的 Seq2
- 最后,收到了 Seq2,此时因为 Seq3,Seq4,Seq5 都收到了,于是 Ack 回 6
所以,快速重传的工作方式是当收到三个相同的 ACK 报文时,会在定时器过期之前,重传丢失的报文段。快速重传机制只解决了一个问题,就是超时时间的问题,但是它依然面临着另外一个问题:就是重传的时候,是重传之前的一个还是重传所有的问题。为例解决这一问题,TCP采用了一个称为SACK的方法(选择性确认)。这种方式需要在 TCP 头部的选项字段里加一个 SACK 的东西,它可以将缓存的数据范围发送给发送方,这样发送方就可以知道哪些数据收到了,哪些数据没收到,知道了这些信息,就可以只重传丢失的数据。如下图,发送方收到了三次同样的 ACK 确认报文,于是就会触发快速重发机制,通过 SACK 信息发现只有 200~299 这段数据丢失,则重发时,就只选择了这个 TCP 段进行重复:
注意: 如果要支持 SACK ,必须双方都要支持。在 Linux 下,可以通过 net.ipv4.tcp_sack 参数打开这个功能(Linux 2.4 后默认打开)。
滑动窗口
前面的快速重传其实就是基于滑动窗口的,而滑动窗口的出现也就是为了解决“一句一句的接收和回应”导致的效率低下问题,如下图所示:
窗口的实现实际上是操作系统开辟的一个缓存空间,发送方主机在等到确认应答返回之前,必须在缓冲区中保留已发送的数据。如果按期收到确认应答,此时数据就可以从缓存区清除。假设窗口大小为 3 个 TCP 段,那么发送方就可以「连续发送」 3 个 TCP 段,并且中途若有 ACK 丢失,可以通过下一个确认应答进行确认。如下图:
图中的 ACK 600 确认应答报文丢失,也没关系,因为可以通过下一个确认应答进行确认,只要发送方收到了 ACK 700 确认应答,就意味着 700 之前的所有数据接收方都收到了。这个模式就叫累计确认或者累计应答。 TCP 头里有一个字段叫窗口大小,这个字段是接收端告诉发送端自己还有多少缓冲区可以接收数据是发送端就可以根据这个接收端的处理能力来发送数据,而不会导致接收端处理不过来。所以,通常窗口的大小是由接收方的窗口大小来决定的。发送方发送的数据大小不能超过接收方的窗口大小,否则接收方就无法正常接收到数据。
发送方的滑动窗口

1 是已发送并收到 ACK确认的数据:1~31 字节
2 是已发送但未收到 ACK确认的数据:32~45 字节
3 是未发送但总大小在接收方处理范围内(接收方还有空间):46~51字节
4 是未发送但总大小超过接收方处理范围(接收方没有空间):52字节以后
先来看看是怎么表示这四个区域的,如图所示:
- SND.WND :表示发送窗口的大小(大小是由接收方指定的);
- SND.UNA :是一个绝对指针,它指向的是已发送但未收到确认的第一个字节的序列号,也就是 #2 的第一个字节。
- SND.NXT :也是一个绝对指针,它指向未发送但可发送范围的第一个字节的序列号,也就是 #3的第一个字节。
- 指向 #4 的第一个字节是个相对指针,它需要 SND.UNA 指针加上 SND.WND 大小的偏移量,就可以指向 #4 的第一个字节了。
当发送方把数据全部都一下发送出去后,可用窗口的大小就为 0 了,表明可用窗口耗尽,在没收到 ACK 确认之前是无法继续发送数据了,如下图所示:
当收到之前发送的数据 32~36 字节的 ACK 确认应答后,如果发送窗口的大小没有变化,则滑动窗口往右边移动 5 个字节,因为有 5 个字节的数据被应答确认,接下来 52~56 字节又变成了可用窗口,那么后续也就可以发送 52~56 这 5 个字节的数据了,如下图所示:
接收方的滑动窗口

其中三个接收部分,使用两个指针进行划分:
- RCV.WND :表示接收窗口的大小,它会通告给发送方。
- RCV.NXT :是一个指针,它指向期望从发送方发送来的下一个数据字节的序列号,也就是 #3 的第一个字节。
- 指向 #4 的第一个字节是个相对指针,它需要 RCV.NXT 指针加RCV.WND 大小的偏移量,就可以指向 #4 的第一个字节了。
注意: 接收方和发送方的窗口大小并不是完全相等,接收窗口的大小是约等于发送窗口的大小的。因为滑动窗口并不是一成不变的。比如,当接收方的应用进程读取数据的速度非常快的话,这样的话接收窗口可以很快的就空缺出来。那么新的接收窗口大小,是通过 TCP 报文中的 Windows 字段来告诉发送方。因为网络传输过程是存在时延的,所以接收窗口和发送窗口是约等于的关系。
流量控制
流量控制的实现
如果发送发一股劲地发送数据,导致接收方都接收速度更不上,那么就会触发大量的TCP报文重发导致大量资源的浪费。为了解决这种现象发生,TCP 提供一种机制可以让发送方根据接收方的实际接收能力控制发送的数据量,这就是所谓的流量控制。而流量控制,就是以滑动窗口为基础的,如下图所示:
也就是说,接收方根据自己的缓冲区大小来控制控制发送方发送的数据量,从而避免发送发一股劲的发送数据,导致接收方接收不了数据,从而导致大量丢包。但是,也存在一个问题:当服务端系统资源非常紧张的时候,操作系统可能会直接减少了接收缓冲区大小,这时应用程序又无法及时读取缓存数据,那么这时候就有严重的事情发生了,会出现数据包丢失的现象。如下图所示:
通过上图可以看到,如果先减少缓存再收缩窗口,就会出现丢包的现象。为了防止这种情况发生,TCP 规定是不允许同时减少缓存又收缩窗口的,而是采用先收缩窗口,过段时间再减少缓存,这样就可以避免了丢包情况。
注意: TCP协议的流量控制还有有点,就是当窗口是在太小的时候,会有一些算法控制不能让发送方发送数据,接收方也不会接收数据。这是因为由于数据量实在太小(比如仅有1字节)了,对应用程序没有实在的用处,有点浪费网络资源了。
窗口关闭
当接收方因为某些原因导致不断缩小发送发的窗口大小,如果发送方的窗口大小缩小为了0,就说明发送发已经不能在再发送数据了,这时候就被称为窗口关闭。窗口关闭时,发送方就会一直等待接收方的窗口大小增大为非0的通知,这个通知时通过ACK报文来传输的。如果这个ACK报文在网络中传输出错,那么问题就大了,这时候就会出现死锁:发送方在等待窗口为非0的ACK报文,而接收方也在等待发送方发来数据。如下图所示:
如果不采取一定的措施,就一直死锁下去,最后可能不得不断开连接,如果真的是网络断开而导致的连接断开倒也没什么,但是如果仅仅只是一次偶然导致的ACK报文丢失而导致连接的断开那就亏大了。因此,TCP协议需要一些措施来解决这个问题:TCP 为每个连接设有一个持续定时器,只要 TCP 连接一方收到对方的零窗口通知,就启动持续计时器。如果持续计时器超时,就会发送窗口探测报文,而对方在确认这个探测报文时,给出自己现在的接收窗口大小。
注意:
- 如果接收窗口仍然为 0,那么收到这个报文的一方就会重新启动持续计时器;
- 如果接收窗口不是 0,那么死锁的局面就可以被打破了
窗口探测的次数一般为 3 次,每次大约 30-60 秒(不同的实现可能会不一样)。如果 3 次过后接收窗口还是 0 的话,有的 TCP 实现就会发 RST 报文来中断连接。也就是说,3次机会还没有成功就会任务这个TCP连接存在问题(也可能是应用程序存在问题),从合情合理的而中断连接。
拥塞控制
前面的流量控制是避免发送方的数据填满接收方的缓存,但是并不知道网络的中发生了什么。一般来说,计算机网络都处在一个共享的环境。因此也有可能会因为其他主机之间的通信使得网络拥堵,在网络出现拥堵时,如果继续发送大量数据包,可能会导致数据包时延、丢失等,这时 TCP 就会重传数据,但是重传就会导致本来拥挤网络的负担更重,于是会导致更大的延迟以及更多的丢包,这个情况就会进入恶性循环被不断地放大。所以,TCP 不能忽略网络上发生的事,它被设计成一个无私的协议,当网络发送拥塞时,TCP 会自我牺牲,降低发送的数据量。于是,就有了拥塞控制,控制的目的就是避免发送方的数据填满整个网络。为了在发送方调节所要发送数据的量,定义了一个叫做拥塞窗口的概念。
拥塞窗口 cwnd 是发送方维护的一个的状态变量,它会根据网络的拥塞程度动态变化的。我们在前面提到过发送窗口 swnd 和接收窗口 rwnd 是约等于的关系,那么由于加入了拥塞窗口的概念后,此时发送窗口的值是swnd = min(cwnd, rwnd),也就是拥塞窗口和接收窗口中的最小值。拥塞窗口 cwnd 变化的规则:
- 只要网络中没有出现拥塞,cwnd 就会增大
- 但网络中出现了拥塞,cwnd 就减少
问题: TCP如何判断网络中是否发送了拥塞? 只要发送方没有在规定时间内接收到 ACK 应答报文,也就是发生了重传,就会认为网络出现了用拥塞。
拥塞控制主要是下面的四个算法:
- 慢启动
- 拥塞避免
- 拥塞发生
- 快速恢复
慢启动算法
在刚建立连接完成后,首先是有个慢启动的过程,这个慢启动的意思就是一点一点的提高发送数据包的数量,如果网络中所有的主机一上来就发大量的数据,这很容易造成网络的拥塞。慢启动的算法实现思路则是:当发送方每收到一个 ACK,拥塞窗口 cwnd 的大小就会加 1。这里假定拥塞窗口 cwnd 和发送窗口 swnd 相等,举例:
- 连接建立完成后,一开始初始化 cwnd = 1,表示可以传一个 MSS 大小的数据
- 当收到一个 ACK 确认应答后,cwnd 增加 1,于是一次能够发送 2 个
- 当收到 2 个的 ACK 确认应答后, cwnd 增加 2,于是就可以比之前多发2 个,所以这一次能够发送 4 个
- 当这 4 个的 ACK 确认到来的时候,每个确认 cwnd 增加 1, 4 个确认 cwnd 增加 4,于是就可以比之前多发 4 个,所以这一次能够发送 8 个

可以看到是成一个指数性的增长速率,而慢启动算法的指数增长cwnd的上限是 ssthresh ,当cwnd小于这个值使用慢启动算法,大于这个值就使用下面的拥塞避免算法。
注意: ssthresh 的值一般大小是65535个字节。
拥塞避免算法
进入拥塞避免算法后,它的规则是:每当收到一个 ACK 时,cwnd 增加 1/cwnd。接上前面的慢启动的例子,现假定 ssthresh 为 8:当 8 个 ACK 应答确认到来时,每个确认增加 1/8,8 个 ACK 确认 cwnd 一共增加 1,于是这一次能够发送 9 个 MSS 大小的数据,变成了线性增长。
拥塞避免算法就是将原本慢启动算法的指数增长变成了线性增长,还是增长阶段,但是增长速度缓慢了一些。就这么一直增长着后,网络就会慢慢进入了拥塞的状况了,于是就会出现丢包现象,这时就需要对丢失的数据包进行重传。当触发了重传机制,也就进入了下面的拥塞发生算法。
拥塞发生算法
前面知道,重传分为超时重传和快速重传。当发生了超时重传,则就会使用拥塞发生算法。这个时候,ssthresh 和 cwnd 的值会发生变化:
- ssthresh 设为 cwnd/2
- cwnd 重置为 1

但是,这种法方式缺点很明显,就是cwnd降得太快了,会造成严重得网络卡顿。因此,快速重传算法就取代了超时重传。当接收方发现丢了一个中间包的时候,发送三次前一个包的 ACK,于是发送方就会快速地重传,不必等待超时再重传。TCP 认为这种情况不算严重,因为大部分没丢,只丢了一小部分,则 ssthresh 和 cwnd 变化如下:
- cwnd = cwnd / 2 ,也就是设置为原来的一半,而不是直接设置为1
- ssthresh = cwnd
-
快速恢复算法
快速重传和快速恢复算法一般同时使用,快速恢复算法是认为,如果还能收到 3 个重复 ACK。说明网络也不那么糟糕,所以没有必要像超时重传那么强烈。正如前面所说,进入快速恢复之前,cwnd 和 ssthresh 已被更新了:
cwnd = cwnd/2 ,也就是设置为原来的一半
- ssthresh = cwnd
然后,进入快速恢复算法如下:
- 拥塞窗口 cwnd = ssthresh + 3 ( 3 的意思是确认有 3 个数据包被收到了)
- 重传丢失的数据包
- 如果再收到重复的 ACK,那么 cwnd 增加 1
- 如果收到新数据的 ACK 后,把 cwnd 设置为第一步中的 ssthresh 的值,原因是该 ACK 确认了新的数据,说明从 duplicated ACK 时的数据都已收到,该恢复过程已经结束,可以回到恢复之前的状态了,也即再次进入拥塞避免状态

