传输控制协议(TCP,Transmission Control Protocol)是一种面向连接的、可靠的、基于字节流的传输层通信协议。TCP 协议假设下层协议可以提供简单的不可靠数据报, 并在此基础上构建可靠的端到端字节流服务。TCP 协议通常工作在 IP 协议上,依赖 IP 协议提供的地址和路由机制。
本文将介绍 TCP 协议的握手、挥手、流量控制、拥塞控制等基本机制。
TCP 包结构
- 发送方端口
- 接收方端口
- 序列号(SEQ)
- 确认号码(Acknowledge Number):设置了 ACK 标志位后有效,表示期待要收到下一个数据包的 SEQ
- 资料偏移(offset): 表示数据段开始位置相对于 TCP 数据包开头的偏移量,也是 TCP Header 的长度
- 保留位: 目前不使用
- 标志位(Flag): 一共有 9bit, 对应位置1表示标志位有效
- ACK: 表示确认收到了发送方发送的数据, ACK=1 时 TCP Header 中的 ACK Number 字段有效。
- PSH: 优先推送。接收方 TCP 应该尽快推送给接收应用程序,而不用等到 TCP 缓存填满后再交付
- RST: 重置连接。表示 TCP 连接中出现严重错误,需要释放并重新建立连接。
- SYN: 表示请求建立连接,SYN 意为同步(synchronize), 即请求同步序列号。
- FIN: 表示此报文段的发送方的数据已经发送完毕,并要求释放 TCP 连接
- 校验和: 根据 TCP 包的头部和数据段计算的校验和,用于保证传输完整无误
IP 协议的数据包大小有限、不保证送达也不保证送达的顺序,如果需要发送大量数据就必须分为多个数据包。发送方 会为自己的每个 TCP 数据包分配一个序列号sequence number,SEQ)。
接收方收到数据包后会按照 SEQ 将数据包去重并排序,然后接收方对已成功收到的包发回一个相应的确认包(ACK)。如果发送方在合理的往返时延(RTT)内未收到确认,那么对应的数据包就被假设为已丢失并进行重传。
acknowledge number 表示期望收到的下一个数据包的序列号,换句话说 acknowledge number 之前的数据包已经全部收到。这种确认方法称为累积确认。累积确认在丢包时效率很低,假设通过10个分组发出了1万个字节的数据。如果第一个分组丢失,在纯粹的累计确认协议下,接收方不能说它成功收到了1,000到9,999字节,但未收到包含0到999字节的第一个分组。因而,发送方可能必须重传所有1万个字节。
因此,RFC 2018 中引入了选择确认机制(selective acknowledgment,SACK),允许接收方向发送方返回多个 SACK block, 每个 SACK block 表示一段已经成功收到的连续范围的开始与结束字节序号。
三次握手
TCP 连接建立过程需要发送三个 TCP 包,这个过程被称为三次握手:
- 服务端 bind 端口并开始 listen
- 客户端调用 connect 开始建立连接:客户端发送 SYN 包并带上初始序列号, 并进入 SYN_SENT 状态
- 服务端发送 ACK 确认收到了客户端的SYN, 并且发送自己的 SYN 以及自己的初始序列号,并进入 SYN_RCVD 状态。 这里的ACK 和 SYN 是在同一个 TCP 包中发送的
- 客户端确认服务端的SYN。 至此双方都获得了对方的序列号,连接成功建立
四次挥手
TCP 连接断开过程需要发送四个 TCP 包, 这个过程被称为四次挥手:
- 主动方调用 close 开始关闭连接(客户端和服务端都可以主动断开连接, 下图以客户端主动断开为例): 主动方发送 FIN 包并进入 FIN_WAIT_1,表示己方数据发送完成。此后主动方还可以继续接收数据,但是无法继续发送数据。
- 被动方对 FIN 包发送 ACK 并进入 CLOSE_WAIT 状态。此状态下,被动方可以继续发送数据。
- 被动方数据发送完成,调用 close 发送 FIN 包, 并进入 LAST_ACK 状态等待对 FIN 包的 ACK.
- 主动方对 FIN 包发送 ACK 并进入 TIME_WAIT 状态, 在此状态等待 2 MSL 后连接关闭
- 被动方收到 ACK 后连接关闭
在握手过程中服务端可以将 ACK 和 SYN 在同一个包中发送, 因此握手过程中的两对 SYN-ACK 只需要三次传输即可。在挥手过程中,被动方收到主动方的 FIN 包后可能仍有数据需要发送,所以不能将 FIN 和 ACK 在同一个包中发出使得挥手过程必须要经过四次传输。
TIME WAIT
上文中提到的 MSL 是指 Max Segment Lifetime,它是一个 TCP 包在网络中最大的生存时间超过 MSL 的 TCP 包会被丢弃,MSL 的推荐值为两分钟。
被动方在收到 LAST ACK 会一直尝试重传 FIN 包直到到达最大重试次数。 若主动方在 TIME WAIT 状态等待时间过短, 在收到重传的 FIN 包时连接已经关闭,则主动方会向被动方返回 RST,此时被动方会认为遇到了错误,因此无法正常关闭连接。
TIME_WAIT至少需要持续 2MSL 时长,这2个MSL中的第一个MSL是为了等主动方发出去的 LAST ACK从网络中消失,而第二MSL是为了等在被动方收到ACK之前的一刹那可能重传的FIN报文从网络中消失。
2MSL 并不能绝对保证属于本连接的 TCP 包在网络中消失,比如我们利用防火墙拦截主动方发送的所有 LAST ACK 包,那么被动方会一直重传 FIN 包。最后一个 FIN 包在网络中消失的时间只取决于被动方何时停止重传,与主动方 TIME WAIT 状态持续时间无关。
Linux 系统中 TIME_WAIT 的时间为固定的 60 秒,由内核代码里的 TCP_TIMEWAIT_LEN 宏定义, 只有重新编译内核才可以修改。
#define TCP_TIMEWAIT_LEN (60*HZ) /* how long to wait to destroy TIME-WAIT state, about 60 seconds */
TIME WAIT 状态的连接会占用端口, 导致系统无法建立新的 TCP 连接。在本系列的后续文章中我们将介绍如何避免出现过多 TIME WAIT 状态的连接。
拥塞控制
发送数据时当然是越快越好,但是发送速度超过了网络的最大承载能力就会发生丢包。TCP 的目标是尽可能的利用网络承载能力,一方面不浪费带宽,另一方面尽量避免丢包。TCP 协议中控制如何合理利用网络的机制被称为拥塞控制,接下来我们来了解一下拥塞控制所涉及的四个算法:慢开始、拥塞避免、快重传和快恢复。
慢开始 - 拥塞避免
发送方维持一个叫做拥塞窗口 CWND(congestion window)的状态变量,当在网络中传输的数据量(未ACK的数据量)到达 CWND 时就暂停发送。
慢开始算法(SlowStart)将 CWND 的初始值设置的非常小,每一轮成功发送-确认都会使得 CWND 加倍,直到 CWND 达到慢开始算法的阈值 SSThresh 后转为拥塞避免。
慢开始算法的慢是指初始传输速度很慢,但是传输速度会以指数快速增长。
在达到 SSThresh 之后转为使用拥塞避免算法使 CWND 线性增长(加法增大), 避免继续快速增长导致网络拥塞。
无论是在慢开始阶段还是在拥塞避免阶段,只要发送方没有及时收到 ACK 都会判断为出现了网络拥塞。遇到网络拥塞后,发送方会把 SSThresh 设为当前 CWND 的一半, 把 CWND 设为初始值重新执行慢开始算法,这个操作称为“乘法减少”。
乘法减少做的目的就是要迅速减少发送到网络中的数据,使得发生拥塞的路由器有足够时间把队列中积压的数据处理完毕。
快重传
快重传(Fast Retransmit)
- 要求接收方每收到一个失序的报文段后就立即发出重复确认而不是等待自己发送数据时才捎带确认
- 发送方只要一连收到三个重复确认就立即重传对方尚未收到的报文段,而不必等待设置的重传计时器到期
快重传使得发送方迅速重传丢失的数据包减少等待时间。
快恢复
当发送方连续收到三个重复确认时,就执行“乘法减小”算法,把 ssthresh 减半(为了预防网络发生拥塞), 但是接下来并不执行慢开始算法。
考虑到如果网络出现拥塞的话就不会收到好几个重复的确认,所以发送方现在认为网络可能没有出现拥塞。所以此时不执行慢开始算法,而是将 CWND 设置为ssthresh减半后的值,然后执行拥塞避免算法,使 CWND 缓慢增大。
TCP Reno 版本引入了快恢复与快重传机制
流量控制
若发送过快导致超出了接收方处理能力同样会导致丢包重传,因此我们需要控制发送方的发送速率避免发送速率超过接收方处理能力。对发送方发送速率的控制,我们称之为流量控制。
接收方会在返回的 ACK 包的 WIN 字段中告知自己接收窗口(Receiver Window, RWND) 大小, 发送方会取接收窗口 RWND 和拥塞窗口 CWND 中的最小值(min(CWND, RWND))作为自己的发送窗口,当未确认的数据量到达发送窗口规定的上限时便暂停发送。
当发送者收到了一个窗口为0的应答,发送者便停止发送,等待接收者的下一个应答。但是如果这个窗口不为0的应答在传输过程丢失,发送者一直等待下去,而接收者以为发送者已经收到该应答,等待接收新数据,这样双方就相互等待,从而产生死锁。
为了避免流量控制引发的死锁,TCP使用了持续计时器。每当发送者收到一个零窗口的应答后就启动该计时器。时间一到便主动发送报文询问接收者的窗口大小。若接收者仍然返回零窗口,则重置该计时器继续等待;若窗口不为0,则表示应答报文丢失了,此时重置发送窗口后开始发送,这样就避免了死锁的产生。