第3章:线程、进程与Actor模型——并发的正确打开方式
老程序员常说:"多线程编程只有两种状态——正在排查竞态条件,和即将排查竞态条件。"
这一章,我们要聊的是怎么让这句话变成过去式。
一、从"一个人干活"到"一群人协作"——游戏架构的进化史
1.1 无服务端时代:点对点直连
最早的多人游戏,根本没有服务端这个概念。两台电脑通过网线直连,或者通过局域网广播消息,这就是全部的"网络架构"。
┌─────────┐ ┌─────────┐
│ 玩家A │◄───────►│ 玩家B │
│ (主机) │ 直连 │ (客机) │
└─────────┘ └─────────┘这种模式的缺陷显而易见:没有权威状态。如果玩家A说"我击中了你",玩家B说"不,你没击中",谁来仲裁?答案是没有仲裁者。早期的RTS游戏(比如《魔兽争霸3》)采用"确定性帧同步"来解决这个问题——所有客户端执行完全相同的逻辑,只在输入层同步,但这对网络延迟和玩家机器性能要求极高,稍有偏差就会"不同步"。
直觉:想象两个人一起拼图,但各自拼自己的版本,最后要比对是否一致。只要有一步拼错了,整幅图就崩了。
1.2 单进程CS架构:权威服务器的诞生
随着互联网普及,"客户端-服务器"(Client-Server, CS)架构成为主流。一台机器作为"权威服务端",所有游戏逻辑在它上面跑,客户端只负责渲染和发送输入。
flowchart LR
subgraph 客户端群
C1[玩家A客户端]
C2[玩家B客户端]
C3[玩家C客户端]
end
S[单进程游戏服务端
主循环+网络+逻辑+数据库]
C1 <-->|请求/响应| S
C2 <-->|请求/响应| S
C3 <-->|请求/响应| S这是最简单的服务端架构,所有模块挤在一个进程里:网络收发、游戏逻辑、数据库读写、定时任务……全部串行或简单并行执行。对于小型游戏或早期MMORPG(《传奇》《奇迹MU》时代),这套架构够用——毕竟同时在线可能就几百人。
代码视角的单进程主循环:
// 最朴素的游戏服务端主循环
while (g_running) {
// 1. 处理网络输入(接收客户端数据包)
ProcessNetworkInput();
// 2. 执行游戏逻辑(移动、战斗、技能判定)
UpdateGameLogic();
// 3. 处理数据库请求(存档、读档)
ProcessDatabaseOps();
// 4. 发送网络输出(把结果推给客户端)
ProcessNetworkOutput();
// 5. 等待下一帧(固定帧率,比如每秒20帧 = 50ms)
SleepUntilNextFrame();
}这个循环看起来干净,但隐藏着致命的瓶颈:所有步骤串行执行。如果某一步卡住了(比如数据库查询慢了),整个循环就阻塞,所有玩家都会感受到"卡顿"。
1.3 多进程CS架构:分工与隔离
当单进程架构扛不住更多玩家时,自然而然的想法是:"把不同的工作分给不同的进程做。"
flowchart TB
subgraph 客户端
C1[玩家客户端]
end
subgraph 服务端集群
GW[Gateway进程
网络接入层]
LG[Login进程
账号验证]
GM[Game进程
游戏逻辑核心]
DB[DB进程
数据库代理]
CH[Chat进程
聊天/广播]
end
C1 <-->|TCP/长连接| GW
GW -->|验证请求| LG
GW -->|游戏包转发| GM
GM -->|SQL代理| DB
GM -.->|跨服消息| CH多进程架构的核心思想是按职责拆分:
| 进程 | 职责 | 为什么拆分 |
|---|---|---|
| Gateway | 网络接入、加密解密、心跳维护 | 把网络IO和逻辑解耦,Gateway崩溃不影响游戏状态 |
| Login | 账号验证、Session管理 | 安全敏感,独立部署便于审计 |
| Game | 核心玩法逻辑(战斗、经济、地图) | 可以按"服"拆分,水平扩展 |
| DB | 数据库读写缓冲、SQL拼装 | 隔离慢查询对游戏逻辑的干扰 |
| Chat | 世界频道、私聊、邮件 | 高频广播,独立进程避免冲击核心逻辑 |
直觉:想象一个餐厅。单进程就是一个人当服务员、厨师、收银员、洗碗工;多进程就是让每个岗位有专人负责,但需要用"传菜口"(进程间通信IPC)交接工作。
二、框架瓶颈:当玩家数突破设计上限
多进程架构解决了"分工"问题,但没有自动解决"规模"问题。当玩家数量持续增长,开发者会陆续遇到以下瓶颈:
2.1 滚服(Server Rolling)之痛
早期的MMORPG采用"分区制"——每个区服是独立的游戏世界,玩家数据不互通。当一区满了,就开二区;二区满了,开三区。这被称为"滚服"。
flowchart LR
subgraph 区服1[一区"龙腾虎跃"]
S1A[Game进程A]
S1B[Game进程B]
end
subgraph 区服2[二区"风云再起"]
S2A[Game进程A]
S2B[Game进程B]
end
subgraph 区服N[N区"新开"]
SNA[Game进程A]
end
DB1[(一区数据库)]
DB2[(二区数据库)]
DBN[(N区数据库)]
S1A --> DB1
S2A --> DB2
SNA --> DBN滚服的代价是玩家社群被割裂。你的朋友在一区,你在二区,你们永远无法在游戏中相遇。对于社交属性强的MMO来说,这是致命的。但滚服有一个"好处":它把问题推迟了——每个区服的Game进程只需要处理几千到一万玩家,单机性能就够了。
工程建议:滚服不是架构,是妥协。真正的解决方案是"大世界分线"或"分布式地图",但这需要更复杂的架构支持。
2.2 副本(Instance)隔离
副本是一种巧妙的折中:把一部分玩家"关进"一个独立的逻辑空间,这个空间运行在独立的进程或线程里,与主世界互不干扰。
flowchart TB
subgraph 主世界[主世界进程
野外/主城]
MW[共享地图逻辑]
end
subgraph 副本1[副本进程A
5人小队副本]
I1[独立战斗逻辑]
end
subgraph 副本2[副本进程B
40人团队副本]
I2[独立战斗逻辑]
end
subgraph 副本N[副本进程N
竞技场]
IN[独立战斗逻辑]
end
MW -.->|玩家进入副本| I1
MW -.->|玩家进入副本| I2
MW -.->|玩家进入副本| IN副本的优势在于天然隔离:副本A里的玩家技能和副本B里的玩家技能,永远不会互相影响。这意味着每个副本进程可以独立运行,甚至独立崩溃而不影响其他副本。
坑点预警:副本进程的"生命周期管理"是噩梦。副本结束后,进程要不要销毁?玩家掉线后重新连接,如何找回他的副本?副本之间的数据同步(比如副本奖励如何同步到主世界数据库)如果设计不好,就是数据不一致的温床。
2.3 大图分割(Map Sharding):最后的大 boss
当游戏世界大到单机放不下时,就需要把一张地图切成多个" Shard "(分片),每个分片由不同的进程负责。
flowchart TB
subgraph 大世界地图
direction LR
A[Shard A
(0,0)-(1000,1000)]
B[Shard B
(1000,0)-(2000,1000)]
C[Shard C
(0,1000)-(1000,2000)]
D[Shard D
(1000,1000)-(2000,2000)]
end
A <-->|玩家跨Shard| B
A <-->|玩家跨Shard| C
B <-->|玩家跨Shard| D
C <-->|玩家跨Shard| D大图分割的核心挑战是边界穿越。当一个玩家从Shard A走到Shard B,他的状态、视野、背包、任务进度——所有这些数据都要"迁移"。如果Shard A和Shard B对同一个玩家数据的理解不一致(比如Shard A认为他还有100金币,Shard B认为他只有80金币),那就是灾难。
直觉:想象两个城市各有一个户籍管理系统。一个人从城市A搬到城市B,他的档案必须完整转移。如果A说"已转出"但B说"没收到",这个人就变成"黑户"了。
大图分割的解决方案通常依赖一个全局状态服务(Global State Service),类似一个"中央户籍局",但这也引入了新的单点瓶颈。
三、游戏主循环设计:心脏的跳动
无论架构怎么变,每个游戏进程都有一个"心脏"——主循环(Main Loop)。设计好主循环,等于设计好了整个进程的"节拍器"。
3.1 固定帧率 vs 可变帧率
// 固定帧率主循环(每帧50ms,对应20FPS逻辑帧)
constexpr int kFrameMs = 50; // 毫秒
while (running) {
auto frameStart = Now();
Update(); // 执行一帧逻辑
auto elapsed = Now() - frameStart;
if (elapsed < kFrameMs) {
Sleep(kFrameMs - elapsed); // 剩余时间休眠
} else {
// 超时!这一帧处理太慢了
LOG_WARN("Frame overtime: %d ms", elapsed);
}
}固定帧率的优点是确定性。所有逻辑基于"帧"执行,方便录像回放、帧同步调试、逻辑预测。但缺点是如果某一帧处理不完,就会"掉帧",表现为卡顿。
// 可变帧率主循环(尽力而为)
auto lastTime = Now();
while (running) {
auto now = Now();
float deltaTime = (now - lastTime) / 1000.0f; // 秒
lastTime = now;
Update(deltaTime); // 逻辑按实际流逝时间更新
// 不主动休眠,CPU跑满
}可变帧率更适合图形渲染(追求最高帧率),但对游戏服务端来说不推荐——服务端需要严格的时序控制,可变帧率会让"每秒回一次血"这种逻辑变得不可预测。
工程建议:服务端主循环一律用固定帧率,通常设置为1030FPS(即每帧33100ms)。网络层可以用独立线程以更高频率收发数据,但逻辑层必须"按节拍跳舞"。
3.2 主循环内的阶段划分
一个成熟的主循环通常分为以下阶段:
flowchart LR
A[① 网络输入] --> B[② 消息预处理]
B --> C[③ 定时器触发]
C --> D[④ 逻辑更新]
D --> E[⑤ 数据库请求]
E --> F[⑥ 网络输出]
F --> G[⑦ 帧尾清理]
G --> A| 阶段 | 职责 | 注意事项 |
|---|---|---|
| ① 网络输入 | 从操作系统TCP缓冲区读取数据,解析为游戏消息 | 这一帧只读,不做逻辑处理,避免递归触发 |
| ② 消息预处理 | 校验协议号、序列号、基础合法性(如长度、格式) | 非法消息在这里过滤,不进入逻辑层 |
| ③ 定时器触发 | 检查有哪些定时器到期(技能CD、Buff持续时间、刷新机制) | 定时器精度取决于帧率,不要用毫秒级定时器做高精度需求 |
| ④ 逻辑更新 | 执行游戏核心逻辑(移动、战斗、经济交易) | 这是最容易出bug的地方,所有状态变更在此阶段 |
| ⑤ 数据库请求 | 将需要持久化的变更提交给DB进程/线程 | 异步提交,不等待结果,避免阻塞 |
| ⑥ 网络输出 | 把本帧产生的所有响应推送给对应客户端 | 批量发送,减少系统调用次数 |
| ⑦ 帧尾清理 | 回收临时对象、记录性能指标、检查内存泄漏 | 兜底阶段,确保本帧不留垃圾 |
四、进程与线程的本质:C++视角
4.1 进程:拥有独立地址空间的"国家"
在操作系统眼中,进程是一个独立的资源容器。每个进程有自己的:
- 虚拟地址空间(4GB或更多,彼此隔离)
- 打开的文件描述符
- 代码段、数据段、堆、栈
#include <unistd.h>
#include <sys/wait.h>
#include <iostream>
// 最简单的进程创建示例(fork)
int main() {
pid_t pid = fork();
if (pid == 0) {
// 子进程
std::cout << "我是子进程, PID=" << getpid() << std::endl;
// 子进程有自己独立的地址空间
// 修改这里的变量不会影响父进程
exit(0);
} else if (pid > 0) {
// 父进程
std::cout << "我是父进程, PID=" << getpid()
<< ", 子进程PID=" << pid << std::endl;
wait(nullptr); // 等待子进程结束
}
return 0;
}进程之间默认不共享内存,通信必须通过进程间通信(IPC):管道、消息队列、共享内存、Socket、信号量等。
直觉:进程就像两个独立的虚拟机,跑在两台物理隔离的服务器上。它们可以通信,但通信有开销——数据需要"序列化→传输→反序列化"。
4.2 线程:共享地址空间的"工人"
线程是进程内的执行单元。同一进程的所有线程共享:
- 代码段和数据段
- 打开的文件描述符
- 堆内存
但每个线程有自己独立的:
- 栈
- 寄存器状态
- 程序计数器
#include <thread>
#include <iostream>
#include <vector>
// C++11标准线程库基础示例
void WorkerThread(int id) {
std::cout << "线程 " << id << " 开始工作" << std::endl;
// 模拟一些工作
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::cout << "线程 " << id << " 完成" << std::endl;
}
int main() {
std::vector<std::thread> workers;
// 创建4个工作线程
for (int i = 0; i < 4; ++i) {
workers.emplace_back(WorkerThread, i);
}
// 等待所有线程完成
for (auto& t : workers) {
t.join();
}
return 0;
}线程通信的"优势"是可以直接读写共享内存。但这把双刃剑的另一面是:需要锁来保护共享数据。
#include <mutex>
#include <thread>
#include <vector>
std::mutex g_mutex;
int g_counter = 0;
void Increment() {
for (int i = 0; i < 100000; ++i) {
// 如果不加锁,g_counter的最终值几乎肯定不是400000
std::lock_guard<std::mutex> lock(g_mutex);
++g_counter;
}
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back(Increment);
}
for (auto& t : threads) {
t.join();
}
std::cout << "最终计数: " << g_counter << std::endl;
return 0;
}4.3 锁的噩梦:从互斥锁到无锁编程
游戏服务端是锁竞争的重灾区。想象这样一个场景:
1000个玩家在同一个主城,每个玩家每帧都可能移动、聊天、交易。如果所有玩家数据共享一个"玩家列表",每次遍历都要加锁,锁竞争会让多线程退化成"串行执行"。
常见的锁类型:
| 锁类型 | 适用场景 | 坑点 |
|---|---|---|
std::mutex | 简单互斥 | 忘记解锁=死锁;异常抛出不解锁=死锁 |
std::shared_mutex | 读多写少 | 读锁升级写锁容易死锁 |
std::atomic | 简单计数器、标志位 | 不能替代复杂结构的原子操作 |
| 自旋锁 | 极短临界区 | 忙等待浪费CPU,不适合长临界区 |
| 读写锁 | 配置表、静态数据 | 写饥饿问题 |
一个真实的惨案:
某项目使用std::map存储在线玩家,主线程每帧遍历所有玩家更新逻辑,网络线程收到登录请求时插入新玩家。两者共用一把std::mutex。高峰期登录洪水来临时,网络线程抢锁插入玩家,主线程被迫等待,整个游戏逻辑停顿——表现为"全服卡顿"。
解决方案?
- 锁粒度细化:不按"整张表"加锁,而是按"玩家"加锁
- 读写分离:登录请求先放入"待处理队列",主线程在帧头统一处理
- 无锁数据结构:使用
concurrentqueue等第三方无锁队列 - Actor模型:干脆不要用共享内存了——这就是下一节要说的
五、Actor模型:"不要通过共享内存来通信,而要通过通信来共享内存"
5.1 Actor模型的核心思想
Actor模型由Carl Hewitt在1973年提出,核心理念极其简洁:
每个Actor是一个独立的计算实体。Actor之间不共享状态,只能通过发送消息来通信。
flowchart TB
subgraph Actor系统
A1[Actor: 玩家A]
A2[Actor: 玩家B]
A3[Actor: 战斗系统]
A4[Actor: 邮件服务]
end
A1 -->|"攻击玩家B"| A2
A1 -->|"查询排名"| A3
A3 -->|"发放奖励"| A4
A4 -->|"通知新邮件"| A1每个Actor有三个基本属性:
- 状态:私有的,不共享,只有Actor自己能修改
- 行为:收到消息时执行的逻辑
- 邮箱:消息队列,新消息放入邮箱等待处理
Actor模型的关键规则:
- 不共享内存:没有全局变量,没有锁
- 异步消息:发送消息不需要等待回复,即发即走
- 串行处理:每个Actor一次只处理一条消息,天然线程安全
直觉:想象一个公司,每个员工(Actor)有自己的办公桌(状态),只能看到自己的文件。他们之间不直接走到对方桌前翻文件,而是通过公司内部邮件(消息)沟通。因为每个人同一时间只处理一封邮件,所以永远不会出现"两个人同时改同一份文件"的问题。
5.2 "不要通过共享内存来通信,而要通过通信来共享内存"
这句话是Go语言的创造者Rob Pike说的,但完美概括了Actor模型的精髓。
反例(共享内存通信):
// 传统多线程:共享内存+锁
class GameWorld {
public:
void PlayerMove(int playerId, Position pos) {
std::lock_guard<std::mutex> lock(mutex_);
players_[playerId].position = pos; // 共享内存修改
}
void PlayerAttack(int attackerId, int targetId) {
std::lock_guard<std::mutex> lock(mutex_);
// 修改两个玩家的状态——需要锁保护
players_[targetId].hp -= players_[attackerId].damage;
}
private:
std::mutex mutex_;
std::map<int, Player> players_; // 共享状态
};问题:PlayerMove和PlayerAttack竞争同一把锁。玩家越多,锁竞争越严重。而且一旦忘记加锁,就是数据竞争未定义行为。
正例(通信共享内存):
// Actor风格:每个玩家是一个Actor,通过消息通信
struct MoveMessage {
int playerId;
Position newPosition;
};
struct AttackMessage {
int attackerId;
int targetId;
};
class PlayerActor {
public:
void OnMessage(const MoveMessage& msg) {
// 只修改自己的状态——不需要锁!
position_ = msg.newPosition;
}
void OnMessage(const AttackMessage& msg) {
if (msg.targetId == myId_) {
// 我被攻击了,修改自己的HP
hp_ -= CalculateDamage(msg.attackerId);
// 如果需要,发送消息给攻击者Actor
SendMessage(msg.attackerId, DamageResultMessage{...});
}
}
private:
Position position_; // 私有状态
int hp_; // 只有我能修改
int myId_;
};每个PlayerActor维护自己的状态,收到消息时在自己的"执行上下文"中处理。因为Actor是串行处理消息的,所以不需要锁。
5.3 Actor模型的调度
Actor本身不绑定线程。一个线程调度器(Scheduler)可以管理成千上万个Actor:
flowchart TB
subgraph 线程池
T1[工作线程1]
T2[工作线程2]
T3[工作线程3]
end
subgraph Actor邮箱队列
A1[Actor A
邮箱: [M1,M2]]
A2[Actor B
邮箱: [M3]]
A3[Actor C
邮箱: []]
A4[Actor D
邮箱: [M4,M5,M6]]
end
T1 -->|"取出Actor A
处理M1,M2"| A1
T2 -->|"取出Actor B
处理M3"| A2
T3 -->|"取出Actor D
处理M4,M5,M6"| A4调度器的工作流程:
- 扫描所有Actor的邮箱
- 找到有未处理消息的Actor
- 把Actor分配给空闲的工作线程
- 工作线程一次性处理该Actor邮箱中的所有消息(或处理到某个上限)
- 处理完毕,Actor放回队列,线程去取下一个Actor
关键点:一个Actor同时只会被一个线程处理。这意味着Actor内部不需要任何锁——它的状态在任意时刻只可能被一个线程访问。
六、游戏框架中的线程体系
6.1 ThreadObject / Thread / ThreadMgr 三层结构
在工程实践中,游戏框架通常把线程抽象为三个层次:
flowchart TB
subgraph ThreadMgr[线程管理器 ThreadMgr]
TM[管理所有Thread
生命周期+负载均衡]
end
subgraph Threads[线程集合]
T1[Thread: 网络线程]
T2[Thread: 逻辑线程]
T3[Thread: DB线程]
T4[Thread: 定时器线程]
end
subgraph ThreadObjects[线程对象]
O1[ThreadObject: 玩家连接处理器]
O2[ThreadObject: 地图逻辑]
O3[ThreadObject: 战斗计算器]
O4[ThreadObject: 聊天广播器]
end
TM --> T1
TM --> T2
TM --> T3
TM --> T4
T2 --> O1
T2 --> O2
T2 --> O3
T3 --> O4| 层次 | 职责 | 类比 |
|---|---|---|
| ThreadMgr | 管理所有线程的创建、销毁、监控 | 工厂厂长 |
| Thread | 实际的OS线程,运行主循环 | 生产线 |
| ThreadObject | 挂载在线程上的逻辑单元 | 生产线上的工位 |
// 简化的线程对象基类
class ThreadObject {
public:
virtual ~ThreadObject() = default;
// 初始化,在所属线程启动时调用
virtual bool Init() = 0;
// 每帧更新,在主循环中被调用
virtual void Update() = 0;
// 所属线程ID,用于校验消息路由
int GetThreadId() const { return threadId_; }
void SetThreadId(int id) { threadId_ = id; }
protected:
int threadId_ = -1;
};
// 线程基类
class Thread {
public:
bool Start();
void Stop();
// 注册线程对象
void RegisterObject(ThreadObject* obj);
// 线程主循环
void Run() {
for (auto* obj : objects_) {
obj->Init();
}
while (running_) {
for (auto* obj : objects_) {
obj->Update();
}
SleepToNextFrame();
}
}
private:
std::vector<ThreadObject*> objects_;
bool running_ = false;
std::thread thread_;
};
// 线程管理器
class ThreadMgr {
public:
static ThreadMgr& Instance();
Thread* CreateThread(const std::string& name);
void DestroyThread(int threadId);
// 按负载或哈希分配ThreadObject到Thread
void AssignObject(ThreadObject* obj, int preferredThread = -1);
// 跨线程发送消息
void SendMessage(int targetThreadId, Message* msg);
private:
std::map<int, Thread*> threads_;
int nextThreadId_ = 1;
};6.2 线程分类策略
在实际游戏中,线程通常按职责分类:
| 线程类型 | 典型职责 | 线程数 |
|---|---|---|
| 网络线程(Net Thread) | 监听端口、accept连接、收发TCP/UDP包 | 1~2(每监听的端口一个) |
| 逻辑线程(Logic Thread) | 执行游戏主循环、处理玩家消息 | 1~N(可按地图/副本分配) |
| 数据库线程(DB Thread) | 执行SQL查询、缓存读写 | 1~4(按数据库连接池大小) |
| 定时器线程(Timer Thread) | 管理全局定时器、触发超时事件 | 1 |
| 后台线程(Bg Thread) | 日志写入、文件IO、统计上报 | 1~2 |
坑点:新手常犯的错误是"每个玩家一个线程"。这在C++中不可行——线程有栈空间开销(通常几MB),1000个玩家就是几GB的虚拟内存浪费,而且OS调度几千个线程的性能极差。正确的做法是少量线程 + 大量Actor(或协程)。
七、Actor对象间的消息处理机制
7.1 消息定义原则
消息是Actor系统的"血液"。好的消息设计能让系统清晰,坏的消息设计能让系统变成 spaghetti。
// 消息基类:所有游戏消息都继承自它
struct Message {
int msgId; // 协议号/消息类型ID
int senderActor; // 发送者Actor ID
int targetActor; // 目标Actor ID(-1表示广播)
uint64_t timestamp; // 消息产生时间
virtual ~Message() = default;
};
// 具体消息类型示例
struct PlayerMoveMsg : public Message {
float x, y, z; // 目标坐标
uint32_t moveSeq; // 移动序列号(用于防重放/乱序)
};
struct PlayerAttackMsg : public Message {
int targetId; // 攻击目标
int skillId; // 使用的技能
uint32_t attackSeq; // 攻击序列号
};
struct DamageResultMsg : public Message {
int victimId; // 受伤者
int damage; // 伤害值
bool isCrit; // 是否暴击
};消息定义原则:
- 消息不可变:消息一旦创建,内容不应被修改。这避免了"发送者改了一半,接收者已经读了"的竞态问题。
- 消息可序列化:消息需要能被转化为字节流,用于网络传输或持久化。
- 消息自包含:消息应该携带处理所需的全部上下文,不要假设接收者能"去查别的地方"。
- 协议号集中管理:避免魔法数字。
// 协议号集中定义
namespace Protocol {
constexpr int PLAYER_MOVE = 1001;
constexpr int PLAYER_ATTACK = 1002;
constexpr int DAMAGE_RESULT = 1003;
constexpr int LOGIN_REQUEST = 2001;
constexpr int LOGIN_RESPONSE = 2002;
// ...
}7.2 消息队列机制
消息队列是Actor系统的核心数据结构。每个Actor(或每个线程)有自己的消息队列:
#include <queue>
#include <mutex>
#include <condition_variable>
// 线程安全的跨线程消息队列
template<typename T>
class ThreadSafeQueue {
public:
void Push(T item) {
{
std::lock_guard<std::mutex> lock(mutex_);
queue_.push(std::move(item));
}
cond_.notify_one();
}
bool Pop(T& item, int timeoutMs = 100) {
std::unique_lock<std::mutex> lock(mutex_);
if (!cond_.wait_for(lock, std::chrono::milliseconds(timeoutMs),
[this] { return !queue_.empty(); })) {
return false; // 超时
}
item = std::move(queue_.front());
queue_.pop();
return true;
}
size_t Size() const {
std::lock_guard<std::mutex> lock(mutex_);
return queue_.size();
}
private:
std::queue<T> queue_;
mutable std::mutex mutex_;
std::condition_variable cond_;
};在游戏框架中,消息队列的优化版本通常采用无锁队列(Lock-free Queue):
// 使用 moodycamel::ConcurrentQueue 等第三方库
#include "concurrentqueue.h"
moodycamel::ConcurrentQueue<Message*> g_netToLogicQueue;
moodycamel::ConcurrentQueue<Message*> g_logicToDbQueue;
// 生产者(网络线程)
void OnNetworkPacketReceived(Packet* pkt) {
Message* msg = ParsePacket(pkt);
g_netToLogicQueue.enqueue(msg); // 无锁入队
}
// 消费者(逻辑线程)
void LogicThread::Update() {
Message* msg;
while (g_netToLogicQueue.try_dequeue(msg)) {
ProcessMessage(msg);
}
// 处理完网络消息后,执行游戏逻辑
UpdateGameWorld();
}7.3 消息路由与分发
消息从网络层到达后,需要被路由到正确的Actor:
flowchart LR
A[网络层收到原始包] --> B[协议解析]
B --> C[消息路由层]
C --> D{目标类型?}
D -->|玩家消息| E[路由到玩家Actor]
D -->|地图消息| F[路由到地图Actor]
D -->|公会消息| G[路由到公会Actor]
D -->|广播消息| H[推送到所有相关Actor邮箱]class MessageRouter {
public:
void RouteMessage(Message* msg) {
switch (msg->msgId) {
case Protocol::PLAYER_MOVE:
case Protocol::PLAYER_ATTACK:
case Protocol::USE_ITEM:
// 玩家相关消息 → 路由到对应玩家Actor
RouteToPlayerActor(msg->targetActor, msg);
break;
case Protocol::CHAT_WORLD:
case Protocol::CHAT_GUILD:
// 聊天消息 → 路由到聊天服务Actor
RouteToChatService(msg);
break;
case Protocol::GUILD_CREATE:
case Protocol::GUILD_JOIN:
// 公会消息 → 路由到公会管理Actor
RouteToGuildManager(msg);
break;
default:
LOG_ERROR("未知消息类型: %d", msg->msgId);
delete msg;
}
}
private:
void RouteToPlayerActor(int playerId, Message* msg) {
Actor* actor = ActorMgr::Instance().GetPlayerActor(playerId);
if (actor) {
actor->PushMessage(msg); // 放入Actor的邮箱
} else {
LOG_WARN("玩家Actor不存在: %d", playerId);
delete msg;
}
}
// ...
};7.4 请求-响应模式与异步回调
很多消息处理需要"请求-响应"语义。比如玩家请求"打开商店",服务端需要查询数据库返回商品列表。
// Actor内的消息处理示例
class PlayerActor : public Actor {
public:
void OnMessage(const OpenShopMsg& msg) {
// 步骤1:向DB Actor发送查询请求
QueryShopItemsReq* req = new QueryShopItemsReq();
req->shopId = msg.shopId;
req->callbackActor = myActorId_; // 让DB Actor处理完把结果发回给我
SendMessage(DB_ACTOR_ID, req);
// 注意:这里不阻塞等待!玩家Actor继续处理其他消息
}
void OnMessage(const QueryShopItemsRsp& rsp) {
// 步骤2:收到DB Actor的响应
// 现在可以把商品列表发送给客户端了
ShopItemListMsg* out = new ShopItemListMsg();
out->items = rsp.items;
SendToClient(out);
}
};这种"异步请求-回调"模式是Actor编程的核心。它要求程序员从"同步思维"转向"异步思维"——习惯"发送请求,等待回调"而不是"调用函数,立即返回结果"。
坑点预警:
- 回调地狱:多层嵌套异步调用会让代码难以阅读。解决方案是使用"状态机"或"协程(C++20 coroutine)"。
- Actor生命周期:发送请求后,如果Actor在响应到达前被销毁了,怎么办?必须设计"弱引用"或"取消机制"。
- 消息顺序:消息是按发送顺序入队的,但如果跨Actor路由,由于调度原因,到达顺序可能和发送顺序不同。需要序列号或逻辑时钟来保证顺序。
八、Actor模型在游戏框架中的落地实践
8.1 一个简化但完整的Actor框架示例
#include <functional>
#include <map>
#include <memory>
#include <queue>
#include <thread>
#include <vector>
// 前向声明
class Actor;
class ActorSystem;
// 消息基类
struct IMessage {
int targetActorId;
virtual ~IMessage() = default;
};
// Actor基类
class Actor {
public:
explicit Actor(int id) : actorId_(id) {}
virtual ~Actor() = default;
int GetId() const { return actorId_; }
void PushMessage(std::unique_ptr<IMessage> msg) {
mailbox_.push(std::move(msg));
}
void ProcessAllMessages() {
while (!mailbox_.empty()) {
auto msg = std::move(mailbox_.front());
mailbox_.pop();
OnMessage(msg.get());
}
}
virtual void Update() {
ProcessAllMessages();
}
// 子类重写此函数处理具体消息
virtual void OnMessage(IMessage* msg) = 0;
// 发送消息到另一个Actor
void Send(int targetId, std::unique_ptr<IMessage> msg);
protected:
int actorId_;
std::queue<std::unique_ptr<IMessage>> mailbox_;
ActorSystem* system_ = nullptr;
friend class ActorSystem;
};
// Actor系统:管理所有Actor和线程调度
class ActorSystem {
public:
void RegisterActor(std::unique_ptr<Actor> actor) {
actor->system_ = this;
actors_[actor->GetId()] = std::move(actor);
}
void SendMessage(int targetId, std::unique_ptr<IMessage> msg) {
// 实际工程中,这里会根据targetId路由到正确的线程队列
auto it = actors_.find(targetId);
if (it != actors_.end()) {
it->second->PushMessage(std::move(msg));
}
}
void Run(int framesPerSecond) {
const auto frameDuration = std::chrono::milliseconds(1000 / framesPerSecond);
while (running_) {
auto frameStart = std::chrono::steady_clock::now();
// 处理所有Actor的消息
for (auto& [id, actor] : actors_) {
actor->Update();
}
// 休眠到下一帧
auto elapsed = std::chrono::steady_clock::now() - frameStart;
if (elapsed < frameDuration) {
std::this_thread::sleep_for(frameDuration - elapsed);
}
}
}
void Stop() { running_ = false; }
private:
std::map<int, std::unique_ptr<Actor>> actors_;
bool running_ = true;
};
void Actor::Send(int targetId, std::unique_ptr<IMessage> msg) {
msg->targetActorId = targetId;
if (system_) {
system_->SendMessage(targetId, std::move(msg));
}
}8.2 实战建议:何时用Actor,何时不用
Actor模型不是银弹。以下场景适合Actor,以下场景不适合:
适合Actor:
- 玩家对象(每个玩家一个Actor,天然隔离)
- 战斗副本(每个副本一个Actor,生命周期跟随副本)
- 公会/战队(每个公会一个Actor,管理成员和仓库)
- 定时任务(每个定时任务一个Actor,到期自动触发)
不适合Actor(或需要额外设计):
- 全局排行榜(所有玩家竞争同一个排名结构,需要仔细设计"排行榜Actor"的更新策略)
- 世界Boss(所有玩家攻击同一个目标,需要"Boss Actor"高效处理海量伤害消息)
- 跨服聊天(消息需要广播到多个服务器的多个Actor,路由复杂)
九、本章小结
flowchart TB
subgraph 架构演进
A1[无服务端] --> A2[单进程CS]
A2 --> A3[多进程CS]
A3 --> A4[Actor分布式]
end
subgraph 核心概念
B1[进程=独立国家]
B2[线程=共享工厂的工人]
B3[Actor=邮件沟通的办公室]
end
subgraph 关键原则
C1["不要共享内存通信
要通过通信共享内存"]
C2[固定帧率主循环]
C3[消息不可变+自包含]
end
A4 --> C1
B3 --> C1游戏架构从简单到复杂:点对点 → 单进程CS → 多进程CS → 分布式Actor。每一步都是为了解决前一阶段的瓶颈。
进程是隔离单元,线程是执行单元:进程之间不共享内存,线程之间共享内存但需要锁。锁竞争是多线程bug的主要来源。
Actor模型用消息传递替代共享内存:每个Actor有私有状态和邮箱,通过发送消息通信。因为Actor串行处理消息,所以不需要锁。
主循环是服务端的心脏:固定帧率、阶段分明、不阻塞——这是主循环设计的铁律。
消息是Actor系统的血液:设计好消息的结构、路由、队列机制,等于设计好了系统的血管系统。
最后一句工程忠告:
Actor模型能帮你避免90%的锁竞争问题,但剩下的10%——比如两个Actor同时请求修改同一个全局排行榜——仍然需要 careful 的设计。没有银弹,只有 trade-off。
下一章预告:账号登录与验证——第一道安全大门。我们将从网络包到达Gateway的那一刻开始,追踪一次完整的登录流程,看看PHP验证接口、MySQL数据库、libcurl和libjsoncpp是如何协作的,以及为什么"登录"是游戏最容易被攻击的环节。