并发模型:7种常见并发思路与适用场景
游戏服务器的并发模型决定了系统能承受多少连接、如何处理定时逻辑、以及开发复杂度。选择错误的并发模型会导致系统在高并发下崩溃,或者开发复杂度失控。
参考书籍:本章并发模型对比参考《百万在线》中的服务器架构选型实践,以及《游戏服务器架构与优化》中的并发模型章节。
1. 为什么并发模型如此重要
游戏服务器与 Web 服务器有一个本质区别:游戏服务器需要维护长时间的有状态连接。Web 请求是"无状态"的——每个请求独立处理,处理完就释放资源。但游戏连接是"有状态"的——一个玩家可能在线几小时甚至几天,期间服务端需要持续维护他的位置、背包、任务进度等状态。
这意味着:并发模型不仅要能处理高连接数,还要能高效管理大量长时间存在的状态。一个能处理 10 万连接但每个连接占用 1MB 内存的模型,需要 100GB 内存——这在实际中是不可接受的。
选择并发模型时需要权衡四个维度:连接容量(能处理多少连接)、开发复杂度(代码有多难写和维护)、延迟特性(每个操作的延迟是多少)、资源效率(每个连接占用多少内存和 CPU)。
2. 七种并发模型详解
2.1 单线程事件循环(Single-Thread Event Loop)
核心思路:一个线程跑一个无限循环,每次循环处理一批就绪的 I/O 事件和定时器,处理期间不阻塞。
典型代表:Node.js、Redis、Nginx worker
┌─────────────────────────────┐
│ Event Loop │
│ ┌─────┐ ┌──────┐ ┌────┐ │
│ │ poll │→│ handle│→│ next│ │──→ 循环
│ └─────┘ └──────┘ └────┘ │
└─────────────────────────────┘为什么它适合轻量级场景:单线程事件循环没有任何锁、没有竞态条件、代码逻辑非常清晰。对于小游戏服务器(如微信小游戏的后端)、网关层、轻量逻辑服务来说,它是常见选择。
为什么它不适合 MMO:单线程意味着所有连接的处理都在一个线程里。如果一个连接的处理耗时 10ms,其他 999 个连接都要等 10ms。在高并发下,延迟会急剧恶化。
适用场景:小游戏服务器、网关层、轻量逻辑服务、配置管理服务。
坑:Node.js 的事件循环中不能有同步阻塞操作(如 fs.readFileSync),否则会卡住整个循环。Go 中可以用 goroutine 模拟事件循环,但要注意 channel 的阻塞行为。
2.2 线程池(Thread Pool)
核心思路:预先创建一组工作线程,任务从共享队列中取出执行。I/O 密集时配合非阻塞 I/O。
典型代表:Java ExecutorService、C++ 自定义实现
为什么它适合计算密集型任务:线程池可以利用多核 CPU 并行处理任务。对于战斗数值计算、AI 寻路、地图 AOI 计算等 CPU 密集型任务,线程池是常见选择。
为什么它不适合高并发网络 I/O:每个线程占用约 1MB 栈空间,1000 个线程就要 1GB 内存。而且线程切换有开销(约 1-10 微秒),在高并发下会成为瓶颈。
适用场景:战斗数值计算、AI 寻路、地图 AOI 计算、日志写入。
坑:线程池中的任务如果访问共享数据,需要加锁。锁的竞争会导致性能下降——这是线程池最大的痛点。
2.3 Reactor 模型
核心思路:Reactor 监听 I/O 事件,分发给对应的 Handler 处理。Handler 在同一个线程中同步执行。
典型代表:Netty (Java)、libevent、libuv、Boost.Asio
┌──────────────────────────────────┐
│ Reactor │
│ ┌──────────┐ ┌────────────┐ │
│ │ demultiplex │→│ dispatch │ │
│ │ (epoll) │ │ │ │
│ └──────────┘ └────┬───────┘ │
│ ↓ │
│ ┌────────┐ ┌────────┐ ┌────────┐│
│ │Handler1│ │Handler2│ │Handler3││
│ └────────┘ └────────┘ └────────┘│
└──────────────────────────────────┘为什么它是网络代理层的首选:Reactor 模型用单线程处理网络 I/O,避免了线程切换和锁的开销。对于网关/代理层这种"转发消息、不做复杂计算"的场景,Reactor 是常见选择。
为什么它不适合做游戏逻辑:Handler 在 Reactor 线程中同步执行,如果一个 Handler 耗时较长,会阻塞后续所有事件的处理。游戏逻辑通常涉及数据库操作、复杂计算,不适合放在 Reactor 线程中。
适用场景:网关/代理层、高连接数低延迟服务、TCP 连接管理。
2.4 Proactor 模型
核心思路:发起异步 I/O 操作后,由操作系统在完成后通知应用(完成回调)。与 Reactor 的区别:Reactor 通知"就绪可读",Proactor 通知"读完了"。
典型代表:Windows IOCP、Linux io_uring、Boost.Asio (Proactor 模式)
为什么它比 Reactor 更高效:Reactor 模型中,Handler 需要自己调用 read() 来读取数据。如果数据还没到,read() 会阻塞(或者需要非阻塞 I/O + 重试)。Proactor 模型中,操作系统已经把数据读好了,Handler 直接处理数据即可——没有等待、没有重试。
为什么它更难实现:Proactor 依赖操作系统的异步 I/O 支持。Linux 的 io_uring 是一个很好的实现,但 API 复杂、调试困难。回调地狱(callback hell)也是 Proactor 模型的痛点。
适用场景:需要极致 I/O 吞吐的后端、跨服通信、大规模日志写入。
2.5 Actor 模型
核心思路:每个 Actor 是独立计算单元,拥有自己的状态和邮箱。Actor 之间只通过消息通信,不共享状态。天然隔离,无锁。
典型代表:Erlang/OTP、Akka (Java/Scala)、Microsoft Orleans
为什么它是 MMO 的理想选择:MMO 中每个玩家都是一个独立实体,有自己的状态(位置、背包、任务进度)。用 Actor 模型,每个玩家就是一个 Actor,所有操作都通过消息传递,天然避免了竞态条件。
为什么它不适合所有场景:Actor 之间通过消息通信,消息需要序列化/反序列化,这有 CPU 开销。而且 Actor 模型的调试比较困难——你需要追踪消息的流转路径,而不是像单线程那样直接看调用栈。
适用场景:MMO 实体管理、分布式游戏服务器(Erlang 风格)、聊天系统。
2.6 协程(Coroutine)
核心思路:用户态的轻量级线程,可被调度器在多个协程间切换。切换在用户态完成,无需内核介入,开销极低。
典型代表:Go goroutine、Kotlin 协程、Lua 协程、C++20 Coroutines、Erlang 进程
为什么 Go goroutine 是游戏服务器的首选:一个 goroutine 只占用约 2KB 内存,而一个 OS 线程占用约 1MB。这意味着你可以用同样的内存创建 500 倍数量的并发单元。而且 goroutine 的创建和切换开销极低(约 100 纳秒),远低于 OS 线程(约 1-10 微秒)。
为什么它不是银弹:大量 goroutine 会导致 GC 压力增大(需要扫描更多内存)。如果 goroutine 之间需要共享数据,仍然需要使用 sync.Mutex 等同步原语——协程只是简化了并发编程,没有消除并发问题。
适用场景:几乎所有 Go 游戏服务器都用 goroutine,每个连接一个协程、每个定时器一个协程。
2.7 CSP(Communicating Sequential Processes)
核心思路:进程之间不共享内存,只通过 Channel(带缓冲或无缓冲)通信。"Do not communicate by sharing memory; instead, share memory by communicating."
典型代表:Go channel、Occam、Ada
为什么它与 Go 天然契合:Go 语言的 channel 就是 CSP 模型的实现。在 Go 游戏服务器中,你可以把不同的系统(位置服务、战斗服务、聊天服务)设计为独立的 goroutine,通过 channel 通信。每个系统有自己的状态,不需要加锁。
为什么它比 Actor 模型更适合 Go:Actor 模型中,消息发送给特定的 Actor(需要知道 Actor 的地址)。CSP 模型中,消息发送给 Channel(发送者不知道谁接收)。这种解耦让系统更容易扩展——你可以随时添加新的消费者到同一个 channel。
适用场景:Go 游戏服务器架构首选,多系统解耦(位置服务、战斗服务、聊天服务各自独立进程)。
3. 七种模型对比
| 模型 | 并发单元 | 共享状态 | 通信方式 | 延迟 | 吞吐 | 开发难度 | 游戏适用 |
|---|---|---|---|---|---|---|---|
| 单线程事件循环 | 线程 | 无竞态 | 回调 | 极低 | 中 | ⭐⭐ | 小游戏/网关 |
| 线程池 | 线程 | 需加锁 | 共享队列 | 中 | 高 | ⭐⭐⭐ | 战斗/AI 计算 |
| Reactor | 线程 | 无竞态 | 回调 | 低 | 高 | ⭐⭐⭐ | 网络代理层 |
| Proactor | 线程 | 无竞态 | 完成回调 | 极低 | 极高 | ⭐⭐⭐⭐ | I/O 密集后端 |
| Actor | Actor | 无共享 | 消息邮箱 | 中 | 高 | ⭐⭐ | MMO/分布式 |
| 协程 | goroutine | 需同步 | channel | 极低 | 极高 | ⭐ | Go 首选 |
| CSP | 进程 | 无共享 | channel | 低 | 高 | ⭐⭐ | 多系统解耦 |
4. 实际选型建议
4.1 按语言选型
┌─────────────────────────────────────────────────────┐
│ 选型决策树 │
├─────────────────────────────────────────────────────┤
│ 语言是 Go? │
│ └─ YES → goroutine + CSP(Go 天然支持) │
│ 语言是 Java? │
│ ├─ 高并发网络 → Reactor (Netty) │
│ └─ 分布式实体 → Actor (Akka/Orleans) │
│ 语言是 C++? │
│ └─ Reactor/Proactor (Boost.Asio / io_uring) │
│ 语言是 Lua? │
│ └─ 线程池 + Lua 协程(Skynet 模式) │
│ 小型项目 / 快速原型? │
│ └─ 单线程事件循环 (Node.js) │
└─────────────────────────────────────────────────────┘4.2 按游戏类型选型
| 游戏类型 | 推荐模型 | 原因 |
|---|---|---|
| 微信小游戏 | 单线程事件循环 | 连接数少、逻辑简单 |
| 卡牌/挂机 | 单线程事件循环 + 协程 | 无实时同步需求 |
| MMO | Actor / CSP | 需要管理大量有状态实体 |
| FPS/MOBA | Reactor + 协程 | 需要低延迟网络 I/O |
| 塔防 | 协程 | 需要定时逻辑,但不需要高并发 |
| 链游 | CSP | 多系统解耦,链上/链下分离 |
4.3 混合模型:现实中的常见做法
大多数成熟的游戏服务器不会只用一种模型,而是混合使用:
- 网关层:Reactor 模型(处理网络 I/O)
- 游戏逻辑层:CSP/Actor 模型(处理业务逻辑)
- 计算层:线程池(处理 AI、寻路等计算密集任务)
- 定时器层:协程(处理心跳、buff 倒计时等定时逻辑)
这种分层架构让每层使用最适合的模型,既保证了性能,又控制了复杂度。
5. 常见陷阱与避坑指南
5.1 不要过早优化并发模型
很多团队在项目初期就花大量时间设计"完美的并发架构",结果发现实际需求根本用不上那么多并发能力。先用最简单的模型跑起来,等遇到性能瓶颈再优化——在很多项目中,这算是比较务实的策略。
5.2 不要忽视 GC 压力
Go 的 goroutine 虽然轻量,但大量 goroutine 会导致 GC 扫描的内存增多。在 Go 中,每个 goroutine 的栈空间会动态增长,但不会自动收缩。如果创建了 10 万个 goroutine,GC 需要扫描的内存可能达到几 GB。
解决方案:使用对象池复用 goroutine、限制同时存活的 goroutine 数量、定期触发 GC。
5.3 不要混淆"并发"和"并行"
并发是"同时处理多个任务"(可能在同一个线程上交替执行),并行是"同时执行多个任务"(需要多个线程)。单线程事件循环是并发但不并行;线程池是并发且并行。理解这个区别有助于你选择合理的模型。
6. 小结
| 关键问题 | 答案 |
|---|---|
| 为什么需要并发? | 游戏服务器要同时处理数百到数万玩家连接和逻辑更新 |
| Go 为什么适合游戏服务器? | goroutine 极轻量(~2KB),channel 天然支持 CSP,一个连接一个 goroutine 成本极低 |
| Actor 和 CSP 有什么区别? | Actor 发消息给特定 Actor,CSP 通过 Channel 通信(发送者不知道谁接收) |
| Reactor 和 Proactor 区别? | Reactor 通知"可读可写",Proactor 通知"操作已完成",Proactor 更高效但更复杂 |
| Skynet 用什么模型? | 单线程 Actor(Lua 协程服务)+ 线程池 |