tcp 服务的特点

传输层的协议主要有 tcp 和 udp。tcp 相对 udp 的特点是面向连接、字节流和可靠传输

面向连接
使用 tcp 协议通信的双方必须先建立连接,然后才能开始数据读写。双方都必须为该连接分配必要的内核资源,以管理连接的状态和连接上数据的传输。tcp 是全双工的,即双方的数据读写可以通过一个连接进行。完成数据交换后通信双方都必须断开连接以释放系统资源。tcp 协议的连接是一对一的,所以基于广播和多播的应用程序不能使用 tcp 服务。而无连接的 udp 非常适合广播和多播

字节流传输
tcp 协议传输的是字节流(区别于 udp 的报文流)。发送端执行的写操作次数和接收端执行的读操作次数之间没有任何数量关系,应用程序对数据的发送和接受是没有边界限制的。upd 则不然,发送端应用程序每执行一次写操作,udp 模块就将其封装成一个 udp 数据报并发送之。接收端必须及时针对每一个 udp 数据报进行读操作(通过 recvfrom 系统调用),否则就会丢包(这经常发生在较慢的服务器上)。并且,如果用户没有指定足够的应用程序缓冲区来读取 udp 数据,则 udp 数据将被截断

可靠传输
tcp 协议采用应答机制、超时重传机制、报文段重排(tcp 报文段最终是以 ip 数据报发送的,ip 数据报到达接收端可能乱序、重复等)保证接收端接受到的 tcp 报文和发送端发送的一致

tcp 头部结构

tcp 头部包括 20 字节的固定头部以及不超过 40 字节的头部选项

tcp 固定头部

  • 16 位端口号(port number)告知主机改报文是来自哪里(源端口)以及传给哪个上层协议或应用程序(目的端口)。进行 tcp 通信时,客户端通常使用系统自动选择的临时端口号。而服务器则使用知名服务端口号(定义在 /etc/service 文件中)

  • 32 位序号(sequence number)一次 tcp 通信(从 tcp 连接建立到断开)过程中某个传输方向上的字节流的每个字节的编号。假设主机 a 和主机 b 进行 tcp 通信, a 发送给 b 的第一个 tcp 报文段中,序号值被系统初始化为某个随机值 isn。那么在该传输方向上(从 a 到 b),后续的 tcp 报文段中序号值将被系统设置为 isn 加上该报文段所携带数据的第一个字节在整个字节流中的偏移

  • 32 位确认号(acknowledgement number)用作对另一个方发送来的 tcp 报文段的响应。其值是接收到的 tcp 报文段的序号值加 1

  • 4 位头部长度(header length)标识该 tcp 头部有多少 32bit。因为 4 位最大能表示 15,所以 tcp 头部最长是 60 字节

  • 6 位标志位包含下列选项
    1. URG 标志,表示紧急指针(urgent pointer)是否有效
    2. ACK 标志,表示确认号是否有效。携带 ACK 标志的 tcp 报文段为确认报文
    3. PSH 标志,提示接收端应用程序立即从 tcp 接收缓冲区中读走数据,为接受后续数据腾出空间(如果应用程序不将接受到的数据读走,他们就会一直停留在 tcp 接收缓冲区中)
    4. RST 标志,表示要求对方重新建立连接。带 RST 标志的 TCP 报文段为复位报文段
    5. SYN 标志,表示请求建立一个连接。带 SYN 标志的报文段为同步报文段
    6. FIN 标志,表示通知对方本端要关闭连接了。带 FIN 标志的 tcp 报文段为结束报文段
  • 16 位窗口大小(window size)是 tcp 流量控制的一个手段。此处的窗口指的是接受通告窗口(receiver window, rwnd)。它告诉对方本端的 tcp 接受缓冲区还能容纳多少字节数据,这样就可以控制对方发送数据的速度

  • 16 位校验和(tcp checksum)由发送端填充。接受端对 tcp 报文段执行 crc 算法以校验 tcp 报文段(包括头部和数据部分)在传输过程中是否损坏

  • 16 位紧急指针(urgent pointer)是一个正的偏移量。它和序号字段的值想家表示最后一个紧急数据的下一个字节的序号。tcp 紧急指针是发送端向接收端发送紧急数据的方法

tcp 头部选项
tcp 选项字段是可变长的可选信息,这部分最多包含 40 字节。典型的 tcp 头部选项结构如下图所示

选项的第一字段 kind 说明选项类型。有的 tcp 选项没有后面的两个字段,仅包含 1 字节的 kind 字段。第二个字段 length(如果有的话)指定该选项的总长度(包括 bind 字段和 length 字段占据的 2 字节)。第三个字段 info(如果有的话)是选项的具体信息。有下列常见的选项

  • kind=0 是选项表结束选项

  • kind=1 是空操作(nop)选项,没有特殊含义,常用于将 tcp 选项长度填充为 4 字节的整倍数

  • kind=2 是最大报文段长度选项。tcp 初始化连接时,通信双方使用该选项来协商最大报文长度(max segment size, mss)。tcp 模块通常将 mss 设置为 mtu-40 字节(减掉 20 字节的 tcp 头部和 20 字节的 ip 头部),这样携带的报文长度就不会超过 mtu(假设 tcp 头部和 ip 头部不包含选项字段,这是一般情况下),从而避免本机发生 ip 分片

  • kind=3 是窗口扩大因子选项。tcp 初始化时,通信双方使用该选项来协商接收通告窗口的扩大因子。在 tcp 头部中,接收通告窗口大小是用 16 位表示的,故最大为 65535 字节,限制了 tcp 通信的吞吐率。窗口扩大因子解决了这个问题。假设 tcp 头部中接收通告窗口是 n, 窗口扩大因子是 m。那么 tcp 报文段实际接收通告窗口大小是 n « m。m 的取值范围是 0 ~ 14。可以通过修改 /proc/sys/net/ipv4/tcp_window_sealing 内核变量来启用或关闭窗口扩大因子选项。和 mss 选项一样,窗口扩大因子选项只能出现在同步报文段中,否则将被忽略。但同步报文本身不执行窗口扩大操作,即同步报文头部的接受通知窗口大小就是 tcp 报文段的实际接受通告窗口大小。当连接建立好之后发送数据报窗口扩大因子才执行窗口扩大操作

  • kind=4 是选择性确认(selective acknowledgment, sack)选项。tcp 通信时,如果某个 tcp 报文段丢失,则 tcp 模块会重传最后被确认的 tcp 报文段后续的所有报文段,这样原先已经正确传输的 tcp 报文段也可能重复发送,从而降低了 tcp 性能。sack 技术就是为改善这种情况产生的,它使 tcp 模块只重新发送丢失的 tcp 报文段,不用发送所有未被确认得到 tcp 报文段。选择性确认选项用在连接初始化时,表示是否支持 sack 技术。我们可以通过修改 /proc/sys/net/ipv4/tcp_sack 内核变量来启动或关闭选择性确认选项

  • kind=5 是 sack 实际工作的选项。该选项的参数告诉发送方本端已经接收到并缓存的不连续的数据快,从而让发送端可以根据此检查并重发丢失的数据块。每个块边沿包含一个 4 字节的序号。其中块左边沿表示不连续的块的第一个数据的序号,块右边沿则表示不连续块的最后一个数据的序号的下一个序号。这样一对参数之间的数据是没有收到的。因为一个块信息占用 8 字节,所以 tcp 头部选项中实际最多可以包含 4 个这样的不连续数据块(考虑到选项类型和长度占用 2 字节)

  • kind=8 是时间戳选项。该选项提供了较为准确的计算通信双方之间的回路时间的方法,从而为 tcp 流量控制提供重要信息。可以通过修改 /proc/sys/net/ipv4/tcp_timestamps 内核变量来启用或关闭时间戳选项

tcp 连接的建立和关闭

tcp 连接建立和关闭的过程

第一个 tcp 报文段含有 syn 标志,因此它是一个同步报文段,即 ernest-laptop 向 Kongming20 发起连接请求。同时,该同步报文段包含一个 isn 值为 535734930 的序号。第二个报文段也是同步报文段,表 Kongming20 同意与 ernest-laptop 建立连接,同时它发送自己的 isn 值为 2159701207 的序号,并对第一个同步报文段进行确认。确认序号值是 545734931,即第一个同步报文段的序号值加一。第三个 tcp 报文段是 ernest-laptop 对第二个同步报文段的确认。至此,tcp 连接建立成功

第四个 tcp 报文段包含 fin 标志,是一个结束报文,即 ernest-laptop 要求关闭连接。Kongming20 用 tcp 报文段 5 来确认该结束报文段。紧接着 Kongming20 发送自己的结束报文段 6,ernest-laptop 则用 tcp 报文段 7 确认。实际上,仅用于确认的报文段 5 是可以省略的,因为结束报文段 6 也携带了该确认信息。确认报文段 5 是否出现在连接断开的过程中,取决于 tcp 的延迟确认特性

半关闭状态
tcp 连接是全双工的。因此,发送 fin 报文的一端仍然可以接受对端的数据,直到对端也关闭连接。即半关闭状态。可以通过 read 系统调用是否返回 0 判断对端是否已经关闭

连接超时
若客户端访问一个距离它很远的服务器,或者由于网络繁忙,导致服务器对客户端发送的同步报文段没有答应。对于提供可靠服务的 tcp 来说,它必然会进行重连。通常,第一次重连的时间间隔为 1s,第二次间隔为 2s,即后一次为前一次的两倍。重连的次数限制在 /proc/sys/net/ipv4/tcp_syn_retries 内核变量定义。重传次数达到上限后,tcp 模块放弃连接并通知应用程序

tcp 状态转移

tcp 连接的任意一端在任一时刻都处于某种状态,当前状态可以通过 netstat 命令查看。tcp 连接从建立到关闭过程中通信两端的状态变化情况如下图。其中,粗虚线表示典型的服务器端连接的状态转移,粗实线表示典型的客户端连接的状态转移。closed 是一个假想的起始点,并不是一个实际的状态

服务端的典型状态转移过程
服务器通过 listen 系统调用进入 listen 状态,被动等待客户端连接,执行被动打开。服务器一旦监听到某个连接请求(收到同步报文段),就将该连接放入内核等待队列中,并向客户端发送带 syn 标志的确认报文段。此时该连接处于 syn_recvd 状态。如果服务器成功接受到客户端发送回的确认报文段,则该连接转移到 established 状态。established 状态是连接双方都能够进行双向数据传输的状态

当客户端主动关闭连接时(通过 close 或 shutdown 系统调用向服务器发送结束报文段),服务器通过返回确认报文段使连接进入 close_wait 状态,等待服务器应用程序关闭连接。通常,服务器检测到客户端关闭连接后,也会立即给客户端发送一个结束报文段来关闭连接。这将使连接转移到 last_ack 状态。以等待客户端对结束报文段的最后一次确认。一旦确认完成,连接就彻底关闭了

客户端的典型状态转移过程
客户端通过 connect 系统调用主动与服务器建立连接,connect 系统调用首先给服务器发送一个同步报文段,使连接进入到 syn_sent 状态。此后,connect 系统调用可能因为下列原因失败返回

  • 如果 connect 连接的目标端口不存在(未被任何进程监听),或该端口仍被处于 time_wait 状态的连接占用,则服务器将给客户端发送一个复位报文段,connect 调用失败

  • 如果目标端口存在,但 connect 在超时时间内未收取到服务器的确认报文段,则 connect 调用失败

connect 调用失败将使连接立即返回到初始的 closed 状态。如果客户端成功接受到服务器的同步报文段和确认,则 connect 调用成功返回,连接转至 established 状态

当客户端执行主动关闭时,它将向服务器发送一个结束报文段,同时连接进入 fin_wait_1 状态。若此时客户端接受到服务器专门用于确认的确认报文段,则连接进入 fin_wait_2 状态。当客户端处于 fin_wait_2 状态时,服务器处于 close_wait 状态,这一队状态是可能发生半关闭的状态。如果此时接受到服务器的 fin 报文段,则客户端将发送确认报文段并进入 time_wait 状态

状态转移图中还给出了客户端从 fin_wait_1 直接进入 time_wait 状态的路线(不经过 fin_wait_2) 状态。当服务端接收到客户端发送 fin 结束报文段后确认报文段和服务端结束报文段一起发送给客户端(延时确认)时就是该路线

处于 fin_wait_2 状态的客户端连接需要等待服务器发送结束报文段才能转移至 time_wait 状态,否则将一直停留在这个状态。连接停留在 fin_wait_2 状态的情况可能发生在:如果客户端执行半关闭后,未等服务器关闭连接就强行退出了。此时客户端连接由内核接管,即孤儿连接。linux 可以通过设置相关内核参数来指定内核接管的内核连接数目以及孤儿连接在内核中的生存时间

time_wait 状态
客户端连接在接受到服务端的结束报文段后并没有立即进入 closed 状态,而是转移到 time_wait 状态并维持该状态 2msl 时间。time_wait 状态存在的原因有下列几点

  • 可靠的终止 tcp 连接。假设客户端发送的最后一个 ack 报文段丢失,那么服务器等待一定时间后没有接受到客户端的 ack 报文段将重发 fin 报文段。因此客户端需要等待足够长的时间以便能接受到服务端重发的 fin 报文段

  • 保证不会接收到过期的 tcp 报文段。如果不存在 time_wait 状态,则应用程序可以建立一个和刚才关闭的连接相似的连接(具有相同的连接四元组——源端 ip,源端口,目标 ip,目标端口)。这个新的相似连接可能接受到属于原来的连接滞留在网络中的 tcp 报文段

若客户端大量端口处于 time_wait 状态,导致端口耗尽问题。可以通过修改内核参数适当调整 time_wait 时间。也可以通过 socket 选项 SO_REUSEADDR 来强制进程历史使用处于 time_wait 状态的连接占用的端口

复位报文段

在某些特殊条件下,tcp 连接的一端会向另一端发送带 rst 标志的报文段,以通知对端关闭连接或重新建立连接

访问端口不存在
当客户端程序访问一个不存在(没有被监听)或处于 time_wait 状态的端口时,目标主机将给客户端发送一个复位报文段

异常终止连接
tcp 提供了异常终止一个连接的方法,即给对方发送一个复位报文段。一旦发送了复位报文段,发送端所有排队等待发送的数据都将被丢弃。应用程序可以使用 socket 选项 SO_LINGER 来发送复位报文段

处理半打开连接
往半打开状态的连接写入数据,则对方将回应一个复位报文段

拥塞控制

拥塞控制包含慢启动(slow start)、拥塞避免(congestion avoidance)、快速重传(fast retransmit) 和快速恢复(fast recovery) 四部分。拥塞避免算法在 linux 下有多种实现,如 reno 算法、vegas 算法和 cubic 算法等。它们或者部分或者全部实现了拥塞控制的四个部分。可以通过 /proc/sys/net/ipv4/tcp_congestion_control 文件查看机器当前使用的拥塞控制算法

拥塞控制的最终受控变量是发送端向网络一次连续写入(收到其中第一个数据的确认之前)的数据量,称为 SWND(send window, 发送窗口)。不过,发送端最终以 tcp 报文段来发送数据,所以 SWND 限定了发送端能力连续发送的tcp 报文段的数量。这些报文段的最大长度(数据部分)称为 SMSS(sender maximum segment size, 发送者最大段大小),其值一般等于 MSS

发送端需要合理的选择 SWND 的大小。如果 SWND 太小,会引起明显的网络延迟,反之则会导致网络拥塞。接收方可以通过其接收通告窗口(RWND)来控制发送端的 SWND。但这显然不够,所以发送端引入了一个称为拥塞窗口(congestion window, CWND)的状态变量。实际的 SWND 值是 RWND 和 CWND 中的较小者。如下图

慢启动和拥塞避免
tcp 连接建立好之后,cwnd 将被设置成初始值 IW(initial window, 大小为 2~4 个 SMSS)。此时发送端最多能发送 IW 字节的数据。此后发送端每接收到接收端的一个确认,其 CWND 按照下式形式增加

CWND += min(N, SMSS)//N 为此次确认中包含的之前未被确认的字节数

显然,在发送拥塞之前,每个 rrt (往返时间)时间内所有报文段携带的字节都会被确认,即当前 rrt 时间内发送了 CWND 字节内容,CWND 字节全部确认。由上式 SWND 增长的规律,rrt 时间后 CWND = CWND * 2。CWND 将按照指数形式扩大

当 CWND 增长到大于或等于慢启动门限(slow start threshold size, ssthresh)时,tcp 拥塞控制将进入拥塞避免阶段

  • 每个 rtt 时间内按照 CWND += min(N, SMSS) 计算新的 CWND,不论该 rtt 时间内发送端接受到了多少个确认(慢启动阶段时是每接受到一个确认就计算一次)

  • 每接受到一个新数据的确认报文,就按照 CWND += SMSS * SMSS/CWND 的形式增加

显然,拥塞避免阶段 CWND 按照线性增长

判断拥塞发生的依据

  • 传输超时,或者说 tcp 重传定时器溢出

  • 接受到重复的确认报文

如果发送端检测到拥塞发生是由于传输超时,那么它将执行重传并做如下调整

ssthresh = max(FlightSize/2, 2*SMSS) //其值通常为 CWND/2
CWND = SMSS

由于调整后 SWND < ssthresh,拥塞控制将再次进入慢启动阶段

如果发送端检测到拥塞发生是由于接受到重复的确认报文,那么它将执行快重传和快恢复阶段

快速重传和快速恢复

快速重传

当接收端收到一个失序的报文段后就立刻发出重复确认,而不要等到自己发送数据时才进行捎带确认。如,当接收端成功接收到了发送端发送的 m1, m2 并且确认后,没有接收到 m3 而接收到了 m4(失序报文段)。接收端将向发送端重复发送 m2 的确认报文

快速恢复

  1. 当接收到 3 个重复的确认报文段时,按照 ssthresh = max(FlightSize/3, 2 * SMSS) 计算新的 ssthresh。然后立即重传丢失的报文段并按照 CWND = ssthresh + 3 * SMSS 设置新的 CWND

  2. 每次接收到一个重复的确认报文段时,设置 CWND += SMSS

  3. 当接收到新数据的确认时,设置 CWND = ssthresh(1 中计算得到的新值)

由步骤 3 可知,当执行完快速重传和快速恢复后拥塞控制将进入到拥塞避免阶段(CWND >= ssthresh)