《MMO 服务器开发》10.1 消息驱动的实体

实体不该被多条 goroutine 同时改。本节把《晨曦大陆》的玩家实体改成消息驱动的 Actor:状态只由自己的循环推进,外界只拿到一个句柄;再用 8 个发送者、1.6 万条消息的 -race 测试证明实体字段一个锁都不需要,并说清句柄、注册表与路由各自的边界。

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 = 0FIFO 成立
最终坐标(16000, 16000)每条 +1 步,累计正确
-race 报告无数据竞争实体字段确实没被并发触碰
单发送者 5000 条applied = 5000,x = 5000TestSingleSenderFIFO 同样绿

注意 X/Y 的读发生在 r.Stop(7) 之后:Stop 会 <-h.done 等循环退出,这条「done 关闭」的边构成了 happens-before,所以 Snapshot() 的读是安全的、无需加锁的。这不是偷懒,是把同步点从每个字段挪到了生命周期边界上。

本节小结

  • 实体只被消息推进:apply 是唯一的状态入口,实体字段不导出,锁无处可加也无需加。
  • 句柄是唯一被共享的引用,它只暴露 Send;Snapshot() 限定在 Done() 之后调用。
  • 注册表是唯一带锁的结构,读多写少;路由只查表转发,为后续跨进程迁移留出位置。
  • -race 下 8 发送者 × 2000 条无竞争、无乱序、无丢失——「一个实体同时只被一个 goroutine 碰」确实能消掉绝大多数锁。

代价在下一节:既然所有东西都走邮箱,那邮箱满了怎么办?

阅读导航:上一节:目录 · 下一节:10.2 邮箱、顺序保证与背压 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标