在TCP/IP协议栈层面,在进行网络通信的两台主机之间建立逻辑通路是传输层的一个重要工作,这种逻辑通路的建立,一方面通过IP协议中的源IP和目的IP将两台主机联系起来,另一方面通过传输层协议中的源端口号和目的端口号将两台主机上唯一的进程联系起来。在IP协议的首部中会包含传输层的协议号,以区分使用的是哪一个传输层协议。站在传输层的角度,可以认为传输层制定了数据向对方主机发送的策略,例如TCP协议会有一系列机制来尽量保证数据的可靠传输。上述的源IP、目的IP、源端口号、目的端口号和协议号可以唯一的标识一个通信,构成了网络通信的五元组。UDP协议和TCP协议是传输层中最具代表性的协议,本文对两者进行讨论。

TCP/IP协议栈_传输层_UDP和TCP_TCP/IP协议栈

UDP

协议段格式

用户数据报协议UDP(User Datagram Protocol)的协议段格式如下:

TCP/IP协议栈_传输层_UDP和TCP_拥塞控制_02

在讨论UDP的协议段格式时,首先关注两个问题:

  • UDP协议使用 8 Byte 定长报头,上层可以根据这一点将UDP协议包的报头和有效载荷分离(解包)。
  • 借助UDP协议中的目的端口号字段可以将UDP报文准确地交付给上层中的某个应用(分用)。

在这两个工作完成、且数据不出错时,才有可能将有效载荷交付给上层的某个期望的应用程序。

长度字段标识了整个UDP报文的大小,长度字段标识大小 - 8 为有效载荷的大小。UDP是面向数据报的,大部分情况下,传输层在收到一个UDP报文、拿到并解析报头数据后,会将报文的剩余部分视为有效载荷,直接交付给上层。UDP协议虽不保证数据传输的可靠性,但是保证数据的正确性,数据正确与否的检查是通过校验和字段来完成的。当对方传输层拿到一个UDP报文时,首先通过校验和来检查有效数据在传输过程中是否出错,如果有效数据出错,接收端会直接丢弃这个报文

特性

无连接、不可靠、面向数据报是UDP协议的特性。无连接指的是两台主机在通信之前不事先建立可靠的逻辑通路,而是由发送方直接向指定的主机发送数据报,不管对方是否收到,甚至不管对方是否存在。无连接意味着UDP可以随时发送数据,目标主机也无法确定何时收到数据。 

当数据报在传输过程中丢包时,UDP不负责重发,或者当数据报到对方主机时发生乱序现象,UDP也不负责纠正,而是直接丢弃报文,所以,当一个UDP报文发出后,不能保证对方能够收到这个报文并将其传递给上层,即UDP是不可靠的。无连接是UDP不可靠的一个原因。

UDP是面向数据报的,发送方以一个数据报为单位发送报文,接收方也以一个数据报为单位接收报文。

UDP是全双工的,发送和接收数据报可以同时进行。UDP没有真正意义上的发送和接收缓冲区,在send或者recv时,传输层会将UDP报文直接向下交付。

UDP可以随时发送数据,而且UDP本身的处理既简单又高效,所以UDP适用于包含量较少,或者对可靠性要求不高的通信场景,例如DNS、音视频的即时通信等。

TCP

协议段格式和协议概述

传输控制协议TCP(Transmission Control Protocol)的协议段格式如下:

TCP/IP协议栈_传输层_UDP和TCP_UDP_03

4位首部长度标识了首部大小,以 4 Byte 为基本单位,最大能标识的首部大小为 60 Byte。选项字段的长度是可变的,首部大小 = 标准报头的大小 + 选项字段的大小。标准报头的大小定长为 12 Byte,通过定长报头和自描述字段,传输层可以将TCP首部与有效载荷相分离。和UDP一样,TCP通过目的端口号将报文交付给上层的某个应用。

传输控制协议中的“控制”大致体现在控制三个方面:什么时候发、发多少、出错了怎么办,为了达到控制的目的,TCP报文本身需要具有类型,不同的类型决定了自己的要做的动作或者对方要做的动作。TCP报文的类型由协议段中的6个标识位进行标识,这里先将其列出,具体意义和作用会在后文中体现。

  • URG 标识紧急指针字段是否有效。TCP需要保证被收到的报文的顺序是有序的,而紧急指针可以支持数据被插队接收。紧急指针保存了紧急数据(带外数据out-off-band)在当前数据中的偏移量。在一次发送中,紧急数据一般只能是 1 bit。当服务器负载过高时,可以通过带外数据来查询服务器当前的工作状态。
  • ACK 标识确认序号字段是否有效。只要TCP报文具有应答属性,ACK就为 1。
  • PSH 在使用 recv(2) 和 send(2) 进行网络收发信息时,其实并不是将数据在本地主机和网络之间直接进行倒换,而是将数据在本地主机的缓冲区之间进行拷贝。当使用 send(2) 发送数据时,本质是将用户缓冲区的数据拷贝到内核缓冲区,然后操作系统会决定将内核缓冲区中的数据发送到网络中的时机以及进行网络发送;反之,当使用 recv(2) 接收数据时,本质是将内核缓冲区中的数据拷贝到用户缓冲区。用户缓冲区从下层接收字节流、向上层发送字节流,本质是一种生产者-消费者模型。PSH 会通知对方应用立即将用户缓冲区中的数据读走,以腾出缓冲区空间。
  • RST 标识重新建立连接。TCP保证的可靠性是在不出现一些不可抗问题(例如断网)的基础上保证的。TCP认为,连接建立完成后的通信才是可靠的,TCP虽然保证可靠性,但是允许连接建立失败。连接建立成功的本质是,在系统中创建相关的内核数据结构并对其进行维护。在通信中,可能会出现通信双方中的某一方建立连接失败的情况,这时需要其发送RST标志位的报文以申请重新建立连接。
  • SYN 请求建立连接,一般在双方建立通信时互相发送。
  • FIN 断开连接。告诉对方,本端要关闭连接了。一般在双方关闭连接时互相发送。

TCP报文中的其他字段基本都与TCP保证可靠性的机制有关,会在下文讨论TCP保证可靠性的策略时穿插说明。

TCP保证可靠性的策略

TCP为了保证可靠性制定了一些独特机制,这些机制大都需要TCP协议段中的各个字段来辅助实现。

确认应答机制

确认应答是TCP保证可靠性最核心的机制。确认应答用于双方确认对方是否收到了自己发送的报文,当一方收到报文时,需要在应答报文中激活ACK标志位,以向对方说明自己收到了报文。如果收到了ACK报文,就说明最近一次发送的报文被收到;没有ACK应答的报文,无法保证其可靠性。在这个体系中,双方通信中最近一次的消息永远无法得到应答,所以永远无法保证最近一次消息可靠性,所以无法保证双方发出的所有信息是100%可靠的,但是可以保证各个局部的可靠性,只要局部收到了应答,就能确认对方收到了上一条消息。如果发送方一段时间没有收到期望的ACK应答,则认为报文丢失,然后进行重传。

TCP/IP协议栈_传输层_UDP和TCP_滑动窗口_04

有了确认应答机制,就可以猜测TCP实际通信过程中的大致流程即为发送和应答的交叉,这种交叉可以不具规律和连续性,因为一方可能只发送了一个报文,然后等待应答,也可能一次发送了多个报文,然后等待多个报文的应答。

事实上,为了提高效率,TCP往往会一次发送一批数据,包括多段字节流(多个TCP报文),为了使对方完整地收到数据,发送方会给每段字节流一个序号(sequence,在协议段中的序号字段被标识),当对方收到报文时,会根据收到的报文序号在应答中携带一个确认序号(ack seq)。规定:当确认序号为 n 时,则代表 n 之前的所有报文均已收到,下次发送端发送数据时从序号 n 开始。

TCP/IP协议栈_传输层_UDP和TCP_滑动窗口_05

有了序号和确认序号,针对数据或应答的丢失可以分为两种情况讨论:

  • 报文未丢失,少量ack应答丢失。既然接收方已经收到了报文,根据确认序号的定义,如果后续报文接收正常,接收方会返回后续报文的正常的确认序号;即使后面的ack应答全都丢失,也可以根据最后接收到的确认序号进行超时重传。确认序号的定义允许部分应答丢失
  • 少量报文丢失。当中间某个报文丢失而发送端继续发送(多个)报文时,根据确认序号的定义,接收端会(多次)根据丢失报文的前一个报文的序号发送确认序号,当发送方连续收到多个相同的确认序号时,会根据确认序号直接对数据进行补发,即TCP的快重传机制

序号的存在也可以使接收方对收到的数据进行去重,接收方会根据序号判断收到的数据是否重复,并对重复数据进行丢弃。

TCP/IP协议栈_传输层_UDP和TCP_TCP_06

超时重传机制

如果发送方发送报文一定时间间隔后没有收到应答,则无法确定这个报文当前的状态是丢了、还是正在网络中排队、还是对方发来的应答丢了,此时一律对报文进行重发(补发)。因为存在补发,所以对方可能会收到重复的报文,收到重复报文本身是不可靠的一种表现,所以接收方需要对收到的报文进行去重,一如稍早所述,接收方是通过序号进行去重的。

超时重传的重发时间间隔是动态的,与网络状态强相关,这个时间单位默认与机器有关,且以 2 的指数级别增长。在当前机器下,基础重传时间大致为1s,最多重传次数为6次。使用telnet测试一个不存在的ip和端口:

[shr@ Mon Jul 08 17:47:58 ~ $] telnet 111.111.111.111 9999
Trying 111.111.111.111...

使用 tcpdump 查看重传情况:

[shr@ Mon Jul 08 17:47:55 ~ $] sudo tcpdump -i any port 9999
tcpdump: data link type LINUX_SLL2
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on any, link-type LINUX_SLL2 (Linux cooked v2), snapshot length 262144 bytes
17:48:03.817450 eth0  Out IP iZ2zed7lj4oethzhepuzv3Z.38778 > 111.111.111.111.9999: Flags [S], seq 2396286786, win 64240, options [mss 1460,sackOK,TS val 3835783644 ecr 0,nop,wscale 7], length 0
17:48:04.832347 eth0  Out IP iZ2zed7lj4oethzhepuzv3Z.38778 > 111.111.111.111.9999: Flags [S], seq 2396286786, win 64240, options [mss 1460,sackOK,TS val 3835784659 ecr 0,nop,wscale 7], length 0
17:48:06.848347 eth0  Out IP iZ2zed7lj4oethzhepuzv3Z.38778 > 111.111.111.111.9999: Flags [S], seq 2396286786, win 64240, options [mss 1460,sackOK,TS val 3835786675 ecr 0,nop,wscale 7], length 0
17:48:10.880348 eth0  Out IP iZ2zed7lj4oethzhepuzv3Z.38778 > 111.111.111.111.9999: Flags [S], seq 2396286786, win 64240, options [mss 1460,sackOK,TS val 3835790707 ecr 0,nop,wscale 7], length 0
17:48:19.072350 eth0  Out IP iZ2zed7lj4oethzhepuzv3Z.38778 > 111.111.111.111.9999: Flags [S], seq 2396286786, win 64240, options [mss 1460,sackOK,TS val 3835798899 ecr 0,nop,wscale 7], length 0
17:48:35.200348 eth0  Out IP iZ2zed7lj4oethzhepuzv3Z.38778 > 111.111.111.111.9999: Flags [S], seq 2396286786, win 64240, options [mss 1460,sackOK,TS val 3835815027 ecr 0,nop,wscale 7], length 0
17:49:08.480354 eth0  Out IP iZ2zed7lj4oethzhepuzv3Z.38778 > 111.111.111.111.9999: Flags [S], seq 2396286786, win 64240, options [mss 1460,sackOK,TS val 3835848307 ecr 0,nop,wscale 7], length 0

可以看到在第一次连接失败后重传6次,重传的时间间隔依次大致为1s、2s、4s、8s、16s。

/proc/sys/net/ipv4/tcp_syn_retries文件中可以查看最大重传次数:

[shr@ Mon Jul 08 17:50:14 ~ $] cat /proc/sys/net/ipv4/tcp_syn_retries 
6

重传间隔时间指数级别的增长方式,呈现先慢后极快的增长趋势,意味着重传时间先短后长,可以在避免一开始重传过于频繁而导致加重网络拥塞的基础上,最迅速地使对方顺利收到这个报文

超时重传与前文提到的快重传并不冲突。快重传是有条件的,需要发送端后续发送大于等于3个报文,当通信到达末尾而没有连续三个重复的ack时,需要等待超时重传。快重传可以提高tcp通信的效率。

连接管理机制

TCP是面向连接的,连接管理是TCP保证可靠性的关键机制。连接建立的本质其实是在操作系统内核中构建出相应的对象,并对这些对象进行管理。具体来说,进行TCP网络通信的双方需要进行3次握手以建立连接,建立连接后,服务器需要以对象的形式将连接组织在全连接队列中;当要结束通信时,双方需要进行四次挥手以彻底断开连接。双方在整个通信过程中进行连接建立和连接关闭,这即是TCP面向连接的基本表现

TCP/IP协议栈_传输层_UDP和TCP_UDP_07

现在阐述三次握手和四次挥手的过程,以及过程中客户端与服务器双方状态的变化、与 TCP socket 的联系。下文分别以 c 代表客户端,以 s 代表服务端。同时,当提及 SYN、ACK、FIN 等标志位时,切记双方发送的是一个带有相关标识位的完整的 TCP 报文

握手过程及双方的状态转换:

  • 假设 s 一直处于监听状态。首先,c 调用connect(2)向 s 发起连接请求 SYN,c 进入 SYN_SENT 状态;
  • s 收到 SYN 后,捎带地向 c 发送 SYN 和 ACK,紧接着进入 SYN_RCVD 状态。当 s 收到第一个 SYN 时,会将客户端连接放到半连接队列中进行组织。
  • c 收到 s 的捎带应答后,向 s 发送 ACK 应答,紧接着进入 ESTABLISHED 状态,对客户端来讲,此时已经建立连接完成,connect(2)返回。
  • s 收到 c 的 ACK 应答,进入 ESTABLISHED 状态,服务器连接建立完成,accept(2)返回。服务器会将建立的连接放到全连接队列中进行组织。accept(2)默认会阻塞接收客户端的最后一个 ACK 应答,即阻塞等待客户端的连接,当连接建立后,accept(2) 会在全连接队列中拿取连接并返回一个通信句柄。当 s 迟迟无法收到 c 的最后一个 ACK 应答时,即对于长时间处于 SYN_RCVD 状态而无法成功建立连接的半连接,服务器会丢弃这个半连接,半连接队列的节点不会被长期维护

挥手过程及双方的状态转换:

  • c 调用close(2)关闭连接,向 s 发送 FIN,进入 FIN_WAIT_1 状态。
  • s 收到 FIN 后,返回 ACK 应答,进入 CLOSE_WAIT 状态,上层的 read(2)返回 0。
  • c 收到 ACK 后,进入 FIN_WAIT_2 状态,等待服务器关闭连接。
  • 当连接关闭前的工作完成后,s 调用close(2)发送 FIN 表示本端欲关闭连接,进入 LAST_ACK 状态。
  • c 收到 FIN 后,返回 ACK 应答,进入 TIME_WAIT 状态。
  • s 收到 ACK 应答后,服务器连接关闭。
  • 一段时间后,c 退出 TIME_WAIT 状态,客户端连接关闭。

现在阐述三次握手和四次挥手的相关细节。为什么要进行三次握手、四次挥手(握手和挥手的次数问题)?在握手过程中,第二次握手有SYN和ACK的捎带应答,本质可以看做是四次握手。(一.)逻辑上的四次握手,是双方一来一回确认连接的行为,为了保证可靠性,少任何一次都不行。断开连接时,本质是双方没有数据给对方发送了,挥手表明了自己断开连接的意向,发送数据是双方互相进行的,所以必须断开两次,双方各一次。四次挥手,本质是双方确定对方关闭连接的意愿。TCP服务器是被动的,握手时,服务器必须无条件同意,所以可以将SYN和ACK进行捎带;挥手时,服务器和客户端存在一定的协商成分,服务器第二次挥手发送ACK回应客户端后,不一定立即发送FIN断开自己的连接,因为可能存在服务器还有数据未发送的情况,服务器将数据全部发出后,向客户端发送FIN进行后续挥手。故而服务器的FIN和ACK不能捎带,只能是四次挥手。(二.)三次握手的过程,可以验证双方全双工的可靠性:

  • 对于客户端,当收到第二次握手服务器发来的SYN和ACK的捎带应答时,首先可以确认自己的接收通道可靠。其次说明第一次握手的SYN被服务器成功收到,所以可以确定自己的发送通道可靠
  • 对于服务器,当收到第一次握手时客户端的SYN请求时,可以确定自己的接收通道可靠。当收到第三次握手时客户端的ACK应答时,说明第二次握手时自己发送的SYN和ACK捎带应答被对方收到,所以可以确定自己的发送通道可靠

为了更深刻了解三次握手次数的合理性,可以尝试设计一些握手次数方案,并讨论其是否可行:

  • 假设只有一次握手,由于客户端无法确认自己的连接请求是否被服务器收到,继而无法确认连接是否成功建立,所以无法直接保证建立连接时的可靠性。
  • 假设只有两次握手,当服务器收到客户端的FIN请求并返回ACK应答时,服务器需要首先建立连接,维护相关的数据结构,如果客户端在收到ACK之前突然崩溃,服务器依然需要维护连接一段时间,造成资源浪费。当出现问题时,负担都由服务器承担了,使服务器更容易出现问题
  • 三次握手。当前两次握手出现问题时,双方的连接尚未建立,即使服务器可能会在半连接队列中维护一份客户端的半连接,但由于半连接队列中的节点不会被长时间维护,所以前两次握手失败一般不会造成问题。当第三次握手出现问题时,由于(三.)客户端首先建立了连接,问题由客户端承担,尽可能保证了服务器的稳定性。由此可以拓展到三次以上的奇数次握手,都可以确保客户端首先建立连接。

现在对上文提到的连接队列和 TIME_WAIT 状态做解释。

三次握手和四次挥手是双方操作系统的行为,当三次握手完成、连接建立时,服务器会在全连接队列中维护一份客户端信息,accept(2) 会在全连接队列中拿到已经建立连接完成的套接字fd,作为双方通信的句柄。listen(2) 接口中的 backlog 参数 + 1 即为底层已经建立好的全连接队列的最大长度。全连接队列填满后,如果有客户端继续进行第三次握手,服务器会直接丢弃客户端的ACK报文而导致第三次握手失败,客户端无法进入全连接队列,如果长时间无法与这个客户端建立连接,服务器就将其在半连接队列中丢弃,此时可能会出现客户端与服务器的连接状态不一致问题。在指定 listen(2) 中的 backlog 参数时,既不能让全连接队列太长,也不能没有全连接队列。全连接队列太长时,会造成资源的占用,并且当上层的服务过于忙碌时,也无法及时处理队列中的连接;如果没有全连接队列,就需要来一个连接处理一个连接,上层服务没有空闲时,直接拒绝处理,如果出现上层服务忙碌时连接多、空闲时连接少的情况,就会导致服务器资源得不到充分利用。还有一点,虽然半连接队列中的节点不会被长期维护,但是当客户端发送大量SYN请求时,服务器的半连接队列可能一度会被打满,这时后面到来的SYN请求会被直接丢弃,正常客户端的连接就无法进行,即SYN洪水(SYN Flood)。

主动断开连接的一方,在进行第四次挥手之后会进入 TIME_WAIT 状态并持续一段时间,然后自动释放连接。在 TIME_WAIT 状态的持续时间内,主动断开连接的一方无法再次绑定与上次相同的端口号,对于服务器而言,这就意味着短时间内服务无法重启。协议规定,TIME_WAIT 状态的持续时间为两个 MSL(max segment lifetime),即两个网络报文的最长存在时间(当报文被正常传输,或在网络中被阻塞时,都可以认为其是存在的)。标准规定一个 MSL 为2分钟,centos 7 系统默认为60秒,不同机器下可以不同。可以通过tcp_fin_timeout文件中的内容查看和修改当前机器的 MSL 时间:

[shr@ Thu Sep 05 14:50:45 ~ $] cat /proc/sys/net/ipv4/tcp_fin_timeout 
60

TIME_WAIT 状态的持续,一方面可以让双方的历史通信数据得到消散:客户端(假设客户端为主动断开连接的一方)可以接收并丢弃历史报文,使下一轮的通信不被影响;另一方面,处在 TIME_WAIT 状态时,客户端可以接收 FIN 并返回 ACK,以尽量让服务端收到第四次挥手的 ACK 报文,提高四次挥手的容错性

在某些场景下需要服务断线之后立即重启,此时可以通过setsockopt(2)对监听套接字进行设置,将其SOCK_REUSEADDR置为 1,意味着在特定情况下允许 listenfd 在同一个本地地址和端口上启动监听,以此解决服务器处于 TIME_WAIT 状态时bind失败的问题。

#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>

int setsockopt(int sockfd, int level, int optname,
               const void *optval, socklen_t optlen);

TCP/IP协议栈_传输层_UDP和TCP_TCP/IP协议栈_08

在实际开发中,经常将SOCK_REUSEPORTSOCK_REUSEADDR一同置为 1,两者其实存在差异。SO_REUSEPORT允许在同一端口上创建多个监听套接字,这些套接字可以分布在不同的进程或线程中,以实现负载均衡等目的,而SOCK_REUSEADDR侧重的是对端口的顶替。

TCP/IP协议栈_传输层_UDP和TCP_拥塞控制_09

关于上文提到的序号还存在一个问题:本次连接发送的报文的序号和上次连接发送的报文的序号,可能会存在序号混杂和冲突问题。为了解决这个问题,一方面,双方可以在三次握手时协商一个随机数参与序号的生成过程,减少序号的冲突概率;另一方面 TIME_WAIT 状态的存在也会极大降低两次连接发送序号冲突的概率(信道残余是造成序号混杂的根本原因)。

TCP/IP协议栈_传输层_UDP和TCP_TCP_10

流量控制机制

在双方进行通信的过程中,如果接收方的接收缓冲区被打满,则会直接丢弃后续收到的报文,这本身是通信不可靠的一种表现。为了保证通信的可靠性,TCP发送端会根据接收端的处理能力调整自己的数据发送速度和数据量,即流量控制。双方的接收能力通过"窗口大小"进行度量,通过协议段中的窗口大小字段告诉对方。双方第一次对接收能力的协商是在三次握手时进行的,双方会在前两次握手时告诉对方自己的接收能力。在后续的通信过程中,双方也会不定时的进行窗口更新通知和窗口探测

TCP/IP协议栈_传输层_UDP和TCP_TCP/IP协议栈_11

虽然协议段中的窗口大小字段只有 16 位,但并不意味着最多只能表示 65535 字节的窗口大小,在选项字段中可以指定一个窗口扩大因子M,实际的窗口大小会左移 M 位。

一方面,流量控制可以避免双方的缓冲区被打满而导致报文的丢失,提高了通信的可靠性;另一方面,双方可以根据对方的窗口大小一次尽可能多的发送数据,减少网络IO次数以提高通信效率

滑动窗口

已经发送出去,但是暂时没有收到应答的报文,需要被暂时"保存",方便出现问题时进行重传,发送端可以同时存在多个这样的报文。网络发送本质是将TCP发送缓冲区中的数据拷贝至下层,发送缓冲区中天然保存有历史发送的数据,所以不需要另外的空间存储暂未收到应答的报文,只需要对发送缓冲区进行分区即可。对发送缓冲区进行分区,其中维护的已发送且暂未收到应答的报文的区域,即为滑动窗口

TCP/IP协议栈_传输层_UDP和TCP_TCP/IP协议栈_12

需要注意,对于TCP协议段的窗口大小字段,双方探测的是对方的接收缓冲区剩余空间的大小,而不是滑动窗口大小,滑动窗口是发送缓冲区中的一段区域。滑动窗口是动态变化的,滑动窗口的大小首先不能超过对方接收缓冲区剩余空间的大小。根据接收方的行为,滑动窗口主要会出现两种变化:

  • 接收方上层不取数据,极端情况下,接收缓冲区的大小减为0,发送方滑动窗口的右边界向右移动,左边界不变。
  • 接收方上层取走数据,发送方滑动窗口的左右边界都移动,滑动窗口的大小根据接收方接收缓冲区的剩余大小扩大或缩小。

滑动窗口的左边界移动,意味着收到对方的应答;滑动窗口的右边界移动,意味着向对方发送数据。滑动窗口在逻辑上是环形的,在移动到边界时,会进行取模运算回到正确位置。上文提到的流量控制机制即是通过滑动窗口进行的,通过维持滑动窗口在一个合适的大小,使对方接收缓冲区的剩余空间大小维持在一个健康的状态,而接收方反馈的窗口大小可以为发送方滑动窗口的调整进行指导。

延迟应答机制

从一台主机发送数据通过网络到达另一台主机,其中的过程是复杂和耗时的,为了提高网络传输的效率,可以一次发送尽可能多的数据,以此尽量减少网络IO的次数,而一次发送的数据量是由接收方接收缓冲区的剩余空间大小(和网络拥塞情况)决定的,所以现在要让接收方反馈尽量大的窗口大小。为了达成上述的目标,可以让接收方在收到报文后不直接应答,而是等待上层取走一部分数据,尽量腾出缓冲区的空间,再进行ack应答以反馈较大的窗口,即延迟应答

TCP/IP协议栈_传输层_UDP和TCP_TCP/IP协议栈_13

不是所有的报文都能延迟应答,存在两种限制:

  • 数量限制 每 N 个报文就需要应答一次
  • 时间限制 超过一定时间就需要应答一次

每种操作系统对具体数量和时间的设定可以不同,一般 N 为 2,时间为 200ms。

并不是所有的延迟应答都能提高网络传输效率:在等待期间,接收方的缓冲区不一定会被腾出空间。延迟应答的存在可以给我们一定启示:在设计TCP服务器时,尽快拿取缓冲区中的数据。延迟应答不仅尽量减少了发送方的数据发送次数,也减少了应答报文的数量。在接收方的延迟应答等待期间,发送方不会停止发送数据,只是每次发送的数据量变得尽可能多了。

捎带应答机制

ack应答可以被捎带在其他报文中,而不必每次都被单独收发。捎带应答的存在可以使客户端与服务器在应用层体现"一问一答"的通信方式,即上一条报文的应答被捎带在了这一条报文当中。

TCP/IP协议栈_传输层_UDP和TCP_TCP/IP协议栈_14

捎带应答减少了网络IO次数和数据报数量,可以提高网络传输效率。

拥塞控制机制

网络的拥塞情况决定了数据报传输的效率。网络属于公共资源,进行通信的一对客户端和服务器本身对网络状况的变化无能为力,但是TCP可以对网络状况做出检测和评估,并应对网络不良的情况

TCP使用拥塞窗口评估网络的健康状态。网络状态是动态变化的,拥塞窗口的大小也是动态变化的,拥塞窗口越大表明网络状态越好,反之同理。当检测到网络异常时,拥塞窗口大小为 1,后续每收到一个ack应答,拥塞窗口增加 1。上文中只将接收方缓冲区作为影响滑动窗口大小的因素,事实上,TCP对通信效率的提高是在保持网络健康的情况下进行的,网络状况也是发送数据时考虑的重要因素,毕竟公路堵了谁也别想飙车。承上,滑动窗口实际为 min(对方缓冲区的剩余空间大小, 拥塞窗口的大小),即 min(对方主机的接收能力, 网络的承载能力)

当通信时出现少量丢包,TCP首先认为是常规问题,进行重传;当通信时出现大量丢包,首先怀疑是网络出现问题,即发生了网络拥塞。当出现网络拥塞造成的丢包时,为了避免加重网络拥塞,不能立刻进行重传,而是进行慢启动:先缓慢发送个别报文,如果正常收到应答,再开始大量发送报文。慢启动的过程中,报文发送数量是指数级增长的,初始速度慢,后续增长速度极快:初始时速度慢,其实是在慢慢探查当前的网络情况,如果这个过程正常,则可以确定网络趋于健康,所以后续可以快速使通信回归到正常水平。可以看出,TCP使多主机在发生网络拥塞时达成了共识,网络拥塞的严重程度影响了拥塞可以被多少主机察觉到:越严重的拥塞就会有越多主机参与改善,进而保持网络宏观上的健康状态。

慢启动的过程表现在拥塞窗口的变化曲线中,即为一个近似的指数增长曲线。拥塞窗口增长到一定程度,发送方的滑动窗口就以对方的接收窗口为基准了,并且拥塞窗口不会一直进行指数增长,而是存在一个阈值,当到达阈值时,就会以线性方式增长。标准规定,拥塞窗口的阈值为上一次发生网络拥塞时的拥塞窗口大小的二分之一

TCP/IP协议栈_传输层_UDP和TCP_拥塞控制_15

在网络通信的过程中不一定会发生网络拥塞,上述提到的慢启动和拥塞窗口从 1 开始的指数级变化都是在网络拥塞发生时进行的。

粘包问题

TCP是面向字节流的,在TCP一层,不需要关心上层协议的报文格式,只有字节流的概念,这意味着收、发双方的读写次数可以不一致。同时,TCP不需要有报文长度相关的字段,因为序号和滑动窗口的存在可以保证对方收到完整和有序的报文,对于接收方来说,来多少数据就收多少数据,直到连接关闭。

TCP不关心上层报文的具体格式,只有在用户层才有报文的概念。如果用户未定义报文的边界,则在读数据时可能会读上来不完整或者多余的报文,并且影响缓冲区中的其他数据,即数据包的粘包问题。粘包问题同样是用户层的概念。解决粘包问题的核心思想是在应用层明确报文之间的边界,主要有四种做法:

  • 使用定长报文。接收方每次都读取一段定长的字节流,以保证上层数据包的完整。
  • 使用特殊字符。以特殊字符作为数据包的边界,接收方一直读取到该特殊字符,表明已经拿到一个完整报文。
  • 使用定长报头+自描述字段。接收方每次都读取一段定长的字节流,以保证读到完整的报头,再根据报头中包含的报文长度继续读取到一个完整的报文。例如UDP的报文格式。
  • 使用特殊字符+自描述字段。以HTTP为例,以特殊字符分割请求和响应中的各个字段,以及报头部分与正文部分,并根据报头中的正文长度继续读取到一个完整报文。

TCP/IP协议栈_传输层_UDP和TCP_拥塞控制_16

TCP连接异常

在Linux中一切皆文件,建立一个TCP连接并进行通信的过程,可以视为对一个文件进行读写操作,即连接是与文件强相关的,而文件的生命周期是随进程的(不主动close的情况),即连接的生命周期是与进程的生命周期强相关的。建立/断开连接过程中的握手和挥手,是由用户connect/close,然后由操作系统自动完成的。TCP连接发生异常常见有三种情况:

  • 进程异常终止。在操作系统层面,进程的异常终止和正常终止的性质是一样的(进程异常本质是收到了某种信号),操作系统会先进行挥手,然后关闭进程的文件描述符、释放进程资源。
  • 机器重启。机器重启前会先自动关闭进程,与进程终止的处理方式类似。
  • 突然断网/断电。此时双方来不及进行挥手以正常断开连接,如果是客户端突然断网(也是更可能出现的情况),服务器会认为自己依然与客户端保持着连接,此时就出现了双方连接状态不一致的问题。双方后续会出现三种可能的动作:
  • 客户端重新发起连接请求,双方重新建立连接。
  • 服务器发送报文,客户端收到时,客户端会发送RST以尝试重新建立连接。
  • 当服务器长时间未收到客户端的连接请求或任何消息时,服务器会对客户端进行询问,如果没有得到应答,服务器就会关闭这个连接,即TCP的保活机制

使用UDP实现可靠传输

参考TCP实现可靠性的机制,在某些应用场景下,可以在应用层根据需求对UDP套壳,在某种程度上使UDP获得一定可靠性。比较常见的做法是在应用层引入序号、确认应答和超时重传,大致思路为在应用层规定UDP报文的格式,并维护一块缓冲区(可以是vector)和滑动窗口,可以以vector对应的下标作为报文的序号,根据对方的应答对滑动窗口进行调整,对于超时未应答的报文,从缓冲区中拿数据并重新发送。在接收方,收到一批报文后,根据序号进行排序,以保证报文的完整性和顺序。