多人在线游戏架构实战第3章:线程、进程与Actor模型——并发的正确打开方式

📑 目录

第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。高峰期登录洪水来临时,网络线程抢锁插入玩家,主线程被迫等待,整个游戏逻辑停顿——表现为"全服卡顿"。

解决方案?

  1. 锁粒度细化:不按"整张表"加锁,而是按"玩家"加锁
  2. 读写分离:登录请求先放入"待处理队列",主线程在帧头统一处理
  3. 无锁数据结构:使用concurrentqueue等第三方无锁队列
  4. 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有三个基本属性:

  1. 状态:私有的,不共享,只有Actor自己能修改
  2. 行为:收到消息时执行的逻辑
  3. 邮箱:消息队列,新消息放入邮箱等待处理

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_;  // 共享状态
};

问题:PlayerMovePlayerAttack竞争同一把锁。玩家越多,锁竞争越严重。而且一旦忘记加锁,就是数据竞争未定义行为。

正例(通信共享内存)

// 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

调度器的工作流程:

  1. 扫描所有Actor的邮箱
  2. 找到有未处理消息的Actor
  3. 把Actor分配给空闲的工作线程
  4. 工作线程一次性处理该Actor邮箱中的所有消息(或处理到某个上限)
  5. 处理完毕,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;        // 是否暴击
};

消息定义原则

  1. 消息不可变:消息一旦创建,内容不应被修改。这避免了"发送者改了一半,接收者已经读了"的竞态问题。
  2. 消息可序列化:消息需要能被转化为字节流,用于网络传输或持久化。
  3. 消息自包含:消息应该携带处理所需的全部上下文,不要假设接收者能"去查别的地方"。
  4. 协议号集中管理:避免魔法数字。
// 协议号集中定义
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
  1. 游戏架构从简单到复杂:点对点 → 单进程CS → 多进程CS → 分布式Actor。每一步都是为了解决前一阶段的瓶颈。

  2. 进程是隔离单元,线程是执行单元:进程之间不共享内存,线程之间共享内存但需要锁。锁竞争是多线程bug的主要来源。

  3. Actor模型用消息传递替代共享内存:每个Actor有私有状态和邮箱,通过发送消息通信。因为Actor串行处理消息,所以不需要锁。

  4. 主循环是服务端的心脏:固定帧率、阶段分明、不阻塞——这是主循环设计的铁律。

  5. 消息是Actor系统的血液:设计好消息的结构、路由、队列机制,等于设计好了系统的血管系统。

最后一句工程忠告

Actor模型能帮你避免90%的锁竞争问题,但剩下的10%——比如两个Actor同时请求修改同一个全局排行榜——仍然需要 careful 的设计。没有银弹,只有 trade-off。


下一章预告:账号登录与验证——第一道安全大门。我们将从网络包到达Gateway的那一刻开始,追踪一次完整的登录流程,看看PHP验证接口、MySQL数据库、libcurl和libjsoncpp是如何协作的,以及为什么"登录"是游戏最容易被攻击的环节。