《游戏服务端编程实践》2.2.1 Actor 模型详解(Akka / Erlang)
一、引言:为什么我们需要 Actor 模型?
在上一节我们学习了多线程与协程的差异。 线程和协程虽然解决了“并发执行”的问题,但它们都无法直接解决另一个根本性问题:
状态共享与并发安全。
在传统多线程程序中,多个线程共享同一块内存:
- 同时修改变量;
- 争夺锁;
- 产生死锁、竞态条件(race condition)。
协程虽然降低了上下文开销,但如果仍然共享状态,本质问题仍在。
于是,计算机科学家 Carl Hewitt 在 1973 年提出了一个革命性的模型:
Actor Model:一切都是 Actor,Actor 之间只通过消息通信,不共享状态。
二、Actor 模型的核心理念
“No shared state, only message passing.” —— Erlang Design Principle
Actor 模型中:
-
每个 Actor 都是一个独立的实体(类似微服务或小型进程);
-
它拥有:
- 自己的状态(State);
- 自己的行为(Behavior);
- 自己的消息邮箱(Mailbox)。
Actors 之间:
- 不直接访问对方的内存;
- 通过 异步消息传递(Asynchronous Message Passing) 通信;
- 每个消息都是不可变对象(Immutable Message);
- 每个 Actor 在单线程上下文中处理自己的消息,天然线程安全。
三、Actor 模型的组成要素
| 组件 | 职责 | 类比(现实) |
|---|---|---|
| Actor | 独立的执行单元,持有状态 | “一个人” |
| Message | Actor 间通信的数据载体 | “一封信” |
| Mailbox | 消息队列,按序存储待处理消息 | “信箱” |
| Dispatcher / Scheduler | 调度器,决定 Actor 何时执行 | “邮递员 + 日程安排” |
| Supervisor | 监督者,负责错误恢复 | “主管” |
示意图
flowchart LR
A1[Actor A] -->|Message| B1[Actor B]
B1 -->|Response| A1
A1 -->|Message| C1[Actor C]
四、Actor 的运行模型
每个 Actor 是“单线程 + 消息队列”的组合:
graph TD
A[Mailbox] -->|next message| B[Actor Behavior]
B -->|process| C[State Update]
C --> A
运行规则
- 每次只处理一个消息;
- 处理完一个消息后再取下一个;
- 不同 Actor 可并发执行;
- 同一 Actor 内部逻辑是串行的;
- Actor 之间只通过异步消息通信。
五、Actor 的三种操作
每个 Actor 接收到消息后,可以做三件事:
- 发送消息 给其他 Actor;
- 创建新的 Actor;
- 改变自己的行为(行为替换)。
这三个操作构成了 Actor 模型的最小闭环。
六、Actor 模型的数学基础(简述)
Actor 模型不是工程概念,而是一种并发计算理论,与 Lambda Calculus(λ演算)同级。 它将“并发”抽象为:
- Actor = 函数 + 状态 + 信箱 + 调度器
数学表达式:
Actor(a) = <Behavior, State, Mailbox>
Behavior: Message × State → (NewState, NewMessages, NewActors)
因此:
- 每个 Actor 是一个纯函数;
- 每次消息处理都是幂等的;
- 无全局状态;
- 可推理、可分布。
七、Akka:JVM 世界的 Actor 实现
Akka 是最成熟的 Actor 框架之一(Scala/Java 皆可用)。 其架构源自 Erlang OTP,但运行在 JVM 上。
Akka 核心组件结构
graph TD
A[ActorSystem]
A --> B[Dispatcher]
B --> C[Mailbox]
C --> D[Actor]
D --> E[Behavior/Receive]
| 组件 | 说明 |
|---|---|
| ActorSystem | 管理所有 Actor 的上下文 |
| Dispatcher | 调度器(基于 ForkJoinPool) |
| Mailbox | 队列结构(FIFO) |
| ActorRef | Actor 引用(地址) |
| Props | Actor 创建参数 |
| SupervisorStrategy | 监督策略 |
八、Akka Actor 示例(Java)
public class HelloActor extends AbstractActor {
@Override
public Receive createReceive() {
return receiveBuilder()
.match(String.class, msg -> {
System.out.println("Received: " + msg);
getSender().tell("Hello, " + msg, getSelf());
})
.build();
}
public static void main(String[] args) {
ActorSystem system = ActorSystem.create("demo");
ActorRef actor = system.actorOf(Props.create(HelloActor.class), "hello");
actor.tell("World", ActorRef.noSender());
}
}
输出:
Received: World
要点:
- 每个 Actor 独立;
- 消息是不可变的;
- 通信通过
tell(); - ActorRef 是 Actor 的唯一通信句柄。
九、消息传递机制(Akka)
9.1 发送消息
actor.tell(message, sender);
非阻塞调用,将消息放入目标 Actor 的 Mailbox。
9.2 接收消息
- Akka 线程池从 Mailbox 拉取消息;
- 调用
Actor.receive(); - 处理完毕自动取下一个。
9.3 线程安全保证
- 每个 Actor 同一时间只由一个线程执行;
- 状态封装内部,不需锁;
- Actor 间完全独立。
十、Erlang:原生分布式 Actor 模型
Erlang 由 Ericsson 在 1980s 为电信系统开发, 其核心语言特性就是 Actor 模型。
Erlang 的 Actor 称为“进程(process)”, 但不是 OS 进程,而是虚拟机内轻量级进程。
示例:Erlang Actor
loop(State) ->
receive
{msg, From, Data} ->
NewState = handle(Data, State),
From ! {ok, NewState},
loop(NewState)
end.
receive是消息接收;!是异步发送;- 每个进程独立运行;
- 系统可拥有上百万个进程。
Erlang 的核心特性
| 特性 | 说明 |
|---|---|
| 轻量进程 | 可百万级并发 |
| 分布式原生支持 | 可跨节点消息通信 |
| 容错模型 | 进程崩溃不影响系统 |
| 监督树(Supervisor Tree) | 自动重启失败进程 |
| 热更新 | 可无停机升级代码 |
这套机制让电信系统达到了“99.9999999%(九个 9)”的可靠性。
十一、Erlang OTP 监督树模型
graph TD
A[Supervisor]
A --> B[Worker 1]
A --> C[Worker 2]
A --> D[Worker 3]
Supervisor 策略:
- One-for-One:仅重启出错进程;
- One-for-All:组内全部重启;
- Rest-for-One:后续进程重启。
这就是**自愈系统(Self-healing System)**的原型。
十二、Akka 的容错与监督策略
OneForOneStrategy strategy =
new OneForOneStrategy(
10, Duration.create("1 minute"),
DeciderBuilder.match(Exception.class, e -> SupervisorStrategy.restart())
);
-
Actor 崩溃 → 父 Actor 处理;
-
父 Actor 根据策略决定:
- 重启;
- 停止;
- 忽略。
“Let it crash” 是 Erlang/Akka 的哲学核心: 不要试图捕捉所有异常,让系统自己恢复。
十三、消息传递的特性
| 特征 | 说明 |
|---|---|
| 异步 | 发送即返回 |
| 有序 | 每个发送者对同一接收者的消息保持顺序 |
| 不可变 | 防止并发修改 |
| 不丢失(可靠传递) | 由 Mailbox 和调度器保证 |
| 无共享内存 | 天然线程安全 |
十四、Actor 模型与传统并发模型对比
| 对比项 | 共享内存模型 | Actor 模型 |
|---|---|---|
| 通信方式 | 读写共享变量 | 异步消息 |
| 同步机制 | 锁/条件变量 | 队列/异步处理 |
| 线程安全 | 需显式加锁 | 天然安全 |
| 可扩展性 | 复杂 | 高 |
| 容错性 | 异常传递困难 | 层级监督 |
| 状态一致性 | 难保证 | 独立封装 |
十五、Akka Actor 的生命周期
| 阶段 | 回调方法 |
|---|---|
| 创建 | preStart() |
| 运行中 | receive() |
| 重启前 | preRestart() |
| 重启后 | postRestart() |
| 停止 | postStop() |
十六、Actor 模型在游戏服务器中的实践
| 模块 | Actor 粒度 | 优点 |
|---|---|---|
| 玩家(Player) | 每个玩家一个 Actor | 状态隔离 |
| 房间(Room) | 每个战斗房间一个 Actor | 并发逻辑独立 |
| 世界(World) | 逻辑层 Actor | 跨模块协调 |
| 公会(Guild) | 分布式 Actor | 独立存储与逻辑 |
| 聊天 | Actor 池 + 广播 | 消息有序、安全 |
这正是 Skynet、Akka-Cluster、Unity Photon Server 的底层理念。
十七、Actor 调度策略
Akka 提供多种 Dispatcher:
| Dispatcher 类型 | 特点 | 应用场景 |
|---|---|---|
| Default | ForkJoinPool | CPU 密集型任务 |
| Pinned | 每 Actor 一线程 | 独立逻辑 |
| Balancing | 多 Actor 共享池 | 动态负载 |
| CallingThread | 当前线程执行 | 测试与同步执行 |
十八、Akka Cluster:分布式 Actor 系统
Akka Cluster 允许 Actor 跨节点通信。
graph LR
A[Node 1] -->|Message| B[Node 2]
B -->|Reply| A
- Actor 有全局地址(ActorPath);
- 消息透明传输;
- 支持节点自动加入与离开;
- 具备一致性与容错。
这意味着:你可以把一个游戏的“公会系统”放在一台机器上,“战斗系统”放在另一台上,Actor 间通信不需任何 RPC 框架。
十九、Actor 模型的性能特征
| 指标 | Akka | Erlang |
|---|---|---|
| 单节点 Actor 数 | ~10⁶ | ~10⁷ |
| 消息延迟 | 微秒级 | 微秒级 |
| 消息吞吐 | 10⁶/s | 10⁶/s |
| 崩溃隔离 | Supervisor | Supervisor Tree |
| 分布式 | Cluster | Node RPC 原生 |
| 热更新 | 需插件 | 原生支持 |
二十、与协程模型的结合
在现代游戏服务器中,常见混合架构:
| 层级 | 模型 | 示例 |
|---|---|---|
| 网络层 | Reactor(事件驱动) | Netty / epoll |
| 逻辑层 | Actor(消息驱动) | Akka / Skynet |
| 协程层 | Coroutine(执行驱动) | Go / Lua |
| 调度层 | Scheduler(负载平衡) | Akka Dispatcher |
这种结构结合三种并发哲学:
- Reactor 负责 I/O;
- Actor 负责 状态与逻辑隔离;
- 协程负责 代码表达与执行效率。
二十一、Actor 模型的工程价值总结
| 维度 | 传统模型 | Actor 模型优势 |
|---|---|---|
| 状态安全 | 需显式锁 | 封装内安全 |
| 扩展性 | 难以水平扩展 | 自然分布式 |
| 容错性 | 依赖外部监控 | 内建 Supervisor |
| 开发复杂度 | 锁与共享状态难控 | 简化为消息流 |
| 代码表达 | 过程式 | 事件式 |
| 可重放性 | 低 | 可回放消息日志 |
| 分布式部署 | 需 RPC/一致性协议 | 内建消息透明化 |
二十二、应用案例
- Erlang/WhatsApp: 单机 2 百万并发连接,消息延迟 < 100ms。
- Akka/Lightbend: 金融风控与游戏后端框架(Play、Lagom)。
- Skynet(Lua): 每个服务即 Actor,协程实现消息驱动。
- Unity Photon / SmartFox: 游戏房间即 Actor,隔离与可扩展性强。
二十三、示例:用 Akka 实现房间服务器
public class RoomActor extends AbstractActor {
private final Map<String, Integer> players = new HashMap<>();
@Override
public Receive createReceive() {
return receiveBuilder()
.match(Join.class, msg -> {
players.put(msg.name, 100);
broadcast(msg.name + " joined.");
})
.match(Attack.class, msg -> {
players.put(msg.target, players.get(msg.target) - msg.damage);
broadcast(msg.from + " attacks " + msg.target);
})
.build();
}
private void broadcast(String text) {
getContext().getSystem().actorSelection("/user/players/*")
.tell(new Message(text), getSelf());
}
}
特点:
- 每个房间独立 Actor;
- 消息异步传递;
- 无需锁;
- 崩溃可被 Supervisor 自动恢复。
二十四、Actor 模型的设计哲学(总结)
传统并发:共享内存 + 加锁 Actor 并发:共享消息 + 独立状态
Actor 模型通过以下四个设计哲学重新定义了“并发”:
- 隔离(Isolation) 每个 Actor 有自己独立的状态;
- 异步(Asynchrony) 通信永远通过异步消息;
- 封装(Encapsulation) 外部无法访问内部状态;
- 监督(Supervision) 崩溃不是错误,是恢复契机。
二十五、思考与实践
- 为什么 Actor 模型能天然避免锁?
- 如果一个 Actor 处理消息过慢,会发生什么?如何优化?
- 在游戏中,是否应让“玩家”还是“房间”成为 Actor?
- 如何实现跨服务器 Actor 调度?
- 对比 Erlang 与 Akka,哪个更适合构建分布式战斗系统?
二十六、小结
| 核心点 | 说明 |
|---|---|
| 概念 | 并发系统由一组互相发送消息的独立 Actor 组成 |
| 通信 | 异步、无锁、不可变 |
| 状态 | 内部封装、线程安全 |
| 调度 | 每个 Actor 独立运行 |
| 容错 | “Let it crash” 哲学,自愈式监督树 |
| 实现 | Erlang 原生、Akka JVM 实现、Skynet Lua 实践 |
| 应用 | 游戏房间、公会、聊天、任务、分布式逻辑 |
一句话总结:
Actor 模型让我们从“线程思维”进入“消息思维”。 线程让 CPU 忙碌,Actor 让系统有序。
当你用 Actor 构建游戏世界时,每个玩家、房间、公会都像一个小宇宙, 它们彼此独立,却又通过消息联系在一起 —— 这,就是可扩展并发系统的艺术。