一、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 | URG 标记后面的"16位紧急指针"是否有效。 |
- 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 | int flag = 1; |
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 | 客户端能够确定: |
从上面表可以看到,第二步完成之后,服务端还能不能确定“服务端的发送功能”和“客户端的接收功能”是否正常。所以需要第三步。
第三步,服务端收到客户端回复的 ACK,服务端能够确定如下信息:客户端的发送功能正常,服务端的接收功能正常。
2
我们可以假设“客户端”和“服务端”是 2 个人,模拟这 2 个人打招呼的形式来理解为什么需要 3 次握手。
1 | 客户端: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 自动添加了水印。