3、TCP三次握手与Socket:三次握手过程、Socket状态变迁、半连接队列与全连接队列

聊到网络编程,TCP三次握手是绕不开的坎。很多人觉得它就是个「一来一回」的简单流程,背下来就完事了。但说实话,我在生产环境排查过太多跟握手相关的问题——从连接超时到队列溢出,从SYN洪水到端口耗尽。你想想看,如果只是背个流程,遇到这些坑根本无从下手。

这一节,我带你从底层原理到Socket状态变化,再到内核队列的细节,把三次握手彻底讲透。嗯,咱们开始。

3.1 三次握手:不只是「你好你好你好」

TCP是面向连接的协议。什么叫「面向连接」?说白了,就是在发数据之前,双方得先确认一件事:你准备好了,我也准备好了。三次握手就是干这个的。

标准流程是这样的:

  1. 客户端发送SYN:客户端主动发起连接,发送一个SYN包(同步序列号),并带上自己的初始序列号client_isn。此时客户端进入SYN_SENT状态。
  2. 服务端回复SYN+ACK:服务端收到SYN后,分配资源,回复SYN+ACK包。其中ACK确认客户端的client_isn,SYN带上自己的初始序列号server_isn。服务端进入SYN_RCVD状态。
  3. 客户端发送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()取走。

流程是这样的:

  1. 客户端SYN到达 → 服务端创建请求控制块(reqsk) → 放入半连接队列 → 回复SYN+ACK。
  2. 客户端ACK到达 → 内核把请求控制块从半连接队列移到全连接队列 → 连接状态变为ESTABLISHED。
  3. 应用层调用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状态变迁、两个队列的关系画在了一起。我建议你仔细看看,尤其是队列的流转过程。

TCP三次握手与Socket状态、队列关系图 客户端 服务端 CLOSED SYN_SENT ESTABLISHED LISTEN SYN_RCVD ESTABLISHED 半连接队列 SYN Queue 全连接队列 Accept Queue ① SYN 进入半连接队列 ② SYN+ACK ③ ACK 从半连接移到全连接 说明:半连接队列存放收到SYN但未完成握手的连接(SYN_RCVD状态); 全连接队列存放已完成三次握手的连接(ESTABLISHED状态),等待accept()取走。

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握手,你心里就有底了。