[network] Go 长连接服务的三种读写并发模型

2026-06-25

最近维护了几个 Go 编写的服务系统,里面涉及到几种不同的长连接 websocket 读写并发模型,在这进行简单回顾和归纳:

  1. 单读协程 + 加锁直写:每连接 1 个 goroutine,写靠锁串行化。
  2. 读写双协程 + channel:每连接 2 个 goroutine,写收敛到单协程,天然带背压。
  3. 事件驱动(gnet / reactor):没有”每连接协程”,少量 event-loop 用 epoll 管全部连接。

1. 概述

三种模型的差异,核心就一句话:每个连接到底要占多少个 goroutine,写并发安全由谁来保证。

序号 模型 每连接 goroutine 背压
1 单读 + 加锁 1(读) 阻塞 Read 加锁直写
2 双协程 + channel 2(读 + 写) 阻塞 Read 单写协程消费 channel
3 事件驱动 gnet ≈ 0(共享 event-loop) epoll 回调 异步入队

为什么会演化出这几种?根子在于 Read 是阻塞调用——你必须有”人”守着它。守着它的可以是:

  • 一个专属 goroutine(模型一、二);
  • 也可以是 epoll,由内核告诉你”哪个 fd 可读了”,再回调处理(模型三)。

下面所有的 demo 都只是根据本文内容进行简单演示,不涉及严谨的实现细节。


2. 模型

下面分别看三种长连接读写并发模型的实现与取舍。

2.1. 单读协程 + 加锁直写

每个连接只起 1 个 goroutine 守着读循环。写没有专属协程——任何业务 goroutine(推送、心跳回包)拿到连接就直接写,靠每连接一把 sync.Mutex 保证同一连接同一时刻只有一个 writer。

  • 优点:实现最简单;每连接只占 1 个 goroutine,省内存、省调度;写延迟最低(没有中转)。
  • 缺点:写阻塞会被锁放大 —— 临界区里包着可能阻塞的 Write,一旦某个客户端”假死”导致内核发送缓冲区写满,持锁协程就卡住,其它要给同一连接写的协程全堵在 Lock() 上。所以这种模型里,写超时(SetWriteDeadline)是必须的,否则一个慢客户端能拖死发往它的整条写链路。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
type Conn struct {
    ws *websocket.Conn
    mu sync.Mutex // 每连接一把写锁
}

// 任意 goroutine 都能调用,靠锁保证同一连接不并发写
func (c *Conn) Write(data []byte) error {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.ws.SetWriteDeadline(time.Now().Add(5 * time.Second)) // 写超时,别漏
    return c.ws.WriteMessage(websocket.TextMessage, data)
}

// 唯一的读 goroutine
func (c *Conn) readLoop() {
    for {
        _, msg, err := c.ws.ReadMessage()
        if err != nil {
            return
        }
        if string(msg) == "ping" {
            c.Write([]byte("pong")) // 读协程里也走加锁写
        }
    }
}

2.2. 读写双协程 + channel

每个连接起 2 个 goroutine —— readPump 只读、writePump 只写。所有要发的消息先进一个带缓冲的 channel,由唯一的 writePump 取出来写。

写收敛到一个协程,自然就”无锁”了;channel 带缓冲,也就自然有了 背压

  • 优点:业务协程投递即返回,写阻塞只卡 writePump 自己,不卡业务;慢客户端被隔离在自己的队列里,撑满就被踢,不经锁放大;写无锁;writePump 的 select 里还能统一管心跳和写超时。
  • 缺点:每连接 2 个 goroutine,海量连接时内存和调度开销翻倍;消息多一跳 channel 的延迟;close(send) 后再投递会 panic,需要 closed 标志保证只关一次。

模型一和模型二,是同一道题的两种解法:都要保证”同一连接不并发写”,一个用,一个用单写协程。代价分别是”锁竞争 + 阻塞放大”和”多一个协程 + 一跳延迟”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
type Conn struct {
    ws   *websocket.Conn
    send chan []byte // 带缓冲发送队列
}

// 非阻塞投递;满了 = 客户端太慢 -> 丢弃并关闭(背压)
func (c *Conn) Push(data []byte) {
    select {
    case c.send <- data:
    default:
        close(c.send) // 慢客户端,踢掉,不拖累别人
    }
}

// 读协程:只读,把要回的内容丢进队列,不直接写
func (c *Conn) readPump() {
    for {
        _, msg, err := c.ws.ReadMessage()
        if err != nil {
            return
        }
        if string(msg) == "ping" {
            c.Push([]byte("pong"))
        }
    }
}

// 写协程:本连接唯一 writer,无需锁;顺带统一发心跳
func (c *Conn) writePump() {
    tick := time.NewTicker(30 * time.Second)
    defer tick.Stop()
    for {
        select {
        case data, ok := <-c.send:
            if !ok {
                return // send 已关闭
            }
            c.ws.SetWriteDeadline(time.Now().Add(5 * time.Second))
            if c.ws.WriteMessage(websocket.TextMessage, data) != nil {
                return
            }
        case <-tick.C:
            c.ws.WriteMessage(websocket.PingMessage, nil)
        }
    }
}

2.3. 事件驱动(gnet / reactor)

前两种模型都绕不开一件事:Read 阻塞,所以每连接至少要钉一个 goroutine 守着它。连接一多,goroutine 数量就跟着连接数线性涨——百万连接 × 2 = 两百万 goroutine,调度、内存、GC 栈扫描都会吃不消。

reactor 模型(gnet)换了个思路:不再让 goroutine 去 “等” 可读,而是把所有 fd 挂到 epoll 上,由内核告诉你”哪个连接可读了”,再回调处理。这样守连接的不再是 “每连接一个协程”,而是少量 event-loop 协程(一般 ≈ CPU 核数)。这正是 epoll 的用武之地。

  • 优点:goroutine 数量和连接数解耦,百万连接也只有 ≈ 核数个 event-loop,内存和调度开销极低;框架内部还有 ring buffer、内存池等优化,GC 压力小。
  • 缺点:编程模型最复杂——回调式、要手动粘包拆包、要自己维护协议状态机;Next/Peek 的切片回调返回后会被复用,传给新协程前必须 copy;尤其要记住,OnTraffic 里绝不能做阻塞操作(查库、调外部接口),否则会卡住该 event-loop 上的所有连接。

gnet 框架对 epoll 异步事件驱动的封装:

回调 触发时机 典型职责
OnBoot 服务启动一次 保存 engine、打日志
OnOpen 新连接建立 建连接上下文、注册进连接管理器
OnTraffic socket 可读(epoll) 拆包、处理、回包
OnClose 连接断开 注销、清理缓存、收尾
OnTick 定时器周期触发 心跳超时检查、统计上报等

读,是 epoll 就绪后由 event-loop 协程回调 OnTraffic;写,是业务调用 c.AsyncWrite 把数据异步丢进 gnet 写队列,框架内部保证并发安全、无需加锁。

关键点:每条连接的状态挂在 gnet.Conn 自己的 context 上,用 c.SetContext / c.Context() 存取。因为同一连接的所有回调都跑在同一个 event-loop 协程里、彼此串行,所以读写这个 context 天然不需要加锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// 每连接的上下文,挂在 gnet.Conn 上
type ConnContext struct {
    ConnId     string
    ClientID   string
    Addr       string
    StartTime  time.Time
    IsConnOpen bool
}

type Server struct {
    gnet.BuiltinEventEngine
    eng gnet.Engine
}

// 新连接:建上下文 + 注册进连接管理器
func (s *Server) OnOpen(c gnet.Conn) (out []byte, action gnet.Action) {
    ctx := &ConnContext{
        ConnId:     genUUID(),
        Addr:       c.RemoteAddr().String(),
        StartTime:  time.Now(),
        IsConnOpen: true,
    }
    c.SetContext(ctx)                            // 状态挂到连接上
    clientMgr.Add(ctx.ConnId, c)                 // 注册,后面推送靠它找到 c
    return nil, gnet.None
}

// 可读:拆包 -> 处理 -> 回包,全程不阻塞
func (s *Server) OnTraffic(c gnet.Conn) gnet.Action {
    ctx, ok := c.Context().(*ConnContext)
    if !ok || !ctx.IsConnOpen {
        return gnet.Close
    }

    packet, err := readFullPacket(c)             // 自己粘包拆包,凑不齐一个整包就返回
    if err != nil {
        return gnet.Close
    }
    if packet == nil {
        return gnet.None                         // 半个包,等下次 OnTraffic
    }

    rsp := s.process(ctx, packet)                // 业务处理(注意:别在这里阻塞!)
    c.AsyncWrite(rsp, nil)                        // 异步回包,框架保证并发安全
    return gnet.None
}

// 断开:注销 + 清理,状态从 context 里捞
func (s *Server) OnClose(c gnet.Conn, err error) gnet.Action {
    ctx, ok := c.Context().(*ConnContext)
    if ok && ctx != nil {
        ctx.IsConnOpen = false
        clientMgr.Remove(ctx.ConnId)             // 先注销,别让推送再找到它
        clearCache(ctx.ConnId)                   // 清理会话密钥等缓存
        c.SetContext(nil)                        // 解引用,帮 GC
    }
    return gnet.Close
}

3. 对比与选型

把三种模型放一起看:

维度 单读 + 加锁 双协程 + channel 事件驱动 gnet
每连接 goroutine 1 2 ≈ 0(共享 event-loop)
阻塞 Read 阻塞 Read epoll 回调
加锁直写 单写协程消费 channel 异步入队
写并发安全 每连接锁 单写协程天然串行 框架保证
背压 / 慢客户端隔离 无 / 差 有 / 好 有 / 好
内存开销 极低
适用规模 中小 中大 超大(C100K+)
编程复杂度

选型其实有个很自然的顺序:

  1. 先看连接量级:几千到几万,连接数撑不爆 goroutine,模型一足够,写超时记得加。
  2. 再看要不要背压:推送量大、要隔离慢客户端、要稳定的尾延迟,上模型二。
  3. 量级到了 goroutine 本身成为瓶颈(十万、百万长连接),才值得为模型三付出 “回调式编程 + 手动拆包” 的复杂度。

4. 小结

  1. 长连接读写并发模型的本质区别是:每连接占多少 goroutine,写并发安全由谁保证。
  2. 三种解法依次是:锁、单写协程、框架内部队列;从模型一到模型三,每连接 goroutine 数递减(1 → 2 → ≈ 0),背压能力和编程复杂度递增。
  3. Read 阻塞是绕不开的约束:要么用一个 goroutine 钉着它,要么交给 epoll 回调。后者就是 reactor,也是 gnet 去掉”每连接协程”的根本原因。
  4. 没有最好的模型,只有匹配规模的模型。先按连接量级选,再按背压需求选,量级到瓶颈才上事件驱动。

5. 参考