3. Socket通信建立流程:服务端监听、客户端连接、三次握手、数据传输、四次挥手

Socket通信,说白了就是两台机器之间的一条「数据管道」。我见过不少新手,上来就调send、recv,结果连接都建不起来。其实整个流程就五个阶段:监听、连接、握手、传数据、挥手。咱们一个一个拆开看。

核心要点:Socket通信的五个阶段是固定的,任何一方出问题,整个链路就断了。排查故障时,先定位当前卡在哪个阶段。

3.1 服务端监听——守株待兔

服务端先得有个「门牌号」,也就是IP和端口。然后调用listen(),告诉内核:我准备好了,有连接来找我。

// 服务端监听示例(C语言风格)
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
addr.sin_addr.s_addr = INADDR_ANY;

bind(sockfd, (struct sockaddr*)&addr, sizeof(addr));
listen(sockfd, 10);  // 第二个参数是backlog,连接队列长度

这里有个坑,我踩过好几次。listen()的第二个参数叫backlog,它决定了内核为这个socket排队的最大连接数。如果客户端来得太猛,队列满了,新的连接就会被内核直接丢弃。

我曾经遇到过:线上服务突然大量客户端连不上,查了半天发现是backlog设得太小,只有5。高并发场景下,这个值建议至少设到128以上,甚至1024。

3.2 客户端连接——主动敲门

客户端这边简单多了,拿着服务端的IP和端口,调用connect()就行。

// 客户端连接示例
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
inet_pton(AF_INET, "192.168.1.100", &server_addr.sin_addr);

connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));

connect()会触发三次握手。如果服务端没在监听,或者端口不对,connect()会返回-1,errno告诉你具体原因。常见的错误码:

错误码 含义 常见原因
ECONNREFUSED 连接被拒绝 服务端没启动,或者端口不对
ETIMEDOUT 连接超时 服务端在内网但不可达,或者防火墙拦截
EHOSTUNREACH 主机不可达 路由问题,或者IP地址写错了
EADDRINUSE 地址已被使用 本地端口被占用了,换个端口试试

3.3 三次握手——建立连接的「对暗号」

三次握手是TCP协议的核心机制。说白了,就是双方互相确认:你准备好了吗?我准备好了。

TCP三次握手流程图 客户端 服务端 CLOSED LISTEN ① SYN=1, seq=x SYN_SENT ② SYN=1, ACK=1, seq=y, ack=x+1 SYN_RCVD ③ ACK=1, seq=x+1, ack=y+1 ESTABLISHED ESTABLISHED 三次握手的本质:双方确认彼此的收发能力都正常 第一次:客户端说「我要连你」 第二次:服务端说「收到,我也准备好了」 第三次:客户端说「好,开始传数据」

你想想看,为什么非得三次?两次不行吗?我简单解释一下:如果只有两次握手,服务端发了SYN+ACK后就认为连接建立了,但客户端可能没收到这个响应。这时候服务端傻等着,客户端却以为没连上,两边状态就对不上了。第三次握手就是客户端告诉服务端:「你的响应我收到了,咱们开始吧」。

排查技巧:用tcpdump抓包看三次握手。如果只看到SYN发出,没收到SYN+ACK,那多半是防火墙把包丢了。如果收到SYN+ACK但客户端没回ACK,检查客户端的连接队列是否满了。

3.4 数据传输——真正的干活阶段

三次握手完成后,连接进入ESTABLISHED状态。这时候就可以用send()和recv()传数据了。

我个人习惯把数据传输想象成「水管」:数据从一头流进去,从另一头流出来。但TCP有个特点——它是流式协议,没有消息边界。你发两次send(),对方可能一次recv()就全收走了,这叫粘包。

// 发送端
send(sockfd, "Hello", 5, 0);
send(sockfd, "World", 5, 0);

// 接收端可能一次收到 "HelloWorld" 而不是两次 "Hello" 和 "World"

怎么解决粘包?我常用的方法有三种:

  • 固定长度:每个消息固定100字节,不够的补零。简单粗暴,但浪费带宽。
  • 长度前缀:先发4字节的消息长度,再发消息体。这是最通用的做法。
  • 特殊分隔符:比如用\r\n做边界,HTTP协议就是这么干的。

我曾经遇到过:一个游戏服务器,客户端发来的数据包总是解析错误。查了半天,发现是recv()返回的数据长度比预期的少。TCP是流,一次recv()不一定能收完整个消息。正确的做法是循环接收,直到收够指定长度。

3.5 四次挥手——好聚好散

数据传输完了,就该断开连接了。四次挥手比三次握手多一次,因为TCP是双工的,两边都要单独关闭。

TCP四次挥手流程图 主动关闭方 被动关闭方 ① FIN=1, seq=u FIN_WAIT_1 ② ACK=1, seq=v, ack=u+1 CLOSE_WAIT FIN_WAIT_2 ③ FIN=1, ACK=1, seq=w, ack=u+1 LAST_ACK ④ ACK=1, seq=u+1, ack=w+1 TIME_WAIT CLOSED 四次挥手的关键:主动关闭方进入TIME_WAIT状态,等待2MSL 目的是确保最后一个ACK能被对方收到,防止旧连接的数据干扰新连接 TIME_WAIT通常持续60秒(2 × 30秒MSL)

四次挥手的过程,说白了就是:

  1. 主动方说「我不发了」(FIN)
  2. 被动方说「知道了」(ACK),但被动方可能还有数据要发
  3. 被动方发完了,说「我也不发了」(FIN)
  4. 主动方说「收到」(ACK),然后等一会儿再彻底关闭

这里有个容易踩的坑——TIME_WAIT状态。主动关闭的一方会进入TIME_WAIT,持续2MSL(约60秒)。如果服务端频繁主动关闭连接,你会发现大量TIME_WAIT堆积,导致端口被占满。

避坑指南:高并发服务端尽量让客户端主动关闭连接,这样TIME_WAIT就落在客户端身上。如果服务端不得不主动关闭,可以调整内核参数net.ipv4.tcp_tw_reuse和net.ipv4.tcp_tw_recycle来复用TIME_WAIT状态的连接。但注意,tcp_tw_recycle在NAT环境下有坑,我建议慎用。

3.6 常见故障排查清单

最后,我整理了一份排查清单。遇到Socket连接问题,按这个顺序查,基本能定位到根因。

阶段 现象 排查方向
监听 服务端bind()失败 端口是否被占用?用netstat -tlnp检查
连接 connect()超时 防火墙是否放行?服务端是否在监听?
握手 只看到SYN没看到SYN+ACK 中间网络设备丢包,或者服务端backlog满了
数据传输 收不到数据或数据乱码 检查粘包处理逻辑,确认字节序(大端/小端)
挥手 大量TIME_WAIT或CLOSE_WAIT TIME_WAIT调内核参数;CLOSE_WAIT是代码没调close()

嗯,这一章的内容就到这儿。Socket通信的五个阶段,每个阶段都可能出问题。你只要把每个阶段的状态变化记清楚,排查故障时心里就有底了。