《MMO 服务器开发》2.1 阻塞、非阻塞与 Reactor

四种 I/O 模型的判别法、Reactor 模式的三个不变式,以及 Go runtime 的 netpoller 为什么能让「阻塞写法」在 epoll/kqueue 上跑成非阻塞。本节从零写一个单线程事件循环并用 go test 验证分发顺序与超时语义,再把它逐行对回 runtime 源码。

一个 MMO 网关要同时接住几万个长连接,而这些连接绝大多数时间什么都没发生:玩家在跑图、在翻交易行、在挂机。真正的问题从来不是「怎么处理数据」,而是「怎么让一万条都在发呆的连接只占用个位数的线程」。

站内专题 /posts/game/server/practice/ 里的方案文章已经把结论给过了——网关用 Reactor 或 goroutine-per-connection,游戏服内部用 Actor 或单线程 Tick。本节不重复那些取舍,只做一件事:把 Reactor 从零写出来,再证明 Go 标准库 net 包的「阻塞写法」底下跑的正是同一套东西。

本节要回答:为什么 Go 里写 conn.Read(buf) 这种阻塞调用,撑到 2000 条连接也不会把线程耗光? 结论是:net.Conn 的 fd 被注册进了 runtime 的 netpoller(Linux 上是 epoll,macOS 上是 kqueue),Read 遇到 EAGAIN 会把当前 goroutine 挂起(gopark)并把 M 让出去,等 netpoller 报告可读再唤醒。用户写同步代码,内核跑异步事件。

2.1.1 判别四种 I/O 模型:谁在等,等的是什么

关于 I/O 模型的说法很多,但判别只需要问一句:发起调用的那个线程,在数据到达之前处于什么状态? 按这一问,四种模型清清楚楚。

模型调用线程的状态谁在轮询 fd数据从内核到用户态由谁搬
阻塞 I/O睡在 read() 里内核,但只盯一个 fdread() 返回前内核搬完
非阻塞 I/O不睡,立刻拿到 EAGAIN应用自己反复调 read()某次成功的 read() 里内核搬
I/O 多路复用睡在 epoll_wait() / kevent() 里内核,一次盯 N 个 fd就绪后应用再调一次 read()
异步 I/O不睡,拿到「已提交」内核内核搬完再通知应用

这张表里藏着一个常被混淆的点:「同步/异步」和「阻塞/非阻塞」是两个维度。前三种模型都是同步 I/O——因为「数据从内核缓冲区拷到用户缓冲区」这一步,终究要由应用的那次 read() 来完成;只有第四种(io_uring、Windows IOCP)才是真正的异步。Reactor 属于第三种,它用一次 epoll_wait 把 N 个 fd 的等待合并成一个等待。

Reactor 模式本身只有三个不变式,写成代码之前先记住它们:

  1. 一个循环串行处理所有事件。 事件之间天然互斥,业务代码不用加锁。
  2. 「谁就绪了」和「就绪后干什么」必须分开。 前者是内核事件表,后者是应用注册的回调表。
  3. 回调必须短。 循环是单线程的,任何一个回调阻塞,全服停摆。长活儿要丢给别的线程池。

第三条是 MMO 网关最容易翻车的地方:一个回调里同步查一次数据库,整个网关的 P99 立刻从 2ms 变成 200ms。

2.1.2 从零写一个单线程事件循环

先把「谁就绪了」和「就绪后干什么」分开。Reactor 用一个 ready 通道搬运就绪事件(等价于 epoll_wait 的返回值),用一张 regs 表把事件映射到回调:

// snippet: internal/netio/reactor.go(数据结构与注册入口)
type Reactor struct {
	ready chan Event   // 就绪事件队列,等价于 epoll_wait 的返回数组
	wake  chan struct{} // 叫醒信号,等价于 netpollBreak 的自管道

	mu      sync.Mutex
	regs    map[int]map[EventType][]registration // fd -> type -> 按注册顺序的回调
	timers  map[int]timerArm
	nextSeq uint64

	now func() time.Time

	dispatched int
	timedOut   int
}

// Add 为 (fd, type) 注册回调;同一 (fd, type) 上按注册先后顺序依次触发。
func (r *Reactor) Add(fd int, typ EventType, cb Callback) {
	r.mu.Lock()
	defer r.mu.Unlock()
	m := r.regs[fd]
	if m == nil {
		m = make(map[EventType][]registration)
		r.regs[fd] = m
	}
	r.nextSeq++
	m[typ] = append(m[typ], registration{cb: cb, seq: r.nextSeq})
}

循环本体在另一个文件里。下面这段是 L1,go test ./internal/netio/ 可以直接跑:

// file: internal/netio/reactor_loop.go
// tier: L1
// verify: go test ./internal/netio/
// 事件循环本体:一个 select 同时等四件事。
//
// 与 reactor.go 同属 package netio,拆成两个文件只是为了正文里
// 每块代码都能完整贴出来,不影响编译。
package netio

import (
	"context"
	"sort"
	"time"
)

// nextWait 返回距离最近一个超时还有多久;没有挂载定时器时 ok=false。
func (r *Reactor) nextWait() (time.Duration, bool) {
	r.mu.Lock()
	defer r.mu.Unlock()
	var best time.Time
	for _, ta := range r.timers {
		if best.IsZero() || ta.deadline.Before(best) {
			best = ta.deadline
		}
	}
	if best.IsZero() {
		return 0, false
	}
	if d := best.Sub(r.now()); d > 0 {
		return d, true
	}
	return 0, true
}

// fireTimeouts 把所有已到期的定时器一次分发掉,
// 同一轮内按 Arm 的先后顺序(seq)分发,保证「先挂的先响」。
func (r *Reactor) fireTimeouts() {
	r.mu.Lock()
	now := r.now()
	var due []timerArm
	for fd, ta := range r.timers {
		if !ta.deadline.After(now) {
			ta.fd = fd
			due = append(due, ta)
			delete(r.timers, fd)
		}
	}
	r.mu.Unlock()

	sort.Slice(due, func(i, j int) bool { return due[i].seq < due[j].seq })
	for _, ta := range due {
		r.dispatch(Event{FD: ta.fd, Type: EventTimeout, At: now})
	}
}

// dispatch 把事件交给该 (fd, type) 上注册的回调,按注册顺序串行执行。
func (r *Reactor) dispatch(ev Event) {
	r.mu.Lock()
	m := r.regs[ev.FD]
	cbs := append([]registration(nil), m[ev.Type]...)
	r.dispatched++
	if ev.Type == EventTimeout {
		r.timedOut++
	}
	r.mu.Unlock()

	for _, reg := range cbs {
		reg.cb(ev)
	}
}

// Run 跑事件循环直到 ctx 取消。一个 select 同时等四件事:
// 就绪事件、最近一个定时器、外部改表后的唤醒、退出信号。
// 真实 netpoller 里「最近定时器」由 epoll_wait 的 timeout 参数承担、
// 「唤醒」由 netpollBreak 的自管道承担,语义完全一致。
func (r *Reactor) Run(ctx context.Context) (int, int) {
	for {
		wait, hasTimer := r.nextWait()
		var (
			timerC <-chan time.Time
			timer  *time.Timer
		)
		if hasTimer {
			timer = time.NewTimer(wait)
			timerC = timer.C
		}
		stopTimer := func() {
			if timer != nil {
				timer.Stop()
			}
		}
		select {
		case ev := <-r.ready:
			stopTimer()
			r.dispatch(ev)
		case <-timerC:
			r.fireTimeouts()
		case <-r.wake:
			stopTimer() // 定时器表被改动,下一轮重算 nextWait
		case <-ctx.Done():
			stopTimer()
			return r.Stats()
		}
	}
}
        ┌─────────────────────────────────────────────────────┐
        │  Run: for { select { ... } }                        │
        └─────────────────────────────────────────────────────┘
             ▲           ▲             ▲             ▲
             │           │             │             │
         ready 通道    timer.C      wake 通道     ctx.Done()
      (epoll_wait  (epoll_wait  (netpollBreak  (退出信号)
        的返回值)    的 timeout)    的自管道)
             │
             ▼
      dispatch(ev) ──► regs[fd][type] ──► cb1 → cb2 → cb3
                                          (按注册顺序,串行执行)

写这个循环时踩到一个真实的坑,值得单独说:改定时器表必须叫醒循环。 第一版没有 wake 通道,测试 TestArmOverwrites 直接挂了——循环此刻正阻塞在一个 5 秒后的 time.Timer 上,而调用方刚把超时改成 30 毫秒,这次改动要等 5 秒才生效。修法是加一个带缓冲 1 的 channel,Arm / Disarm 之后非阻塞地投一个信号。runtime 里这一步叫 netpollBreak,往 eventfd 写一个字节打断 epoll_wait,是同一个东西。

六个测试全绿,覆盖分发顺序、fd 与事件类型的隔离、超时只触发一次、覆盖语义、Disarm、Remove:

=== RUN   TestDispatchInRegistrationOrder
--- PASS: TestDispatchInRegistrationOrder (0.00s)
=== RUN   TestFDAndTypeIsolation
--- PASS: TestFDAndTypeIsolation (0.00s)
=== RUN   TestTimeoutFiredOnce
--- PASS: TestTimeoutFiredOnce (0.19s)
=== RUN   TestArmOverwrites
--- PASS: TestArmOverwrites (0.25s)
=== RUN   TestDisarmCancels
--- PASS: TestDisarmCancels (0.25s)
=== RUN   TestRemoveDropsEverything
--- PASS: TestRemoveDropsEverything (0.20s)
PASS
ok  	aegis/internal/netio	1.449s

2.1.3 Go 的 netpoller:同一个循环,藏在 runtime 里

上面那个循环是「应用层的 Reactor」。Go 把同一套东西下沉进了 runtime:每个网络 fd 在创建时都会被 runtime_pollOpen 注册进 netpoller,之后所有 Read / Write / Accept 都先试一次系统调用,EAGAIN 就交给 netpoller。

src/internal/poll/fd_unix.go 里 FD.Read 的主循环只有十几行,是整条链路的起点:

// snippet: src/internal/poll/fd_unix.go:163(FD.Read 主循环,Go 1.26.0)
for {
	n, err := ignoringEINTRIO(syscall.Read, fd.Sysfd, p)
	if err != nil {
		n = 0
		if err == syscall.EAGAIN && fd.pd.pollable() {
			if err = fd.pd.waitRead(fd.isFile); err == nil {
				continue
			}
		}
	}
	err = fd.eofError(n, err)
	return n, err
}

waitRead 一路走到 runtime.netpollblock,在那里把 goroutine 挂起:

// snippet: src/runtime/netpoll.go:575(netpollblock 的挂起动作,Go 1.26.0)
if waitio || netpollcheckerr(pd, mode) == pollNoError {
	gopark(netpollblockcommit, unsafe.Pointer(gpp), waitReasonIOWait, traceBlockNet, 5)
}

gopark 把 goroutine 置为 _Gwaiting,waitreason 是 waitReasonIOWait——这条线索在 2.1.4 的排障里会直接用上。goroutine 睡着之后,M 被释放去跑别的 goroutine,所以「阻塞」的只是 goroutine,不是线程。

唤醒侧就是 2.1.2 那个循环的 runtime 版。src/runtime/netpoll_kqueue.go:90 的 netpoll 就是 macOS 上的 kevent 封装,它返回的是一个 gList——一批被唤醒的 goroutine,而不是回调列表:

// snippet: src/runtime/netpoll_kqueue.go:90(netpoll 的签名与返回值,Go 1.26.0)
// netpoll checks for ready network connections.
// Returns a list of goroutines that become runnable,
// and a delta to add to netpollWaiters.
func netpoll(delay int64) (gList, int32) {

调用点在 src/runtime/proc.go 里,调度器把「网络可读了」当成一种「找到活了」:

1765:		list, delta := netpoll(0) // non-blocking
3496:		list, delta := netpoll(0)
3754:		list, delta := netpoll(delay) // block until new work is available
3813:		if list, delta := netpoll(0); !list.empty() {
6571:			list, delta := netpoll(0) // non-blocking - returns list of goroutines

把两张表并排放,差别一目了然:

关注点本节手写的 ReactorGo runtime 的 netpoller
就绪事件载体chan EventgList(被唤醒的 goroutine 链表)
就绪后做什么查 regs 表调回调把 goroutine 丢进运行队列
等待的超时time.Timerepoll_wait / kevent 的 delay 参数
外部改表后叫醒wake 通道netpollBreak 写 eventfd / 自管道
并发粒度回调(无栈,共享循环的栈)goroutine(有栈,可阻塞)

最后一行才是关键分歧。Reactor 的回调没有自己的栈,所以回调里不能阻塞;goroutine 有自己的栈,所以 conn.Read 可以写成同步代码。 Go 选择了后者,代价是每个连接 2KB 起步的栈内存——这笔账怎么算,是 2.2 要量的东西。

本节小结

三条可以带走的结论:

  1. 判别 I/O 模型只看一件事:发起调用的线程在等数据时处于什么状态。 阻塞、多路复用都是「睡」,非阻塞是「轮询」,异步是「内核搬完再通知」。Reactor 属于多路复用。
  2. Reactor 的三个不变式:单循环串行、就绪与处理分离、回调必须短。 前两条是结构,第三条是纪律——它决定了 MMO 网关里哪些活儿不能写进回调。
  3. Go 的 net 包是 runtime 内置的 Reactor。 你写的同步 Read 在 EAGAIN 时走 gopark 挂起 goroutine,netpoller 用 kqueue/epoll 报告可读后再 goready 唤醒它。同步写法、异步内核。

排障时有个小技巧:goroutine 因为网络 I/O 挂起时,GODEBUG=scheddetail=1 会打印 status=4(I/O wait)。看到这个 waitreason,说明它没在等锁、没在等 channel,而是在等 fd 就绪——下一步就该去查对端是不是不发包了。

一句话收束:Reactor 不是一种库,而是一种把「等」从线程身上摘下来、交给内核去做的结构。 Go 把它做进了 runtime,所以你在业务代码里看不到它;但 2.2 会证明,它一直在那里。

阅读导航:上一节:1.3 一个 MMO 服务端最少要有哪些零件 · 下一节:2.2 goroutine-per-connection 与 Go 调度器 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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