05 并发模型:七种常见并发思路与适用场景
本章目标:帮助读者理解游戏服务器并发模型的第一性决策——状态放在哪、谁有权写它、并发发生在哪一层。读完本章,你应该能根据游戏类型、团队技术栈和项目阶段,独立做出并发模型选型,而不是照搬别人的架构图。
参考书籍:模型分类参考《七周七并发模型》的方法论框架,《百万在线》的服务器架构选型实践,以及《游戏服务器架构与优化》的并发章节。
与第 6 章的关系:框架是并发模型的产品化封装。选框架之前先想清楚并发模型——Skynet 是 Actor 的产品化,Netty 是 Reactor 的产品化,Go 生态是 CSP 的产品化。模型想清楚了,框架选择往往就水到渠成;反过来先选框架再补模型认知,经常会用错框架。
为什么并发模型是服务端的第一性决策
游戏连接和 Web 请求的本质区别
Web 后端的默认假设是"无状态请求":请求进来,查库、计算、返回,连接释放,下一批请求和上一批没有任何关系。这个假设让水平扩展变得简单——任何一台机器都能处理任何请求。
游戏服务器做不到这一点。一个玩家的连接可能持续几个小时甚至几天,期间服务端要持续维护他的位置、背包、任务进度、战斗状态。更关键的是:玩家 A 的操作会影响玩家 B 看到的世界——这份跨连接的共享状态,就是并发问题的主要来源。
这意味着游戏服务器的并发模型要同时回答两个问题:
- 怎么扛住大量长时间存在的连接(连接容量问题)
- 怎么管理这些连接背后互相影响的共享状态(状态一致性问题)
Web 后端基本只需要回答第一个问题,第二个问题被推给了数据库的事务。游戏服务器把第二个问题拉回到了进程内部——这正是它难的地方。
一个容易被忽略的数字账
连接容量不只是"epoll 能挂多少 fd"的问题,而是资源账:
- 10 万个连接,每个连接维护 1MB 状态,就需要 100GB 内存
- 10 万个 OS 线程,每个线程 MB 级栈空间,内存和调度开销都会失控
- 10 万个连接全部挤在一个事件循环里,任何一次 10ms 的阻塞都会让所有玩家同时卡顿
所以并发模型的选型,本质上是在连接容量、开发复杂度、延迟特性、资源效率四个维度之间做取舍。没有全赢的模型,只有适合当前游戏形态的模型。
| 权衡维度 | 关键问题 | 典型翻车场景 |
|---|---|---|
| 连接容量 | 单进程能稳定挂多少连接? | 网关用线程模型,5000 连接后内存和切换开销失控 |
| 开发复杂度 | 团队写得对、写得快、查得了吗? | 手写无锁队列,出问题时没人能调试 |
| 延迟特性 | 高峰期尾延迟可控吗? | 平均延迟很好,但每几分钟有一批请求超时 |
| 资源效率 | 每个连接花多少内存和 CPU? | 每连接开一个 goroutine 加一个大缓冲,10 万连接吃光内存 |
七种模型与主流框架的映射
理解模型之后再看框架,会发现框架光谱其实就是模型光谱:
| 并发模型 | 代表框架/运行时 | 一句话概括 |
|---|---|---|
| 单线程事件循环 | Node.js、Redis、Nginx worker | 一个循环跑完所有事件,永不阻塞 |
| 线程池 | Java ExecutorService、C++ 线程池 | 任务进队列,固定数量线程消费 |
| Reactor | Netty、libuv、Boost.Asio | 事件就绪通知 + Handler 分发 |
| Proactor | Windows IOCP、Linux io_uring | I/O 完成通知,数据已经备好 |
| Actor | Erlang/OTP、Skynet、Orleans | 状态封闭在 Actor 内,消息驱动 |
| 协程 | Go goroutine、Lua 协程 | 用户态轻量执行单元,阻塞式写法 |
| CSP | Go channel | 不共享内存靠通信,通过 Channel 交换数据 |
先建立一个前置共识:主逻辑线程与单写者
在展开七种模型之前,需要先说清楚一个行业里反复被验证的结论,否则很容易把"选并发模型"理解成"选一个模型用到底"。
绝大多数上线游戏的核心逻辑,最终都收敛到"每个房间/场景/分区一个单线程逻辑上下文"。 不管外层用 Reactor、协程还是 Actor,房间内的位置、血量、Buff、技能时序,都由一个顺序执行的逻辑上下文独占地推进。
这不是巧合,而是由游戏逻辑的性质决定的:
- 房间内状态之间高度耦合(伤害触发 Buff、Buff 改变移速、移速影响判定),拆到多线程会制造大量锁和时序问题
- 游戏逻辑对抖动极其敏感——不怕平均慢一点,怕"偶尔特别慢"
- 帧同步和回放、裁决等能力,前提就是逻辑执行顺序确定、可重演
这就是单写者原则:同一份状态在任何时刻只有一个执行流有权写它。单写者带来的好处不是"快",而是可推理——出问题时你不需要怀疑并发交错,只需要看顺序执行的轨迹。
所以更贴近现实的并发模型设计,不是"整个服务器用一个模型",而是:
┌──────────────────────────────────────────────┐
│ 连接层(高并发 I/O) │
│ Reactor / Proactor / 每连接一个协程 │
└───────────────────┬──────────────────────────┘
│ 消息(解码、校验后投递)
┌───────────────────▼──────────────────────────┐
│ 逻辑层(每房间/分区一个上下文) │
│ 单线程 Tick 推进,状态单写,顺序确定 │
└───────┬───────────────────────┬──────────────┘
│ 消息/任务 │ 异步写队列
┌───────▼────────┐ ┌────────▼─────────────┐
│ 计算层(线程池) │ │ 外围层(异步化) │
│ 寻路/AOI/数值批量 │ │ 落库、日志、埋点、通知 │
└────────────────┘ └──────────────────────┘并发发生在边缘,串行发生在核心。 接入层用高并发 I/O 模型扛连接,计算层用线程池消化重计算,外围层异步化隔离慢操作,而房间内部保持单线程顺序推进。后面的七种模型,都是这套分层里某一层的候选方案。
七种并发模型详解
以下每个模型按统一结构展开:核心思路 → 它解决什么问题 → 它引入什么新问题 → 游戏中的真实用法 → 适用场景与坑。
1. 单线程事件循环(Single-Thread Event Loop)
核心思路:一个线程跑一个无限循环,每轮处理一批就绪的 I/O 事件、到期定时器和待执行任务,处理期间不允许任何阻塞。
典型代表:Node.js、Redis、Nginx worker、Lua 单进程游戏服
┌────────────────────────────────────┐
│ Event Loop │
│ │
│ epoll_wait ──► 就绪事件队列 │
│ │ │
│ ▼ │
│ 逐个执行 Handler(不阻塞) │
│ │ │
│ ▼ │
│ 到期定时器 ──► 下一轮 ◄──────────── │
└────────────────────────────────────┘它解决什么问题:把并发问题的复杂度直接降为零。没有锁、没有竞态、没有内存可见性问题,所有状态天然只被一个线程碰,代码逻辑和执行顺序完全一致。Redis 用这个模型做到了单机 10 万 QPS,证明了"简单"和"高性能"并不矛盾——前提是操作都是内存级的短操作。
它引入什么新问题:所有操作共享一个执行流,一个慢操作拖住所有人。一次同步的数据库查询、一次大 JSON 序列化、一次 GC 停顿,都会让全部玩家的操作排队等待。这也是它和"游戏主逻辑单线程"的区别——Redis 里的操作是微秒级的,而游戏逻辑动辄涉及落库和复杂计算,裸用事件循环装游戏逻辑会很痛。
游戏中的真实用法:
// 轻量卡牌服的主循环骨架(伪代码):
func mainLoop() {
for {
now := time.Now()
// 1. 非阻塞地取网络事件(设置很短的超时)
events := poller.Wait(1 * time.Millisecond)
for _, ev := range events {
msg := decode(ev) // 只做解码,不做重活
queue.Push(msg) // 进待处理队列
}
// 2. 处理到期的定时器(体力恢复、建筑升级)
timerWheel.Advance(now)
// 3. 批量处理消息,限制每轮配额防止饿死定时器
for i := 0; i < 500 && queue.Len() > 0; i++ {
handleOne(queue.Pop())
}
}
}注意这段代码里的两个防御性设计:解码和处理分离(重活进队列限流处理),每轮处理配额(防止消息洪峰把定时器饿死)。单线程模型不是"一个 while 循环搞定一切",而是要在单线程内部自己做调度纪律。
适用场景:小游戏服务(微信小游戏后端)、卡牌/挂机/回合制的全量逻辑、网关层、配置和管理接口。
常见的坑:
- 循环里混入同步阻塞调用(同步 DB、同步 HTTP、
time.Sleep),整个服务冻结 - 定时器用无序遍历而不是时间轮/最小堆,定时器多了之后每轮开销线性上涨
- 错误处理缺失,一个 Handler panic 带崩整个循环(Node.js 里是常见事故)
2. 线程池(Thread Pool)
核心思路:预先创建固定数量的工作线程,任务对象进入共享队列,工作线程循环取任务执行。用固定的线程数消化海量的短任务。
典型代表:Java ExecutorService、Go 里的 worker pool 模式、C++ 自定义线程池
它解决什么问题:让多核 CPU 的算力可以被安全地"批发"出去。线程创建和销毁是昂贵操作(MB 级栈分配、内核调度初始化),线程池通过复用把这笔成本摊薄。对于任务之间彼此独立、不共享可变状态的计算,线程池能拿到接近线性的多核加速。
它引入什么新问题:任务一旦需要访问共享数据,就回到锁的世界。锁的代价远不只是加解锁那几行代码——线程等待、上下文切换、缓存失效、优先级反转,最终往往表现为尾延迟恶化和偶发超时,而不是稳定的变慢。这是线程池模型最隐蔽的成本(后文"锁的真实代价"一节详细展开)。
游戏中的真实用法——游戏服务端里线程池的标准位置是"逻辑层旁边的计算层":
// 房间逻辑线程发现需要给 200 个怪物重新寻路
// ——这是纯计算,不碰房间状态,丢给线程池
func (r *Room) onNeedPathBatch(targets []Movable) {
task := func() {
results := make([]Path, len(targets))
for i, t := range targets {
results[i] = pathfinder.Find(t.Pos, t.Dest) // 只读快照
}
r.resultCh <- results // 结果回投给房间线程合并
}
computePool.Submit(task)
}
// 房间线程在下一帧消费结果
func (r *Room) tick() {
select {
case results := <-r.resultCh:
r.applyPaths(results) // 唯一写状态的地方:房间线程自己
default:
}
// ... 正常推进逻辑
}这个例子里有两个关键纪律:线程池只碰快照或只读数据,结果通过 channel 回投给单写者合并。这样线程池提供了多核算力,却没有把共享写引入房间逻辑。
适用场景:寻路、AOI 计算、大规模数值结算、战斗离线演算、日志压缩与写入、图片/表格处理等后台作业。
常见的坑:
- 任务里直接读写共享 map/切片,用锁保护后高频路径上出现热点锁
- 队列无上限,下游卡住时任务无限堆积,内存缓慢上涨直到 OOM
- 任务之间有隐式依赖(A 的结果被 B 需要),固定线程数下可能互相等待形成饥饿
3. Reactor 模型
核心思路:由一个事件分发器(Reactor)监听一批 I/O 句柄,句柄就绪时(可读/可写)回调对应的 Handler 处理。Reactor 回答的是"哪个连接可以读写了",读和写本身由 Handler 自己做。
典型代表:Netty、libevent、libuv、Redis、Nginx、muduo
单 Reactor:
epoll ──► Reactor 分发 ──► Handler1 / Handler2 / Handler3 ...
(同一个线程内依次执行)
主从 Reactor(Netty 的 boss/worker):
mainReactor (boss) subReactor (worker) x N
只管 accept ──────────────► 每个管理一批连接的读写事件
新连接分配到某个 worker Handler 在 worker 线程内执行它解决什么问题:用少量线程服务海量连接。C10K 问题的标准答案就是 Reactor——线程数和连接数解耦,1 万个连接可以只由 8 个事件循环线程服务。对于"收消息、转发消息"这类 I/O 密集而计算轻量的场景,Reactor 是吞吐和延迟的平衡点。
它引入什么新问题:Handler 在事件循环线程内同步执行,任何耗时操作都会阻塞同一个循环上的所有连接。所以 Reactor 有条铁律:事件循环线程里只做 I/O 和轻处理,重活转投业务线程/队列。Netty 提供的 EventLoop 与业务线程池分离的编程范式,就是在强制这条纪律。
游戏中的真实用法——网关是 Reactor 的教科书场景:
// Netty 网关:mainReactor 管 accept,subReactor 管读写,业务逻辑转发
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup) // 主从 Reactor
.channel(NioServerSocketChannel.class)
.childOption(ChannelOption.TCP_NODELAY, true) // 游戏必开,禁 Nagle
.childHandler(new ChannelInitializer<SocketChannel>() {
protected void initChannel(SocketChannel ch) {
ch.pipeline()
.addLast(new LengthFieldDecoder()) // 解决粘包/拆包
.addLast(new ProtobufDecoder())
.addLast(new GatewayHandler()); // 鉴权 + 路由转发
}
});网关 Handler 里通常只做三件事:会话鉴权、协议校验、把消息投递到对应房间/逻辑进程的队列。一旦有人在网关 Handler 里写同步数据库查询,整条接入链路的延迟就会被最慢的查询拖住——这是 Reactor 场景最常见的事故。
适用场景:网关/接入层、代理和转发服务、聊天推送、高连接数低计算量的服务。
常见的坑:
- Handler 里做阻塞操作(同步 RPC、同步 DB),事件循环被拖死
- 没处理粘包/半包(TCP 是字节流,需要按长度或分隔符切消息)
- 忘记
TCP_NODELAY,小包被 Nagle 算法攒着,实时对战延迟莫名多几十毫秒
4. Proactor 模型
核心思路:发起异步 I/O 后立刻返回,操作系统在操作完成后通知应用。和 Reactor 的本质区别:Reactor 通知你"可以读了"(你自己去读),Proactor 通知你"已经读好了"(数据在缓冲区里)。从"就绪通知"变成"完成通知"。
典型代表:Windows IOCP、Linux io_uring、Boost.Asio 的异步模式
Reactor: 内核:"fd 可读了" ──► 你调用 read() 拷贝数据 ──► 处理
Proactor: 你预先提交"读到这个缓冲区" ──► 内核完成读取并拷贝完毕
──► 通知你:"数据在 buffer 里,直接处理"它解决什么问题:把"就绪→发起系统调用→拷贝"三步压缩成内核里一步完成,减少系统调用次数和上下文切换。io_uring 在高 IOPS 场景下(海量小包收发、大量落盘)相比 epoll 有明显优势,因为它还天然支持批量提交和批量收割。
它引入什么新问题:回调风格让控制流变得破碎——一个连接的生命周期被拆成一串回调,出错处理、超时、资源释放都散落在各处(回调地狱)。另外 io_uring 的 API 心智负担不低,版本兼容问题也不少,团队需要相当的系统编程能力才驾驭得住。
游戏中的真实用法:Windows 平台的游戏服长期以来直接用 IOCP(完成端口几乎是 Windows 高性能网络的事实标准)。Linux 上 io_uring 更多出现在两类位置:网关的超高连接密度接入,以及持久化层的批量落盘(把每帧的脏数据攒批,用 io_uring 批量写)。团队不熟的情况下,Reactor + 用户态缓冲优化通常已经够用,Proactor 属于"确认瓶颈在系统调用层之后再上"的选项。
适用场景:极致 I/O 吞吐的网关、大流量落盘、跨服通信密集的节点。
常见的坑:
- 回调里持有资源忘了释放,连接异常路径上泄漏缓冲区
- io_uring 版本差异(内核 5.x 各版本能力不同),开发环境和线上环境行为不一致
- 把业务逻辑写进完成回调,回调栈越叠越深,最后没人能看懂执行顺序
5. Actor 模型
核心思路:把系统拆成一个个 Actor——每个 Actor 拥有私有状态和一个邮箱(mailbox),外部永远不能直接碰它的状态,只能往邮箱投消息;Actor 一次只从邮箱取一条消息处理,处理完再取下一条。并发安全不是靠锁保证的,而是靠"状态封闭 + 串行消费"这个结构保证的。
典型代表:Erlang/OTP、Skynet、Akka、Microsoft Orleans
┌─────────┐ msg ┌──────────────┐ msg ┌─────────┐
│ Actor A │ ──────► │ Actor B │ ────────► │ Actor C │
│ 私有状态 │ │ [邮箱: m3,m2] │ │ 私有状态 │
└─────────┘ │ 串行消费 m1→m2→m3 │
└──────────────┘
外部只能投递消息,永远不能直接读写 B 的状态它解决什么问题:把"不要共享可变状态"从编程纪律升级为运行时结构。只要 Actor 内部逻辑是顺序的,整条链路就没有数据竞争,不需要锁。Skynet 用"C 服务 + Lua 业务"把这个模型做到了极轻量——每个服务就是一个 Actor,若干 worker 线程从全局队列挑就绪的服务执行,几万服务在几十个线程上复用。
Erlang 系(含 Loki/Poseidon 这类游戏应用)进一步证明了这个模型在电信级可用性上的价值:进程隔离让单个 Actor 崩溃不影响其他 Actor,配合监督树实现"让一部分进程死掉再重建"的容错哲学。
它引入什么新问题:
- 跨 Actor 操作变成异步——问 Actor B 要数据需要发消息再等回复,代码从"函数调用"变成"请求-响应消息对",写起来啰嗦,出错场景(超时、乱序、重复)需要自己处理
- 邮箱本身是队列——热点 Actor 的邮箱会堆积,堆积意味着延迟上涨,而大多数 Actor 框架默认邮箱无上限(背压问题,见后文)
- 调试困难——没有传统调用栈,追踪一个 bug 需要看消息流转日志而不是断点单步
游戏中的真实用法——Skynet 风格的服务划分:
-- 每个房间一个 skynet service(Actor),状态封闭在服务内部
local skynet = require "skynet"
skynet.start(function()
skynet.dispatch("lua", function(session, source, cmd, ...)
if cmd == "join" then
room:add_player(...) -- 直接改 room,没有锁
elseif cmd == "action" then
local events = room:apply(...) -- 串行处理,顺序确定
broadcast(events) -- 结果广播出去
end
end)
end)
-- 网关服务把玩家消息路由到对应房间服务:
-- skynet.send(room_addr, "lua", "action", uid, payload)room 这个 Lua table 只在服务内部可见,worker 线程虽然只有几个,但任意时刻一个服务只在一个线程里执行——单写者原则由运行时结构保证。
适用场景:房间/场景管理、MMO 实体与服务拆分、聊天系统、匹配服务、任何"实体多、实体间低耦合"的拓扑。
常见的坑:
- 热点 Actor(全局排行榜、世界频道)成为单点瓶颈——需要按 key 分片成多个 Actor
- 死锁变体:A 给 B 发消息后同步等待 B 回复,B 又在等 A 处理消息(消息层面的循环等待)
- 邮箱无上限 + 上游不限速 = 延迟悄然恶化到分钟级才发现
6. 协程(Coroutine)
核心思路:用户态的轻量执行单元,由运行时(而不是内核)调度。协程之间的切换就是保存/恢复少量寄存器,不进内核,开销在百纳秒级;协程的栈也从 MB 级压缩到 KB 级且按需增长。
典型代表:Go goroutine、Kotlin 协程、Lua 协程、C++20 coroutines、Erlang 进程(严格说 Erlang 进程也是这个思路的极致)
它解决什么问题:解决"高并发"与"好写代码"的矛盾。回调/事件风格的代码把一个线性流程切碎成 callbacks,协程让你用同步阻塞的写法获得异步非阻塞的性能:
// 一个连接一个 goroutine:写法完全是顺序的,读就是读,写就是写
func handleConn(conn net.Conn) {
defer conn.Close()
session := newSession(conn)
for {
msg, err := readMessage(conn) // "阻塞"在这里——但只是这个
if err != nil { // goroutine 被挂起,不占线程
return
}
room := routeTo(msg) // 投递到房间(channel/队列)
room.Submit(session, msg)
}
}
// 启动一万个连接就是一万个 goroutine:
// 10000 × 2KB 初始栈 ≈ 20MB,加上运行时数据也在百 MB 内同样的事用 Reactor 回调写,需要状态机管理"读一半的包""等回复的请求";用协程写,就是普通函数。这就是 Go 成为游戏服务端主流语言的核心原因:并发单元的成本降到了"可以随便开"的量级。
它引入什么新问题:
- 协程没有消除并发问题,只是把它变得容易忽略——两个 goroutine 同时改一个 map,照样数据竞争,而且 Go 的 map 并发写会直接 panic。协程的易用性让新手更容易在不知不觉间共享了状态
- GC 压力——goroutine 的栈是 GC 扫描对象,百万级 goroutine 会让 GC 的标记时间明显变长
- 泄漏——阻塞在 channel 上的 goroutine 没人唤醒就永远存在,泄漏是缓慢累积的
游戏中的真实用法:Go 游戏服务端的常见协程拓扑——每个连接一个 goroutine;每个房间一个 goroutine(房间内单写者);定时任务、落库、日志各一个消费者 goroutine。协程在这里扮演的角色是"把不同关注点隔离成独立的顺序执行流",而不是"把一件事并行拆成多份"。
适用场景:几乎所有 Go 游戏服务端;Lua 协程在 Skynet 服务内部串行业务流程(比如"发消息→等回包→再处理"用协程包装成同步写法)。
常见的坑:
- 多个 goroutine 写共享 map 导致 panic——要么收敛到单写者,要么加锁,要么分片
go func(){...}出去之后没人管:panic 无人 recover 导致整个进程退出,或 goroutine 永久阻塞泄漏- 以为协程是免费的:每个 goroutine 挂一个 64KB 缓冲,100 万个就是 64GB
7. CSP(Communicating Sequential Processes)
核心思路:执行单元之间不共享内存,通过 Channel 通信。和 Actor 的关键区别在通信的寻址方式:Actor 把消息发给一个具体的 Actor(需要知道对方身份),CSP 把消息发给一个 Channel(发送者不知道也不关心谁在消费)。
典型代表:Go channel(语言级内置)、Core.async(Clojure)
Actor: A ──msg──► [B 的邮箱] ──► B (点对点,知道对方是谁)
CSP: A ──msg──► [Channel] ──► 谁消费?──► B 或 C 或 B+C
(中介,发送方与消费方解耦)它解决什么问题:发送方和消费方的拓扑解耦。一个 Channel 后面可以是一个消费者、多个消费者(负载均衡)、或多个生产者+多个消费者(汇聚),增减任何一方都不影响另一方的代码。"Do not communicate by sharing memory; share memory by communicating" 这句 Go 谚语说的就是这套结构。
它引入什么新问题:channel 的阻塞语义需要认真对待——向满的 channel 发送会阻塞发送方,从空 channel 接收会阻塞接收方;没有消费者时生产者悄然堆积或阻塞;select 不加超时分支时可能永远等待。CSP 把"系统里谁在等谁"这个问题从锁的世界换到了 channel 的世界,问题形式变了,谨慎程度不能变。
游戏中的真实用法——CSP 在游戏服务端最常见的形态是流水线:
// 接入 → 逻辑 → 落库 的三段流水线,每段独立伸缩
func gatewayStage(in <-chan Message, out chan<- Message) {
for msg := range in {
if valid(msg) { out <- msg }
}
close(out)
}
func logicStage(in <-chan Message, out chan<- PersistJob) {
for msg := range in {
room := roomTable.Get(msg.RoomID)
room.Submit(msg) // 房间内部仍是单写者
if dirty := room.TakeDirty(); dirty != nil {
out <- PersistJob{RoomID: room.ID, Data: dirty}
}
}
close(out)
}
func persistStage(in <-chan PersistJob) {
for job := range in {
batchBuffer.Add(job) // 攒批
if batchBuffer.Full() { flush() } // 批量入库
}
}每段之间用带缓冲 channel 连接,缓冲就是显式的容量上限——满了就阻塞上游,压力自然向入口传播,而不是无限堆积在内存里(这正是背压,后文"流控与背压"一节展开)。
适用场景:Go 游戏服务端的整体骨架、多阶段流水线(接入→逻辑→落库)、多生产者汇聚(各房间的事件汇总到统计服务)。
常见的坑:
- 无缓冲 channel 在收发双方节奏不一致时造成隐式同步点,吞吐骤降
- 向已无人接收的 channel 发送导致 goroutine 永久阻塞(泄漏)
- 用 channel 传递大对象引用,消费方意外修改,产生"共享了内存"的事故——通信传递的应该是所有权
七种模型对比总表
| 模型 | 并发单元 | 状态共享 | 通信方式 | 典型延迟 | 开发难度 | 游戏中的位置 |
|---|---|---|---|---|---|---|
| 单线程事件循环 | 线程 | 无竞态 | 队列/回调 | 极低(无阻塞时) | ⭐⭐ | 小游戏全量逻辑、网关 |
| 线程池 | 线程 | 需加锁 | 共享队列 | 中(受锁影响) | ⭐⭐⭐ | 寻路/AOI/批量计算 |
| Reactor | 事件循环 | 无竞态(循环内) | 回调 | 低 | ⭐⭐⭐ | 网关、接入层 |
| Proactor | 完成端口 | 无竞态(回调内) | 完成回调 | 极低 | ⭐⭐⭐⭐ | 极致 I/O 网关、落盘 |
| Actor | Actor | 无共享(封闭) | 邮箱消息 | 中(含排队) | ⭐⭐ | 房间/实体/服务拆分 |
| 协程 | goroutine | 需自律 | channel | 极低 | ⭐⭐ | 连接、房间、异步任务 |
| CSP | 顺序进程 | 无共享(靠通信) | channel | 低 | ⭐⭐ | 流水线、多段解耦 |
两个读表提示:
- 开发难度低不等于不容易写出 bug。协程开发难度评级低是因为写法自然,但共享状态的错误也更隐蔽——难度评的是"写出正确代码的心智负担",而协程模型的心智负担集中在"你能不能忍住不共享状态"
- 延迟一列指的是模型自身引入的开销,不含业务处理时间。Actor/CSP 的"中"来自队列排队,这个排队在高负载下既是祸(延迟涨)也是福(削峰)
现实中的混合架构
成熟的游戏服务器几乎从不"用一个模型",而是按层混用。两个典型案例:
案例 1:卡牌/挂机服(无实时同步)
HTTP/WebSocket 网关(Reactor 或每连接一个 goroutine)
│
▼
单进程业务逻辑(事件循环 or 单 goroutine 串行处理)
│ │
▼ ▼
定时器(最小堆) 异步任务队列(channel)
体力恢复/建筑升级 ▼
落库消费者(攒批写入 MySQL)特点:逻辑本身串行就够(单服几千在线),重点在外围异步化——落库、推送、活动结算全部走 channel 扔给后台消费者,主逻辑永远不等数据库。
案例 2:MOBA/FPS 房间服(强实时)
网关层:主从 Reactor(每连接会话,TCP_NODELAY,长度字段分包)
│ 消息解码+校验后投递
▼
房间层:每个房间一个 goroutine(或 Skynet service)
内部以固定频率 Tick(如 15Hz 逻辑帧)
状态单写,输入按帧序消费
│
├──► 广播聚合:一帧内合并状态更新,减少包量
├──► 计算层:寻路/AOI 快照丢线程池,结果回投房间
└──► 持久化 channel:战绩、结算异步落库特点:并发的边界被精确地画在房间门口——房间外一切都是消息,房间内一切都是顺序。这套结构同时满足了性能(I/O 多路复用)、正确性(单写者)和可观测性(房间内执行轨迹可回放)。
混合架构的分工口诀
| 层 | 职责 | 适合的模型 |
|---|---|---|
| 连接层 | 海量连接、编解码、路由 | Reactor / Proactor / 每连接一个协程 |
| 逻辑层 | 房间/场景/分区状态推进 | 单线程 Tick(以协程/Actor/服务为容器) |
| 计算层 | 重计算、可并行 | 线程池(只碰快照,结果回投) |
| 外围层 | 落库、日志、推送、埋点 | 消费者协程 + 队列(异步化隔离) |
并发模型的下半场:流控与背压
选对模型只是上半场。队列是所有这些模型(Actor 邮箱、CSP channel、线程池任务队列)的共同底座,而队列的行为决定了系统在过载时的死法或活法。
为什么高峰时先崩的是队列
高峰来时,CPU 往往还没满,但队列已经开始堆积:网关写缓冲、Actor 邮箱、落库队列、广播待发列表。如果系统对堆积的唯一反应是"继续接、继续塞",那么:
- 队列变长 → 尾延迟上涨
- 尾延迟上涨 → 客户端超时重试
- 重试放大入口流量 → 队列更长
最后系统不是"扛住了",而是靠积压把故障推迟了几分钟,然后雪崩。没有上限的队列,本质上是把故障延迟暴露。
稳定的系统给每个队列配三样东西
// 有上限的落库队列:满时的策略是显式选择的
func enqueuePersist(persistCh chan<- PersistJob, job PersistJob) {
select {
case persistCh <- job:
// 正常入队
default:
metrics.Inc("persist_queue_overflow")
log.Warn("persist queue full, degrade to local file")
appendToLocalWAL(job) // 降级:先落本地,事后补偿
}
}- 显式容量上限——单连接写缓冲上限、单房间广播积压上限、落库队列长度上限,都是写进代码的设计参数,不是"内存够就行"
- 超限策略——拒绝、降级、丢过期消息(如位置同步包,丢旧保新)、暂停接单,四选一,按消息的业务价值定
- 优先级隔离——核心链路(登录、战斗广播、结算)和外围链路(公告、红点、埋点)分开排队,外围再挤也不能拖核心
退化顺序要提前设计
一个实用的退化层次,按顺序启用:
- 先丢低优先级、可过期的消息(旧位置包、非关键推送)
- 再降级外围异步链路(埋点采样、日志降频)
- 再限制新流量进入(登录排队、匹配限速)
- 最后才动核心主链路
关键问题不是"怎么不丢消息",而是:如果系统现在只能处理一半流量,哪一半需要活下来? 这个答案属于产品决策,需要技术提前实现成代码。
背压为什么需要提前做
很多团队想"上线前压测,发现瓶颈再补流控"。这个顺序通常来不及——没有容量边界,压测看不出真实故障形态;没有优先级隔离,压测只能证明"系统会一起死";没有降级路径,高峰演练没有任何抓手。流控和背压应该作为架构的一部分,而不是优化项。
锁的真实代价:为什么老手偏爱单写者
只要用了线程池或多线程共享结构,就绕不开锁。这里值得花一节讲清楚锁的真实成本,因为它经常被低估。
锁的代价不只是加解锁
| 成本 | 说明 |
|---|---|
| 线程等待与切换 | 竞争时线程挂起、唤醒,每次微秒级,高 contention 下累积 |
| 缓存失效 | 锁保护的 cacheline 在核间弹跳,NUMA 机器上更糟 |
| 尾延迟恶化 | 平均值好看,P99 毛刺,玩家感受就是"偶尔卡" |
| 死锁/活锁风险 | 锁顺序管理随锁数量指数变难 |
| 可复现性差 | 依赖线程调度时机,问题本地测不出、线上偶发 |
最后一条在工程上最致命:锁引发的问题通常表现为偶发、难复现、时序相关,排障成本远超它省下的开发时间。
游戏服务器里的高频热点锁
实践中最容易出事的位置:全局玩家表、房间/场景索引、广播连接列表、定时器容器、共享对象池、跨线程日志聚合器。它们的共同点是:出现在高频路径上,且被所有线程共享。
细分锁通常不如不共享
锁竞争出现后,第一反应往往是"把锁粒度拆细"。这个方向有时有效,但更常见更稳的做法是先问:这份状态真的需要共享吗?
- 分片:全局玩家表按
playerID % N分成 N 个带各自锁的分片,竞争直接降为 1/N - 复制:各线程持有一份只读快照,定期刷新,读路径完全无锁
- 单写者:状态收敛到拥有它的房间/Actor/协程内,外部通过消息读写——并发问题转化为边界问题
- 异步聚合:统计数据各线程本地累加,定期合并,而不是实时改全局计数器
什么时候锁仍然合理
锁不是敌人,它在低频路径上完全合理:配置热更新、GM 命令、启动期初始化、低频后台任务。原则只有一条:别让锁出现在本应顺序推进的核心链路高频路径上。
如果锁竞争已经发生,比较稳妥的治理顺序是:
- 定位是哪份共享状态在热路径上被争用(用 pprof 的 mutex profile,而不是猜)
- 判断这份状态是否真的需要跨线程共享
- 优先考虑分片、复制、单写者、异步聚合这类结构解法
- 结构改不了时,才做锁粒度优化、读写锁或无锁结构
代码示例:单写者房间服的最小骨架
本章概念密度高,代码分散在各模型小节里。这一节把三个关键词——单写者、消息驱动、背压——装进一个可读的最小骨架。它用到的正是模型光谱里"协程 + CSP + 单线程逻辑上下文"的组合:接入层是协程模型,投递靠 channel(CSP),房间内部是单线程逻辑上下文。第 6 章的 Go 框架,几乎都是这个骨架的产品化封装。
房间:单写者上下文 + 有界队列
// 房间:单写者上下文。所有状态只被 Run 这一个 goroutine 碰
type Room struct {
id uint64
players map[uint64]*Player // 位置、血量、Buff——只有下面 Run 里的代码有权写
msgCh chan Message // 有界队列:容量是显式设计参数,不是"内存够就行"
}
const roomQueueSize = 4096
func NewRoom(id uint64) *Room {
return &Room{
id: id,
players: make(map[uint64]*Player),
msgCh: make(chan Message, roomQueueSize),
}
}
// 提交消息:满时的策略是显式选择的,而不是默默阻塞调用方
func (r *Room) Submit(msg Message) error {
select {
case r.msgCh <- msg:
return nil
default:
return ErrRoomBusy // 背压:调用方按消息价值决定丢弃、降级还是断开
}
}
// 唯一的写者:顺序消费消息,天然无锁、顺序确定(可重演的前提)
func (r *Room) Run(tick <-chan time.Time) {
for {
select {
case msg := <-r.msgCh:
r.handle(msg)
r.drainPending() // 积压一口气清掉,再推进帧
r.advanceFrame() // 定时同步、回合结算、超时判定
case <-tick:
r.advanceFrame() // 没消息也照常推进:世界不能因为安静而停摆
}
}
}
// drainPending:非阻塞清空积压,配额上限防止极端洪峰占满一整帧
func (r *Room) drainPending() {
for i := 0; i < 256; i++ {
select {
case msg := <-r.msgCh:
r.handle(msg)
default:
return
}
}
}两个值得注意的防御性设计:队列容量写进代码(呼应"流控与背压"一节的三件套),每轮处理配额(防止消息洪峰把帧推进饿死)。单写者的红利在这里兑现——handle 里改任何字段都不需要锁,出问题时只需看顺序执行轨迹,不用怀疑并发交错。
接入层:并发发生在边缘
// 一个连接一个 goroutine(协程模型):写法是顺序的,成本是 KB 级的
func handleConn(conn net.Conn, table *RoomTable) {
defer conn.Close()
for {
msg, err := decodeMessage(conn) // "阻塞"在这里——挂起的只是这个 goroutine
if err != nil {
table.Route(conn.ID).Submit(Message{Kind: KindDisconnect, ConnID: conn.ID})
return // 断线也作为消息投递:状态变更仍只发生在房间里
}
room := table.Get(msg.RoomID) // 连接层只解码、路由,不碰任何房间状态
if err := room.Submit(msg); errors.Is(err, ErrRoomBusy) {
if msg.Kind == KindMove {
continue // 丢旧保新:位置包过期即弃(背压退化第 1 档)
}
conn.Close() // 核心消息进不去:说明房间已过载到值得断连
return
}
}
}这就是"并发发生在边缘,串行发生在核心"的完整形态:连接层承受阻塞与乱序,房间内是确定的顺序世界。注意断线的处理方式——它不是直接删玩家,而是投递一条 KindDisconnect 消息,让房间在正确的顺序位置上处理掉线(可能与"正在结算"这样的时序问题天然串行化)。
房间想用多核时:委托,而不是加锁
// 房间内遇到重计算(如 200 个怪批量寻路):不阻塞逻辑线程,委托线程池
func (r *Room) handle(msg Message) {
switch msg.Kind {
case KindRepathAll:
targets := r.snapshotMovables() // 1. 单写者内取只读快照,随后立即返回
pool.Submit(func() {
paths := pathfinder.Batch(targets) // 2. 线程池并行计算,不碰房间状态
r.Submit(Message{Kind: KindPathsReady, Data: paths}) // 3. 结果以消息回房间
}) // 提交失败走降级:直接用旧路径,绝不阻塞房间
case KindPathsReady:
r.applyPaths(msg.Data.([]Path)) // 4. 写状态的仍然只有 Run——锁根本没有出现
}
}这是对"线程池"一节那个隐蔽成本的正面回答:任务一旦访问共享数据就回到锁的世界——除非共享数据只有一个写者。这里线程池从不直接写房间状态,只通过消息回投,所以计算层拿到了多核加速,房间内保持了零锁。混合架构的分工口诀(接入高并发 I/O、计算用线程池、核心单线程推进)在这个函数里全部落地。
这个骨架的所有扩展——多房间(每房间一个 Run + 房间表路由)、跨服(消息出进程改走 RPC,房间内模型不变)、持久化(脏数据走异步写队列)——都不动摇"单写者"这个核心决策。下一节选型指南里按语言、按游戏类型的选择,本质上是给这个骨架的接入层和计算层换不同实现。
选型指南
按语言/生态选型
团队主语言是什么?
│
├─ Go ────────────► goroutine + channel(CSP)为主干
│ 房间单 goroutine 单写者,连接每连接一个协程
│
├─ Java ──────────► Netty(Reactor)做接入
│ 业务逻辑单线程化或 Actor(Akka/Orleans)
│
├─ C++ ───────────► Reactor(muduo/asio)起步
│ 确认系统调用层是瓶颈后再考虑 io_uring
│
├─ C + Lua ───────► Skynet(Actor)几乎是默认答案
│
├─ Node.js ───────► 单线程事件循环 + worker_threads 分担重活
│ 只适合轻量/卡牌/回合制形态
│
└─ Python ────────► 单线程 asyncio 起步,确认规模后再谈重构
(Python 做游戏后端 usually 停留在原型/小规模)按游戏类型选型
| 游戏类型 | 连接特征 | 状态特征 | 常见组合 |
|---|---|---|---|
| 卡牌/挂机/回合制 | 可 HTTP,少量长连接 | 玩家私有为主 | 事件循环/协程 + 异步落库队列 |
| SLG/策略 | 长连接低频 | 联盟级共享状态 | 协程 + 联盟 Actor 分片 |
| MMO | 大量长连接高频 | 场景内强共享 | Actor/分场景进程 + 场景内单线程 |
| FPS/MOBA | 房间制强实时 | 房间内强共享 | Reactor 网关 + 每房间单写者 Tick |
| 消除/解谜单人 | 请求-响应即可 | 几乎全私有 | HTTP + 事件循环 |
按项目阶段选型
- 原型期:用最熟悉的模型把逻辑跑通。单线程事件循环或协程串行都行,此时正确的决策是"不纠结并发"
- 成长期:在线规模上来后,先加的是房间/分区隔离(把玩家拆进独立的单写者上下文),而不是引入多线程共享
- 规模期:才轮到跨进程拆分(网关、战斗、世界分离)、线程池消化计算热点、以及 I/O 层的深度优化
这个顺序和第 6 章的框架光谱一致:并发架构是跟着在线规模长出来的,不是立项时一次画出来的。
常见陷阱清单
- 过早优化并发。项目初期花两周设计"完美的多线程架构",结果 DAU 撑不起一半的复杂度。先跑起来,让真实负载告诉你瓶颈在哪。
- 把协程当免费资源。goroutine 本身轻,但挂在它身上的缓冲区、定时器、闭包引用不轻。10 万连接 × 每连接 64KB 缓冲 = 6.4GB,这种账要在设计期算。
- 无界队列。所有
make(chan T)默认无缓冲或随手给个BufferSize,从不考虑满了怎么办。给每个关键队列一个显式上限和超限策略。 - 并发/并行不分。并发是"交替处理多个任务",并行是"同时执行多个任务"。单线程事件循环是并发不并行;线程池是并发且并行。理解这个区别,才能明白"单线程也能扛高并发"和"多核用不上"为什么同时成立。
- 在事件循环/Reactor 线程里做重活。同步 DB、大序列化、复杂计算,任何一项都会让同循环上的所有连接陪葬。
- goroutine 泄漏。
go出去的协程阻塞在没人写的 channel 上,监控里 goroutine 数缓慢爬升是典型信号,上线前就该有 goroutine 数指标。 - 锁竞争靠加机器解决。横向扩容解决不了单进程内的热点锁——分片/单写者是结构问题,加机器是容量问题,两回事。
- 忽略 GC 与并发的相互作用。大量存活 goroutine 和高频分配会让 GC 标记时间变长,GC 停顿又让所有协程一起停。对象池复用、减少小对象分配、必要时调 GC 参数,都是规模期的常规功课。
实战案例:一次「重启就好」的高峰队列失控排查
案例为教学示例(非摘自真实项目),数字经过取整简化,聚焦排查思路与回填逻辑。
背景与现象
版本活动上线的第一个晚高峰,网关 P99 突破 3 秒,玩家批量超时重连;CPU 峰值只有 45%,内存以每高峰约 240MB 的速度缓涨。值班的处置是重启进程:立刻恢复,十几分钟后复发,一晚上重启三次。活动房间入口流量涨到 2400 条/秒,高峰持续 10 分钟;基础监控自始至终没有一条告警。
第一步:现象与口径
三个口径在同一时刻各说各话:
- 基础监控:CPU 45%、内存缓涨 240MB,全部在告警阈值内——「系统健康」;
- 网关与玩家:P99 3 秒、批量超时重连——「系统不可用」;
- 队列视角:队列深度指标 0 个——堆积的第一现场没有被记录,队列深度是复盘时用消息量倒推出来的。
口径差不在数字上,在视角缺失上:0 个队列指标让「堆积过程」成为监控盲区——先补视角,再谈归因。
第二步:分层归因
按消息的处理链路逐层走:
接入层 连接 goroutine 正常,解码与路由无积压——入口流量真实涨到 2400 条/秒
房间层 活动房间消息队列是无界切片:入 2400、消费上限 2000,净增 400 条/秒持续堆积
落库层 落库队列随房间积压同步变长——是受害者不是首因,房间先卡、落库才慢
监控层 CPU、内存、错误数都有面板,队列深度一个指标都没有——堆积过程全程不可见账本收敛:消费上限 = 1 秒 ÷ 0.5ms = 2000 条/秒;入口 2400 − 2000 = 净增 400 条/秒;高峰 10 分钟(600 秒)积压 400 × 600 = 24 万条;末条消息等待 24 万 × 0.5ms = 120 秒,远超客户端 3 秒超时——超时触发重试,入口 2400 × 1.2 = 2880 条/秒,净增升到 880 条/秒,雪崩进入正循环;内存侧 24 万条 × 1KB = 240MB,对上监控里的缓涨曲线。
第三步:根因
- 直接原因:活动房间消息队列无界——
append进切片,从不设上限;入口大于消费速率时唯一的去向就是堆积,重启是唯一能清空它的操作。 - 机制原因:陷阱清单 8 条里的第 3 条「无界队列」写着的正是这件事;队列三件套 3 样全无——无显式容量上限、无超限策略(4 选一没得选)、无优先级隔离;退化顺序 4 层没有一层实现,高峰时连「丢旧位置包」这第一档都不存在。
- 深层原因:流控和背压被当成优化项,而不是架构的一部分——「上线前压测、发现瓶颈再补流控」的顺序里,没有降级路径的压测只能证明「系统会一起死」;连接容量在设计期算过 fd 的账,队列的账没人算(「10 万连接 × 64KB = 6.4GB 要在设计期算」的同款账目,24 万条 × 1KB 就躺在队列里没人看见)。
第四步:处置与回填
- 队列有界化:活动房间消息队列换成有界 channel,上限对齐讲义骨架的 4096——容量是写进代码的设计参数,不是「内存够就行」。
- 超限四选一:位置包丢旧保新(第 04 讲 Checklist 的同款纪律,接入层直接弃)、核心消息满时降级写本地 WAL 事后补偿——按消息业务价值显式选择。
- 队列视角进监控:深度、水位 75%、溢出计数三项进面板,水位越线即告警——「堆积过程」从此可见。
- 退化顺序进代码与演练:退化顺序 4 层写进代码并进高峰演练,先丢过期消息、再降级外围、再限新流量、最后才动核心。
复核:下一个高峰,P99 从 3 秒 → 180ms,积压 24 万 → 0,CPU 还是 45%——算力没变,卡顿没了。
回填清单:
| 回填项 | 落点 | 回链 |
|---|---|---|
| 房间队列有界化 + 超限四选一 | 房间骨架 | 第 05 讲流控三件套 / 代码示例 |
| 队列深度、水位、溢出计数进监控面板 | 监控告警 | 第 13 讲监控合账 |
| 退化四层进高峰演练预案 | 容量预案 | 第 05 讲退化顺序 / 第 17 讲发布演练 |
| 队列容量账进设计期检查单 | 设计检查 | 第 05 讲数字账 / 陷阱清单第 3 条 |
案例的三个教训
- CPU 不高不等于健康——队列是所有并发模型的共同底座,堆积发生在算力见顶之前;监控里没有队列深度,就等于没有过载预警。
- 队列容量是设计参数,不是运行结果——上限写进代码,满时的四选一按业务价值显式选择;「内存够就行」不是没有上限,是把故障推迟到高峰再一起收账。
- 背压是架构的一部分,不是优化项——退化顺序要提前设计并演练,压测补不出流控:没有降级路径的压测,只能证明「系统会一起死」。
小结
| 关键问题 | 答案 |
|---|---|
| 游戏服务端并发要解决什么? | 大量长连接 + 连接背后互相影响的共享状态,第二点是它和 Web 后端的本质区别 |
| 七种模型分别是什么? | 单线程事件循环、线程池、Reactor、Proactor、Actor、协程、CSP |
| 最重要的结构原则? | 单写者:并发发生在边缘(连接/计算/外围),核心逻辑每房间/分区一个顺序执行流 |
| Actor 和 CSP 的区别? | Actor 发消息给特定 Actor(知道对方身份),CSP 发给 Channel(发送方与消费方解耦) |
| Reactor 和 Proactor 的区别? | Reactor 通知"就绪可读写"(你自己读),Proactor 通知"已完成"(数据已备好) |
| 协程解决了什么? | 用同步阻塞的写法获得异步非阻塞的性能——并发单元成本降到可以随便开的量级 |
| 系统过载时靠什么活下来? | 显式队列上限 + 超限策略 + 优先级隔离 + 预先设计的退化顺序 |
| 锁竞争怎么办? | 先问"这状态需要共享吗":分片/复制/单写者/异步聚合,最后才是锁优化 |
| 一开始该选哪种? | 用最熟悉的模型跑通原型;规模上来后先做房间隔离,再做进程拆分,最后才谈多线程共享 |