4、连接建立与握手:客户端连接流程、服务端监听与Accept、自定义握手协议设计
好,咱们今天来聊聊连接建立这个环节。说实话,很多新手觉得这不就是调个 connect() 和 accept() 嘛,有啥好讲的?但我在项目中踩过的坑告诉我,这里面的门道比你想象的多得多。
你想想看,一个游戏服务器可能要同时处理几万个连接。如果连接建立这块没设计好,后面全是麻烦。咱们今天就把客户端怎么连、服务端怎么接、以及中间怎么握手,掰开了揉碎了讲清楚。
4.1 客户端连接流程:不只是调个connect
客户端发起连接,看起来简单。但实际项目中,我建议你至少考虑这几个步骤:
- DNS解析:把域名转成IP。这里有个坑——DNS解析可能很慢。我建议你提前做异步解析,或者干脆缓存IP。
- Socket创建:调
socket()创建套接字。嗯,这里要注意协议族的选择,游戏一般用AF_INET或AF_INET6。 - 非阻塞设置:我个人习惯一创建完就设成非阻塞模式。为什么?因为阻塞模式下,网络一抖,你的UI线程就卡死了。
- 发起connect:调
connect()。非阻塞模式下,这个调用会立即返回,但连接可能还没建立好。 - 等待连接完成:用
select()或epoll()等事件循环,等套接字可写,就说明连接建立成功了。 - 发送握手包:连接建立后,别急着发业务数据。先发一个握手包,确认双方身份和协议版本。
核心要点:客户端连接不是一锤子买卖。它是个状态机,有「正在连接」、「已连接」、「连接失败」等状态。你的代码得能处理这些状态切换。
我曾经在一个项目里,客户端连上服务端后直接发登录请求。结果服务端还没准备好,直接把包丢了。从那以后,我强制要求所有客户端必须先走握手流程。
4.2 服务端监听与Accept:别让accept成为瓶颈
服务端这边,监听和接受连接是基本功。但怎么做得高效,这里头有讲究。
4.2.1 监听Socket的创建
服务端先创建一个监听socket,绑定端口,然后开始监听。代码大概长这样:
// 创建监听socket
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
// 设置地址复用,避免"Address already in use"
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 绑定地址
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(8888);
bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr));
// 开始监听,backlog设大一点
listen(listen_fd, 1024);
这里有个细节——backlog 参数。它表示已完成三次握手但还没被 accept 的连接队列长度。我建议设成 1024 或更大。为什么?因为如果客户端瞬间大量连接,队列满了,新连接就会被内核直接丢弃。
4.2.2 Accept的正确姿势
很多人写 accept() 就是简单调一下。但实际项目中,我建议这样做:
- 用非阻塞模式:监听socket也设成非阻塞。这样
accept()不会卡住你的事件循环。 - 循环accept:每次触发可读事件时,用
while循环把队列里的连接全收完。不然可能漏掉连接。 - 设置新连接的TCP参数:比如禁用Nagle算法(
TCP_NODELAY),这对游戏这种小包频繁的场景特别重要。
小技巧:我习惯在 accept 之后,立刻给新连接分配一个连接ID,并记录到连接池里。这样后续所有操作都基于这个ID,而不是裸的fd,方便管理。
4.2.3 惊群问题
如果你用多线程或多进程模型,多个线程同时 accept 同一个监听socket,就会发生「惊群」——所有线程都被唤醒,但只有一个能成功。这在Linux 2.6之前是个大问题。
现在的解决方案:
- SO_REUSEPORT:每个线程创建自己的监听socket,绑定同一个端口。内核帮你做负载均衡。
- EPOLLEXCLUSIVE:用 epoll 时加这个标志,避免惊群。
我个人更推荐 SO_REUSEPORT,因为它简单粗暴,而且性能更好。
4.3 自定义握手协议设计:别裸奔
连接建立后,第一件事就是握手。为什么需要自定义握手?说白了,就是确认对方是「自己人」,而不是随便一个TCP客户端。
4.3.1 握手协议要做什么
一个完整的握手协议,至少包含这些信息:
| 字段 | 长度 | 说明 |
|---|---|---|
| 魔数 | 4字节 | 固定值,比如 0x12345678,用来快速识别非法连接 |
| 协议版本 | 2字节 | 客户端和服务端的协议版本号,用于兼容性检查 |
| 连接类型 | 1字节 | 比如 0x01 表示普通客户端,0x02 表示管理后台 |
| 时间戳 | 8字节 | 客户端当前时间,用于防重放攻击 |
| 校验和 | 4字节 | 前面所有字段的CRC32,防止数据被篡改 |
握手包的格式,我一般这样设计:
struct HandshakePacket {
uint32_t magic; // 魔数,固定 0x12345678
uint16_t version; // 协议版本,比如 1001
uint8_t conn_type; // 连接类型
uint64_t timestamp; // 时间戳
uint32_t checksum; // CRC32 校验
};
4.3.2 握手流程
握手不是一次就完的。我习惯用「两次握手」:
- 客户端发握手包:连接建立后,客户端立刻发送
HandshakePacket。 - 服务端验证并回复:服务端收到后,检查魔数、版本、校验和。如果都OK,回复一个确认包。
- 客户端确认:客户端收到确认包后,才算真正建立连接。之后才能发业务数据。
注意:如果服务端发现握手包非法(比如魔数不对),不要直接断开连接。我建议先记录日志,然后发送一个错误码,再断开。这样方便排查问题。
我曾经遇到过一个情况——某个客户端版本太老,发的握手包版本号是 1000,但服务端已经升级到 1002 了。结果服务端直接断开连接,客户端也不报错,用户就卡在登录界面。后来我改成服务端在握手失败时返回具体错误码,客户端根据错误码提示用户更新版本,问题就解决了。
4.3.3 超时处理
握手不能无限等。我建议设置一个超时时间,比如 5 秒。如果超时还没完成握手,直接断开连接。
为什么是 5 秒?因为正常网络环境下,握手包来回也就几十毫秒。5 秒已经足够宽松了。如果超过 5 秒,要么是网络有问题,要么是客户端有问题,没必要再等。
4.4 实战中的避坑指南
最后,分享几个我踩过的坑:
- 不要信任客户端的IP和端口:尤其是用NAT的情况下,你看到的IP可能是网关的,不是客户端的真实IP。
- 握手包要加密吗?:如果只是内部通信,明文就够了。但如果要过公网,我建议至少做个简单的异或加密,或者直接用TLS。
- 连接数太多怎么办?:服务端要限制最大连接数。我一般设成 10000,超过的直接拒绝。不然内存会被吃光。
- Accept后立刻收数据?:别急。先完成握手,再开始收业务数据。不然可能收到的是乱序的垃圾数据。
嗯,连接建立和握手这块,说白了就是「先验明正身,再谈正事」。设计得好,后面省心一大半。下一章咱们聊聊连接维护和心跳检测,那又是另一番天地了。