/* 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++ 仍然占据主导地位,原因有三:
| 延迟 | 无 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 完整消息处理流程
以「玩家移动」为例,走一遍完整流程:
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 保护共享状态
- ✅ 设计简单文本协议,正确处理粘包问题
- ✅ 实现玩家加入、移动、离开的完整广播逻辑
网硕互联帮助中心



评论前必须登录!
注册