记录一下最近对 TCP 连接建立和拆除的一些新的理解和探究。
两将军问题
TCP 的连接建立和拆除都与两将军问题有关,但并不是说连接和拆除都存在两将军问题,比较认同中科大郑烇老师的这篇文章的观点,连接建立过程不存在两将军问题,但连接拆除是存在的。(btw,郑烇老师的计算机网络课真的好,有空一定完整听一遍)。两将军问题是所有尝试在不可靠通信链路上建立可靠连接的协议都绕不开的问题,不只是 TCP。我个人对两将军问题本质的总结是:处于不可靠通信链路两端的通信双方无法通过通信来对某一个事情/状态达成共识,因为达成共识总是依赖最后一次确认消息,而最后一次确认消息总可能丢失,所以不存在有限次的确认可以使双方达成共识。但是出现这种问题的前提是,要求双方在达成共识后便不再通信,也就是说,双方的状态不能通过后续的通信修正或回退。两将军问题详细描述如下(整理自Martin Kleppmann 分布式系统合集翻译版):
背景
两将军 A,B 分别位于山谷两侧,山谷中是敌军。将军 A,B 必须同时进攻才可以获胜,如果只有一方进攻则必败(通信双方需要就进攻时间达成共识)。他们通信的唯一方法是派信使穿过山谷到达另一方,但信使可能被俘导致消息无法送达(通信链路不可靠,消息可能丢失)。
分析
站在将军 A 的角度,他有两种选择:
- 派很多信使,并且自己一定会进攻(因为他假定总有一个能到对面,对方一定能和自己达成共识)。这时将军 B 只要收到消息就可以确定进攻,因为他知道 A 一定会进攻,所以 B 一定是安全的。但 A 是不安全的,因为极限情况下信使全部被俘,A 的假定就不成立了,B 没能和他达成共识。此时 A 会面临独自进攻。况且霰弹打鸟的方式浪费太多网络带宽了。
- A 只有收到 B 的回复后才会决定进攻。现在 A 是安全的,但是当 B 发送确认消息后,他陷入了上一步中 A 的困境,B 不能决定进攻,因为他知道 A 只有收到自己的确认才会进攻,但他无法知道 A 有没有收到自己的确认,如果自己单方面决定进攻,有可能导致双方的不一致。所以 B 又依赖 A 的确认。
如此死循环,最后发送确认的一方总是担心确认丢失而不敢作出决定,于是共识永远无法达成。
尝试翻译成人话:
A ———————我知道明天进攻,但我要知道你知道明天进攻———————-》 B
A 《——————–我知道明天进攻,但我要知道你知道我知道明天进攻———————– B
A ———————我知道你知道明天进攻,但我要知道你知道我知道你知道明天进攻———————-》 B
……..
解决方案
两将军问题无解,但是有工程解。
- 添加第二层保护机制。因为实际生活中大多数状态是可以回退的,即使暂时处于不一致状态,也可以通过后续的回退来补救,不会像两将军那样后果严重。比如 Martin Kleppmann 视频中举的例子,商店的扣款和发货,即使暂时出现状态不一致(比如扣款了但没发货),那大不了可以退款不是?
- 超时重传机制。可以缓解两将军问题,比如 TCP。
关于 TCP 的三次握手和四次挥手中是否存在两将军问题下面再分析。
关于连接建立
一、为什么需要三次握手,两次为什么不行
首先要明白 TCP 握手握的是啥,我觉得主要有两点:
a. 双方对于“连接已建立”这个状态的共识
b. 双方对于对方 ISN,窗口大小的共识
a 影响双方对于 TCP 连接资源的分配,操作系统对已建立的 TCP 连接需要分配资源,如果双方关于 a 没有达成共识,比如客户端认为没有处于已连接状态,而服务器单方面认为已经处于连接状态,而为其分配了资源,那么就会造成服务器资源浪费。b 就影响双方能否正确通信了,如果连 ISN 都没达成共识,那我怎么知道我收到的这个包序号在不在合法范围内?那数据包的重排,确认就无从谈起了。
下面假设 A 是主动建立一方,B 是被动一方。其实三次握手拆开看是四次:
(1) 第一次 A 发送同步包,告知 B 自己的 ISN 和窗口大小
(2.1) 第二次 B 确认 A 的同步,双方就 A 的 ISN 和窗口大小达成共识
(2.2) 第三次 B 发送同步包,告知 A 自己的 ISN 和窗口大小
(3) 第四次 A 确认 B 的同步,双方就 B 的 ISN 和窗口大小,以及连接建立的状态达成共识
只不过 2.1 和 2.2 可以合并成一个包,于是就成了三次握手(btw,挥手是四次是因为 TCP 支持半关闭,有时第二次和第三次挥手没法合成一个,但有时是可以合成三次的,下面的实验中会看到)。
回到问题,为什么不能是两次,也就是为什么不能省掉最后一次。我觉得有两个角度:
(1) 为了防止已失效的连接请求报文段突然又传送到了服务端,因而产生错误。这就是教科书上给出的解释。其实本质就是像上面说的,双方没有对于 a 达成共识。没有第三次握手,B 无从知道 A 有没有收到自己的同步消息,它只能假定对方收到了然后单方面进入连接状态,这很有可能导致不一致(参考两将军问题)。
(2) B 无法知道 A 是否已经接收到自己的同步信号,它只能假定 A 知道了自己的 ISN,窗口大小。如果这个同 步信号丢失了,A 和 B 就 B 的 ISN,窗口大小将无法达成一致,对应上面的 b,那还谈什么通信呢。
二、三次握手的手动模拟以及异常情况处理
(1) 正常三次握手过程模拟
正常三次握手时序图:
服务器:VMWare 中 Ubuntu 20.04 启动一个简单的 TCP echo server,监听端口 9999,虚拟机使用 NAT 模式。
客户端: Windows 上使用 scapy 构造握手报文。
IP | MAC | |
---|---|---|
服务器 | 192.168.40.128 | 00:0C:29:27:A4:B5 |
客户端 | 192.168.40.1 | 00-50-56-C0-00-08 |
用 wireshark 抓包,过滤双方 TCP 报文和 ARP 报文
1 | ((ip.src==192.168.40.1&&ip.dst==192.168.40.128)||(ip.dst==192.168.40.1&&ip.src==192.168.40.128))||arp |
1 | from scapy.all import * |
首先是两个 ARP 报文,客户端的 IP 层收到上面传输层交付的 TCP SYN 报文段,在路由表中查询目的 IP 192.168.40.128,发现和自己在同一网段,但是没有对方的 MAC 地址,于是先缓存 SYN 报文,广播 ARP request(注:scapy 貌似会自己维护一个 ARP cache table,而不是使用操作系统的,所以即使操作系统的 ARP 表中有缓存,这一步也可能进行 ARP 询问),获得对方 MAC 以后,填到 SYN 报文以太网帧的 Dst 里,就可以发出去了。后面就是正常的三次握手。收到 6 号 ACK 以后,服务器端的连接已经是 ESTABLISHED 状态。
这个模拟我只有用 NAT 模式成功了,桥接模式下,第一步客户端通过 ARP 查询虚拟机 MAC 地址的时候,虚拟机ARP reply 的以太网帧的 Src MAC 居然是宿主机的 MAC,而 ARP 部分的 SRC 又是虚拟机自己的 MAC,大概是这种冲突导致第一个 SYN 报文的以太网帧的目的 MAC 没法正常填(会填成全 1 的广播 MAC,服务器直接丢弃了),三次握手没法进行。暂时不知道为什么会这样,猜想是虚拟机桥接到物理机所使用的网卡了,在链路层封装以太网帧的时候填的都是同一个网卡的 MAC。
(2) 三次握手中的异常情况
第一次握手丢失
客户端超时重传 SYN 报文,重传次数由内核参数 tcp_syn_retries 决定,默认 5 次
第二次握手丢失
由于第二次握手既是服务器端的 SYN,又是对客户端第一次 SYN 的确认,所以若第二次丢失,客户端会超时重传它的 SYN,而由于服务器收不到确认,它也会重传第二次握手。服务器重传 SYN+ACK 报文次数由内核参数 tcp_synack_retries 决定,默认 5 次。
第三次握手丢失
发送第三次握手后,客户端单方面认为连接已建立。如果服务器没收到第三次握手,会重传第二次握手。我用 scapy 模拟了第三次握手丢失的情况,其实就不发第三个 ACK 就好了。
1
2
3
4
5
6
7
8
9
10from scapy.all import *
dst_ip = "192.168.40.128"
dst_port = 9999
src_port = 2222
# 构造 SYN
syn = IP(dst=dst_ip)/TCP(dport=dst_port,sport=src_port,seq=RandInt(),flags="S")
# 发送 SYN
sr1(syn, verbose=False)可见重传次数也是 5 次,且时间间隔为 1, 2, 4, 8, 16。服务器端连接状态变化如下,重传期间是 SYN_RECV 状态,最后一次重传也超时后,变为关闭状态。如果这时候又收到了迟到的 ACK 或者数据,服务器会回复一个 RST。
总之,TCP 不会重传 ACK,ACK 也不需要被确认。可想而知如果 ACK 本身还需要确认和重传,那不就陷入了两将军问题的死循环了吗?所以发送 ACK 的一方总是假定对方能收到确认,如果 ACK 丢失,则由对方重传对应的报文。因为不需要被确认,所以 ACK 不消耗序列号,而 SYN,FIN 和数据都需要被确认,故它们要消耗序列号。
三、三次握手能否带数据
前两次不可以,第三次可以。因为前两次握手时双方的 ISN 都没达成共识,没法保证收发数据的正确性。第三次可以带数据是因为 ACK 本身就可以捎带,如果客户端收到第二次握手就直接发送数据,那这个数据报文就起到了第三次握手的确认作用。下面是验证:
1 | data = "Hello" |
可见握手正常完成,第三次握手夹带的数据也被正确地 echo 了。
四、TCP 三次握手是否存在两将军问题
三次握手不存在两将军问题,或者说存在,但几乎没啥影响。因为 TCP 三次握手的目的本来就是通信,它并不是像两将军问题那样要求达成共识后便不再通信的场景。三次握手虽然有可能在某一时刻出现双方不一致的状态,比如最后一次 ACK 丢失,客户端处于 ESTABLISHED 状态,服务器处于 SYN_RECV 状态,但这种不一致不会一直存在,比如这时候客户端发了一个数据报文段过来,那服务器自然就知道,哦,看来你已经收到我的 SYN 了,于是服务器也转变为 ESTABLISHED 状态,不一致就消除了。亦或者客户端一直没发送数据,那 TCP 的超时重传机制也会确保这种不一致不会一直存在,服务器重传 5 次 SYN 以后就会变成 CLOSED 状态,此后只要客户端试图发送数据,就会收到一个 RST,于是客户端就也变成 CLOSED 状态了。总之,三次握手中的不一致状态都可以通过后面的数据交互、超时重传机制来修复,自然就不存在两将军问题了。
关于连接拆除
一、四次挥手的手动模拟以及异常情况处理
(1) 正常四次挥手过程模拟
正常四次挥手时序图:
1 | from scapy.all import * |
成功断开连接,但是可以发现是三次挥手,是因为 TCP server 在 read() 返回 0 之后立刻调用了 close(),所以 ACK 和 FIN 合成了一个包,像三次握手一样。但是在客户端半关闭的前提下,服务器端可以在收到客户端的 FIN 之后,先回复一个 ACK,然后继续发送数据,发完再 close(),这种情况下服务器的 ACK 和 FIN 之间还有数据报文段,所以一定是四次挥手。其实这里可以用打断点的方式强行把第二、三次挥手分开:
在收到客户端 FIN,read() 返回 0 后断住,不让程序直接 close()。可以看到这次服务器的内核单独回复了一个 ACK。
接下来让服务器执行 close():
这样四次挥手就完整地分开了。
(2) 四次挥手异常情况处理
这个情况有点复杂,实在懒得写了,参考小林coding的这篇文章,写得很全。
二、四次挥手是否存在两将军问题
四次挥手存在两将军问题,因为挥手的目的是说再见,之后不再通信,所以如果不能达成共识,会导致不一致一直存在。比如客户端最后一次 ACK 可能丢失,如果它单方面进入了 CLOSED 状态,那服务器还在 LAST_ACK 状态,会出现不一致。TCP 的弥补措施是添加了一段 TIME_WAIT 状态,结合超时重传机制,就是先不让客户端进入 CLOSED 状态,留一段时间来回复服务器的超时重传,等待对方和自己就连接断开达成共识。可以说是不完美但足够用的工程解。
参考链接: