一、运行期由事件驱动,而不是由 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,却不限制并发,造成同一任务重入。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。