一、TCP 特性

尽管 TCP 和 UDP 都是用 IP 协议作为网络层,但 TCP 却提供和 UDP 完全不同的网络服务。TCP 是面向连接的稳定可靠字节流服务。TCP 首部的很多字段都是为了实现这 2 大特性而设计的。

在一个 TCP 连接中,仅有两方能进行彼此通信。所以说广播和多播不适用于 TCP 协议。

为了通过 IP 数据报实现可靠性传输,需要考虑很多事情,如数据的破坏、丢包、重复以及分片顺序混乱等问题。TCP 通过检验和、序列号、确认序列号、重发控制、连接管理以及窗口控制等机制来实现可靠性传输。

二、TCP 协议首部

摘自《TCP/IP 详解卷 1》中的关于 TCP 首部定义的图:

TCP 协议的实现较 UDP 协议复杂太多,它的首部的各个字段的用法也比 UDP 首部字段复杂多,这里先对 TCP 首部字段的功能做个大概的介绍。

  • 16 位源端口号:表示发送端端口号。传输层使用端口号来标识发送端和接收端的应用程序,而网络层是通过 IP 地址来标识主机,这样使用“IP 地址+端口”就可以精确定位到某一台主机上的某一应用程序。
  • 16 位目的端口号:表示接收端端口号。
  • 32 位序列号:序列号用来标识从 TCP 发送端已经发送的字节数。达到最大值$2^{32}-1$之后,再从 0 开始。
  • 32 位确认序列号:确认序列号用来标识 TCP 接收端期望接收的下一个序列号(反过来想也就是,TCP 接收端已经接受到的字节数为确认序列号减去1)。只有ACK标志位为 1 时,该字段才有效。只要 TCP 连接建立,这个字段会一直起作用,也就是说只要 TCP 连接建立,ACK标志位会一直为 1。
  • 4 位首部长度:和 IP 首部一样,是以4个字节(32 bit)为单位的,所以 TCP 首部最大长度也是15*4=60字节。若没有“选项”字段,长度固定为 20 字节。
  • 6 位标志位:他们中的多个可以同时被设置为 1。
1
2
3
4
5
6
URG 标记后面的"16位紧急指针"是否有效。
ACK 标记前面的"32位确认序号"是否有效。
PSH 接收方应该尽快的将这个报文交给上层的网络层。
RST 重建连接。
SYN 标记这个TCP段是用来同步初始序号(ISN)的。
FIN 标记发送端已经完成了发送任务。
  • 16 位窗口大小:窗口大小为字节数,用于 TCP 的流量控制,这个值是接收端期望接受的字节数。
  • 16 位校验和:和 UDP 类似,校验和覆盖首部和数据部分。
  • 16 位紧急指针:只有前面提到的URG标记位为 1 时,这个字段才有效。
  • 选项:添加一些附加数据。和 UDP 不同,TCP 的“选项”字段使用的比较多。

三、三次握手与四次挥手

3.1 完整的 TCP 会话流程图

本文通过真实的网络示例来讲解 TCP 的三次握手和四次挥手。读者可以先下载作者写本文时使用的网络包示例,然后使用 wireshark 打开(当然也可以使用 wireshark 随便抓取一个网络包),选中编号为No.9的包,右键选择“追踪流” –> “TCP 流”:

上图是使用 wireshark 抓取的一个 Http 接口请求的过程(不含 DNS 解析等步骤),包含了 TCP 连接建立、Http 请求、Http 响应、TCP 连接断开。现在以这个示例为基础,来画出该 Http 接口请求中涉及的整个 TCP 会话的流程(也是本文最重要的图):

  • 箭头上方标出了该 TCP 包SYN、ACK、FIN、PSH等标志位的设置情况(大家可能注意到,除了第一个箭头上没有ACK之外,其他的箭头上都有ACK,这是因为ACK标记位只是用于标记 TCP 首部的32位确认序列号是否有效。在此之后的32位确认序列号一直有效,所以也就一直有ACK标记位。);箭头下发标出了该 TCP 包的序号和确认序号。
  • seq_num:表示 32 位的序号,紧跟其后括号[]中的是相对序号。
  • ack_num:表示 32 位的确认序号,紧跟其后括号[]中的是相对确认序号。
  • payload_len:表示本次 TCP 携带的数据大小(字节)。
  • 在三次握手和四次挥手的部分,旁边的红色粗体字表示当前端的 TCP 状态。在这个示例中是服务端执行主动关闭。

3.2 Wireshark 的相对序号

相对序号是 Wireshark 引出的概念,TCP 协议中没有这个概念。Wireshark 使用相对数值来显示序号和确认序号,这个相对值是相对于初始序号(ISN)而言的。因为人类更加习惯跟踪更小数值,所以 Wireshark 默认用相对数值来展示。如果需要查看真实的序号,可以在 wireshark 中选中该网络包,在最下方的数据窗口查看,如:

3.3 Wireshark 的 TCP 流量图

我们也可以使用 Wireshark 自带的统计功能来查看整个 TCP 会话的过程。通过菜单“统计” –> “流量图”打开流量图窗口,在“显示”选项选择“显示的分组”,“流类型”选项选择“TCP 流”,如图:

四、TIME_WAIT 及 MSL

4.1 TIME_WAIT 状态为何存在?

这里我们不使用“客户端”、“服务端”来表示 TCP 连接的 2 端,转而使用“主动断开连接端”、“被动断开连接端”来表示 TCP 通讯的 2 端。因为执行主动断开连接的端可能是服务端也可能是客户端(虽然我们大多数情况下遇到的是客户端执行主动断开)。

在“主动断开连接端”收到了“被动断开连接端”发来的LAST_ACK之后,会给“被动断开连接端”回复一个ACK确认消息。但这个时候为了确保“被动断开连接端”有足够的时间能够收到该消息,“主动断开连接端”不能马上关闭 socket,需要等待一定的时间来确保“被动断开连接端”可以收到ACK确认消息。“主动断开连接端”在等待的这个时间段内的状态我们称之为TIME_WAIT状态。

归纳为一句话就是:TIME_WAIT 状态就是“主动断开的一方”在发送完最后一次 ACK 后进入的等待状态。

4.2 等待时间

那么TIME_WAIT状态需要持续多久了,也就是“主动断开连接端”在发送完最后一个 ACK 之后需要等待多久了?
《TCP/IP 详解 卷 1:协议》中提到:默认 TIME_WAIT 的超时时间是 2 倍的 MSL。MSL 是Maximum Segment Lifetime的缩写,表示报文的最大生存时间,这个时间和系统的 TCP 实现有关,每个系统是不一样的。

4.2.1 windows 系统 MSL

注册表HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters下的TcpTimedWaitDelay键(如果没有可以新建一个)就对应了2*MSL(2 倍的 MSL)的值。

4.2.2 Linux 系统 MSL

以 CentOS 为例(摘自网络,仅供参考):

  • 查看默认的 MSL 值(60s):
1
cat /proc/sys/net/ipv4/tcp_fin_timeout
  • 修改默认为 120:
1
echo 120 > /proc/sys/net/ipv4/tcp_fin_timeout
  • 修改完成后,重新加载配置文件:
1
sysctl -p /etc/sysctl.conf
  • 查看是否已经生效:
1
sysctl -a | grep fin

4.3 SO_REUSEADDR

  • 如果进程中的某个 TCP 连接处于TIME_WAIT等待状态,因为这个等待时间比较长,在这期间该连接使用的端口将一直被占用。

  • 如果一个服务端进程(绑定了某个端口)退出(正常退出或异常退出)后,立即启动一个新的该进程,可能由于 Windows 系统对端口的释放不及时,导致这个端口还没有被释放,不能被再次绑定,从而导致新进程绑定端口失败。

那么遇到上面的问题如何解决了?
我们在网络编程中经常设置的SO_REUSEADDR选项就可以解决这个问题,

1
2
int flag = 1;
setsockopt(socket, SOL_SOCKET, SO_REUSEADDR, reinterpret_cast<const char*>(&flag), sizeof(flag));

SO_REUSEADDR提供如下四个功能:

  • SO_REUSEADDR 允许启动一个监听服务器并捆绑其众所周知端口,即使以前建立的将此端口用做他们的本地端口的连接仍存在。这通常是重启监听服务器时出现,若不设置此选项,则bind时将出错。

  • SO_REUSEADDR 允许在同一端口上启动同一服务器的多个实例,只要每个实例捆绑一个不同的本地 IP 地址即可。由于设置该Socket选项不需要任何特殊权限,恶意程序可以轻易使用SO_REUSEADDR强制绑定已用于标准网络协议服务的套接字,从而使这些服务拒绝访问。不用担心,我们可以使用SO_EXCLUSIVEADDRUSE选项解决这个问题。

  • SO_REUSEADDR 允许单个进程捆绑同一端口到多个套接口上,只要每个捆绑指定不同的本地 IP 地址即可。这一般不用于 TCP 服务器。

  • SO_REUSEADDR 允许完全重复的捆绑:当一个 IP 地址和端口绑定到某个套接口上时,还允许此 IP 地址和端口捆绑到另一个套接口上。一般来说,这个特性仅在支持多播的系统上才有,而且只对 UDP 套接口而言(TCP 不支持多播)。

其中第一个功能就可以用来解决该问题。

关于SO_REUSEADDR更详细的介绍可以访问微软的官方文档:
using-so-reuseaddr-and-so-exclusiveaddruse

五、为什么要进行 3 次握手?

前面的文章介绍了 TCP 的三次握手,那么 TCP 的握手为什么是 3 次了?

本文从 3 个角度来解释为什么要进行 3 次握手?

1

TCP 的握手的目的是让通信双方都确定双方能够正常发送和接收信息。

第一步,服务端收到客户端发送的 SYN,服务端能够确定如下信息:“客户端的发送功能正常,服务端自己的接受功能正常”。但客户端还什么都不能确定。
第二步,客户端收到服务端回复的 SYN+ACK,截至目前,客户端能够确定如下信息:“客户端自己的发送/接收功能都正常,服务端的接收和发送功能都正常”;服务端还是只能确定自己的接受功能正常,还不知道自己的发送功能是否正常,客户端的接受功能是否正常。

大家可以看到,到第二步完成,客户端和服务端能够确认的信息分别如下:

1
2
3
4
5
6
7
8
9
10
11
客户端能够确定:
客户端-发送 正常
客户端-接收 正常
服务端-发送 正常
服务端-接收 正常

服务端能够确定:
客户端-发送 正常
客户端-接收 ?
服务端-发送 ?
服务端-接收 正常

从上面表可以看到,第二步完成之后,服务端还能不能确定“服务端的发送功能”和“客户端的接收功能”是否正常。所以需要第三步。

第三步,服务端收到客户端回复的 ACK,服务端能够确定如下信息:客户端的发送功能正常,服务端的接收功能正常。

2

我们可以假设“客户端”和“服务端”是 2 个人,模拟这 2 个人打招呼的形式来理解为什么需要 3 次握手。

1
2
3
客户端:hi,服务端,你能听到我说话吗?
服务端:hi,客户端,我能听到你说的话,你能听到我说的话吗?
客户端:嗯,服务端,我也能听到你说的话。

3

对照文章开始处 TCP 握手图的前 2 步,现在我们假设 TCP 只有 2 次握手:

服务端在收到客户端的SYN并且回复SYN+AKC之后,就认为连接已经建立完成了,并为之分配相应的资源。
但客户机却因为网络延迟等问题一直没收到服务端回复的SYN+ACK,这样客户端就认为连接没有建立成功,糟糕的是,客户端会因为连接没有成功而不停的重试,这样每次服务端都会认为连接建立成功并分配资源。

如果按照上面描述的那样,客户端一直没有收到服务端回复的SYN+ACK,且一直这样尝试建立连接,就会造成服务端资源极大的浪费,加重服务端的负担。

六、为什么要进行 4 次挥手?

对比上面的图,我们不难发现:4 次挥手相比 3 次握手多了一次,主要是因为握手的ACK和SYN是合并在一条发送的,而挥手的ACK和FIN是分开发送的,所以挥手比握手多了一次。

现在我们分析为什么 TCP 挥手的ACK和FIN(分别对应图中的第 2,3 条线)要分开发送?

“被动断开方”之所以叫称之为“被动”是因为 TCP 连接的断开并不是它想的,也不是它主动触发的,是对面的“主动断开方”想要断开的,也许这个时候“被动断开方”还正想发送点数据给“主动断开方”了。

为了让“被动断开方”有机会将想要发送的数据发送完,主动断开方在发送完FIN并收到了ACK确认信息进入FIN_WAIT_2状态后,只关闭了发送功能了,但仍然保留接收功能。这样“被动断开方”就有机会将没有发送完的数据发送完成,发送完成之后,“被动断开方”也发送一个FIN,相当于告诉“主动断开方”:“我的数据已经发完了呀,以后不会再发数据了,你可以安心的把接收功能关闭了,另外我自己也要关闭了呀”。

《TCP/IP 详解 卷 1:协议》在线阅读地址:http://www.52im.net/topic-tcpipvol1.html

文章图片带有“CSDN”水印的说明:
由于该文章和图片最初发表在我的CSDN 博客中,因此图片被 CSDN 自动添加了水印。