10.1 消息驱动的实体
上一章我们把消息从连接送到了网关,但消息到了之后,谁来改状态?如果一张场景图里有一千个玩家实体,而每条连接、每个定时器、每个 AI tick 都可能去读写某个实体,那么「加锁」就会变成绕不开的事——而锁在 MMO 里恰恰是最容易写出死锁、优先级反转和偶发脏读的地方。
Actor 模型给的答案很直接:实体之间不共享内存,只通过消息通信;一条消息没处理完,就不处理下一条。于是「同一个实体在同一时刻只被一个 goroutine 碰」成为一条不变量,绝大多数锁自然消失。这不是把锁藏起来,而是把并发点从「字段级」上移到了「实体级」——一个实体内部是单线程的。
这一节用 aegis 的 internal/actor 包把《晨曦大陆》的玩家实体改成消息驱动,并给出句柄、注册表与路由的边界。代码是 L1,go test 可直接跑。
与站内 practice 文章的分工
站内已有一批服务端架构文章讨论「怎么切分、怎么取舍」,本书这一节只负责「怎么从零写出来并真跑」。分工如下:
| 站内文章 | 它负责的 | 本节负责的 |
|---|---|---|
| 游戏服务器实体运行时所有权架构设计 | 实体归属哪个进程、跨进程怎么迁移的方案取舍 | 进程内一个实体的运行时形态:句柄、循环、消息 |
| 游戏服务器异步任务队列设计 | 队列分层、优先级、削峰填谷的架构选择 | 邮箱的最小实现与拒绝策略(见 10.2) |
| 游戏服务器消息扇出架构设计 | 广播/扇出在集群层面怎么组织 | 单实体收消息的路径与 FIFO 保证 |
| 道具背包服务如何处理并发修改 | 高竞争资源的并发修改方案(CAS/队列/分段) | 把「并发修改」整体消掉:一个实体一个循环 |
一句话:它们谈架构方案与取舍,本书谈从零实现 + 真跑。
实体只被消息推进
先看实体本身。它没有任何锁,因为除了自己的循环,没有第二个 goroutine 会碰它的字段。apply 是唯一的状态变更入口,收到的每条消息都在这里被消费掉。
下面这段是 L1,go test 可以直接跑:
// file: internal/actor/entity.go
// tier: L1
// verify: go test ./internal/actor/ -run TestSingleSenderFIFO
package actor
// Command numbers reused verbatim from the protocol chapter; the actor layer
// never invents its own wire values.
const (
CmdEnterWorldReq uint16 = 0x0101
CmdMoveReq uint16 = 0x0201
CmdChatSend uint16 = 0x0301
)
// EntityID is a world-unique entity identifier.
type EntityID uint64
// Msg is one message handed to an entity. It carries values only: never a
// pointer into another entity's state.
type Msg struct {
Cmd uint16
Seq uint32
From EntityID
Payload []byte
}
// Entity is a player or NPC. Every field below is touched by exactly one
// goroutine — the one running this entity's loop — so none of them needs a lock.
type Entity struct {
ID EntityID
X int32
Y int32
HP int
Applied int
lastSeq map[EntityID]uint32
outOfOrder int
}
func newEntity(id EntityID) *Entity {
return &Entity{ID: id, HP: 100, lastSeq: make(map[EntityID]uint32)}
}
// OutOfOrder reports how many messages arrived with a non-increasing sequence
// number from a sender that had already been seen.
func (e *Entity) OutOfOrder() int { return e.outOfOrder }
func (e *Entity) apply(msg Msg) {
if msg.From != 0 {
if last, ok := e.lastSeq[msg.From]; ok && msg.Seq <= last {
e.outOfOrder++
}
e.lastSeq[msg.From] = msg.Seq
}
if msg.Cmd == CmdMoveReq && len(msg.Payload) >= 4 {
dx := int32(int16(uint16(msg.Payload[0]) | uint16(msg.Payload[1])<<8))
dy := int32(int16(uint16(msg.Payload[2]) | uint16(msg.Payload[3])<<8))
e.X += dx
e.Y += dy
}
e.Applied++
}
Msg 里刻意只有值:Cmd、Seq、From 和一段 Payload 字节。它不携带指向别的实体的指针,也不携带指向别的实体状态的引用——这是 Actor 模型能成立的前提。如果允许「顺手把邻居实体的结构体指针塞进消息里」,那共享内存又回来了,锁也跟着回来。
lastSeq 这张表是为验证顺序保证而留的:它记录每个发送者最近一次的序号,只要新来的消息序号不增,就记一次乱序。生产代码里它同时也是 11 章「命令去重」的种子。
句柄与路由:谁持有实体的引用
实体自己锁不住自己——真正决定并发安全的是谁能拿到它。所以我们不把 *Entity 交出去,只交出一个 Handle,它对外只有一个 Send。
下面这段同样是 L1,go test 可以直接跑:
// file: internal/actor/handle.go
// tier: L1
// verify: go test ./internal/actor/ -run 'TestSingleSenderFIFO|TestRouteAfterStopIsClosed'
package actor
import "sync"
// Handle is the only reference other goroutines hold to an entity. It exposes a
// Send method and nothing else, so nobody outside can touch Entity fields.
type Handle struct {
id EntityID
mb *Mailbox
e *Entity
done chan struct{}
}
// ID returns the entity id behind the handle.
func (h *Handle) ID() EntityID { return h.id }
// Send routes a message into the entity's mailbox.
func (h *Handle) Send(msg Msg) error { return h.mb.Send(msg) }
// Done is closed after the entity loop has drained and exited.
func (h *Handle) Done() <-chan struct{} { return h.done }
// Stop asks the loop to finish; wait on Done for it to drain.
func (h *Handle) Stop() { h.mb.Close() }
// Snapshot returns a copy of the entity state. It is safe only after Done has
// closed, when the loop is guaranteed to have stopped and the happens-before
// edge through Done protects the read.
func (h *Handle) Snapshot() Entity { return *h.e }
// Registry is the router: it maps an entity id to its handle. It is the only
// shared, lock-protected structure in the runtime, and it is read-mostly.
type Registry struct {
mu sync.RWMutex
m map[EntityID]*Handle
cap int
policy RejectPolicy
}
// NewRegistry returns a registry that spawns entities with the given mailbox
// capacity and rejection policy.
func NewRegistry(capacity int, policy RejectPolicy) *Registry {
return &Registry{m: make(map[EntityID]*Handle), cap: capacity, policy: policy}
}
// Spawn starts an entity with its own goroutine and returns its handle.
func (r *Registry) Spawn(id EntityID) *Handle {
e := newEntity(id)
h := &Handle{id: id, mb: NewMailbox(r.cap, r.policy), e: e, done: make(chan struct{})}
r.mu.Lock()
r.m[id] = h
r.mu.Unlock()
go func() {
defer close(h.done)
for {
msg, ok := h.mb.Recv()
if !ok {
return
}
e.apply(msg)
}
}()
return h
}
// Lookup returns the handle for id, if the entity is alive.
func (r *Registry) Lookup(id EntityID) (*Handle, bool) {
r.mu.RLock()
h, ok := r.m[id]
r.mu.RUnlock()
return h, ok
}
// Route resolves the handle through the table and sends msg to it.
func (r *Registry) Route(id EntityID, msg Msg) error {
h, ok := r.Lookup(id)
if !ok {
return ErrClosed
}
return h.Send(msg)
}
// Stop stops the entity, waits for it to drain, and removes it from the table.
func (r *Registry) Stop(id EntityID) {
r.mu.Lock()
h, ok := r.m[id]
delete(r.m, id)
r.mu.Unlock()
if ok {
h.Stop()
<-h.done
}
}
三个边界值得单独说。
第一,句柄是唯一被共享的东西。Handle 里确实有一个 *Entity 字段,但它没有导出,也没有任何导出方法返回它——唯一的出口是 Snapshot(),而它被文档明确限定为「Done() 关闭之后才能调用」。这不是靠纪律,是靠 API 形状把「随手读一下实体字段」这条捷径堵死。
第二,注册表是运行时里唯一带锁的结构,而且它几乎只读。Lookup 走 RLock,Spawn/Stop 才拿写锁,所以一万个实体同时被路由不会有写竞争。锁没有被消灭,而是被收拢到一张「id → 句柄」的表上——这正是 Actor 模型愿意付的代价。
第三,路由(Route)只做一件事:查表、转发。它不解释消息内容,也不关心实体在哪。把实体挪到别的进程时,只要换掉这一层,上层逻辑一行不用改——这就是 7.3 网关职责边界里说的「位置透明」。
真跑:无锁实体与 -race 验证
光看代码不能证明「一个锁都不需要」。真正能证明的是把同一个句柄交给 8 个 goroutine 猛敲,然后开着 -race 跑一遍。下面这段是 entity_test.go 的节选(snippet,不是完整文件):
// snippet: internal/actor/entity_test.go(节选)
// TestConcurrentSendersNoRace hammers one entity from many goroutines. Run with
// -race it proves that the entity fields need no lock: only the loop touches
// them. Each sender's own order is still preserved.
func TestConcurrentSendersNoRace(t *testing.T) {
r := NewRegistry(64, RejectBlock)
h := r.Spawn(7)
const senders, per = 8, 2000
var wg sync.WaitGroup
for s := 0; s < senders; s++ {
wg.Add(1)
go func(id EntityID) {
defer wg.Done()
for i := 0; i < per; i++ {
_ = h.Send(Msg{Cmd: CmdMoveReq, Seq: uint32(i + 1), From: id, Payload: movePayload(1, 1)})
}
}(EntityID(s + 1))
}
wg.Wait()
r.Stop(7)
s := h.Snapshot()
if s.Applied != senders*per {
t.Fatalf("applied = %d, want %d", s.Applied, senders*per)
}
if s.OutOfOrder() != 0 {
t.Fatalf("out-of-order = %d, want 0", s.OutOfOrder())
}
if s.X != senders*per || s.Y != senders*per {
t.Fatalf("position = (%d,%d), want (%d,%d)", s.X, s.Y, senders*per, senders*per)
}
}
本机实测(Go 1.26,macOS arm64,go test ./internal/actor/ -race -count=1):
| 断言 | 结果 | 说明 |
|---|---|---|
| 8 个发送者 × 2000 条 = 16000 条全部落地 | applied = 16000 | 一条不丢 |
| 每个发送者内部无乱序 | out-of-order = 0 | FIFO 成立 |
| 最终坐标 | (16000, 16000) | 每条 +1 步,累计正确 |
-race 报告 | 无数据竞争 | 实体字段确实没被并发触碰 |
| 单发送者 5000 条 | applied = 5000,x = 5000 | TestSingleSenderFIFO 同样绿 |
注意 X/Y 的读发生在 r.Stop(7) 之后:Stop 会 <-h.done 等循环退出,这条「done 关闭」的边构成了 happens-before,所以 Snapshot() 的读是安全的、无需加锁的。这不是偷懒,是把同步点从每个字段挪到了生命周期边界上。
本节小结
- 实体只被消息推进:
apply是唯一的状态入口,实体字段不导出,锁无处可加也无需加。 - 句柄是唯一被共享的引用,它只暴露
Send;Snapshot()限定在Done()之后调用。 - 注册表是唯一带锁的结构,读多写少;路由只查表转发,为后续跨进程迁移留出位置。
-race下 8 发送者 × 2000 条无竞争、无乱序、无丢失——「一个实体同时只被一个 goroutine 碰」确实能消掉绝大多数锁。
代价在下一节:既然所有东西都走邮箱,那邮箱满了怎么办?
阅读导航:上一节:目录 · 下一节:10.2 邮箱、顺序保证与背压 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。