你真的了解 TCP 吗?

237 阅读12分钟

TCP/IP 参考模型和协议栈

说到 TCP/IP 参考模型,就先地说说最初 ISO 提出的理论化模型 —— OSI (open system interconnection)参考模型,OSI 参考模型将计算机网络分为 7 层,TCP/IP 参考模型就是基于 OSI 为原型建立的实际应用模型,它将 OSI 模型中 7 层结构简化到 5 层。有趣的是,一般来说都是先建立模型,再制定协议,但 TCP/IP 是先有协议,再根据已制定的 TCP/IP 协议,参考 OSI 建立的 TCP/IP 模型。下图就是三者的对应关系:

TCP.jpg

为什么一般都称 TCP/IP 协议,而很少单独提 TCP 协议,IP 协议?

在最底层的以太网协议中(Ethernet)规定了基础的数据包的基本格式,但仅仅依靠以太网协议不能实现多个局域网之间的通讯。这个时候 IP 协议通过定义一套自己的地址规则(IP地址)实现了路由功能,数据包通过寻找对应地址传输实现了局域网之间的通讯。但是 IP 协议只是一个地址协议,它并不负责数据包的可靠性和完整性,若数据包传输过程中出现丢包,乱序等情况,IP 协议是无法追溯和恢复的。这个时候就需要 TCP 协议来处理。所以本质上 TCP 和 IP 是两种协议,但在大部分场景下都是搭配使用来实现数据包的可靠传输。

TCP 数据包

以太网协数据包大小是 1522 字节,其中 1500 字节是负载(这里可以理解为实际需要传输的信息),22 字节是头信息。

IP 数据包在以太网数据包的的负载中,IP 数据包一般包含最小 20 字节的头信息和最大 1480 的负载。

TCP 数据包在 IP 数据包的负载中,TCP 数据包一般也包含最小 20 字节和最大 1460 的负载。在 1460 字节的 TCP 数据包负载中可能还会分应用头部信息和用户传输信息,这个结构需要视应用层的协议而定。

TCP 数据包.jpg

因此在 HTTP 1.1 中,一条 1500 字节的信息需要两个 TCP 数据包。

老生常谈的三次握手与四次挥手

三次握手

三次握手是为了保证连接是双工的,也就是说需要保证客户端与服务端都具有收发消息的能力。三次握手具体过程如下图:

三次握手.jpg

第一次握手:

客户端发送 SYN 段消息给服务端,服务端接收成功。SYN 中包含客户端的初始序列号。

客户端视角:自己的发送消息能力 OK。

服务端视角:自己的接收消息能力和客户端的发送消息能力都 OK。

第二次握手:

服务端返回 SYN 段 + ACK 包给客户端,客户端接受成功。服务端发送的 SYN 段包含服务端的初始序列号,ACK 包是客户端的 SYN 段 + 1。

客户端视角:服务端接收到了自己发送的消息,并且给自己响应了消息,说明服务端的接受消息能力,和发送消息能力都 OK,自己的接受消息能力 OK,此时客户端已经能确认双方的收发能力都正常。

服务端视角:自己的发送消息能力 OK。

第三次握手:

客户端发送 ACK 包给服务端。此时 ACK 包为服务端的 SYN 段 + 1。

服务端视角:客户端既然响应了自己的消息,说明客户端的接受消息能力 OK,此时服务端也能确定双方的收发能力都正常。

四次挥手

四次挥手是为了保证连接双方都彻底关闭。四次挥手具体过程如下图:

四次挥手.jpg

第一次挥手:

客户端向服务端发送结束 FIN 段消息,FIN 段中包含一个 K 标志符和对方最近一次发过来的数据 ACK 包,客户端进入 FIN-WAIT-1 状态。服务端收到消息。

第二次挥手:

服务端响应 ACK 包,其中 ACK 是 K 标志符 + 1,表示服务端收到的消息是客户端最近一次发送过来的 FIN 段。服务端进入 CLOSE-WAIT 状态。客户端收到消息后进入 FIN-WAIT-2 状态,即将关闭。

第三次挥手:

服务端向客户端发送结束 FIN 段消息,FIN 段中包含一个 L 标志符和最近一次发送的 ACK 包,表示是上一次的后续关闭消息,告知客户端可以关闭,后服务端进入 LAST-ACK 最后确认状态。客户端收到 FIN 段消息。

第四次挥手:

客户端收到服务端的 FIN 段消息后,响应 ACK 包给服务端,ACK 是 L 标志符 + 1,告知服务端,客户端已关闭,服务端也可以关闭。这里客户端会等待 2MSL 时间后真正关闭。服务端收到客户端发送的 FIN 消息后关闭。

为什么四次挥手时,客户端发送完最后一条 ACK 消息后,还需要等待 2MSL 才关闭?

MSL(全称:Max Segment Lifetime 报文最大生存时间)RFC 793中规定MSL为2分钟,实际应用中常用的是30秒,1分钟和2分钟等。

客户端发送完最后一条 ACK 包后进入 Time-wait 状态等待 2MSL 主要有两个用处:

  1. 保证 TCP 协议中的全双工连接能够可靠关闭。

    客户端发送最后一次 ACK 包,若此时发生了丢包,服务端会请求重传。若客户端发送最后一次 ACK 包后立马关闭,则接收不到服务端的重传请求,导致服务端一直处在 LAST-ACK 状态,无法关闭。

  2. 保证这次 TCP 连接的重复数据段从网段中消失。

    如客户端发送最后一次 ACK 包后立马关闭,此时一个新的 TCP 连接开始建立,恰好建立在相同服务器,相同端口上。上一个最后一次 ACK 包发生了丢包,服务端请求重传,新的 TCP 就会接收到这个重传请求,从而对新会话产生影响。

为什么有时通过 netstat -nat 指令查看服务端 TCP 连接状态会出现大量的 CLOSE-WAIT?

一般这种情况是因为客户端的并发请求量超过了服务端的可承受范围,导致服务端不能及时向客户端发送结束 FIN 段消息,一直处在 Close-wait 状态。

TCP 流量控制

什么是流量控制?为什么需要流量控制?

由于通讯双方的网络速率和处理能力存在差异,如果能力较优秀的一方发送消息过快都会导致能力较差的一方无法及时响应,严重的可能导致接收方服务崩溃。所以需要设置一个中间缓冲区来控制流量,将消息先放入缓冲区中,如果缓冲区满将回复发送方停止发送,若发送方仍继续发送,则将发送的无法进入缓存的消息丢弃。

TCP 流量控制的实现原理

TCP流量控制.jpg

通过上图可以看到,首先在缓冲区窗口空余时,发送方与接收方正常通讯,每次通讯,除了应当返回的消息,接收方也会返回缓冲区的剩余窗口大小给发送方,发送方通过判断剩余窗口数来决定是否发送消息,同时接收方从缓冲区中获取消息进行处理。当发送方接收到剩余窗口为 0,发送方停止发送,并定时发出请求,检测缓冲区是否还有剩余窗口,直到检测到缓冲区存在剩余窗口后,双方恢复正常通讯。

TCP 拥塞控制

什么是拥塞控制?为什么需要拥塞控制?

拥塞控制与流量控制不同,流量控制是解决发送方和接收方速率不匹配的问题,而拥塞控制是调节网络的负载,避免网络资源被耗尽。当接收方网络资源繁忙,因未及时响应发送方,导致发送方重传数据,将会使网络更加拥堵,产生雪崩效应。

TCP 拥塞控制的实现原理

拥塞控制.jpeg

拥塞控制中存在一个状态变量叫做拥塞窗口(cwnd),指某一源端数据流在一个RTT内可以最多发送的数据包数。

为了探测当前网络的拥塞程度,发送端开始发送1个报文段的 cwnd 给接收端,然后通过慢启动算法指数级增大 cwnd(图中通过慢启动算法增加到包含16个报文段的 cwnd),为了防止 cwnd 增长过快,还设置了一个慢启动阈值 ssthresh(图中为 16),当达到 ssthresh 将进入拥塞避免算法,通过加 1 增大 cwnd 直到网络拥塞的临界值(图中该临界值为 20),网络拥塞的临界值是根据当前网络状况所决定的,是动态变化的值。当到达临界值后,拥塞控制会将拥塞窗口调至初始值 1,重新开始整个拥塞控制算法周期,此时会计算出一个本周期慢启动阈值 ssthresh,一般是上一个周期网络拥塞的临界值的一半。

上面就是拥塞控制的基本流程,但其中的问题也很明显,当一个拥塞控制周期达到网络拥塞的临界值时,将 cwnd调至初始值 1,此时会传输速率会呈现断崖式下跌,这种大幅度的波动显然是不合理的,,所以在 TCP Reno 版本之后,拥塞控制也作出了相应的优化方案,采用了快重传+快恢复算法。

拥塞控制快速恢复.jpeg

第一个周期开始也是采用了慢启动算法+拥塞避免算法来增加 cwnd,直到达到网络拥塞的临界值,发送方连续收到三个重复确认的 ACK,此时发送方不会等待重传计时器到期,会执行快重传算法,此时将新阈值 ssthresh 设置为当前网络拥塞的临界值的一半,启动快重传算法此时的 cwnd 为新阈值 ssthresh + 3,立即重传数据后进入快恢复状态,将 cwnd 调至新阈值 ssthresh 后直接进入拥塞避免算法。

若到达到网络拥塞的临界值,网络拥塞等其他情况造成发送方未收到三个重复确认的 ACK,则发送方等待重传计时器到期后,仍会将 cwnd 调整至初始值 1,进入慢启动算法,不会启用快重传+快恢复算法。

TCP 粘包 / 拆包

下面是一个简易的 TCP 连接数据流转模型。

tcp 粘包和拆包.jpg

如图所示,整个流程中客户端应用层发送一次请求数据包(根据数据包的大小占用一个或多个数据字节)到 tcp 发送缓冲区,tcp 发送缓冲区再将缓冲区中的数据字节通过 tcp 连接传输至 tcp 接收缓冲区,tcp 接收缓冲区再将数据字节发送至服务端应用层。

粘包

客户端应用层发送一次 A 请求数据包,占用了一个数据字节 15 到发送缓冲区。后客户端应用层有发送了一次 B 请求数据包,占用了两个数据字节 16, 17 到发送缓冲区,发送缓冲区会根据当前缓冲区的大小和一些规则判断,将 A 请求数据包(数据字节 15)和 B 请求数据包(数据字节 16,17)进行粘包,粘包后形成一个新的数据包,共用一个 tcp 报文段头部,进行传输。

总的来说,粘包就是将两个不同的 tcp 请求数据包放到一个新的 tcp 请求数据包中进行请求。

拆包

若客户端应用层发送一次 C 请求数据包,原本占用了一个4 个数据字节 9,10,11,12,但真正在 tcp 连接中进行传输时,被分成了两个新的数据包 9,10 和11,12 进行。

总的来说,拆包就是将一个 tcp 请求数据包拆成两个 tcp 请求数据包进行请求。

从上面流程也可以看出,真正控制 tcp 请求的发送与接收其实并不是一般开发者编写的应用层,而是 tcp 的缓冲区,缓冲区是由 Linux 内核进行控制。

为什么会出现粘包 / 拆包

  1. 应用程序写入的数据大于 Socket 套接字缓冲区大小,则会发生拆包。反之应用程序写入的数据小于 Socket 套接字缓冲区大小,则可能会发生粘包。
  2. 应用程序写入的报文长度大于 MSS(最大报文长度),则会进行 MSS 大小的 TCP 分段拆包。
  3. 接收方接收缓冲区数据不及时也可能会出现粘包的现象。

怎样处理粘包 / 拆包

由于传输层业务无感知的,所以处理粘包 / 拆包只能通过业务层的收发两端制定协议,通过协议中制定的规则来组装和拆解数据。常见的协议处理有:

  1. 使用带消息头的协议。将本次请求数据包长度记录在协议头,读取时先读取协议头中的数据包长度信息,再根据长度信息读取包内容的对应长度数据。

  2. 每次传输固定长度消息。每次发送和读取定长的内容,当本次发送的数据包长度小于规定发送长度时,剩下的长度通过特定的占位符补足。

  3. 通过特定的分隔符分隔数据包。每个完整请求的数据包末尾都添加特定的分隔符进行区分。