3、TCP三次握手与Socket:三次握手过程、Socket状态变迁、半连接队列与全连接队列
聊到网络编程,TCP三次握手是绕不开的坎。很多人觉得它就是个「一来一回」的简单流程,背下来就完事了。但说实话,我在生产环境排查过太多跟握手相关的问题——从连接超时到队列溢出,从SYN洪水到端口耗尽。你想想看,如果只是背个流程,遇到这些坑根本无从下手。
这一节,我带你从底层原理到Socket状态变化,再到内核队列的细节,把三次握手彻底讲透。嗯,咱们开始。
3.1 三次握手:不只是「你好你好你好」
TCP是面向连接的协议。什么叫「面向连接」?说白了,就是在发数据之前,双方得先确认一件事:你准备好了,我也准备好了。三次握手就是干这个的。
标准流程是这样的:
- 客户端发送SYN:客户端主动发起连接,发送一个SYN包(同步序列号),并带上自己的初始序列号
client_isn。此时客户端进入SYN_SENT状态。 - 服务端回复SYN+ACK:服务端收到SYN后,分配资源,回复SYN+ACK包。其中ACK确认客户端的
client_isn,SYN带上自己的初始序列号server_isn。服务端进入SYN_RCVD状态。 - 客户端发送ACK:客户端收到SYN+ACK后,再回复一个ACK包,确认服务端的
server_isn。此时客户端进入ESTABLISHED状态。服务端收到这个ACK后,也进入ESTABLISHED状态。
为什么非得三次?两次行不行?我记得刚入行时也问过这个问题。答案是:两次无法防止历史重复连接的干扰。如果客户端发了一个SYN,网络延迟了,客户端超时重传了一个新的SYN,旧的SYN突然又到了服务端。两次握手的话,服务端直接就建立连接了,但客户端其实已经不需要这个连接了。三次握手让服务端等待客户端的ACK,如果客户端发现序列号不对,可以发RST断开,避免资源浪费。
核心要点:三次握手的本质是「双方确认彼此的收发能力都正常」,同时协商初始序列号,防止历史连接干扰。
3.2 Socket状态变迁:从CLOSED到ESTABLISHED
每次握手都伴随着Socket状态的切换。我个人习惯用netstat或ss命令看这些状态,排查问题非常直观。
先看客户端的状态变迁:
- CLOSED:初始状态,啥也没干。
- 调用
connect()→ 发送SYN → SYN_SENT - 收到SYN+ACK → 发送ACK → ESTABLISHED
再看服务端的状态变迁:
- CLOSED:初始状态。
- 调用
listen()→ LISTEN(被动打开) - 收到SYN → 发送SYN+ACK → SYN_RCVD
- 收到ACK → ESTABLISHED
这里有个细节:服务端从LISTEN到SYN_RCVD,其实是在内核里完成的,应用层感知不到。只有到了ESTABLISHED,accept()才能把它取出来。
| 阶段 | 客户端状态 | 服务端状态 | 关键事件 |
|---|---|---|---|
| 初始 | CLOSED | CLOSED → LISTEN | 服务端调用listen() |
| 第一次握手 | CLOSED → SYN_SENT | LISTEN | 客户端调用connect() |
| 第二次握手 | SYN_SENT | LISTEN → SYN_RCVD | 服务端收到SYN |
| 第三次握手 | SYN_SENT → ESTABLISHED | SYN_RCVD → ESTABLISHED | 服务端收到ACK |
小技巧:如果你用ss -tna看到大量SYN_RCVD状态的连接,说明服务端收到了SYN但没收到ACK。这可能是网络问题,也可能是——嗯,后面会讲——半连接队列满了。
3.3 半连接队列与全连接队列:内核里的两个缓冲区
这是面试高频考点,也是生产环境最容易出问题的地方。我当年第一次遇到连接超时,查了半天才发现是队列溢出了。
服务端在listen()之后,内核会维护两个队列:
- 半连接队列(SYN Queue):存放已完成第一次握手(收到SYN,未完成第三次握手)的连接。状态是
SYN_RCVD。 - 全连接队列(Accept Queue):存放已完成三次握手(收到ACK)的连接。状态是
ESTABLISHED,等待应用层调用accept()取走。
流程是这样的:
- 客户端SYN到达 → 服务端创建请求控制块(reqsk) → 放入半连接队列 → 回复SYN+ACK。
- 客户端ACK到达 → 内核把请求控制块从半连接队列移到全连接队列 → 连接状态变为
ESTABLISHED。 - 应用层调用
accept()→ 从全连接队列取出一个连接 → 返回新的Socket fd。
避坑指南:我曾经遇到一个线上问题,客户端连接总是超时。查了半天,发现是listen()的backlog参数设得太小,全连接队列满了,内核直接丢弃了ACK包。客户端以为连接成功了,服务端却根本不认。后来我把backlog调大,问题解决。
两个队列的大小怎么控制?
- 半连接队列大小:由
net.ipv4.tcp_max_syn_backlog和backlog参数共同决定。内核会取两者中的较小值,再做一些调整。 - 全连接队列大小:就是
listen(sockfd, backlog)中的backlog参数。但实际有效值还受net.core.somaxconn限制,取两者中的较小值。
你可以用ss -lnt查看当前全连接队列的使用情况:
# ss -lnt
State Recv-Q Send-Q Local Address:Port
LISTEN 0 128 0.0.0.0:8080
这里的Recv-Q表示全连接队列中当前等待accept()的连接数,Send-Q表示队列的最大长度。如果Recv-Q接近Send-Q,说明队列快满了,应用层处理不过来。
3.4 一张图看懂全流程
下面这张SVG图把三次握手、Socket状态变迁、两个队列的关系画在了一起。我建议你仔细看看,尤其是队列的流转过程。
3.5 常见问题与排查思路
讲完理论,咱们聊聊实战。我在工作中遇到过几次跟握手相关的问题,这里列几个典型的:
- 半连接队列溢出:现象是客户端连接超时,服务端
ss -tna看到大量SYN_RCVD。原因可能是SYN洪水攻击,或者tcp_max_syn_backlog太小。我曾经被SYN洪水搞过一次,后来开了tcp_syncookies才稳住。 - 全连接队列溢出:现象是客户端连接成功(收到SYN+ACK并回复ACK),但服务端
accept()取不出来,客户端后续发数据会超时。排查时看ss -lnt的Recv-Q是否接近Send-Q。解决办法是调大backlog或somaxconn,或者优化应用层的处理速度。 - 连接建立慢:如果客户端
connect()耗时很长,可能是网络延迟大,或者服务端处理SYN慢。可以用tcpdump抓包看握手时间间隔。
我的习惯:每次上线新服务,我都会先用ss -lnt确认队列大小设置合理。生产环境我一般把backlog设为1024以上,同时确保net.core.somaxconn也调大。别小看这个细节,能省很多事。
嗯,三次握手的内容就到这里。你想想看,从SYN到ACK,从CLOSED到ESTABLISHED,从半连接队列到全连接队列——这一整套流程,其实就是TCP可靠性的基石。理解了它,后面再聊粘包拆包、TLS握手,你心里就有底了。