云计算百科
云计算领域专业知识百科平台

C++ 游戏服务器开发从 Socket 到广播,手写多人在线服务器

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}

1 为什么游戏服务器选择 C++

说到服务器开发,Java、Go、Python 都是流行的选择。但在对战类游戏场景中,C++ 仍然占据主导地位,原因有三:

维度C++ 的优势
延迟 无 GC 停顿,延迟可控在毫秒级以内
吞吐量 单机可支撑数千并发房间
内存控制 手动管理,无额外运行时开销
生态 大量成熟框架(Skynet、KBEngine、Pomelo 等)

当然,C++ 也有其代价:开发效率相对较低,内存 bug(野指针、double-free)更难排查。因此,性能越敏感的系统,C++ 的收益越大;如果是内部工具或低并发系统,用 Go 往往更合适。

2 服务器架构设计

2.1 总体架构

本文的 Demo 采用最直观的「一连接一线程」模型,便于理解核心概念:

请添加图片描述

主线程专职 accept(),每来一个客户端就 detach 一个子线程去处理它。子线程之间共享玩家列表,用 std::mutex 保护。

2.2 生产环境的演进

「一连接一线程」简单直观,但有明显上限——线程创建开销大,上千连接时内存压力显著。生产中通常迁移到:

  • Reactor 模型 + epoll(Linux):单线程或少量 I/O 线程处理所有网络事件
  • 线程池:网络 I/O 与业务逻辑分离
  • 协程(C++20 / libco):用同步写法实现异步性能

本文聚焦「读懂核心流程」,不引入这些复杂性。想深入可参考 muduo 网络库——陈硕老师的经典开源实现。

3 协议设计

3.1 为什么需要自定义协议

HTTP 的头部开销在高频小包场景下代价较大。游戏服务器通常使用自定义二进制协议(极致性能)或自定义文本协议(易于调试)。本文选择文本协议,用竖线分隔字段,换行符标记消息边界。

3.2 消息格式

方向命令格式示例含义
客户端 → 服务器 JOIN JOIN|Alice 玩家以名字 Alice 加入
客户端 → 服务器 MOVE MOVE|320.5|240.0 玩家移动到坐标 (320.5, 240.0)
客户端 → 服务器 QUIT QUIT 玩家主动退出
服务器 → 客户端 WELCOME WELCOME|7 服务器分配 ID = 7
服务器 → 客户端 PLAYER_JOIN PLAYER_JOIN|7|Alice 广播:7 号玩家加入
服务器 → 客户端 PLAYER_MOVE PLAYER_MOVE|7|320.5|240.0 广播:7 号玩家移动
服务器 → 客户端 SNAPSHOT SNAPSHOT|1,Alice,100,200;2,Bob,300,150 全量玩家快照

3.3 粘包处理

TCP 是流式协议,不保证消息边界。两条消息可能粘在一起收到,也可能一条消息被拆成两次收到。本文用「换行符作为消息分隔符」,接收时维护一个缓冲区,按 \\n 切割处理:

// 接收缓冲区,处理粘包
std::string leftover;

int n = recv(fd, buf, BUF_SIZE – 1, 0);
leftover += buf;

// 按换行符切割,逐条处理
size_t pos;
while ((pos = leftover.find('\\n')) != std::string::npos) {
std::string line = leftover.substr(0, pos);
leftover = leftover.substr(pos + 1);
processMessage(line); // 处理单条消息
}

4 核心代码解析

4.1 启动服务器(四步走)

一个 TCP 服务器的启动固定遵循四个步骤:

// Step 1: 创建 socket(就像买一部手机)
int server_fd = socket(AF_INET, SOCK_STREAM, 0);

// Step 2: 绑定地址(给手机装上 SIM 卡,选择号码)
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(9999); // 端口 9999
addr.sin_addr.s_addr = INADDR_ANY; // 监听所有网卡
bind(server_fd, (sockaddr*)&addr, sizeof(addr));

// Step 3: 开始监听(手机开机,等待来电)
listen(server_fd, 10); // 10 = 半连接队列大小

// Step 4: 接受连接(接听电话)
int client_fd = accept(server_fd, &client_addr, &client_len);

4.2 多线程处理客户端

每次 accept() 返回一个新的 client_fd,立即开一个线程去处理它:

while (true) {
int client_fd = accept(server_fd, ...);

// 每个玩家独立线程,互不阻塞
std::thread(handle_client, client_fd).detach();
}

4.3 广播函数

广播是服务器的核心操作:收到一个玩家的消息后,把结果推送给所有其他人。注意必须加锁,避免多线程同时修改 g_players 导致竞态条件:

// 全局玩家表 + 互斥锁
std::map<int, Player> g_players;
std::mutex g_mutex;

void broadcast(const std::string& msg, int exclude_fd = –1) {
std::lock_guard<std::mutex> lock(g_mutex); // RAII 自动解锁

for (auto& [fd, player] : g_players) {
if (fd != exclude_fd) { // 不发给消息来源方
send_msg(fd, msg);
}
}
}

4.4 完整消息处理流程

以「玩家移动」为例,走一遍完整流程:

  • 客户端发送:MOVE|320.5|240.0\\n
  • 服务端 recv() 接收到数据,追加到 leftover 缓冲区
  • 按 \\n 切割,得到一条完整消息
  • 解析为 cmd=MOVE, x=320.5, y=240.0
  • 加锁,更新 g_players[fd].x 和 .y
  • broadcast("PLAYER_MOVE|7|320.5|240.0") 发给所有其他玩家
  • 其他玩家的客户端收到消息,更新对应角色的位置
  • 4.5 完整服务端代码

    /**
    * 简易游戏服务器 Demo
    * 编译:g++ -std=c++17 -o game_server game_server.cpp -lpthread
    */

    #include <iostream>
    #include <thread>
    #include <mutex>
    #include <map>
    #include <vector>
    #include <string>
    #include <sstream>
    #include <cstring>
    #include <unistd.h>
    #include <arpa/inet.h>
    #include <sys/socket.h>
    #include <netinet/in.h>

    #define PORT 9999
    #define BUF_SIZE 1024

    struct Player {
    int fd;
    int id;
    float x, y;
    std::string name;
    };

    std::map<int, Player> g_players;
    std::mutex g_mutex;
    int g_next_id = 1;

    void send_msg(int fd, const std::string& msg) {
    std::string line = msg + "\\n";
    send(fd, line.c_str(), line.size(), 0);
    }

    void broadcast(const std::string& msg, int exclude_fd = –1) {
    std::lock_guard<std::mutex> lock(g_mutex);
    for (auto& [fd, player] : g_players) {
    if (fd != exclude_fd) send_msg(fd, msg);
    }
    }

    std::string make_snapshot() {
    std::ostringstream oss;
    oss << "SNAPSHOT|";
    bool first = true;
    for (auto& [fd, p] : g_players) {
    if (!first) oss << ";";
    oss << p.id << "," << p.name << "," << p.x << "," << p.y;
    first = false;
    }
    return oss.str();
    }

    void handle_client(int fd) {
    char buf[BUF_SIZE];
    std::string leftover;

    while (true) {
    int n = recv(fd, buf, BUF_SIZE – 1, 0);
    if (n <= 0) break;
    buf[n] = '\\0';
    leftover += buf;

    size_t pos;
    while ((pos = leftover.find('\\n')) != std::string::npos) {
    std::string line = leftover.substr(0, pos);
    leftover = leftover.substr(pos + 1);

    std::vector<std::string> parts;
    std::stringstream ss(line);
    std::string token;
    while (std::getline(ss, token, '|')) parts.push_back(token);
    if (parts.empty()) continue;

    std::string cmd = parts[0];

    if (cmd == "JOIN" && parts.size() >= 2) {
    Player p{ fd, 0, 400, 300, parts[1] };
    { std::lock_guard<std::mutex> lock(g_mutex); p.id = g_next_id++; g_players[fd] = p; }
    std::cout << "[+] " << p.name << " 加入 (id=" << p.id << ")\\n";
    send_msg(fd, "WELCOME|" + std::to_string(p.id));
    { std::lock_guard<std::mutex> lock(g_mutex); send_msg(fd, make_snapshot()); }
    broadcast("PLAYER_JOIN|" + std::to_string(p.id) + "|" + p.name, fd);
    }
    else if (cmd == "MOVE" && parts.size() >= 3) {
    float x = std::stof(parts[1]), y = std::stof(parts[2]);
    int pid = –1;
    { std::lock_guard<std::mutex> lock(g_mutex);
    if (g_players.count(fd)) { g_players[fd].x = x; g_players[fd].y = y; pid = g_players[fd].id; } }
    if (pid != –1)
    broadcast("PLAYER_MOVE|" + std::to_string(pid) + "|" + std::to_string(x) + "|" + std::to_string(y), fd);
    }
    else if (cmd == "QUIT") break;
    }
    }

    int pid = –1; std::string pname;
    { std::lock_guard<std::mutex> lock(g_mutex);
    if (g_players.count(fd)) { pid = g_players[fd].id; pname = g_players[fd].name; g_players.erase(fd); } }
    close(fd);
    if (pid != –1) {
    std::cout << "[-] " << pname << " 离开\\n";
    broadcast("PLAYER_QUIT|" + std::to_string(pid));
    }
    }

    int main() {
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_port = htons(PORT);
    addr.sin_addr.s_addr = INADDR_ANY;
    bind(server_fd, (sockaddr*)&addr, sizeof(addr));
    listen(server_fd, 10);

    std::cout << "游戏服务器启动,监听端口 " << PORT << "…\\n";

    while (true) {
    sockaddr_in client_addr{};
    socklen_t client_len = sizeof(client_addr);
    int client_fd = accept(server_fd, (sockaddr*)&client_addr, &client_len);
    if (client_fd < 0) continue;
    std::thread(handle_client, client_fd).detach();
    }

    close(server_fd);
    return 0;
    }

    5 运行 Demo

    5.1 编译与启动

    # 编译(需要 g++ 支持 C++17)
    g++ -std=c++17 -o game_server game_server.cpp -lpthread

    # 启动
    ./game_server
    # 输出:游戏服务器启动,监听端口 9999…

    5.2 用 telnet 手动测试

    不需要客户端程序,直接用 telnet 模拟玩家:

    # 终端 1:玩家 Alice
    telnet localhost 9999
    > JOIN|Alice
    < WELCOME|1
    < SNAPSHOT|

    # 终端 2:玩家 Bob(新开一个终端)
    telnet localhost 9999
    > JOIN|Bob
    < WELCOME|2
    < SNAPSHOT|1,Alice,400,300 ← 看到已有玩家

    # 此时终端 1 收到:
    < PLAYER_JOIN|2|Bob

    # 终端 2 移动:
    > MOVE|100|200
    # 终端 1 收到:
    < PLAYER_MOVE|2|100.0|200.0

    5.3 浏览器可视化客户端

    配套的 game_client.html 提供实时可视化界面:

    • 打开后输入服务器地址,点击 Connect
    • WASD / 方向键控制角色移动,点击地图可传送
    • 多标签页打开即可看到多个玩家实时同步位置
    • 没有服务器?点击 Connect 后自动进入 Demo 模式(内置 Bot)

    6 性能瓶颈与优化方向

    当前 Demo 在教学上足够清晰,但有几个显而易见的生产问题:

    问题现状优化方向
    并发模型 一连接一线程 epoll + 线程池 / 协程
    消息解析 文本协议,有额外 CPU 开销 Protobuf 或自定义二进制协议
    广播优化 遍历所有玩家 按房间 / AOI 区域过滤
    状态同步 每次 MOVE 广播完整坐标 帧同步 / 增量同步,降低带宽
    防作弊 无校验 服务器权威计算,客户端仅提交操作输入

    6.1 epoll 改造思路

    用 epoll 替换「一线程一连接」,单线程可监听数万个 fd:

    int epfd = epoll_create1(0);

    // 注册监听 socket
    epoll_event ev{ .events = EPOLLIN, .data.fd = server_fd };
    epoll_ctl(epfd, EPOLL_CTL_ADD, server_fd, &ev);

    while (true) {
    epoll_event events[64];
    int n = epoll_wait(epfd, events, 64, –1);

    for (int i = 0; i < n; i++) {
    if (events[i].data.fd == server_fd) {
    // 新连接
    int client_fd = accept(server_fd, ...);
    epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, ...);
    } else {
    // 已有连接有数据可读
    handle_data(events[i].data.fd);
    }
    }
    }

    7 总结

    通过这个 Demo,我们走完了 C++ 游戏服务器的最小闭环:

    • ✅ 用 4 个系统调用(socket / bind / listen / accept)搭起 TCP 服务
    • ✅ 多线程处理并发连接,mutex 保护共享状态
    • ✅ 设计简单文本协议,正确处理粘包问题
    • ✅ 实现玩家加入、移动、离开的完整广播逻辑
    赞(0)
    未经允许不得转载:网硕互联帮助中心 » C++ 游戏服务器开发从 Socket 到广播,手写多人在线服务器
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!