多人在线游戏架构实战第1章:网络编程基础——一切故事的起点

📑 目录

第1章:网络编程基础——一切故事的起点

单机游戏和网络游戏的区别,不只是"有没有网"。
它是"你的CPU要不要听别人的话"。

本章是全书的地基。彭放从最基础的socket讲起,没有一上来就甩框架,而是先让你理解一个根本问题:为什么游戏服务器不能像你写的本地计算器那样,一行行顺序执行?

答案是:因为网络有别人。而"别人"是不可控的。


一、单机游戏 vs 网络游戏:本质差异

单机游戏的假设

┌─────────────────────────────┐
│          游戏进程            │
│  ┌─────┐  ┌─────┐  ┌─────┐ │
│  │输入 │─▶│逻辑 │─▶│渲染 │ │
│  └─────┘  └─────┘  └─────┘ │
│        完全可控              │
└─────────────────────────────┘

单机游戏的世界里只有一个进程,所有数据在内存里,所有时序由CPU说了算。你按W,角色立刻往前——延迟就是代码执行时间,纳秒级。

网络游戏的现实

┌──────────┐     网络(20-200ms)      ┌──────────┐
│  客户端   │◄───────────────────────►│  服务端   │
│  你的机器  │                         │  别人的机器 │
└──────────┘                         └──────────┘
         ↑ 延迟不可控、丢包不可控、顺序不可控 ↑

网络游戏的时序被"网络"这个黑箱切断了。你按W,消息发出去,服务端收到、处理、广播给其他玩家——这个往返,在国内大概是20-80ms,跨国可能200ms以上。

20ms是什么概念?

60FPS的渲染周期是16.67ms。也就是说,网络延迟比你的一帧还长。如果服务端没收到你的操作就强行渲染,你看到的就是"卡顿";如果客户端不等服务端确认就预测渲染,看到的就是"回溯"。

MOBA里的"走A取消后摇"、FPS里的"客户端预测+服务端校验"——这些机制的存在,全都是因为网络延迟这个物理事实。

核心差异总结

维度单机游戏网络游戏
状态所有权本地服务端(权威)
输入响应即时延迟
并发性单用户多用户竞争资源
安全性信任本地必须校验一切
复杂度逻辑+渲染逻辑+网络+并发+持久化

所以游戏服务端的第一个原则就是:服务端拥有最终解释权。 客户端可以预测、可以插值、可以做很多优化,但一切争议以服务端状态为准。


二、IP地址与TCP/IP:先搞懂地址和路

IP地址:网络世界的门牌号

IPv4地址32位,写成192.168.1.1这种四段式。关键要理解:

  • 公网IP:全球唯一,比如服务器的IP
  • 内网IP:局域网内部用,比如你家路由器的192.168.x.x
  • 127.0.0.1:localhost,永远指向本机
  • 0.0.0.0:绑定所有可用接口,服务端监听时常用
// 最简单的IP地址表示
struct sockaddr_in {
    sa_family_t    sin_family;   // AF_INET (IPv4)
    in_port_t      sin_port;     // 端口号,要用htons转换
    struct in_addr sin_addr;     // IP地址
};

端口号16位,范围0-65535。熟知端口(0-1023)需要root权限,比如80是HTTP,443是HTTPS。游戏服一般开在5000以上的高位端口。

TCP/IP协议栈:四层模型

┌─────────────────────────────────────┐
│  应用层   │ HTTP / FTP / 自定义协议   │  ← 游戏协议在这里
├─────────────────────────────────────┤
│  传输层   │ TCP / UDP                 │  ← 游戏一般用TCP(或UDP+重传)
├─────────────────────────────────────┤
│  网络层   │ IP / ICMP                 │  ← 路由选择
├─────────────────────────────────────┤
│  链路层   │ Ethernet / WiFi           │  ← 网卡驱动
└─────────────────────────────────────┘

TCP提供的是可靠的字节流传输:顺序保证、不重不漏、有拥塞控制。代价是延迟和开销。

UDP提供的是尽力而为的数据报:不保证顺序、不保证到达、没有拥塞控制。代价是你自己处理丢包和乱序。

游戏为什么一般用TCP?

MMORPG里,聊天、交易、技能释放、属性变化——这些都不能丢包。用TCP,内核帮你做可靠性,你专心写业务逻辑。

MOBA/FPS为什么有时候用UDP?

因为位置同步包每50ms发一个,丢了一两个没关系,下一个包会跟上。TCP的重传机制反而会造成"等待旧包、阻塞新包"的队头阻塞。这类游戏通常用UDP+应用层重传(只重传关键包),或者干脆不补,靠预测插值平滑。

本书以TCP为主,这是正确的教学路径——先学会可靠传输,再去掉可靠性,才能理解UDP为什么省。


三、阻塞式网络编程:最直观的起点

什么是阻塞?

想象你在一家餐厅点单:

  • 阻塞模式:你站在柜台前,盯着厨师做。在菜做好之前,你什么都不干,就干等。
  • 非阻塞模式:你点完单拿到叫号器,去旁边坐着刷手机。叫号器响了再回来取餐。

网络编程里的"阻塞",就是socket函数在条件不满足时,把进程挂起,直到条件满足才返回。

服务端基础代码结构

#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <cstring>
#include <iostream>

// ========== 阻塞式服务端:一次只能服务一个客户端 ==========
int main() {
    // 1. 创建socket
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0) {
        perror("socket failed");
        return -1;
    }

    // 2. 绑定地址
    struct sockaddr_in server_addr{};
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(8888);           // 端口8888
    server_addr.sin_addr.s_addr = INADDR_ANY;     // 0.0.0.0

    if (bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
        perror("bind failed");
        close(listen_fd);
        return -1;
    }

    // 3. 开始监听,backlog=5表示内核最多缓存5个未accept的连接
    if (listen(listen_fd, 5) < 0) {
        perror("listen failed");
        close(listen_fd);
        return -1;
    }

    std::cout << "Server listening on 0.0.0.0:8888 (blocking mode)\n";

    // 4. 接受连接——这里是阻塞的!没有客户端连进来,进程就挂在这里
    struct sockaddr_in client_addr{};
    socklen_t client_len = sizeof(client_addr);
    int client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_len);
    
    std::cout << "Client connected!\n";

    // 5. 收发数据——recv也是阻塞的!没有数据就挂起
    char buffer[1024];
    while (true) {
        memset(buffer, 0, sizeof(buffer));
        int recv_len = recv(client_fd, buffer, sizeof(buffer) - 1, 0);
        
        if (recv_len <= 0) {
            std::cout << "Client disconnected.\n";
            break;  // 客户端断开
        }
        
        std::cout << "Received: " << buffer << "\n";
        
        // Echo回去
        send(client_fd, buffer, recv_len, 0);
    }

    // 6. 关闭连接
    close(client_fd);
    close(listen_fd);
    return 0;
}

关键函数速查

函数作用阻塞点
socket()创建通信端点不阻塞
bind()绑定IP和端口不阻塞
listen()开始监听不阻塞
accept()等待客户端连接阻塞!
recv()等待接收数据阻塞!
send()发送数据通常不阻塞(除非缓冲区满)
connect()客户端发起连接阻塞!

阻塞式客户端

#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <iostream>

int main() {
    int sock_fd = socket(AF_INET, SOCK_STREAM, 0);
    
    struct sockaddr_in server_addr{};
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(8888);
    inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr);  // 本机测试

    // connect会阻塞,直到三次握手完成或超时
    if (connect(sock_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
        perror("connect failed");
        return -1;
    }

    std::cout << "Connected to server!\n";

    // 发消息
    const char* msg = "Hello, Game Server!";
    send(sock_fd, msg, strlen(msg), 0);

    // 收回复——阻塞等待
    char buffer[1024];
    int recv_len = recv(sock_fd, buffer, sizeof(buffer) - 1, 0);
    if (recv_len > 0) {
        buffer[recv_len] = '\0';
        std::cout << "Server reply: " << buffer << "\n";
    }

    close(sock_fd);
    return 0;
}

阻塞的问题

上面的服务端有一个致命缺陷:一次只能服务一个客户端。

accept()阻塞在第一个客户端上,第二个客户端连进来的时候,内核的backlog队列会暂存它,但服务端根本没时间调用第二个accept()——因为第一个recv()也在阻塞。

Client A连接 ──▶ accept()返回A ──▶ recv(A)阻塞
                                    ↑
Client B连接 ──▶ backlog队列等待 ◀──┘  服务端没空理B!

在真实游戏里,这意味着:玩家A在发消息,玩家B连上来但收不到任何响应——B看到的画面是"连接中……"一直转圈。

解决方案有两个方向:

  1. 多进程/多线程:每个连接一个线程(资源开销大)
  2. 非阻塞+IO多路复用:单线程监控多个socket(本章讲方向2的起步)

四、非阻塞式网络编程:解放CPU的第一步

设置非阻塞模式

Linux下用fcntl

#include <fcntl.h>

// 设置socket为非阻塞
int set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags < 0) return -1;
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

Windows下用ioctlsocket

#include <winsock2.h>

u_long mode = 1;  // 1=非阻塞,0=阻塞
ioctlsocket(sock_fd, FIONBIO, &mode);

非阻塞模式下的行为变化

// 非阻塞accept:如果没有新连接,立即返回-1,errno=EAGAIN/EWOULDBLOCK
int client_fd = accept(listen_fd, ...);
if (client_fd < 0) {
    if (errno == EAGAIN || errno == EWOULDBLOCK) {
        // 没有新连接,不阻塞,可以做别的事
    }
}

// 非阻塞recv:如果没有数据,立即返回-1,errno=EAGAIN
int recv_len = recv(client_fd, buffer, sizeof(buffer), 0);
if (recv_len < 0) {
    if (errno == EAGAIN || errno == EWOULDBLOCK) {
        // 暂时没有数据,去处理其他客户端
    }
}

非阻塞服务端骨架

#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <vector>
#include <cstring>
#include <iostream>

int set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

int main() {
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    set_nonblocking(listen_fd);  // 监听socket也设为非阻塞
    
    // ... bind和listen省略 ...
    
    std::vector<int> client_fds;  // 维护所有客户端连接
    
    while (true) {
        // 尝试accept,不阻塞
        struct sockaddr_in client_addr{};
        socklen_t len = sizeof(client_addr);
        int client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &len);
        
        if (client_fd >= 0) {
            set_nonblocking(client_fd);
            client_fds.push_back(client_fd);
            std::cout << "New client: " << client_fd << "\n";
        } else if (errno != EAGAIN && errno != EWOULDBLOCK) {
            perror("accept error");
            break;
        }
        
        // 轮询所有客户端,尝试recv
        for (int fd : client_fds) {
            char buffer[1024];
            int recv_len = recv(fd, buffer, sizeof(buffer) - 1, 0);
            
            if (recv_len > 0) {
                buffer[recv_len] = '\0';
                std::cout << "From " << fd << ": " << buffer << "\n";
                send(fd, buffer, recv_len, 0);  // echo
            } else if (recv_len == 0) {
                // 客户端主动断开
                close(fd);
                // 从client_fds移除(实际用更高效的数据结构)
            } else if (errno != EAGAIN && errno != EWOULDBLOCK) {
                // 真正的错误
                close(fd);
            }
            // recv_len < 0 && errno == EAGAIN → 暂时没有数据,跳过
        }
        
        // 这里可以插入:处理游戏逻辑、发送广播、存档……
        // 因为没有阻塞,CPU不会空等
    }
    
    return 0;
}

非阻塞的本质

阻塞模式:CPU在等网络,干不了别的 → CPU利用率低

非阻塞模式:CPU一直在轮询,没有网络事件时做无用功 → CPU利用率高,但忙等浪费

阻塞模式时间线:
accept()阻塞 ━━━━━━━━━━━━━━━━━━━┓
                               ┃  处理逻辑
recv()阻塞   ━━━━━━━━━━━┓      ┃
                        ┃ 处理 ┃
recv()阻塞   ━━━━━━━┓   ┃ 逻辑 ┃
                    ┃ 处┃      ┃
                    ┃ 理┃      ┃
时间:▓▓░░░░░░░░░░░▓▓░░░▓▓░░░▓▓  (▓=干活 ░=阻塞)

非阻塞模式时间线:
accept()?有! → recv()?无! → recv()?有! → 处理 → recv()?无! → ...
时间:▓░▓░▓▓▓░▓░▓░▓▓▓░▓░▓░▓▓▓░▓░▓░▓▓▓  (▓=干活 ░=立刻返回)

非阻塞解决了"一阻塞全家等死"的问题,但引入了新问题:轮询所有socket太蠢了。

100个连接,每次循环检查100个recv(),99个返回EAGAIN——CPU在做"没有意义的检查"。如果连接数到10000呢?这个循环本身的执行时间就变成不可忽略了。

这就是IO多路复用存在的理由。 第2章会讲:与其你问每个socket"有没有数据",不如让内核告诉你"哪些socket有数据"。


五、Win/Linux系统差异:跨平台的坑

socket类型定义

// Linux
#include <sys/socket.h>
int fd = socket(AF_INET, SOCK_STREAM, 0);

// Windows
#include <winsock2.h>
SOCKET sock = socket(AF_INET, SOCK_STREAM, 0);
// 注意:SOCKET是UINT_PTR类型,不是int!
// 错误检查不能和Linux一样用 < 0

关闭socket

// Linux
close(fd);

// Windows
closesocket(sock);
// 而且Windows需要先WSAStartup()初始化Winsock

错误码

// Linux:全局变量errno
#include <errno.h>
if (errno == EAGAIN) { ... }

// Windows:WSAGetLastError()
#include <winsock2.h>
if (WSAGetLastError() == WSAEWOULDBLOCK) { ... }

字节序转换

// 两套API名字不同,功能一样
// Linux/通用
htons(8888);   // Host TO Network Short (16位)
htonl(...);    // Host TO Network Long (32位)
ntohs(...);    // Network TO Host Short
ntohl(...);    // Network TO Host Long

// Windows 同名,但实现可能不同

作者的处理策略

彭放在书中用条件编译封装了这些差异:

#ifdef _WIN32
    #include <winsock2.h>
    #define CLOSE_SOCKET closesocket
    #define GET_ERROR WSAGetLastError()
    typedef SOCKET SocketType;
#else
    #include <unistd.h>
    #include <sys/socket.h>
    #define CLOSE_SOCKET close
    #define GET_ERROR errno
    typedef int SocketType;
#endif

工程建议:直接上Linux开发。 Windows的Winsock2是上世纪90年代的API设计,行为细节和POSIX差异很多。生产环境99%是Linux,学习阶段没必要在兼容性上浪费时间。


六、服务端/客户端代码结构的工程化思考

从"能跑"到"能维护"

上面示例代码都是平铺直叙的main函数。真实项目中,网络层至少要拆成这几个模块:

Network/
├── BaseSocket.h/cpp      # socket的RAII封装
├── Network.h/cpp         # 网络事件循环(第2章细化)
├── NetworkListen.h/cpp   # 监听端口的抽象
├── NetworkConnector.h/cpp # 主动连接的抽象(客户端用)
├── Buffer.h/cpp          # 收发缓冲区
└── Packet.h/cpp          # 应用层协议封包

第2章的预告:Buffer和Packet为什么重要

非阻塞模式下,recv()返回的数据不一定是"一条完整消息"。TCP是字节流,不是消息边界:

客户端发了两条消息:[HEADER]["Hello"][HEADER]["World"]
服务端recv()可能收到:
  情况1: [HEADER]["Hello"][HEADER]["Wo"]   ← 第二条没全到
  情况2: [HEADER]["Hel"]                   ← 第一条都没全到
  情况3: [HEADER]["Hello"][HEADER]["World"]  ← 凑巧全到了

没有Buffer和Packet层,你的"解析逻辑"和"网络逻辑"混在一起,代码很快变成意大利面条。

Buffer负责:不管recv()一次给多少,我先攒着,凑够一条完整消息再交给上层。

Packet负责:定义消息格式(比如2字节长度+变长payload),让Buffer知道"凑到多少算一条"。

这是第2章的核心,但第1章要先埋个种子:TCP是流,你要自己造消息的"边界"。


七、工程建议与常见坑点

✅ 做对的事

  1. 总是检查返回值。网络函数没有"肯定成功"这回事。accept()可能在信号中断后返回-1errno==EINTR,这时候要重试。

  2. 设置SO_REUSEADDR。服务端重启时,旧连接还在TIME_WAIT状态,不设置这个选项会绑定失败:

    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
  3. 处理SIGPIPE。向一个已断开的连接发数据,Linux默认发SIGPIPE信号终止进程。游戏服不能因为这个崩:

    signal(SIGPIPE, SIG_IGN);  // 忽略SIGPIPE,让send()返回EPIPE错误码
  4. 心跳机制。TCP连接可能"假死"——网线拔了、路由器重启了,但两端都不知道。游戏服必须有应用层心跳:客户端每30秒发一个心跳包,服务端60秒没收到就踢掉。

❌ 常见的坑

  1. 忘记转换字节序bind()里的端口号必须用htons(),否则在x86小端机器上端口会错。这是个经典到不能再经典的bug,但每年都有人踩。

  2. backlog设太小listen(fd, 5)在并发测试时不够。生产环境一般设到128或更大,但真正的瓶颈不是backlog,是你accept()的速度。

  3. 缓冲区溢出。上面的示例用char buffer[1024],真实消息可能更长。要么动态分配,要么设计协议时限定单条消息上限。

  4. 没有优雅关闭close(fd)直接关闭可能丢数据。正确的关闭流程是:先shutdown(fd, SHUT_WR)发FIN,等对方也关,再close()

  5. 在非阻塞socket上用阻塞式send。如果发送缓冲区满,send()也会返回EAGAIN。非阻塞模式下,send不是"一定立刻发完"——它只保证"塞到内核缓冲区里,塞不下就告诉我"。


八、本章核心回顾

flowchart LR
    A[单机游戏] --"网络引入延迟和不确定性"--> B[网络游戏]
    B --"需要可靠传输"--> C[TCP字节流]
    C --"socket API"--> D[阻塞模式]
    D --"一次只能服务一个客户端"--> E[非阻塞模式]
    E --"轮询所有socket效率低"--> F[第2章: IO多路复用]
  1. 单机到网络:状态权威在服务端,客户端只是"视图"
  2. TCP是流:没有消息边界,必须自己设计封包
  3. 阻塞的本质:CPU在等资源,利用率低
  4. 非阻塞的代价:解放了CPU,但引入了忙等
  5. 下一步:IO多路复用——让内核告诉你"谁准备好了"

九、一个可以跑的小作业

基于本章代码,实现一个"猜数字"游戏服务器:

  • 服务端随机生成1-100的数字
  • 客户端连接后,可以发送猜测的数字
  • 服务端回复"大了""小了"或"猜对了"
  • 支持多客户端同时连接(用非阻塞+vector存储客户端)

这个作业会逼你面对:连接管理、消息解析、状态维护——游戏服务器最基础的三件事。


阻塞和非阻塞的区别,本质上是"控制权在谁手里"。
阻塞模式,控制权交给内核,你被动等待;
非阻塞模式,控制权回到你的代码,但你要自己决定"什么时候去查"。
IO多路复用是第三种答案:控制权给内核,但内核只汇报"有事的",不报"没事的"。

三种模式没有绝对的好坏——只有对场景的理解够不够深。

"先让代码跑起来,再去想怎么让它跑得更好。" 🖤