《游戏服务端编程实践》1.3.2 运行期事件与资源管理

从连接、定时器、房间 tick、跨服消息、配置变更与进程信号出发,讲清游戏服务端运行期的事件驱动模型、优先级与饥饿问题、资源生命周期与关闭顺序,并给出带取消、周期重排和真实时钟启动测试的 Go 最小堆定时器实现,帮助读者把运行期从随手起 goroutine 收敛为可观测、可测试的事件循环。

一、运行期由事件驱动,而不是由 main 函数驱动

启动流程只决定进程能不能起来;真正决定玩家体验的是运行期。运行期的本质是一句话:外部输入到达,服务端在有限时间内改变世界状态并给出响应。这里的“外部输入”远不止客户端请求,还包括定时器、房间 tick、跨服消息、配置变更和操作系统信号。

把运行期拆成事件,是为了回答三个工程问题:谁在什么时候被唤醒、这次唤醒允许做多少事、做完之后哪些资源必须被释放。如果这三个问题没有统一答案,代码会退化成“到处起 goroutine、到处加锁、到处 sleep”的形态,问题只在压力上来后才暴露。

事件来源典型触发允许的耗时失败后的表现
客户端消息移动、技能、聊天毫秒级单连接卡顿或断线
定时器心跳、 buff 到期、活动开启毫秒级延迟累积、任务重复
房间 tick帧同步、AI 更新固定帧预算整房间卡顿
跨服消息跨服战、世界事件十毫秒到秒级局部状态不一致
配置变更热更新、开关下发秒级新旧配置混用
进程信号SIGTERM、SIGHUP受停机预算约束数据丢失或半关闭

这张表的重点不是数字,而是预算不同。把配置变更这种秒级事件塞进 50 毫秒的 tick 里,等于主动制造卡顿;把战斗结算扔到无界队列里,则会在高峰期把内存吃光。

二、统一事件循环与多 goroutine 的取舍

同一类事件应该走同一条执行路径,否则“顺序”和“并发”会同时失去保证。常见做法有两类。

模型优点代价适用场景
单事件循环顺序确定、无锁、易调试单核上限、重活会阻塞全服房间逻辑、回合制、小规模世界
每连接 goroutine编程简单、阻塞互不影响连接数一多调度和内存压力大网关、登录、短连接服务
固定 worker 池并发可控、能隔离慢任务需要队列与背压战斗结算、写库、跨服调用
分片事件循环兼顾顺序与多核路由和迁移复杂大世界、多房间

选择标准不是“哪个先进”,而是状态归谁所有。一个房间的实体状态如果只被一个循环修改,就不需要为每个实体加锁;一旦把房间拆给多个 worker,就必须引入消息传递或锁,复杂度立刻上升。

三、事件优先级与饥饿

只要队列有优先级,就有饥饿。登录风暴时如果高优先级队列持续有包,低优先级的聊天和日志可能长期得不到执行。工程上通常用三种手段缓解:按权重轮询多个队列、给每类事件设置单轮配额、对超时事件做降级或丢弃。

需要强调的是,优先级不是免费的。每增加一级优先级,就要回答“它会不会饿死别人”“它的积压如何观测”“它满了之后丢谁”。没有观测和背压的优先级队列,只是把故障从“慢”变成“不可预测”。

四、资源生命周期与关闭顺序

运行期资源可以按“谁创建、谁持有、谁关闭”分类。连接、会话、房间、地图实例、临时对象池的关闭顺序不能拍脑袋,通常应遵循“先停止产生新引用,再等待在途操作,最后释放底层资源”。

资源创建者关键引用关闭前提
连接网关 accept会话、订阅不再读写,心跳停止
会话登录成功角色、房间角色已下线或已迁移
房间匹配或玩家创建玩家、定时器成员清空,tick 已停
地图实例世界服加载房间、NPC无房间引用,AI 已停
对象池进程启动临时实体所有借用者已归还

关闭顺序颠倒的典型后果是“连接已断,但房间仍持有会话并继续 tick”,最终表现为空指针、重复扣道具或内存泄漏。更稳妥的做法是让资源实现一个幂等的 Close,并在关闭时先把自己从父级索引中摘除,再释放子资源。

五、定时器管理:最小堆与时间轮

游戏服务端的定时器有两个硬要求:能取消、到期顺序稳定。最小堆适合数量不大、精度要求高的场景;时间轮适合海量短定时器,但实现和取消更复杂。无论选哪种,都要明确周期任务的重排策略:是从“原定时间”递推,还是从“本次处理时间”重新计时。

前者会在进程卡顿后补跑多个周期,后者会跳过错过的周期。对 buff 到期、活动开启这类业务,通常选择后者,并在日志里记录跳过次数,避免一次卡顿引发全服补算。

六、失败场景:tick 内做阻塞 IO

假设房间 tick 每 50 毫秒执行一次,某次 tick 里同步调用数据库保存战绩,数据库抖动 800 毫秒。这个房间的 16 次 tick 会全部错过,玩家看到的是“人物瞬移、技能不结算”。更糟的是,如果 tick 由单个全局循环驱动,阻塞会扩散到所有房间。

正确的拆分是:tick 只做内存状态推进,把保存请求写入有界队列,由独立 worker 落盘;队列满时明确降级(丢弃非关键日志、合并保存、拒绝新请求),而不是无限堆积。定时器泄漏也是同一类问题:创建了周期任务却忘记在房间销毁时取消,回调继续访问已释放的房间,最终表现为“房间没了但 CPU 还在涨”。

七、可运行实现:最小堆定时器

下面的实现把“到期判定”和“回调执行”分开:Advance 在锁内完成堆操作,在锁外执行回调,避免回调里再次操作定时器造成死锁。周期任务按 now + interval 重排,因此不会补跑风暴。

// file: ch01/runtime/timer.go
// tier: L1
// verify: go test ./ch01/runtime/
package runtime

import (
	"container/heap"
	"errors"
	"sync"
	"time"
)

var (
	ErrClosed      = errors.New("runtime: timer closed")
	ErrBadInterval = errors.New("runtime: interval must be positive")
	ErrNilCallback = errors.New("runtime: nil callback")
)

// TimerID 是定时任务的唯一标识。
type TimerID uint64

// Task 是定时器堆中的一个任务。
type Task struct {
	ID       TimerID
	At       time.Time
	Interval time.Duration
	Fn       func()
	index    int
	seq      uint64
}

type taskHeap []*Task

func (h taskHeap) Len() int { return len(h) }
func (h taskHeap) Less(i, j int) bool {
	if h[i].At.Equal(h[j].At) {
		return h[i].seq < h[j].seq
	}
	return h[i].At.Before(h[j].At)
}
func (h taskHeap) Swap(i, j int) {
	h[i], h[j] = h[j], h[i]
	h[i].index = i
	h[j].index = j
}
func (h *taskHeap) Push(x any) {
	task := x.(*Task)
	task.index = len(*h)
	*h = append(*h, task)
}
func (h *taskHeap) Pop() any {
	old := *h
	n := len(old)
	task := old[n-1]
	old[n-1] = nil
	task.index = -1
	*h = old[:n-1]
	return task
}

// Timer 负责到期判定和回调调度,回调本身必须保持轻量。
type Timer struct {
	mu      sync.Mutex
	tasks   taskHeap
	byID    map[TimerID]*Task
	nextID  TimerID
	seq     uint64
	closed  bool
	running bool
	wake    chan struct{}
	stop    chan struct{}
	done    chan struct{}
	now     func() time.Time
}

// New 创建一个使用系统时钟的定时器。
func New() *Timer { return NewWithClock(time.Now) }

// NewWithClock 允许测试注入逻辑时钟,配合 Advance 使用。
func NewWithClock(now func() time.Time) *Timer {
	t := &Timer{
		byID: make(map[TimerID]*Task),
		wake: make(chan struct{}, 1),
		stop: make(chan struct{}),
		done: make(chan struct{}),
		now:  now,
	}
	heap.Init(&t.tasks)
	return t
}

// After 注册一个延迟执行的一次性任务。
func (t *Timer) After(d time.Duration, fn func()) (TimerID, error) {
	if d < 0 {
		d = 0
	}
	return t.add(t.now().Add(d), 0, fn)
}

// Every 注册周期任务;从本次处理的 now 重新计时,避免补跑风暴。
func (t *Timer) Every(interval time.Duration, fn func()) (TimerID, error) {
	if interval <= 0 {
		return 0, ErrBadInterval
	}
	return t.add(t.now().Add(interval), interval, fn)
}

// At 注册指定绝对时间执行的一次性任务,主要供测试使用。
func (t *Timer) At(at time.Time, fn func()) (TimerID, error) {
	return t.add(at, 0, fn)
}

func (t *Timer) add(at time.Time, interval time.Duration, fn func()) (TimerID, error) {
	if fn == nil {
		return 0, ErrNilCallback
	}
	t.mu.Lock()
	defer t.mu.Unlock()
	if t.closed {
		return 0, ErrClosed
	}
	t.nextID++
	task := &Task{ID: t.nextID, At: at, Interval: interval, Fn: fn, seq: t.seq}
	t.seq++
	heap.Push(&t.tasks, task)
	t.byID[task.ID] = task
	t.signal()
	return task.ID, nil
}

// Cancel 取消尚未触发的任务。
func (t *Timer) Cancel(id TimerID) bool {
	t.mu.Lock()
	defer t.mu.Unlock()
	task, ok := t.byID[id]
	if !ok {
		return false
	}
	heap.Remove(&t.tasks, task.index)
	delete(t.byID, id)
	return true
}

// Advance 执行所有 At <= now 的任务,返回触发数量。
func (t *Timer) Advance(now time.Time) int {
	t.mu.Lock()
	var due []*Task
	for len(t.tasks) > 0 && !t.tasks[0].At.After(now) {
		task := heap.Pop(&t.tasks).(*Task)
		due = append(due, task)
		if task.Interval > 0 {
			task.At = now.Add(task.Interval)
			task.seq = t.seq
			t.seq++
			heap.Push(&t.tasks, task)
		} else {
			delete(t.byID, task.ID)
		}
	}
	t.mu.Unlock()
	for _, task := range due {
		task.Fn()
	}
	return len(due)
}

// Start 启动后台循环。
func (t *Timer) Start() {
	t.mu.Lock()
	if t.running || t.closed {
		t.mu.Unlock()
		return
	}
	t.running = true
	t.mu.Unlock()
	go t.loop()
}

func (t *Timer) loop() {
	defer close(t.done)
	for {
		t.mu.Lock()
		if t.closed {
			t.mu.Unlock()
			return
		}
		if len(t.tasks) == 0 {
			t.mu.Unlock()
			select {
			case <-t.wake:
			case <-t.stop:
				return
			}
			continue
		}
		wait := time.Until(t.tasks[0].At)
		t.mu.Unlock()
		if wait <= 0 {
			t.Advance(time.Now())
			continue
		}
		timer := time.NewTimer(wait)
		select {
		case <-timer.C:
		case <-t.wake:
			if !timer.Stop() {
				<-timer.C
			}
		case <-t.stop:
			if !timer.Stop() {
				<-timer.C
			}
			return
		}
	}
}

func (t *Timer) signal() {
	select {
	case t.wake <- struct{}{}:
	default:
	}
}

// Close 停止后台循环并丢弃未触发任务,可重复调用。
func (t *Timer) Close() {
	t.mu.Lock()
	if t.closed {
		t.mu.Unlock()
		return
	}
	t.closed = true
	t.tasks = nil
	t.byID = make(map[TimerID]*Task)
	running := t.running
	t.mu.Unlock()
	close(t.stop)
	if running {
		<-t.done
	}
}

测试覆盖到期顺序、取消、周期重排、真实时钟启动以及关闭后的拒绝行为。运行方式:

cd .local/checks/gs-practice-lab
gofmt -l ch01/runtime
go vet ./ch01/runtime/
go test ./ch01/runtime/

小结

运行期不是“main 起来之后随便跑”,而是一组有预算、有优先级、有生命周期的受控事件。先确定状态归谁所有,再决定用单循环还是多 worker;先定义资源的父子关系和关闭顺序,再写释放代码;定时器则要同时考虑取消、重排和回调隔离。

常见错误与边界

  • 在 tick 或事件回调里做同步数据库、RPC、文件写入,导致整条事件链卡死。
  • 关闭顺序颠倒:先关底层连接,再关依赖它的会话和房间。
  • 使用无界队列承接高峰流量,把延迟问题变成内存问题。
  • 周期定时器不取消,房间销毁后回调继续访问旧状态。
  • 认为优先级越高越好,却没有为低优先级事件设置配额和观测。
  • 把定时器回调放到独立 goroutine,却不限制并发,造成同一任务重入。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist