“简述TCP三次握手以及四次挥手的流程,为什么需要三次握手以及四次挥手。”——这几乎是每份Python后端面试题库里都会出现的一道经典题。我既是面试官也当过求职者,见过太多人在这一步翻车:流程背得滚瓜烂熟,被追问一句“为什么是三次而不是两次”就卡壳。这说明大多数人只是背了答案,没有真正理解TCP作为一个“带状态的可靠传输协议”到底在解决什么问题。
今天这篇文章就把这题拆开讲透。先按报文级别拆解三次握手和四次挥手,再解释为什么偏偏是三次、四次而不是两次、三次,最后用Python的socket代码把整个过程实际跑一遍,顺带聊聊CLOSE_WAIT和TIME_WAIT这两个面试追问率极高的坑。适合准备Python后端、爬虫、网络相关岗位的求职者,也适合写了不少socket代码却从没系统梳理过协议细节的工程师。
1. 为什么这道题躺在Python面试题里这么多年
1.1 一道看似基础,实则分层很开的问题
TCP三次握手和四次挥手这道题,表面上是问“流程”,但实际上是一个分层很开的考察点。初级候选人能讲出“SYN、SYN-ACK、ACK”和“FIN、ACK、FIN、ACK”就算过关,中高级候选人会被追问到序列号怎么算、状态怎么迁移、为什么不能少一次握手、TIME_WAIT有什么用。同样的题目,面试官可以根据候选人的回答深度,迅速判断出这个人对网络的理解停留在背诵层面还是原理层面。
这也是为什么Python岗位的面试题里会反复出现它。Python开发者日常打交道最多的网络场景——用requests写爬虫、用Flask/Django写Web服务、用socket写长连接、用websocket做消息推送——底层几乎全部建立在TCP之上。连接能不能建立、连接为什么断开、服务端为什么出现大量CLOSE_WAIT,这些问题排查到最后,全都要回到握手和挥手这一层来理解。
所以,这道题不是网络工程岗位的专属,而是所有写网络程序的工程师的必修课。Python本身封装了socket API,你调用一次connect(),背后就是一次完整的三次握手;你调用close(),背后就是一次四次挥手。不理解这个过程,出了问题就只能瞎猜,连排查方向都找不到。
1.2 面试官通过这道题在看什么
作为面试官,我问这道题时,内心其实在看三件事。
第一,你有没有真正理解“可靠传输”这四个字。TCP不是简单地“把数据发出去”,它要保证数据不丢、不重、不乱序,这一切的基础就是连接建立时的协商机制。三次握手不只是打招呼,而是在不可靠的网络环境里,让通信双方确认“我能发、你能收、你能发、我能收”,并且同步彼此的初始序列号。
第二,你能不能把状态机讲清楚。TCP每一个状态都不是凭空设计的,连接建立和释放过程中的每个状态变化,都对应网络中的某个事件。比如服务端收到SYN会进入SYN_RCVD,主动关闭方发出FIN会进入FIN_WAIT_1,被动关闭方收到FIN后如果应用层没有及时close,就会一直停在CLOSE_WAIT。能把这些状态串起来讲,说明你对TCP的理解是成体系的。
第三,你会不会把理论知识用到实际排错里。这是区分“背题选手”和“实战选手”的关键分水岭。面试官大概率会追加一句:“你线上服务大量CLOSE_WAIT怎么排查?”或者“为什么爬虫跑久了客户端有大量TIME_WAIT?”如果你能直接答出“CLOSE_WAIT是被动关闭方应用没调close,TIME_WAIT是主动关闭方频繁建短连接”,这题就稳了。
2. 三次握手完整流程:从报文到状态机
2.1 三次握手到底做了哪三件事
TCP三次握手发生在客户端主动发起连接时,完整过程是:客户端发送SYN报文,服务端回复SYN+ACK报文,客户端再回复ACK报文。三次握手完成后,双方都进入ESTABLISHED状态,连接建立成功。
我用一个生活化的类比来帮助理解。你想约朋友去一家餐厅,你发消息说“我周六晚上七点想到这家餐厅”(这是第一次握手,SYN)。朋友回复“我周六晚上七点可以,你也确认一下你收到我的消息了”(这是第二次握手,SYN+ACK)。你收到后回复“好,我确认收到”(这是第三次握手,ACK)。只有经过这三次确认,双方才都确定:对方在、自己的消息对方也收到了、约定的时间和地点没问题。
放到TCP里,三次握手解决的是三个问题:
- 第一次握手(客户端发SYN,seq=x):服务端确认“客户端的发送能力正常,且我愿意接受这次连接”。
- 第二次握手(服务端发SYN+ACK,seq=y,ack=x+1):客户端确认“服务端的接收和发送能力都正常”,同时得知自己的SYN被对方正确收到。
- 第三次握手(客户端发ACK,ack=y+1):服务端确认“客户端的接收能力正常”,同时得知自己的SYN+ACK被对方正确收到。
注意第二次握手这个动作非常关键,它把SYN和ACK两个标志位放在同一个报文中,是因为服务端在回复客户端的同时,自己也要发起一个方向上的连接协商。本质上,三次握手可以理解为“一次双方各自发起、但是互相交错确认”的过程。
2.2 每一步的序列号是怎么算的
握手过程除了标志位,最重要的就是序列号。很多人背流程只记住了SYN、ACK,忽略了seq和ack,但恰恰是这两个数字决定了TCP的可靠传输基础。
先明确几个关键点:
- seq(序列号)表示本报文段第一个字节的序号,新连接建立时由双方各自随机生成一个初始序列号(ISN)。
- ack(确认号)表示“我期望收到对方的下一个字节的序列号”,也就是对方最后一个收到的seq加一。
- SYN和FIN报文本身虽然不携带应用数据,但要消耗一个序列号,所以计算ack时要加一。
三次握手的序列号变化如下:
| 步骤 | 方向 | 标志位 | seq | ack |
|---|---|---|---|---|
| 第一次 | 客户端 -> 服务端 | SYN=1 | x(客户端ISN) | 无效,不携带 |
| 第二次 | 服务端 -> 客户端 | SYN=1, ACK=1 | y(服务端ISN) | x+1 |
| 第三次 | 客户端 -> 服务端 | ACK=1 | x+1 | y+1 |
这里有个面试常问的点:为什么第二次握手的ack是x+1而不是x?因为SYN报文虽然没有应用数据,但它本身占据一个序列号空间。客户端发送的SYN,其序列号为x,服务端确认收到这个SYN,期望客户端下一个报文从x+1开始,所以ack=x+1。
这个机制的意义在于:双方在握手阶段就完成了ISN的交换。之后的每个数据包都会带着基于这个初始序列号递增的seq,接收方依靠ack来告诉对方“我收到了哪些数据,我还缺哪些数据”,这样丢包、重传、乱序才有了判断依据。
2.3 握手过程中的系统资源与队列
研究握手流程时还有一个被忽视但面试常考的话题:连接建立过程中,服务端经历了两个队列。
客户端发送SYN后,服务端会创建一个半连接,放入半连接队列(syn queue),此时服务端状态为SYN_RCVD。只有收到客户端的第三次ACK后,服务端才会把连接从半连接队列移入全连接队列(accept queue),状态变为ESTABLISHED,等待应用调用accept()取出。
两个队列的区别是实战中非常关键的知识点。如果服务端应用处理连接的速度跟不上新连接到达的速度,全连接队列会满,新的连接就无法被accept,客户端表现为connect成功但请求无响应;如果恶意客户端只发SYN不回复ACK,半连接队列会被占满,合法的连接请求也会被丢弃,这就是经典的SYN Flood攻击原理。
在Python里,socket.listen(b)里的b参数并不是限制最大连接数,而是限制全连接队列中未accept的已完成连接数量。很多人误以为listen(5)是“最多允许5个客户端连接”,实际上它表示内核全连接队列最多积压5个已完成三次握手、等待accept的连接。这个误解在面试里很常见,现在记住:listen的 backlog 控制的是accept队列长度,不是最大连接数。
3. 为什么必须是三次:最容易被忽略的底层原因
3.1 收发能力的相互确认需要三次
面试官最爱问的一个问题就是:为什么不能是两次握手?答案要从“全双工通信的确认需求”说起。
TCP是全双工协议,数据在两个方向上独立传输。一条连接要想正常工作,双方必须都确认:我能发数据,你能收数据;你能发数据,我也能收数据。也就是说,需要四个维度的确认:客户端发送能力、服务端接收能力、服务端发送能力、客户端接收能力。
用握手过程来匹配这四个维度:
- 第一次握手后,服务端收到SYN,服务端能确认:客户端能发(SYN发出去了)、服务端自己能收(收到SYN了)。但此时服务端不知道客户端能不能收。
- 第二次握手后,客户端收到SYN+ACK,客户端能确认:服务端能发(SYN+ACK收到了)、客户端自己能收(收到SYN+ACK了)、服务端能收(它回复了我的SYN,说明我的SYN到达了)。此时客户端已经确认了自己的收发能力都OK,但它不确定服务端的接收确认是否可靠传导。
- 第三次握手后,服务端收到ACK,服务端才能确认:客户端能收(ACK收到了)。此时服务端才能确定“我发的SYN+ACK确实到了客户端那里”。
如果只有两次握手,第二次握手完成后,客户端已经确信双方收发正常,但服务端不确定客户端是否收到了自己的SYN+ACK。也就是说,服务端无法确认客户端接收能力是否正常,更无法确认自己发送的初始序列号是否被客户端接受。
3.2 初始序列号的同步必须双方确认
第二次握手除了确认收发能力,还承载一个重要任务:服务端把自己的初始序列号y发给客户端。但服务端要确认客户端真的收到了这个y,并且客户端也在自己的确认号里接受了它。
这就要靠第三次握手来完成。客户端在第三次报文中发送ack=y+1,这既是对服务端SYN的确认,也意味着“我已经收到了你的初始序列号,后续我会按照这个序列号基准来接收你的数据”。
如果只有两次握手,服务端发出自己的ISN后就认为连接建立,但客户端可能根本没有收到这个ISN,后续双方对数据的编号理解就会错位,可靠传输根本无从谈起。所以可以说,第三次握手承载了“ISN同步确认”这个不可省略的使命。
3.3 防止历史重复报文造成资源浪费
这是三次握手最经典、也最能体现设计巧妙性的原因,面试答出来基本就是加分项。
场景是这样的:客户端发送了一个SYN报文请求建立连接,但这个报文在网络中因为拥堵被延迟了很久。客户端等不到回复,超时重传了一个新的SYN。这两个SYN的初始序列号不同,假设旧的是seq=100,新的是seq=200。
如果只有两次握手,当旧SYN(seq=100)终于到达服务端时,服务端会认为这是一个新的连接请求,立刻进入ESTABLISHED状态并分配资源。但实际上客户端早就放弃了这个连接,它期望建立的是seq=200的新连接。结果就是:服务端建立了一个客户端根本不需要的“闲置连接”,白白消耗资源。
三次握手可以完美解决这个问题。服务端收到旧SYN后回复SYN+ACK(seq=服务端ISN,ack=101)。客户端收到后发现:咦,这个ack=101不是我所期望的201,这不是我为当前连接发出的SYN。于是客户端发送一个RST报文中止这个半连接。服务端收到RST后清理掉该连接,不会让它进入ESTABLISHED。随后客户端真正的新SYN到达,双方正常完成三次握手。
这个设计解决的本质问题是:在不可靠的网络中,旧报文可能迟到,连接建立机制必须有能力识别并拒绝这种“已经过期的连接请求”,避免资源浪费。两次握手做不到这一点,三次握手通过“双方确认序列号一致”实现了历史报文的甄别。
4. 四次挥手:全双工关闭的真正含义
4.1 四次挥手的完整过程与状态迁移
连接关闭比建立要复杂,因为TCP是全双工的,每个方向都必须独立关闭。四次挥手的过程,以客户端主动关闭为例:
- 第一次挥手:客户端发送FIN报文(seq=u),表示“我的数据发完了,我这边不再发送数据,但如果你还有数据,我仍然可以继续接收”。客户端进入FIN_WAIT_1状态。
- 第二次挥手:服务端回复ACK(ack=u+1),表示“我收到了你的关闭请求,但我这边可能还有数据要发”。服务端进入CLOSE_WAIT状态,客户端收到后进入FIN_WAIT_2状态。
- 第三次挥手:服务端数据处理完毕,发送FIN报文(seq=v),表示“我这边数据也发完了,现在可以关闭连接了”。服务端进入LAST_ACK状态。
- 第四次挥手:客户端回复ACK(ack=v+1),经过TIME_WAIT后连接彻底关闭。客户端进入TIME_WAIT状态,服务端收到ACK后进入CLOSED状态。
这个过程的重点在于:第二次和第三次挥手之间的间隔并不是固定的。服务端收到FIN后可能还有数据要发送,它会在数据全部发送完毕后再发出自己的FIN。这个间隔可能很短,也可能很长,取决于应用层什么时候处理完数据并调用close。
4.2 为什么挥手需要四次而不能合并
面试官第二个高频追问是:为什么关闭连接需要四次,不能像握手那样把第二次和第三次合并成一次,变成三次挥手?
原因在于,握手是双方空闲状态下同步发起协商,挥手则存在“一方已经结束,另一方还在工作”的不对称状态。主动关闭方发送FIN时,它的发送方向已经关闭,但数据可能仍在传输通道里向被动方流动;被动方收到FIN时,只是知道了“对方不发数据了”,它自己的发送方向还处于可用状态,可能还有未发送的数据。
第二次挥手只是一个确认,告诉对方“我收到你的关闭请求了”。此时被动方不能立刻关闭自己的发送方向,因为数据还没发完。所以FIN必须推迟到被动方的数据全部发送完毕后再发出。
换句话说,ACK和FIN分离的关键原因是:被动关闭方可能还有数据要发送,关闭发送方向的时机不由自己收到FIN的那一刻决定,而由应用层数据处理完毕的那一刻决定。这个过程是独立且可能滞后的,因此ACK和FIN无法合并成一次发送,挥手自然需要四次。
4.3 TIME_WAIT与2MSL等待
四次挥手完成后,主动关闭方不会立刻进入CLOSED,而是停留在TIME_WAIT状态,持续最长4分钟(2MSL,即两倍最大报文段生存时间)。很多Python面试题会专门问这个状态,理解它需要抓住两个原因。
第一个原因:保证最后一次ACK能可靠到达。第四次挥手发送的ACK可能丢失,届时候被动方会在超时后重发FIN,主动方需要重新回复ACK。如果主动方在发送ACK后立刻关闭连接,这个ACK一旦丢失,被动方就会一直停留在LAST_ACK状态,连接永远无法正常关闭。TIME_WAIT让主动方在2MSL内保持可响应状态,如果收到被动方重发的FIN,可以再次回复ACK。
第二个原因:确保本连接产生的旧报文在网络中消逝。一个报文从发出到失效,最长需要MSL时间。而最坏情况是某个报文从A到B需要MSL,对端回复又需要MSL,所以往返需要2MSL。主动方等待2MSL,保证了本连接关闭前发出的所有报文都已经在网络中消失,不会干扰后续可能复用相同端口的新连接。
面试中还有一个关于TIME_WAIT的现实问题:如果服务端是主动关闭方,它会积累大量TIME_WAIT连接,占据端口和内核资源。Python开发中常见的一个场景是,使用短连接频繁连服务端,每完成一次请求就关闭连接,服务端作为主动关闭方会残留大量TIME_WAIT。这是为什么很多高并发服务要开启keep-alive长连接的原因之一——减少不必要的连接创建和销毁。
5. 面试追问变体:三次挥手什么时候成立
5.1 被动关闭方恰好同时关闭的特殊情况
面试官往往会在你答完四次挥手后追加一句:难道挥手就一定是四次吗?能不能是三次?
答案是:可以,但需要满足一个特定条件——被动关闭方在收到FIN报文时,恰好自己的数据也已全部发送完毕,应用层立即发起close。这种情况下,被动方可以把第二次挥手的ACK和第三次挥手的FIN放在同一个报文中发送,原本的四次挥手就变成了三次:客户端发FIN,服务端回FIN+ACK,客户端回ACK。
这个场景在实际中比较少见,因为通常被动方不会恰好在收到对方FIN的瞬间就处理完所有数据。但只要发生,抓包时看到的挥手报文数量就是三个,而不是四个。面试时能主动说出这个变体,说明你对协议的理解不是死记硬背的。
还有一种更少见的场景是双方同时关闭(simultaneous close):客户端和服务端同时发送FIN,双方都在收到对方FIN的同时也发出了自己的FIN。这种情况下,双方都会经历从FIN_WAIT_1直接进入CLOSING状态,再进入TIME_WAIT,挥手报文仍然是四个方向交错,但状态迁移路径和标准的顺序挥手不同。掌握这个场景可以在面试里作为加分项补充,不过优先级低于前面所有内容。
5.2 RST:异常断开不走挥手流程
除了正常的四次挥手,TCP还有一个异常断开方式——发送RST报文。RST表示“连接异常终止”,收到RST的一方会立即丢弃该连接上的所有数据,直接进入CLOSED状态,不进入TIME_WAIT,也不走FIN/ACK的协商流程。
什么时候会触发RST?典型场景有三种:一是连接双方中有一方崩溃重启,内核发现socket表里没有对应连接,收到旧连接的数据包时回复RST;二是对方发送的数据包序列号不在窗口范围内;三是应用层调用SO_LINGER设置了特殊选项后主动发送RST关闭。
对Python开发者来说,最常见的RST场景是:对方程序崩溃退出,你继续向这个连接写数据,然后收到ConnectionResetError。很多爬虫新手在抓取网页时突然遇到“Connection reset by peer”,就是这个原因。理解了RST机制,排查这类异常时就能直接定位到“对端已经不在正常关闭流程里了”,从而检查对方服务是否健康。
5.3 半关闭状态的实际应用
四次挥手过程中其实存在一个半关闭阶段:客户端发送FIN后,它的发送方向已经关闭,但仍然可以接收数据。如果你用Python的socket开发,可以调用shutdown(socket.SHUT_WR)来实现半关闭——告诉对端“我不再发送数据了,但我会继续读你发来的数据”,而不是直接关闭整个socket。
这个机制在面试里也有考点:如果主动关闭方只想关闭发送方向,但还希望继续接收对端的剩余数据,就应该用shutdown而不是close。close会同时关闭读写两个方向,而shutdown可以只关写方向,让对端有机会把最后的数据发完,然后对端再关闭连接。
在HTTP协议中,很多场景都用到了半关闭特性:客户端发送完请求后关闭写方向,服务器读完请求后知道已经不会再有更多请求数据,于是处理完响应后关闭自己的发送方向。如果你用Python写过简单的HTTP服务器,会发现在读取完请求头、请求体之后,需要处理EOF或半关闭信号,才能正确判断“客户端请求已经发完”。
6. 用Python实际验证一次:代码、抓包与常见故障
6.1 从socket API看握手到挥手的完整映射
理论讲完,用Python代码把全过程跑一遍。先写一个最简单的TCP服务端和客户端。
服务端:
import socket server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind(('0.0.0.0', 8888)) server.listen(5) print('server listening on 8888') conn, addr = server.accept() print(f'accept connection from {addr}') data = conn.recv(1024) print(f'received: {data.decode()}') conn.send(b'hello from server') conn.close() server.close()客户端:
import socket client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) client.connect(('127.0.0.1', 8888)) print('connected') client.send(b'hello from client') data = client.recv(1024) print(f'received: {data.decode()}') client.close()这段代码背后的协议行为是这样的。客户端执行connect()时,操作系统会发送第一个SYN报文,connect()会阻塞直到收到服务端的SYN+ACK并发出ACK,然后返回。服务端的accept()返回一个已建立连接的socket对象,说明全连接队列中已经有了一个完成三次握手的连接。
客户端执行close()时,操作系统发送FIN,服务端recv()返回b''表示对端关闭了写方向。如果服务端也调用close(),它发出了自己的FIN,客户端完成第四次ACK,连接关闭。代码里服务端先发送响应数据再close,正好能演示四次挥手中“服务端收到FIN后可能还有数据要发送”的场景。
6.2 运行过程中的状态观察
用命令行工具可以直接观察到握手和挥手的状态变化。在Linux或macOS上,先启动服务端,再启动客户端,然后执行:
netstat -ant | grep 8888在客户端connect成功后观察,可以看到连接处于ESTABLISHED状态。此时立刻在服务端或客户端执行Ctrl+C关闭程序,快速执行同样的netstat命令,可以看到状态变化:
- 客户端主动关闭后,短暂处于FIN_WAIT_2或TIME_WAIT状态。
- 服务端收到FIN后,如果应用没有及时close,会处于CLOSE_WAIT状态。
如果系统里有tcpdump,还可以抓包验证每次握手和挥手的标志位:
sudo tcpdump -i lo0 port 8888 -n -X抓包输出里会清晰看到S(SYN)、F(FIN)、.(ACK)组合,以及seq和ack的具体数值。建议自己把前面章节讲的序列号计算规律,和抓包结果一一对应着核对一遍,记忆会深刻很多。
6.3 Python服务端大量CLOSE_WAIT的实战排查
我在实际运维Python后端服务时,最常见的故障之一就是服务端大量连接停留在CLOSE_WAIT状态。CLOSE_WAIT意味着:客户端已经发送了FIN,服务端的操作系统也回复了ACK,但服务端应用层始终没有关闭这个socket。
原因通常是代码里漏了close调用,或者处理逻辑异常导致finally分支没有执行。比如用户开发了这样一个请求处理器:
def handle(conn): data = conn.recv(1024) # 这里抛出了异常,导致后续的conn.close()没有执行 result = do_something(data) conn.send(result) conn.close()如果do_something抛异常,后面的conn.close()永远执行不到,这个连接就会一直停留在CLOSE_WAIT,直到进程崩溃或被系统回收。正确写法是使用try-finally或上下文管理器确保socket一定被关闭:
def handle(conn): try: data = conn.recv(1024) result = do_something(data) conn.send(result) finally: conn.close()排查命令可以这样用,找出CLOSE_WAIT连接的数量和对应进程:
netstat -ant | grep CLOSE_WAIT | wc -l ss -ant | grep CLOSE_WAIT这个错误在Python面试的编程题里也经常被埋进去,面试官会故意让你看一段明明调用了recv但没关闭连接的代码,问你这样写线上会出现什么问题。能答出“大量CLOSE_WAIT导致文件描述符耗尽,服务端无法接受新连接”,这题就过关了。
6.4 爬虫客户端大量TIME_WAIT的应对思路
与CLOSE_WAIT相对,TIME_WAIT通常出现在主动关闭方。Python爬虫爱好者经常遇到这个问题:用requests快速抓取大量网页,默认每次请求都建立新连接,请求完毕立刻关闭,客户端作为主动关闭方就会产生大量TIME_WAIT。
TIME_WAIT本身不是错误,它是协议为了保证可靠关闭而必须经历的状态。但大量TIME_WAIT会让客户端可用的本地端口被占用,因为TIME_WAIT状态下端口不能立刻复用,最终可能导致“Cannot assign requested address”错误。
应对思路有三个:一是使用连接池,让多个请求复用同一个TCP连接,减少连接创建和关闭次数。requests的Session对象配合urllib3连接池就是为此设计的。二是启用SO_REUSEADDR,允许端口在TIME_WAIT状态下被重新绑定,但要注意这并不会绕过TIME_WAIT本身,只是让bind更方便。三是服务端和客户端都尽量保持长连接,避免频繁建连和断连。
实测下来,对爬虫场景最有效的还是连接复用,既能减少TIME_WAIT,又能降低握手带来的额外延迟。所以面试中被问“如何优化爬虫性能”,除了并发和限速,别忘了从TCP连接生命周期这个角度回答,会显得你的思考更有深度。
我个人在带团队和准备面试时的体会是:TCP三次握手和四次挥手这题,千万不要只背那几行流程。真正值钱的是理解每一次握手、每一次挥手背后的“为什么”——为什么是三次、为什么是四次、为什么有TIME_WAIT、为什么会有CLOSE_WAIT。把这些想透了,再去翻Python的socket文档,你会发现每个接口的设计都豁然开朗,线上排查故障时也能直接定位到内核状态和代码问题,而不需要靠运气瞎试。