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协议的核心机制。说白了,就是双方互相确认:你准备好了吗?我准备好了。
你想想看,为什么非得三次?两次不行吗?我简单解释一下:如果只有两次握手,服务端发了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是双工的,两边都要单独关闭。
四次挥手的过程,说白了就是:
- 主动方说「我不发了」(FIN)
- 被动方说「知道了」(ACK),但被动方可能还有数据要发
- 被动方发完了,说「我也不发了」(FIN)
- 主动方说「收到」(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通信的五个阶段,每个阶段都可能出问题。你只要把每个阶段的状态变化记清楚,排查故障时心里就有底了。