Skip to content

05 并发模型:七种常见并发思路与适用场景 ​

本章目标:帮助读者理解游戏服务器并发模型的第一性决策——状态放在哪、谁有权写它、并发发生在哪一层。读完本章,你应该能根据游戏类型、团队技术栈和项目阶段,独立做出并发模型选型,而不是照搬别人的架构图。

参考书籍:模型分类参考《七周七并发模型》的方法论框架,《百万在线》的服务器架构选型实践,以及《游戏服务器架构与优化》的并发章节。

与第 6 章的关系:框架是并发模型的产品化封装。选框架之前先想清楚并发模型——Skynet 是 Actor 的产品化,Netty 是 Reactor 的产品化,Go 生态是 CSP 的产品化。模型想清楚了,框架选择往往就水到渠成;反过来先选框架再补模型认知,经常会用错框架。


为什么并发模型是服务端的第一性决策 ​

游戏连接和 Web 请求的本质区别 ​

Web 后端的默认假设是"无状态请求":请求进来,查库、计算、返回,连接释放,下一批请求和上一批没有任何关系。这个假设让水平扩展变得简单——任何一台机器都能处理任何请求。

游戏服务器做不到这一点。一个玩家的连接可能持续几个小时甚至几天,期间服务端要持续维护他的位置、背包、任务进度、战斗状态。更关键的是:玩家 A 的操作会影响玩家 B 看到的世界——这份跨连接的共享状态,就是并发问题的主要来源。

这意味着游戏服务器的并发模型要同时回答两个问题:

  1. 怎么扛住大量长时间存在的连接(连接容量问题)
  2. 怎么管理这些连接背后互相影响的共享状态(状态一致性问题)

Web 后端基本只需要回答第一个问题,第二个问题被推给了数据库的事务。游戏服务器把第二个问题拉回到了进程内部——这正是它难的地方。

一个容易被忽略的数字账 ​

连接容量不只是"epoll 能挂多少 fd"的问题,而是资源账:

  • 10 万个连接,每个连接维护 1MB 状态,就需要 100GB 内存
  • 10 万个 OS 线程,每个线程 MB 级栈空间,内存和调度开销都会失控
  • 10 万个连接全部挤在一个事件循环里,任何一次 10ms 的阻塞都会让所有玩家同时卡顿

所以并发模型的选型,本质上是在连接容量、开发复杂度、延迟特性、资源效率四个维度之间做取舍。没有全赢的模型,只有适合当前游戏形态的模型。

权衡维度关键问题典型翻车场景
连接容量单进程能稳定挂多少连接?网关用线程模型,5000 连接后内存和切换开销失控
开发复杂度团队写得对、写得快、查得了吗?手写无锁队列,出问题时没人能调试
延迟特性高峰期尾延迟可控吗?平均延迟很好,但每几分钟有一批请求超时
资源效率每个连接花多少内存和 CPU?每连接开一个 goroutine 加一个大缓冲,10 万连接吃光内存

七种模型与主流框架的映射 ​

理解模型之后再看框架,会发现框架光谱其实就是模型光谱:

并发模型代表框架/运行时一句话概括
单线程事件循环Node.js、Redis、Nginx worker一个循环跑完所有事件,永不阻塞
线程池Java ExecutorService、C++ 线程池任务进队列,固定数量线程消费
ReactorNetty、libuv、Boost.Asio事件就绪通知 + Handler 分发
ProactorWindows IOCP、Linux io_uringI/O 完成通知,数据已经备好
ActorErlang/OTP、Skynet、Orleans状态封闭在 Actor 内,消息驱动
协程Go goroutine、Lua 协程用户态轻量执行单元,阻塞式写法
CSPGo channel不共享内存靠通信,通过 Channel 交换数据

先建立一个前置共识:主逻辑线程与单写者 ​

在展开七种模型之前,需要先说清楚一个行业里反复被验证的结论,否则很容易把"选并发模型"理解成"选一个模型用到底"。

绝大多数上线游戏的核心逻辑,最终都收敛到"每个房间/场景/分区一个单线程逻辑上下文"。 不管外层用 Reactor、协程还是 Actor,房间内的位置、血量、Buff、技能时序,都由一个顺序执行的逻辑上下文独占地推进。

这不是巧合,而是由游戏逻辑的性质决定的:

  • 房间内状态之间高度耦合(伤害触发 Buff、Buff 改变移速、移速影响判定),拆到多线程会制造大量锁和时序问题
  • 游戏逻辑对抖动极其敏感——不怕平均慢一点,怕"偶尔特别慢"
  • 帧同步和回放、裁决等能力,前提就是逻辑执行顺序确定、可重演

这就是单写者原则:同一份状态在任何时刻只有一个执行流有权写它。单写者带来的好处不是"快",而是可推理——出问题时你不需要怀疑并发交错,只需要看顺序执行的轨迹。

所以更贴近现实的并发模型设计,不是"整个服务器用一个模型",而是:

text
        ┌──────────────────────────────────────────────┐
        │              连接层(高并发 I/O)              │
        │      Reactor / Proactor / 每连接一个协程       │
        └───────────────────┬──────────────────────────┘
                            │  消息(解码、校验后投递)
        ┌───────────────────▼──────────────────────────┐
        │           逻辑层(每房间/分区一个上下文)        │
        │     单线程 Tick 推进,状态单写,顺序确定         │
        └───────┬───────────────────────┬──────────────┘
                │ 消息/任务               │ 异步写队列
        ┌───────▼────────┐      ┌────────▼─────────────┐
        │  计算层(线程池) │      │  外围层(异步化)       │
        │ 寻路/AOI/数值批量 │      │  落库、日志、埋点、通知  │
        └────────────────┘      └──────────────────────┘

并发发生在边缘,串行发生在核心。 接入层用高并发 I/O 模型扛连接,计算层用线程池消化重计算,外围层异步化隔离慢操作,而房间内部保持单线程顺序推进。后面的七种模型,都是这套分层里某一层的候选方案。


七种并发模型详解 ​

以下每个模型按统一结构展开:核心思路 → 它解决什么问题 → 它引入什么新问题 → 游戏中的真实用法 → 适用场景与坑。

1. 单线程事件循环(Single-Thread Event Loop) ​

核心思路:一个线程跑一个无限循环,每轮处理一批就绪的 I/O 事件、到期定时器和待执行任务,处理期间不允许任何阻塞。

典型代表:Node.js、Redis、Nginx worker、Lua 单进程游戏服

text
┌────────────────────────────────────┐
│            Event Loop               │
│                                    │
│   epoll_wait ──► 就绪事件队列        │
│       │                            │
│       ▼                            │
│   逐个执行 Handler(不阻塞)          │
│       │                            │
│       ▼                            │
│   到期定时器 ──► 下一轮 ◄──────────── │
└────────────────────────────────────┘

它解决什么问题:把并发问题的复杂度直接降为零。没有锁、没有竞态、没有内存可见性问题,所有状态天然只被一个线程碰,代码逻辑和执行顺序完全一致。Redis 用这个模型做到了单机 10 万 QPS,证明了"简单"和"高性能"并不矛盾——前提是操作都是内存级的短操作。

它引入什么新问题:所有操作共享一个执行流,一个慢操作拖住所有人。一次同步的数据库查询、一次大 JSON 序列化、一次 GC 停顿,都会让全部玩家的操作排队等待。这也是它和"游戏主逻辑单线程"的区别——Redis 里的操作是微秒级的,而游戏逻辑动辄涉及落库和复杂计算,裸用事件循环装游戏逻辑会很痛。

游戏中的真实用法:

go
// 轻量卡牌服的主循环骨架(伪代码):
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 级栈分配、内核调度初始化),线程池通过复用把这笔成本摊薄。对于任务之间彼此独立、不共享可变状态的计算,线程池能拿到接近线性的多核加速。

它引入什么新问题:任务一旦需要访问共享数据,就回到锁的世界。锁的代价远不只是加解锁那几行代码——线程等待、上下文切换、缓存失效、优先级反转,最终往往表现为尾延迟恶化和偶发超时,而不是稳定的变慢。这是线程池模型最隐蔽的成本(后文"锁的真实代价"一节详细展开)。

游戏中的真实用法——游戏服务端里线程池的标准位置是"逻辑层旁边的计算层":

go
// 房间逻辑线程发现需要给 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

text
单 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 的教科书场景:

java
// 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 的异步模式

text
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

text
┌─────────┐   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,配合监督树实现"让一部分进程死掉再重建"的容错哲学。

它引入什么新问题:

  1. 跨 Actor 操作变成异步——问 Actor B 要数据需要发消息再等回复,代码从"函数调用"变成"请求-响应消息对",写起来啰嗦,出错场景(超时、乱序、重复)需要自己处理
  2. 邮箱本身是队列——热点 Actor 的邮箱会堆积,堆积意味着延迟上涨,而大多数 Actor 框架默认邮箱无上限(背压问题,见后文)
  3. 调试困难——没有传统调用栈,追踪一个 bug 需要看消息流转日志而不是断点单步

游戏中的真实用法——Skynet 风格的服务划分:

lua
-- 每个房间一个 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,协程让你用同步阻塞的写法获得异步非阻塞的性能:

go
// 一个连接一个 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 成为游戏服务端主流语言的核心原因:并发单元的成本降到了"可以随便开"的量级。

它引入什么新问题:

  1. 协程没有消除并发问题,只是把它变得容易忽略——两个 goroutine 同时改一个 map,照样数据竞争,而且 Go 的 map 并发写会直接 panic。协程的易用性让新手更容易在不知不觉间共享了状态
  2. GC 压力——goroutine 的栈是 GC 扫描对象,百万级 goroutine 会让 GC 的标记时间明显变长
  3. 泄漏——阻塞在 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)

text
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 在游戏服务端最常见的形态是流水线:

go
// 接入 → 逻辑 → 落库 的三段流水线,每段独立伸缩
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 网关、落盘
ActorActor无共享(封闭)邮箱消息中(含排队)⭐⭐房间/实体/服务拆分
协程goroutine需自律channel极低⭐⭐连接、房间、异步任务
CSP顺序进程无共享(靠通信)channel低⭐⭐流水线、多段解耦

两个读表提示:

  • 开发难度低不等于不容易写出 bug。协程开发难度评级低是因为写法自然,但共享状态的错误也更隐蔽——难度评的是"写出正确代码的心智负担",而协程模型的心智负担集中在"你能不能忍住不共享状态"
  • 延迟一列指的是模型自身引入的开销,不含业务处理时间。Actor/CSP 的"中"来自队列排队,这个排队在高负载下既是祸(延迟涨)也是福(削峰)

现实中的混合架构 ​

成熟的游戏服务器几乎从不"用一个模型",而是按层混用。两个典型案例:

案例 1:卡牌/挂机服(无实时同步) ​

text
HTTP/WebSocket 网关(Reactor 或每连接一个 goroutine)
        │
        ▼
单进程业务逻辑(事件循环 or 单 goroutine 串行处理)
        │                │
        ▼                ▼
  定时器(最小堆)    异步任务队列(channel)
  体力恢复/建筑升级      ▼
                    落库消费者(攒批写入 MySQL)

特点:逻辑本身串行就够(单服几千在线),重点在外围异步化——落库、推送、活动结算全部走 channel 扔给后台消费者,主逻辑永远不等数据库。

案例 2:MOBA/FPS 房间服(强实时) ​

text
网关层:主从 Reactor(每连接会话,TCP_NODELAY,长度字段分包)
        │ 消息解码+校验后投递
        ▼
房间层:每个房间一个 goroutine(或 Skynet service)
        内部以固定频率 Tick(如 15Hz 逻辑帧)
        状态单写,输入按帧序消费
        │
        ├──► 广播聚合:一帧内合并状态更新,减少包量
        ├──► 计算层:寻路/AOI 快照丢线程池,结果回投房间
        └──► 持久化 channel:战绩、结算异步落库

特点:并发的边界被精确地画在房间门口——房间外一切都是消息,房间内一切都是顺序。这套结构同时满足了性能(I/O 多路复用)、正确性(单写者)和可观测性(房间内执行轨迹可回放)。

混合架构的分工口诀 ​

层职责适合的模型
连接层海量连接、编解码、路由Reactor / Proactor / 每连接一个协程
逻辑层房间/场景/分区状态推进单线程 Tick(以协程/Actor/服务为容器)
计算层重计算、可并行线程池(只碰快照,结果回投)
外围层落库、日志、推送、埋点消费者协程 + 队列(异步化隔离)

并发模型的下半场:流控与背压 ​

选对模型只是上半场。队列是所有这些模型(Actor 邮箱、CSP channel、线程池任务队列)的共同底座,而队列的行为决定了系统在过载时的死法或活法。

为什么高峰时先崩的是队列 ​

高峰来时,CPU 往往还没满,但队列已经开始堆积:网关写缓冲、Actor 邮箱、落库队列、广播待发列表。如果系统对堆积的唯一反应是"继续接、继续塞",那么:

  1. 队列变长 → 尾延迟上涨
  2. 尾延迟上涨 → 客户端超时重试
  3. 重试放大入口流量 → 队列更长

最后系统不是"扛住了",而是靠积压把故障推迟了几分钟,然后雪崩。没有上限的队列,本质上是把故障延迟暴露。

稳定的系统给每个队列配三样东西 ​

go
// 有上限的落库队列:满时的策略是显式选择的
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)   // 降级:先落本地,事后补偿
    }
}
  1. 显式容量上限——单连接写缓冲上限、单房间广播积压上限、落库队列长度上限,都是写进代码的设计参数,不是"内存够就行"
  2. 超限策略——拒绝、降级、丢过期消息(如位置同步包,丢旧保新)、暂停接单,四选一,按消息的业务价值定
  3. 优先级隔离——核心链路(登录、战斗广播、结算)和外围链路(公告、红点、埋点)分开排队,外围再挤也不能拖核心

退化顺序要提前设计 ​

一个实用的退化层次,按顺序启用:

  1. 先丢低优先级、可过期的消息(旧位置包、非关键推送)
  2. 再降级外围异步链路(埋点采样、日志降频)
  3. 再限制新流量进入(登录排队、匹配限速)
  4. 最后才动核心主链路

关键问题不是"怎么不丢消息",而是:如果系统现在只能处理一半流量,哪一半需要活下来? 这个答案属于产品决策,需要技术提前实现成代码。

背压为什么需要提前做 ​

很多团队想"上线前压测,发现瓶颈再补流控"。这个顺序通常来不及——没有容量边界,压测看不出真实故障形态;没有优先级隔离,压测只能证明"系统会一起死";没有降级路径,高峰演练没有任何抓手。流控和背压应该作为架构的一部分,而不是优化项。


锁的真实代价:为什么老手偏爱单写者 ​

只要用了线程池或多线程共享结构,就绕不开锁。这里值得花一节讲清楚锁的真实成本,因为它经常被低估。

锁的代价不只是加解锁 ​

成本说明
线程等待与切换竞争时线程挂起、唤醒,每次微秒级,高 contention 下累积
缓存失效锁保护的 cacheline 在核间弹跳,NUMA 机器上更糟
尾延迟恶化平均值好看,P99 毛刺,玩家感受就是"偶尔卡"
死锁/活锁风险锁顺序管理随锁数量指数变难
可复现性差依赖线程调度时机,问题本地测不出、线上偶发

最后一条在工程上最致命:锁引发的问题通常表现为偶发、难复现、时序相关,排障成本远超它省下的开发时间。

游戏服务器里的高频热点锁 ​

实践中最容易出事的位置:全局玩家表、房间/场景索引、广播连接列表、定时器容器、共享对象池、跨线程日志聚合器。它们的共同点是:出现在高频路径上,且被所有线程共享。

细分锁通常不如不共享 ​

锁竞争出现后,第一反应往往是"把锁粒度拆细"。这个方向有时有效,但更常见更稳的做法是先问:这份状态真的需要共享吗?

  • 分片:全局玩家表按 playerID % N 分成 N 个带各自锁的分片,竞争直接降为 1/N
  • 复制:各线程持有一份只读快照,定期刷新,读路径完全无锁
  • 单写者:状态收敛到拥有它的房间/Actor/协程内,外部通过消息读写——并发问题转化为边界问题
  • 异步聚合:统计数据各线程本地累加,定期合并,而不是实时改全局计数器

什么时候锁仍然合理 ​

锁不是敌人,它在低频路径上完全合理:配置热更新、GM 命令、启动期初始化、低频后台任务。原则只有一条:别让锁出现在本应顺序推进的核心链路高频路径上。

如果锁竞争已经发生,比较稳妥的治理顺序是:

  1. 定位是哪份共享状态在热路径上被争用(用 pprof 的 mutex profile,而不是猜)
  2. 判断这份状态是否真的需要跨线程共享
  3. 优先考虑分片、复制、单写者、异步聚合这类结构解法
  4. 结构改不了时,才做锁粒度优化、读写锁或无锁结构

代码示例:单写者房间服的最小骨架 ​

本章概念密度高,代码分散在各模型小节里。这一节把三个关键词——单写者、消息驱动、背压——装进一个可读的最小骨架。它用到的正是模型光谱里"协程 + CSP + 单线程逻辑上下文"的组合:接入层是协程模型,投递靠 channel(CSP),房间内部是单线程逻辑上下文。第 6 章的 Go 框架,几乎都是这个骨架的产品化封装。

房间:单写者上下文 + 有界队列 ​

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 里改任何字段都不需要锁,出问题时只需看顺序执行轨迹,不用怀疑并发交错。

接入层:并发发生在边缘 ​

go
// 一个连接一个 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 消息,让房间在正确的顺序位置上处理掉线(可能与"正在结算"这样的时序问题天然串行化)。

房间想用多核时:委托,而不是加锁 ​

go
// 房间内遇到重计算(如 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,房间内模型不变)、持久化(脏数据走异步写队列)——都不动摇"单写者"这个核心决策。下一节选型指南里按语言、按游戏类型的选择,本质上是给这个骨架的接入层和计算层换不同实现。


选型指南 ​

按语言/生态选型 ​

text
团队主语言是什么?
│
├─ 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 章的框架光谱一致:并发架构是跟着在线规模长出来的,不是立项时一次画出来的。


常见陷阱清单 ​

  1. 过早优化并发。项目初期花两周设计"完美的多线程架构",结果 DAU 撑不起一半的复杂度。先跑起来,让真实负载告诉你瓶颈在哪。
  2. 把协程当免费资源。goroutine 本身轻,但挂在它身上的缓冲区、定时器、闭包引用不轻。10 万连接 × 每连接 64KB 缓冲 = 6.4GB,这种账要在设计期算。
  3. 无界队列。所有 make(chan T) 默认无缓冲或随手给个BufferSize,从不考虑满了怎么办。给每个关键队列一个显式上限和超限策略。
  4. 并发/并行不分。并发是"交替处理多个任务",并行是"同时执行多个任务"。单线程事件循环是并发不并行;线程池是并发且并行。理解这个区别,才能明白"单线程也能扛高并发"和"多核用不上"为什么同时成立。
  5. 在事件循环/Reactor 线程里做重活。同步 DB、大序列化、复杂计算,任何一项都会让同循环上的所有连接陪葬。
  6. goroutine 泄漏。go 出去的协程阻塞在没人写的 channel 上,监控里 goroutine 数缓慢爬升是典型信号,上线前就该有 goroutine 数指标。
  7. 锁竞争靠加机器解决。横向扩容解决不了单进程内的热点锁——分片/单写者是结构问题,加机器是容量问题,两回事。
  8. 忽略 GC 与并发的相互作用。大量存活 goroutine 和高频分配会让 GC 标记时间变长,GC 停顿又让所有协程一起停。对象池复用、减少小对象分配、必要时调 GC 参数,都是规模期的常规功课。

实战案例:一次「重启就好」的高峰队列失控排查 ​

案例为教学示例(非摘自真实项目),数字经过取整简化,聚焦排查思路与回填逻辑。

背景与现象

版本活动上线的第一个晚高峰,网关 P99 突破 3 秒,玩家批量超时重连;CPU 峰值只有 45%,内存以每高峰约 240MB 的速度缓涨。值班的处置是重启进程:立刻恢复,十几分钟后复发,一晚上重启三次。活动房间入口流量涨到 2400 条/秒,高峰持续 10 分钟;基础监控自始至终没有一条告警。

第一步:现象与口径 ​

三个口径在同一时刻各说各话:

  • 基础监控:CPU 45%、内存缓涨 240MB,全部在告警阈值内——「系统健康」;
  • 网关与玩家:P99 3 秒、批量超时重连——「系统不可用」;
  • 队列视角:队列深度指标 0 个——堆积的第一现场没有被记录,队列深度是复盘时用消息量倒推出来的。

口径差不在数字上,在视角缺失上:0 个队列指标让「堆积过程」成为监控盲区——先补视角,再谈归因。

第二步:分层归因 ​

按消息的处理链路逐层走:

text
接入层   连接 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,对上监控里的缓涨曲线。

第三步:根因 ​

  1. 直接原因:活动房间消息队列无界——append 进切片,从不设上限;入口大于消费速率时唯一的去向就是堆积,重启是唯一能清空它的操作。
  2. 机制原因:陷阱清单 8 条里的第 3 条「无界队列」写着的正是这件事;队列三件套 3 样全无——无显式容量上限、无超限策略(4 选一没得选)、无优先级隔离;退化顺序 4 层没有一层实现,高峰时连「丢旧位置包」这第一档都不存在。
  3. 深层原因:流控和背压被当成优化项,而不是架构的一部分——「上线前压测、发现瓶颈再补流控」的顺序里,没有降级路径的压测只能证明「系统会一起死」;连接容量在设计期算过 fd 的账,队列的账没人算(「10 万连接 × 64KB = 6.4GB 要在设计期算」的同款账目,24 万条 × 1KB 就躺在队列里没人看见)。

第四步:处置与回填 ​

  1. 队列有界化:活动房间消息队列换成有界 channel,上限对齐讲义骨架的 4096——容量是写进代码的设计参数,不是「内存够就行」。
  2. 超限四选一:位置包丢旧保新(第 04 讲 Checklist 的同款纪律,接入层直接弃)、核心消息满时降级写本地 WAL 事后补偿——按消息业务价值显式选择。
  3. 队列视角进监控:深度、水位 75%、溢出计数三项进面板,水位越线即告警——「堆积过程」从此可见。
  4. 退化顺序进代码与演练:退化顺序 4 层写进代码并进高峰演练,先丢过期消息、再降级外围、再限新流量、最后才动核心。

复核:下一个高峰,P99 从 3 秒 → 180ms,积压 24 万 → 0,CPU 还是 45%——算力没变,卡顿没了。

回填清单:

回填项落点回链
房间队列有界化 + 超限四选一房间骨架第 05 讲流控三件套 / 代码示例
队列深度、水位、溢出计数进监控面板监控告警第 13 讲监控合账
退化四层进高峰演练预案容量预案第 05 讲退化顺序 / 第 17 讲发布演练
队列容量账进设计期检查单设计检查第 05 讲数字账 / 陷阱清单第 3 条

案例的三个教训 ​

  1. CPU 不高不等于健康——队列是所有并发模型的共同底座,堆积发生在算力见顶之前;监控里没有队列深度,就等于没有过载预警。
  2. 队列容量是设计参数,不是运行结果——上限写进代码,满时的四选一按业务价值显式选择;「内存够就行」不是没有上限,是把故障推迟到高峰再一起收账。
  3. 背压是架构的一部分,不是优化项——退化顺序要提前设计并演练,压测补不出流控:没有降级路径的压测,只能证明「系统会一起死」。

小结 ​

关键问题答案
游戏服务端并发要解决什么?大量长连接 + 连接背后互相影响的共享状态,第二点是它和 Web 后端的本质区别
七种模型分别是什么?单线程事件循环、线程池、Reactor、Proactor、Actor、协程、CSP
最重要的结构原则?单写者:并发发生在边缘(连接/计算/外围),核心逻辑每房间/分区一个顺序执行流
Actor 和 CSP 的区别?Actor 发消息给特定 Actor(知道对方身份),CSP 发给 Channel(发送方与消费方解耦)
Reactor 和 Proactor 的区别?Reactor 通知"就绪可读写"(你自己读),Proactor 通知"已完成"(数据已备好)
协程解决了什么?用同步阻塞的写法获得异步非阻塞的性能——并发单元成本降到可以随便开的量级
系统过载时靠什么活下来?显式队列上限 + 超限策略 + 优先级隔离 + 预先设计的退化顺序
锁竞争怎么办?先问"这状态需要共享吗":分片/复制/单写者/异步聚合,最后才是锁优化
一开始该选哪种?用最熟悉的模型跑通原型;规模上来后先做房间隔离,再做进程拆分,最后才谈多线程共享

游戏知识体系