TCP 是现在最常见的协议之一,比如 HTTP、MQTT 这些协议都是在 TCP 的基础上实现的。

基础知识

如果想要基于 TCP 进行一些简单通讯,基本上了解这几点就够了。

  • TCP 是面向连接的协议,两端进行通讯之前先要握手建立连接
  • 通讯双方的其中一端(服务端)需要监听一个端口,另一端(客户端,可以有多个)向该端口发送连接请求
  • 连接建立后两端都可以主动发送数据
  • TCP 是可靠的流式协议,能保证传输的数据按顺序到达

三次握手

服务端监听了一个端口,客户端希望跟服务端建立连接。 握手的目的在于,双端都要互相确认连接的有效性。

  • 客户端主动向服务端发送 SYN 包
  • 服务端向客户端发送连接 SYN-ACK 包
  • 客户端再向服务端发送一个 ACK 包

那么问题来了,明明两次握手就够了,为什么会要求客户端再进行一次 ACK。

考虑网络状况很烂的情况,客户端发送了 SYN 包,结果一万年没有回应,它就又发了第二次 SYN。 此时服务端终于收到了第一个 SYN,然后按照常规流程建立了 TCP 连接,传输数据,然后关闭了连接。 然后那个第二次 SYN 终于到了,服务端发送 SYN-ACK 后,如果不要求客户端再进行 ACK,就会直接视为连接建立成功。 实际上这个连接已经完成了,而服务端却认为客户端重新发起了一次连接。 而服务端建立连接之后需要开辟缓冲区用于临时存储收到的包,可能还要消耗线程等其他资源。 考虑一种 DDoS 攻击,伪造 IP 地址向服务端高频率发送 SYN,服务端的资源很快就会被耗尽,而且甚至都不知道攻击者的真实 IP。

但是如果要求客户端 ACK,就能避免这个问题。 因为服务端的 SYN-ACK 包只会发往这个伪造 IP,攻击者的机器无法收到。 攻击者也就不知道正确的序列号,ACK 中的确认号就不知道怎么填。 所以连接就不能成功建立。

四次挥手

服务端和客户端交互完毕后,其中一端主动发起关闭请求,以通知另一端。 这时挥手的目的就在于确保双方都关闭连接,并且被通知的一端数据全部发送完毕。 一般来说最好是客户端主动关闭。 这里我们假设主动关闭的一端是客户端。

  • 客户端发送 FIN 包
  • 服务端回应一个 ACK 包
  • 服务端发送 FIN 包
  • 客户端回应 ACK 包

需要注意的一点是,服务端的 ACK 包和 FIN 包实际上是可以一起发的。 但是通常来说,服务端收到客户端的 FIN 之后,之前客户端发过来的数据可能还没有处理完。 服务端的 ACK 包会很快发出去,但是 FIN 包的发送需要由用户程序决定的。 只有在用户的应用程序把缓冲区中的数据处理完毕,这里的处理可能包括向客户端继续发送数据。 而客户端只能接收这些数据,不能向服务端发送新的数据。 处理完毕后服务端才能发送 FIN 包,等待客户端的 ACK,才能关闭连接。

还有一点需要注意,服务端只有收到客户端的 ACK 之后才能正常释放资源。 因此客户端在发送 ACK 之后需要等待两倍的 MSL(maximum segment lifetime)。 这个 MSL 是一个报文在网络中的最大生存时间。 如果在一个 MSL 之内这个 ACK 丢包了,服务端就立刻补发一个 FIN 包,这个 FIN 就能在两倍 MSL 内到达客户端。 因此如果在这段时间内再次收到服务端的 FIN,客户端就需要补发一个 ACK。

数据传输

TCP 协议相比于 UDP 有许多不同点。

有序

数据流中的数据被拆成一个一个的包发出去,每个包都有一个递增的序列号,用来表明在流中的顺序。 序列号的起始值是随机决定的,出现在三次握手中的 SYN 和 SYN-ACK 包中,客户端和服务端各自都要随机生成一个以避免一些攻击。

丢包重传

数据流的每个包的序列号是递增,在正常情况下可以视为是唯一的。 一种简单的思路就是:接收方收到包之后,可以发送一个确认包指明这个包已经收到了。 但实际上采用的是累积确认的方式,确认包中的序列号是已经接收到的连续包中序列号最大值,也就是说小于等于这个序列号的包都已经接收到了。 当然这是指已经把收到的包扔进流里,并不能保证用户应用程序已经读取到。 如果发送方接收到的连续三个确认包都是同一序列号,那就可以认为下一个序列号丢包了,于是考虑重发。 当然也有其他的确认机制,各自有各自的优势。

另外发送方在发送一个包之后会开启一个计时器,如果在 RTO(retransmission timeout)内没有收到确认包就可以视为丢包,考虑重发。 因为网络状况是不确定的,因此 RTO 肯定不是一个定值,那么应该怎么计算 RTO 呢? 这个时候我们引入 RTT(round-trip time)的概念,它指的是一个数据包从发送到接收到确认的时间。 当然这个时间只能用近似值来预测,我们可以每次都记录数据包传输的 RTT,然后做一些平滑运算得到 SRTT(Smoothed RTT),作为未来的估计。 此外还需要考虑 RTT 的相对于预测值的波动。 具体的算法在后续拥塞控制章节中会有介绍。

避免错误

序列号的引入可以保证包的有序且不重复,确认机制可以处理丢包的问题。

TCP 首部中有一个 16 位的 checksum 用于校验数据是否有出错。 但这个校验实际上是比较弱的,应当与数据链路层中的 CRC 校验配合考虑,基本能发现绝大部分问题。

流量控制

假设不限制发送的范围,发送方如果往发送缓冲区写入大量数据,而实际上接收方的缓冲区也不一定塞得下这么多数据,就会浪费很多流量。 TCP 使用滑动窗口来控制流量,只有在窗口范围内的数据才会被考虑发送。 窗口的起始指针会随着确认包中序列号的后移不断后移。 对于已经确认被接收到的数据,它的内存会被回收。 窗口的长度由接收方根据一定的策略以及自己的缓冲区剩余容量决定。

特别的,如果接收方指定长度为零(缓冲区爆了),意味着发送方无法发送数据包。 这个时候为了避免死锁,发送方应当定时发送一个不带实际数据的小包,以从接收方获取长度,直到不为零。

拥塞控制

TCP 有一些策略可以在保证高效率的情况下,有效降低网络拥堵。

慢启动

在通讯刚开始时,窗口长度先设为较小的值(比如一个 MSS),因为无法确定网络状况,避免直接造成拥堵。 每经过一个确认包,就将窗口长度翻倍。

当窗口长度到达 ssthresh(slow start threshold),切换到拥塞避免算法,每次仅线性增长一个 MSS 的长度。

快重传

接收方会在接收到数据后立刻回应确认包。 确认包会携带接收方接收到的连续数据的序列号的最大值。 如果发送方累积收到三个确认包中的这个最大序列号都相同,意味着下一个序列号的包丢失了。 发送方就会跳过超时机制,直接补发这个包。