4、连接建立与握手:客户端连接流程、服务端监听与Accept、自定义握手协议设计

好,咱们今天来聊聊连接建立这个环节。说实话,很多新手觉得这不就是调个 connect()accept() 嘛,有啥好讲的?但我在项目中踩过的坑告诉我,这里面的门道比你想象的多得多。

你想想看,一个游戏服务器可能要同时处理几万个连接。如果连接建立这块没设计好,后面全是麻烦。咱们今天就把客户端怎么连、服务端怎么接、以及中间怎么握手,掰开了揉碎了讲清楚。

4.1 客户端连接流程:不只是调个connect

客户端发起连接,看起来简单。但实际项目中,我建议你至少考虑这几个步骤:

  1. DNS解析:把域名转成IP。这里有个坑——DNS解析可能很慢。我建议你提前做异步解析,或者干脆缓存IP。
  2. Socket创建:调 socket() 创建套接字。嗯,这里要注意协议族的选择,游戏一般用 AF_INETAF_INET6
  3. 非阻塞设置:我个人习惯一创建完就设成非阻塞模式。为什么?因为阻塞模式下,网络一抖,你的UI线程就卡死了。
  4. 发起connect:调 connect()。非阻塞模式下,这个调用会立即返回,但连接可能还没建立好。
  5. 等待连接完成:用 select()epoll() 等事件循环,等套接字可写,就说明连接建立成功了。
  6. 发送握手包:连接建立后,别急着发业务数据。先发一个握手包,确认双方身份和协议版本。

核心要点:客户端连接不是一锤子买卖。它是个状态机,有「正在连接」、「已连接」、「连接失败」等状态。你的代码得能处理这些状态切换。

我曾经在一个项目里,客户端连上服务端后直接发登录请求。结果服务端还没准备好,直接把包丢了。从那以后,我强制要求所有客户端必须先走握手流程。

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 握手流程

握手不是一次就完的。我习惯用「两次握手」:

  1. 客户端发握手包:连接建立后,客户端立刻发送 HandshakePacket
  2. 服务端验证并回复:服务端收到后,检查魔数、版本、校验和。如果都OK,回复一个确认包。
  3. 客户端确认:客户端收到确认包后,才算真正建立连接。之后才能发业务数据。

注意:如果服务端发现握手包非法(比如魔数不对),不要直接断开连接。我建议先记录日志,然后发送一个错误码,再断开。这样方便排查问题。

我曾经遇到过一个情况——某个客户端版本太老,发的握手包版本号是 1000,但服务端已经升级到 1002 了。结果服务端直接断开连接,客户端也不报错,用户就卡在登录界面。后来我改成服务端在握手失败时返回具体错误码,客户端根据错误码提示用户更新版本,问题就解决了。

4.3.3 超时处理

握手不能无限等。我建议设置一个超时时间,比如 5 秒。如果超时还没完成握手,直接断开连接。

为什么是 5 秒?因为正常网络环境下,握手包来回也就几十毫秒。5 秒已经足够宽松了。如果超过 5 秒,要么是网络有问题,要么是客户端有问题,没必要再等。

4.4 实战中的避坑指南

最后,分享几个我踩过的坑:

  • 不要信任客户端的IP和端口:尤其是用NAT的情况下,你看到的IP可能是网关的,不是客户端的真实IP。
  • 握手包要加密吗?:如果只是内部通信,明文就够了。但如果要过公网,我建议至少做个简单的异或加密,或者直接用TLS。
  • 连接数太多怎么办?:服务端要限制最大连接数。我一般设成 10000,超过的直接拒绝。不然内存会被吃光。
  • Accept后立刻收数据?:别急。先完成握手,再开始收业务数据。不然可能收到的是乱序的垃圾数据。

嗯,连接建立和握手这块,说白了就是「先验明正身,再谈正事」。设计得好,后面省心一大半。下一章咱们聊聊连接维护和心跳检测,那又是另一番天地了。