写点什么

Android C++ 系列:Linux 网络(四)TCP 详解

作者:轻口味
  • 2021 年 12 月 11 日
  • 本文字数:4687 字

    阅读完需:约 15 分钟

Android C++系列:Linux网络(四)TCP详解

1. tcp 状态转换图

这个图 N 多人都知道,它排除和定位网络或系统故障时大有帮助,但是怎样牢牢地将这 张图刻在脑中呢?那么你就一定要对这张图的每一个状态,及转换的过程有深刻 的认识, 不能只停留在一知半解之中。下面对这张图的 11 种状态详细解析一下,以便加强记忆!不过在这之前,先回顾一下 TCP 建立连接的三次握手过程,以及关闭连接的四次握手过程。



1.1 建立连接协议(三次握手)

  1. 客户端发送一个带 SYN 标志的 TCP 报文到服务器。这是三次握手过程中的报文 1;

  2. 服务器端回应客户端的,这是三次握手中的第 2 个报文,这个报文同时带 ACK 标志和 SYN 标志。因此它表示对刚才客户端 SYN 报文的回应;同时又标志 SYN 给客户端,询问客户 端是否准备好进行数据通讯;

  3. 客户必须再次回应服务段一个 ACK 报文,这是报文段 3。

1. 2 连接终止协议(四次握手)

由于 TCP 连接是全双工的,因此每个方向都必须单独进行关闭。这原则是当一方完成它的数据发送任务后就能发送一个 FIN 来终止这个方向的连接。收到一个 FIN 只意味着这 一方向上没有数据流动,一个 TCP 连接在收到一个 FIN 后仍能发送数据。首先进行关闭的一方 将执行主动关闭,而另一方执行被动关闭。


  1. TCP 客户端发送一个 FIN,用来关闭客户到服务器的数据传送(报文段 4);

  2. 服务器收到这个 FIN,它发回一个 ACK,确认序号为收到的序号加 1(报文段 5)。 和 SYN 一样,一个 FIN 将占用一个序号;

  3. 服务器关闭客户端的连接,发送一个 FIN 给客户端(报文段 6);

  4. 客户段发回 ACK 报文确认,并将确认序号设置为收到序号加 1(报文段 7)。

1.3 11 种状态

  • CLOSED: 这个没什么好说的了,表示初始状态。

  • LISTEN: 这个也是非常容易理解的一个状态,表示服务器端的某个 SOCKET 处于监听状态,可以接受连接了。

  • SYN_RCVD: 这个状态表示接受到了 SYN 报文,在正常情况下,这个状态是服务器端的 SOCKET 在建立 TCP 连接时的三次 握手会话过程中的一个中间状态,很短暂,基本 上用 netstat 你是很难看到这种状态的,除非你特意写了一个客户 端测试程序,故意将三次 TCP 握手过程中最后一个 ACK 报文不予发送。因此这种状态 时,当收到客户端的 ACK 报文 后,它会进入到 ESTABLISHED 状态。

  • SYN_SENT: 这个状态与 SYN_RCVD 遥想呼应,当客户端 SOCKET 执行 CONNECT 连接时,它首先发送 SYN 报文,因此也随即 它会进入到了 SYN_SENT 状 态,并等待服务端的发送三次握手中的第 2 个报文。SYN_SENT 状态表示客户端已发送 SYN 报文。

  • ESTABLISHED:这个容易理解了,表示连接已经建立了。

  • FIN_WAIT_1: 这个状态要好好解释一下,其实 FIN_WAIT_1 和 FIN_WAIT_2 状态的真正含义都是表示等待对方的 FIN 报 文。而这两种状态的区别 是:FIN_WAIT_1 状态实际上是当 SOCKET 在 ESTABLISHED 状态时,它想主动关闭连接,向 对方发送了 FIN 报文,此时该 SOCKET 即 进入到 FIN_WAIT_1 状态。而当对方回应 ACK 报文后,则进入到 FIN_WAIT_2 状 态,当然在实际的正常情况下,无论对方何种情况下,都应该马 上回应 ACK 报文,所以 FIN_WAIT_1 状态一般是比较 难见到的,而 FIN_WAIT_2 状态还有时常常可以用 netstat 看到。

  • FIN_WAIT_2:上面已经详细解释了这种状态,实际上 FIN_WAIT_2 状态下的 SOCKET,表示半连接,也即有一方要求 close 连接,但另外还告诉对方,我暂时还有点数据需要传送给你,稍后再关闭连接。

  • TIME_WAIT: 表示收到了对方的 FIN 报文,并发送出了 ACK 报文,就等 2MSL 后即可回到 CLOSED 可用状态了。如果 FIN_WAIT_1 状态下,收到了对方同时带 FIN 标志和 ACK 标志的报文时,可以直接进入到 TIME_WAIT 状态,而无须经过 FIN_WAIT_2 状态。

  • CLOSING: 这种状态比较特殊,实际情况中应该是很少见,属于一种比较罕见的例外状态。正常情况下,当你发送 FIN 报文后,按理来说是应该先收到(或同时收到)对方的 ACK 报文,再收到对方的 FIN 报文。但是 CLOSING 状态表 示你发送 FIN 报文后,并没有收到对方的 ACK 报文,反而却也收到了对方的 FIN 报文。什 么情况下会出现此种情况 呢?其实细想一下,也不难得出结论:那就是如果双方几乎在同时 close 一个 SOCKET 的话,那么就出现了双方同时 发送 FIN 报 文的情况,也即会出现 CLOSING 状态,表示双方都正在关闭 SOCKET 连接。

  • CLOSE_WAIT: 这种状态的含义其实是表示在等待关闭。怎么理解呢?当对方 close 一个 SOCKET 后发送 FIN 报文给自 己,你系统毫无疑问地会回应一个 ACK 报文给对 方,此时则进入到 CLOSE_WAIT 状态。接下来呢,实际上你真正需要 考虑的事情是察看你是否还有数据发送给对方,如果没有的话,那么你也就可以 close 这个 SOCKET,发送 FIN 报文 给对方,也即关闭连接。所以你在 CLOSE_WAIT 状态下,需要完成的事情是等待你去关闭连接。

  • LAST_ACK: 这个状态还是比较容易好理解的,它是被动关闭一方在发送 FIN 报文后,最后等待对方的 ACK 报文。当收 到 ACK 报文后,也即可以进入到 CLOSED 可用状态了。

2. TCP 流量控制(滑动窗口)

介绍 UDP 时我们描述了这样的问题:如果发送端发送的速度较快,接收端接收到数据后处理的速度较慢,而接收缓冲区的大小是固定的,就会丢失数据。TCP 协议通过’滑动窗口 (Sliding Window)’机制解决这一问题。看下图的通讯过程。



  1. 发送端发起连接,声明最大段尺寸是 1460,初始序号是 0,窗口大小是 4K,表示“我 的接收缓冲区还有 4K 字节空闲,你发的数据不要超过 4K”。接收端应答连接请求,声明最大 段尺寸是 1024,初始序号是 8000,窗口大小是 6K。发送端应答,三方握手结束。

  2. 发送端发出段 4-9,每个段带 1K 的数据,发送端根据窗口大小知道接收端的缓冲区满了,因此停止发送数据。

  3. 接收端的应用程序提走 2K 数据,接收缓冲区又有了 2K 空闲,接收端发出段 10,在应答已收到 6K 数据的同时声明窗口大小为 2K。

  4. 接收端的应用程序又提走 2K 数据,接收缓冲区有 4K 空闲,接收端发出段 11,重新声明窗口大小为 4K。

  5. 发送端发出段 12-13,每个段带 2K 数据,段 13 同时还包含 FIN 位。

  6. 接收端应答接收到的 2K 数据(6145-8192),再加上 FIN 位占一个序号 8193,因此应答 序号是 8194,连接处于半关闭状态,接收端同时声明窗口大小为 2K。

  7. 接收端的应用程序提走 2K 数据,接收端重新声明窗口大小为 4K。

  8. 接收端的应用程序提走剩下的 2K 数据,接收缓冲区全空,接收端重新声明窗口大小为 6K。

  9. 接收端的应用程序在提走全部数据后,决定关闭连接,发出段 17 包含 FIN 位,发送端应答,连接完全关闭。


上图在接收端用小方块表示 1K 数据,实心的小方块表示已接收到的数据,虚线框表示接收缓冲区,因此套在虚线框中的空心小方块表示窗口大小,从图中可以看出,随着应用程序 提走数据,虚线框是向右滑动的,因此称为滑动窗口。


从这个例子还可以看出,发送端是一 K 一 K 地发送数据,而接收端的应用程序可以两 K 两 K 地提走数据,当然也有可能一次提走 3K 或 6K 数据,或者一次只提走几个字节的数据,也就 是说,应用程序所看到的数据是一个整体,或说是一个流(stream),在底层通讯中这些数 据可能被拆成很多数据包来发送,但是一个数据包有多少字节对应用程序是不可见的,因此 TCP 协议是面向流的协议。而 UDP 是面向消息的协议,每个 UDP 段都是一条消息,应用程序必 须以消息为单位提取数据,不能一次提取任意字节的数据,这一点和 TCP 是很不同的。

3. TCP 半链接状态

当 TCP 链接中 A 发送 FIN 请求关闭,另一段 B 回应 ACK 后,B 没有立即发送 FIN 给 A 时,A 方处在半链接状态,此时 A 可以接收 B 发送的数据,但是 A 已不能再向 B 发送数据。


#include <sys/socket.h>int shutdown(int sockfd, int how);
复制代码


  • sockfd: 需要关闭的 socket 的描述符

  • how:允许为 shutdown 操作选择以下几种方式:

  • SHUT_RD:关闭连接的读端。也就是该套接字不再接受数据,任何当前在套接字接受缓冲区的数据将被丢弃。 进程将不能对该套接字发出任何读操作。对 TCP 套接字该调用之后接受到的任何数据将被确认然后无声的丢弃掉。

  • SHUT_WR:关闭连接的写端,进程不能在对此套接字发出写操作

  • SHUT_RDWR:相当于调用 shutdown 两次:首先是以 SHUT_RD,然后以 SHUT_WR


使用 close 中止一个连接,但它只是减少描述符的参考数,并不直接关闭连接,只有当描述符的参考数为 0 时才关闭连接。 shutdown 可直接关闭描述符,不考虑描述 符的参考 数,可选择中止一个方向的连接。


注意:


  1. 如果有多个进程共享一个套接字,close 每被调用一次,计数减 1,直到计数为 0 时,也就是所用进程都调用了 close,套接字将被释放;

  2. 在多进程中如果一个进 程中 shutdown(sfd, SHUT_RDWR)后其它的进程将无法进行通信. 如果一个进程 close(sfd) 将不会影响到其它进程. 得自己理解引用计数的用法了。

4. 2MSL

MSL 是 Maximum Segment Lifetime 英文的缩写,中文可以译为“报文最大生存时间”,2MSL 即两倍的 MSLTCP TIME_WAIT 状态也称为 2MSL 等待状态。


2MSL TIME_WAIT 状态存在的理由:


TIME_WAIT 状态的存在有两个理由:


  1. 让 4 次握手关闭流程更加可靠;4 次握手的最后一个 ACK 是是由主动关闭方发送出去的,若这个 ACK 丢失,被动关闭方会再次发一个 FIN 过来。若主动关闭方能够保持一个 2MSL 的 TIME_WAIT 状态,则有更大的机会让丢失的 ACK 被再次发送出去;

  2. 防止 lost duplicate 对后续新建正常链接的传输造成破坏。lost duplicate 在实际的网络中非常常见,经常是由于路由器产生故障,路径无法收敛,导致一个 packet 在路由器 A,B,C 之间做类似死循环的跳转。IP 头部有个 TTL,限制了一个包在网络中的最大跳数,因此这个包有两种命运,要么最后 TTL 变为 0,在网络中消失;要么 TTL 在 变为 0 之前路由器路径收敛,它凭借剩余的 TTL 跳数终于到达目的地。但非常可惜的是 TCP 通过超时重传机制在早些时候发送了一个跟它一模一样的包,并先于它达到了目的地,因此它的命运也就注定被 TCP 协议栈抛弃。另外一个概念叫做 incarnation connection,指跟上次的 socket pair 一摸一样的新连接,叫做 incarnation of previous connection。lost duplicate 加上 incarnation connection,则会对我们的传输造成致命的错误。大家都知道 TCP 是流式的,所有包到达的顺序是不一致的,依靠序列号由 TCP 协议栈做顺序的拼接;假设一个 incarnation connection 这时收到的 seq=1000, 来了一个 lost duplicate 为 seq=1000, len=1000, 则 tcp 认为这个 lost duplicate 合法,并存放入了 receive buffer,导致传输出 现错误。通过一个 2MSL TIME_WAIT 状态,确保所有的 lost duplicate 都会消失掉,避免对新连接造成错误。


该状态为什么设计在主动关闭这一方:


  1. 发最后 ack 的是主动关闭一方;

  2. 只要有一方保持 TIME_WAIT 状态,就能起到避免 incarnation connection 在 2MSL 内的重新建立,不需要两方都有如何正确对待 2MSL TIME_WAIT?


RFC 要求 socket pair 在处于 TIME_WAIT 时,不能再起一个 incarnation connection。但绝大部分 TCP 实现,强加了更为严格的限制。在 2MSL 等待期间,socket 中使用的本地端口在默认情况下不能再被使用。若 A 10.234.5.5:1234 和 B 10.55.55.60:6666 建立了连接,A 主动关闭,那么在 A 端只要 port 为 1234,无论对方的 port 和 ip 是什么,都不允许再起服务。显而易见这是比 RFC 更为严格的限制,RFC 仅仅是要求 socket pair 不一致,而实现当中只要这个 port 处于 TIME_WAIT,就不允许起连接。这个限制对主动打开方来说是无所谓的,因为一般用的是临时端口;但对于被动打开方,一般是 server,就悲剧了,因为 server 一般是熟知端口。比如 http,一般端口是 80,不可能允许这个服务在 2MSL 内不能起来。解决方案是给服务器的 socket 设置 SO_REUSEADDR 选项,这样的话就算熟知端口处于 TIME_WAIT 状态,在这个端口上依旧可以将服务启动。当然,虽然有了 SO_REUSEADDR 选项,但 sockt pair 这个限制依旧存在。比如上面的例子,A 通过 SO_REUSEADDR 选项依旧在 1234 端口上起了监听,但这时我们若是从 B 通过 6666 端 口去连它,TCP 协议会告诉我们连接失败,原因为 Address already in use.

5. 总结

本文介绍了 TCP 的三次握手、四次挥手、11 种状态、TCP 滑动窗口流量控制、TCP 半连接状态以及 TCP TIME_WAIT 两倍报文最大生存时长。

发布于: 刚刚阅读数: 2
用户头像

轻口味

关注

🏆2021年InfoQ写作平台-签约作者 🏆 2017.10.17 加入

Android、音视频、AI相关领域从业者。 邮箱:qingkouwei@gmail.com

评论

发布
暂无评论
Android C++系列:Linux网络(四)TCP详解