无锁 Reactor 网络模型与 gnet / evio 架构拆解
一、核心概念与架构设计
先说一个可能不受欢迎的结论:绝大多数 Go 服务不需要 Reactor 框架。net 包配上运行时自带的 netpoller,已经让"一个连接一个 goroutine"这套写法能撑到几十万连接,而代码可读性远胜回调式状态机。gnet 这类框架存在的理由很窄,但确实存在。
要看清这个边界,得先把 goroutine-per-connection 的成本列清楚。每个连接至少要付出这些东西:
- 一个 goroutine,初始栈 2048 字节(下面会给源码依据);
- 一个
net.TCPConn,内含poll.FD、本地与远端地址结构; - 读写缓冲,通常 4 KiB 到 64 KiB 不等;
- 如果用了
SetReadDeadline,还会多出一个运行时定时器对象; - GC 需要扫描以上所有指针。
按 1M 连接粗算,光 goroutine 栈就是 2 GiB,加上连接对象和读写缓冲,几 GiB 起步。而在这套模型里,连接数带来的调度与内存成本主要花在"连接对象本身"上,真正的 IO 复用已经由 netpoller 做完了。Reactor 框架要解决的问题正是这部分:把每连接的对象开销压到最小,用回调替代 goroutine 栈来保存连接状态。
Reactor 模式有三个角色:Acceptor 负责接受连接,EventLoop 负责跑 epoll 循环并分发事件,Handler 处理具体的读写与业务逻辑。按线程组织方式,常见三种形态:
| 形态 | 结构 | 问题 |
|---|---|---|
| 单 Reactor 单线程 | 一个线程既 accept 又处理所有读写 | 无法利用多核,一个慢请求阻塞全局 |
| 单 Reactor 多线程 | Reactor 收到事件后交给工作线程池 | 工作线程池与 IO 之间要加锁传递 |
| 主从 Reactor | 主 Reactor 只 accept,连接分给多个子 Reactor | 结构复杂,需要处理跨 loop 操作 |
gnet、Netty、libuv 走的都是第三种。gnet 的多 Reactor 模型可以画成一条很短的链路:
text
┌──────────── Sub Reactor 0:epoll + RingBuffer + 回调
客户端 ──→ 内核(epoll/kqueue) ──→ 连接 ──┼──────────── Sub Reactor 1:epoll + RingBuffer + 回调
↑ SO_REUSEPORT 或主分发 └──────────── Sub Reactor N:epoll + RingBuffer + 回调
二、深度原理与底层剖析
2.1 goroutine 到底占多少栈
runtime/stack.go 里的常量是 stackMin = 2048,fixedStack 是把 stackMin + stackSystem 向上取整到 2 的幂,在 Linux 与 macOS 上就是 2048 字节。更值得知道的是它的自适应机制:
go
// startingStackSize is the amount of stack that new goroutines start with.
// It is updated every GC by tracking the average size of stacks scanned during the GC.
var startingStackSize uint32 = fixedStack
每轮 GC 会统计所有 P 上被扫描过的栈的平均大小,加上 stackGuard 后向上取整到 2 的幂,夹在 fixedStack 与 maxstacksize(64 位平台 1 GB)之间。也就是说 goroutine 的起始栈不是固定的 2 KiB,而是跟随负载形状浮动,GODEBUG=adaptivestackstart=0 可以关掉这个行为。
本地实测(Go 1.25.9,先造 2000 个浅栈 goroutine,再造 200 个深递归 goroutine,每次触发 GC 后读取 /gc/stack/starting-size:bytes):
text
启动时 startingStackSize = 2048 字节
浅栈负载后 startingStackSize = 2048 字节
深栈负载后 startingStackSize = 2048 字节
三个值都是 2048,因为平均栈用量没有超过下限,被 fixedStack 夹住了。这个数字是"每连接最少 2 KiB 栈"的依据。要让这个值上浮,需要大量 goroutine 的平均栈用量明显变大,比如每个请求的调用链很深。
2.2 Reactor 与 Go 调度器的分工
Day 51 讲过,Go 的 netpoller 本身就是 epoll 加就绪链表,加上"谁来轮询"的三条路径:空闲的 P 主动轮询、sysmon 每 10 ms 兜底、定时器唤醒。从这个角度看,Go 运行时已经内置了一个 Reactor,只是它把事件的处理方式交给了 goroutine 调度器,而不是回调函数。
两者的分工差异可以这样概括:
| 维度 | goroutine-per-connection | 用户态 Reactor(gnet) |
|---|---|---|
| 连接状态存放 | goroutine 栈上的局部变量 | 连接对象字段,状态机显式编码 |
| 上下文切换 | 调度器切 G,栈按需增长 | 函数调用,无栈切换 |
| 阻塞业务逻辑 | 直接写阻塞代码,调度器接管 | 必须投递到独立协程池,否则阻塞整个 loop |
| 超时与定时 | SetReadDeadline,运行时定时器 |
需要自己实现时间轮或定时器堆 |
| 生态兼容 | 全部标准库与第三方库 | 只拿到一个字节流接口,TLS、HTTP 都不复用 |
所以引入 Reactor 买到的不是"IO 更快"。epoll 还是那个 epoll,系统调用次数也差不多。买到的是每连接的固定开销更低、事件分发没有调度器参与、连接状态的内存布局更紧凑。代价是协议实现、超时管理、背压策略全部落到自己头上。
2.3 gnet v2 的架构要点
gnet 目前的版本线在 v2.10.0,它把自己的定位说得很清楚:不是替代 net 包,而是为"每报文处理成本敏感、连接数巨大"的场景提供一个传输层框架。几个关键设计:
多 event loop + 连接固定绑定。 每个 loop 一个 epoll(Linux)或 kqueue(FreeBSD/DragonFly/Darwin)实例,配一个专职 goroutine。新连接被分配到一个 loop 后就固定在那里,该连接的读写事件全部由这个 loop 串行处理,因此连接级别的状态不需要任何锁。这是它宣称 "lock-free during the entire runtime" 的前提,代价是跨连接的操作(广播、房间内消息推送)必须显式提交到目标连接所属的 loop。
连接分发有两种方式。 一种是走 SO_REUSEPORT,每个 loop 持有自己的监听 socket,内核按四元组哈希分发;另一种是主 acceptor 接受连接后按负载均衡策略分配。gnet 支持三种策略:RoundRobin、LeastConnections、SourceAddrHash。
事件触发模式默认是水平触发。 边沿触发需要显式打开:WithEdgeTriggeredIO 从 v2.5 起提供,v2.6 又加了 WithEdgeTriggeredIOChunk 控制单次可读写字节上限。官方在选项注释里写了一句警告,大意是"除非你完全清楚自己在做什么,否则不要开",并且它只对流式协议有效。这个态度很合理,Day 51 里已经看到,ET 模式下的漏读不会立刻报错,而是在压力上来后表现为连接假死。
内存管理自建了一套缓冲体系。 提供弹性 Ring Buffer、链表缓冲和混合缓冲三种实现,默认单连接流缓冲上限 MaxStreamBufferCap = 64 KiB。框架层的环形缓冲比 bytes.Buffer 更省事的地方在于读写指针可以独立推进,不用为了丢弃已读数据而移动内存。
业务逻辑交给 ants 协程池。 这一点很关键:loop 线程上跑的是回调和状态机,任何耗时操作(数据库查询、加解密、外部 RPC)都必须投递出去。如果直接在 OnTraffic 回调里做阻塞调用,整个 loop 上几百条连接会一起卡住。
协议要自己写。 gnet 只提供 TCP、UDP、Unix Domain Socket 这一层,HTTP、RPC、WebSocket 都要在它的字节流接口上自行实现。官方那个 TechEmpower 榜单上的 HTTP 实现明确标注了"只为跑分优化,离生产可用还有距离"。
io_uring 仍在路线图上。 截至 v2.10.0 还没有落地,这与 Go 标准库对这一接口的谨慎态度是同一个原因(Day 51 有详细讨论)。
2.4 evio:gnet 的前身
evio 是更早的一个事件驱动网络库,gnet 的文档里直接写明它衍生自 evio。两者的差别主要在工程完善度:evio 提供了 kqueue/epoll 的事件循环和每连接固定缓冲区,而 gnet 在其基础上补上了环形缓冲、协程池、多路复用器的触发模式选项、负载均衡与跨平台支持。
如果今天做选型,evio 的意义更多在于它的代码足够短,适合作为读源码理解 Reactor 的入口;真正上生产的是 gnet。
2.5 什么时候值得上 Reactor 框架
把前面几条合起来看,适用场景其实很集中:
- 长连接、小报文、连接数极大,比如 IM 网关、推送服务、游戏服务端;
- 自定义二进制协议,本来就不需要 HTTP 或 gRPC 生态;
- 作为 L4 代理或协议转换层,转发逻辑简单但吞吐要求极高。
反过来的场景同样明确:REST/gRPC 服务、需要复杂 TLS 配置、需要 net/http 生态里的中间件与可观测性、团队里没人愿意维护一套自研协议实现。这些情况下 goroutine-per-connection 是更好的工程选择,因为它把复杂性交给了经过十年打磨的运行时。
三、独创可运行代码演练
下面用纯 syscall 实现一个主从 Reactor 的最小可用版本:N 个 event loop,每个 loop 独占一个 epoll 实例和一个通过 SO_REUSEPORT 绑定同一端口的监听 socket,连接状态显式挂在 conn 对象上,按 \n 分帧并把内容转成大写字回显。
go
//go:build linux
// 主从 Reactor 模型的最小实现:
// 每个 event loop 独占一个 epoll 实例与一个监听 socket,多个 loop 通过 SO_REUSEPORT
// 绑定同一个端口,由内核把新连接按四元组哈希分发到不同 loop。
// loop 之间不共享任何可变状态,也就不需要锁。
//
// go run main.go -loops 4 -conns 200
//
// 只依赖标准库 syscall。这是教学实现,缺少 TLS、定时器、优雅退出等生产必备能力。
package main
import (
"bufio"
"bytes"
"flag"
"fmt"
"log"
"net"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
)
const (
soReusePort = 0x0F // Linux 的 SO_REUSEPORT,标准库未导出
maxLineLen = 4 << 10
maxWriteBuf = 1 << 20 // 单连接写缓冲上限,超过就断开,作为最简单的背压策略
)
// conn 是一条连接的全部状态。在 Reactor 模型里,状态必须显式挂在连接对象上,
// 而不能像 goroutine-per-connection 那样藏在调用栈里。
type conn struct {
fd int
loop *loop
inbuf []byte // 应用层读缓冲:处理半包和粘包
outbuf []byte // 应用层写缓冲:内核缓冲区满时暂存
writable bool // 是否已注册 EPOLLOUT
}
type loop struct {
id int
epfd int
ln int
conns map[int]*conn
accepted atomic.Int64
readBy atomic.Int64
}
func newLoop(id, port int) *loop {
l := &loop{id: id, conns: make(map[int]*conn)}
// 1. 每个 loop 自己建一个监听 socket,务必带 SO_REUSEPORT
fd, err := syscall.Socket(syscall.AF_INET,
syscall.SOCK_STREAM|syscall.SOCK_NONBLOCK|syscall.SOCK_CLOEXEC, 0)
if err != nil {
log.Fatalf("loop %d socket: %v", id, err)
}
if err := syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1); err != nil {
log.Fatalf("loop %d SO_REUSEADDR: %v", id, err)
}
if err := syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, soReusePort, 1); err != nil {
log.Fatalf("loop %d SO_REUSEPORT: %v", id, err)
}
if err := syscall.Bind(fd, &syscall.SockaddrInet4{Port: port}); err != nil {
log.Fatalf("loop %d bind %d: %v", id, port, err)
}
if err := syscall.Listen(fd, 512); err != nil {
log.Fatalf("loop %d listen: %v", id, err)
}
// 2. 每个 loop 一个 epoll 实例
epfd, err := syscall.EpollCreate1(syscall.EPOLL_CLOEXEC)
if err != nil {
log.Fatalf("loop %d epoll_create1: %v", id, err)
}
l.ln, l.epfd = fd, epfd
l.ctl(syscall.EPOLL_CTL_ADD, fd, uint32(syscall.EPOLLIN))
return l
}
func (l *loop) ctl(op, fd int, events uint32) {
ev := syscall.EpollEvent{Events: events, Fd: int32(fd)}
if err := syscall.EpollCtl(l.epfd, op, fd, &ev); err != nil {
log.Printf("loop %d epoll_ctl(op=%d, fd=%d): %v", l.id, op, fd, err)
}
}
// run 是单个 loop 的事件循环,一个 loop 对应一个 goroutine,串行处理所有事件。
func (l *loop) run() {
events := make([]syscall.EpollEvent, 256)
for {
n, err := syscall.EpollWait(l.epfd, events, -1)
if err != nil {
if err == syscall.EINTR {
continue
}
log.Fatalf("loop %d epoll_wait: %v", l.id, err)
}
for i := 0; i < n; i++ {
fd := int(events[i].Fd)
if fd == l.ln {
l.acceptAll()
continue
}
c := l.conns[fd]
if c == nil {
continue
}
mask := events[i].Events
if mask&(syscall.EPOLLIN|syscall.EPOLLRDHUP|syscall.EPOLLHUP) != 0 {
c.onReadable()
}
if mask&syscall.EPOLLOUT != 0 {
c.onWritable()
}
if mask&(syscall.EPOLLERR|syscall.EPOLLHUP) != 0 {
c.close()
}
}
}
}
func (l *loop) acceptAll() {
for {
nfd, _, err := syscall.Accept4(l.ln, syscall.SOCK_NONBLOCK|syscall.SOCK_CLOEXEC)
if err != nil {
if err == syscall.EAGAIN || err == syscall.EINTR {
return
}
log.Printf("loop %d accept4: %v", l.id, err)
return
}
c := &conn{fd: nfd, loop: l}
l.conns[nfd] = c
l.accepted.Add(1)
// 连接建立后只注册读事件:可写是常态,常挂 EPOLLOUT 会让事件循环空转
l.ctl(syscall.EPOLL_CTL_ADD, nfd, uint32(syscall.EPOLLIN|syscall.EPOLLRDHUP))
}
}
var scratch = make([]byte, 16<<10)
func (c *conn) onReadable() {
for {
n, err := syscall.Read(c.fd, scratch)
if n > 0 {
c.loop.readBy.Add(int64(n))
c.inbuf = append(c.inbuf, scratch[:n]...)
c.processFrames()
}
if err == syscall.EAGAIN {
return // 数据读干净,等下一次通知
}
if err == syscall.EINTR {
continue
}
if err != nil || n == 0 {
c.close()
return
}
if len(c.inbuf) > maxLineLen {
c.close() // 单行超过上限,直接断开,避免缓冲被撑爆
return
}
}
}
// processFrames 按 '\n' 切分应用层消息,逐条处理。
// 这正是 Reactor 模型把"阻塞语义"换成"状态机"的代价:半包要自己缓存。
func (c *conn) processFrames() {
for {
i := bytes.IndexByte(c.inbuf, '\n')
if i < 0 {
return
}
out := make([]byte, i+1)
copy(out, c.inbuf[:i])
out[i] = '\n'
upcase(out[:i])
c.inbuf = c.inbuf[i+1:]
if !c.write(out) {
return
}
}
}
func upcase(b []byte) {
for i := range b {
if b[i] >= 'a' && b[i] <= 'z' {
b[i] -= 'a' - 'A'
}
}
}
// write 直接尝试写出,遇到 EAGAIN 就把剩余部分挂到写缓冲并开启 EPOLLOUT。
// 返回 false 表示连接已关闭。
func (c *conn) write(p []byte) bool {
for len(p) > 0 {
n, err := syscall.Write(c.fd, p)
if n > 0 {
p = p[n:]
}
if err == syscall.EINTR {
continue
}
if err == syscall.EAGAIN {
c.outbuf = append(c.outbuf, p...)
if len(c.outbuf) > maxWriteBuf {
c.close() // 背压保护:对端不读,就不允许无限堆积
return false
}
if !c.writable {
c.writable = true
c.loop.ctl(syscall.EPOLL_CTL_MOD, c.fd, uint32(syscall.EPOLLIN|syscall.EPOLLOUT|syscall.EPOLLRDHUP))
}
return true
}
if err != nil {
c.close()
return false
}
}
return true
}
func (c *conn) onWritable() {
for len(c.outbuf) > 0 {
n, err := syscall.Write(c.fd, c.outbuf)
if n > 0 {
c.outbuf = c.outbuf[n:]
}
if err == syscall.EAGAIN {
return
}
if err == syscall.EINTR {
continue
}
if err != nil {
c.close()
return
}
}
// 写缓冲清空,取消 EPOLLOUT,避免可写事件一直把我们叫醒
if c.writable {
c.writable = false
c.loop.ctl(syscall.EPOLL_CTL_MOD, c.fd, uint32(syscall.EPOLLIN|syscall.EPOLLRDHUP))
}
}
func (c *conn) close() {
if _, ok := c.loop.conns[c.fd]; !ok {
return
}
delete(c.loop.conns, c.fd)
_ = syscall.EpollCtl(c.loop.epfd, syscall.EPOLL_CTL_DEL, c.fd, nil)
_ = syscall.Close(c.fd)
}
// startClient 建立 conns 条连接,每条连接发若干行并校验回显。
func startClient(addr string, conns, lines int) {
var wg sync.WaitGroup
var okCount atomic.Int64
for i := 0; i < conns; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
c, err := net.Dial("tcp", addr)
if err != nil {
return
}
defer c.Close()
msg := strings.Repeat("a", 32) + "\n"
want := strings.ToUpper(strings.TrimRight(msg, "\n")) + "\n"
r := bufio.NewReader(c)
for j := 0; j < lines; j++ {
if _, err := c.Write([]byte(msg)); err != nil {
return
}
got, err := r.ReadString('\n')
if err != nil || got != want {
return
}
}
okCount.Add(1)
}(i)
}
wg.Wait()
fmt.Printf("客户端:%d 条连接全部校验通过 = %d\n", conns, okCount.Load())
}
func main() {
loops := flag.Int("loops", 4, "event loop 数量(通常等于 CPU 核数)")
conns := flag.Int("conns", 200, "客户端并发连接数")
lines := flag.Int("lines", 20, "每条连接发送的消息数")
port := flag.Int("port", 9527, "监听端口")
flag.Parse()
ls := make([]*loop, 0, *loops)
for i := 0; i < *loops; i++ {
l := newLoop(i, *port)
ls = append(ls, l)
go l.run()
}
fmt.Printf("已启动 %d 个 event loop,监听 127.0.0.1:%d\n", *loops, *port)
time.Sleep(100 * time.Millisecond) // 等所有 loop 把监听 fd 注册完
startClient(fmt.Sprintf("127.0.0.1:%d", *port), *conns, *lines)
var total int64
fmt.Println("各 loop 的连接分布:")
for _, l := range ls {
n := l.accepted.Load()
total += n
fmt.Printf(" loop %d: 接受连接 %d 条,读取字节 %d\n", l.id, n, l.readBy.Load())
}
fmt.Printf("合计接受 %d 条连接\n", total)
time.Sleep(50 * time.Millisecond)
}
这份实现里有四处是 Reactor 的骨架,值得对照着看:
- 每个 loop 自己建监听 socket 并打开
SO_REUSEPORT,连接分发交给内核,省掉了主 Reactor 的转发逻辑。 - 连接建立后只注册
EPOLLIN|EPOLLRDHUP,EPOLLOUT按需开关。 - 状态机在
conn上:inbuf处理半包,outbuf承接写不出去的数据,writable记录当前注册状态。 - 每次
read/write都必须处理EINTR,这是信号打断系统调用的正常路径,不是错误。
代码通过 GOOS=linux GOARCH=amd64 与 GOOS=linux GOARCH=arm64 交叉编译以及 go vet 校验。它是教学实现,缺的东西也不少:没有 TLS、没有空闲连接与读超时、没有优雅退出、没有心跳、写缓冲上限之外没有任何背压梯度。
四、生产踩坑与专家级调优建议
在 loop 回调里做阻塞操作是最常见的致命错误。 一个 loop 上可能挂着几万条连接,回调里一次同步数据库查询或 DNS 解析就能让这些连接的 P99 一起抬头。规范做法是把所有阻塞操作投递到独立协程池,回调只做协议解析与状态迁移。判断标准很简单:回调里出现任何可能等待的调用,都是错的。
跨 loop 操作必须显式提交并唤醒。 你要给另一个 loop 上的连接发消息时,不能直接改它的状态。典型实现是每个 loop 配一个任务队列加一个 eventfd:投递方 push 后往 eventfd 写 8 字节,宿主的 epoll_wait 立刻返回并处理队列。不加这个唤醒机制,任务会一直躺在队列里等到下一条网络事件到来。这与 Go 运行时 netpollBreak 的做法完全一致。
空闲时挂 EPOLLOUT 会变成忙等。 可写是常态,注册了可写事件之后 epoll_wait 会不停地返回它,CPU 直接跑满。只在写缓冲非空时注册,写空后立刻取消。
SO_REUSEPORT 的分发在连接少时并不均匀。 内核按四元组哈希,客户端连接数很少、源端口集中时,各 loop 的负载可能差好几倍。压测时连接数要给足,生产上也要监控各 loop 的连接数与字节数分布,偏差过大就改用 LeastConnections 之类的显式策略。
loop 数量要与可用的 CPU 对齐。 一般设成 GOMAXPROCS,而 Go 1.25 起 GOMAXPROCS 默认跟随 cgroup 的 CPU 带宽限制,容器里不再需要手动改环境变量。反过来,如果 loop 数远超可用核数,多个 loop 会争抢 CPU,延迟抖动比 goroutine 模型更明显,因为 loop 内部的串行化放大了排队效应。
协议正确性比性能更容易翻车。 半包、粘包、超长行、慢速攻击(一次只发一个字节)、心跳超时、优雅关闭,这些在 net + goroutine 模型下由标准库和调用栈自然帮你处理掉的部分,在 Reactor 里全靠自己写。写之前先把帧格式、长度上限、超时策略定死,再动手。
可观测性要自己搭。 换成 Reactor 之后,net/http/pprof 那套便利没有了,连接数、积压、分发耗时都要自己在 loop 里统计,注意别在高频路径上做重操作。Day 46 到 Day 48 讲的 pprof 与 trace 仍然可用,但 goroutine profile 的参考价值会下降,因为连接不再对应 goroutine。
选型要用数据说话。 引入 gnet 之前,先用 pprof 与 trace 确认瓶颈在连接管理本身,而不是在业务逻辑、下游依赖或 GC 上。多数情况下,把每连接的缓冲大小调小、减少每连接的定时器数量、复用协议解析缓冲,能达到接近的效果,而付出的复杂度远低于自研协议栈。
五、核心总结
goroutine-per-connection 的成本集中在每连接的固定开销上:2 KiB 起始栈(startingStackSize 默认等于 fixedStack,随负载自适应上浮)、连接对象与读写缓冲、可能的定时器对象。Go 的 netpoller 已经把 IO 复用做到位了,所以 Reactor 框架省下的不是系统调用,而是这些连接级对象的开销与调度成本。
gnet 走的正是这条路:多 event loop 各持一个 epoll 与一个监听 socket,连接固定绑定 loop 因此天然无锁,缓冲用弹性 Ring Buffer 自己管理,业务逻辑投递到 ants 协程池,边沿触发是可选而非默认,io_uring 仍在路线图上。它只提供传输层,协议正确性需要自己保证。
判断要不要用它,问三个问题就够了:连接数是否已经让每连接的对象成本成为主要开销?协议是否是自定义的、不依赖标准库生态?团队是否有能力维护一套自己的协议状态机与超时体系?三个都答"是",Reactor 才有意义,否则 goroutine 加 netpoller 是更稳的选择。