Contents

Go 并发原语实战:sync.Pool、sync.Once 与 sync.Cond 的正确打开方式

Go 的并发编程有两套武器库:一套是 CSP 模型的 goroutine + channel,另一套是标准库 sync 包提供的底层原语。大多数教程把焦点放在 channel 上,但真正到生产环境里,sync.Poolsync.Oncesync.Cond 这三个原语用得反而不少——它们各自解决 channel 不擅长的问题。本文用实际代码逐一拆解。

sync.Pool:对象复用,压住 GC 开销

问题场景

在高吞吐场景下频繁创建临时对象(如 JSON buffer、protobuf 对象),GC 压力会成为瓶颈。sync.Pool 的思路很简单:用过的对象不要丢,存起来下次复用,减少堆分配。

基本用法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

import (
    "bytes"
    "sync"
)

var bufPool = sync.Pool{
    New: func() interface{} {
        return bytes.NewBuffer(make([]byte, 0, 4096))
    },
}

func Process(data []byte) string {
    buf := bufPool.Get().(*bytes.Buffer)
    buf.Reset()
    defer bufPool.Put(buf)

    buf.Write(data)
    // 模拟处理逻辑
    buf.WriteString("-processed")
    return buf.String()
}

关键点:

  • New 函数在 Pool 为空时创建新对象
  • Get 后必须 Reset,清理上次使用的残留数据
  • Put 前确保对象不再被引用

实战:HTTP 中间件复用 JSON Buffer

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
var jsonBufPool = sync.Pool{
    New: func() interface{} {
        return new(bytes.Buffer)
    },
}

func JSONMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        buf := jsonBufPool.Get().(*bytes.Buffer)
        buf.Reset()
        defer jsonBufPool.Put(buf)

        // 读取请求体到复用的 buffer
        if _, err := io.Copy(buf, r.Body); err != nil {
            http.Error(w, "read error", http.StatusBadRequest)
            return
        }
        r.Body = io.NopCloser(bytes.NewReader(buf.Bytes()))
        next.ServeHTTP(w, r)
    })
}

避坑

  • Pool 不保证存活:GC 触发时 Pool 中的对象会被清理,别把 Pool 当缓存用。
  • 不要 Put 大对象:Pool 中的对象在 GC 前会驻留内存,放 10MB 的 buffer 进去等于泄漏。
  • Get 的对象类型不安全:如果多个地方往同一个 Pool 放不同类型,Get 出来断言会 panic。一个 Pool 一种类型。

sync.Once:延迟初始化的标准答案

为什么不用 init()?

init() 在包加载时执行,无法控制时机,也无法处理依赖运行时参数的初始化。sync.Once 把初始化推迟到第一次使用时,且保证只执行一次。

基本用法

 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
package config

import "sync"

var (
    instance *Config
    once     sync.Once
)

type Config struct {
    DBUrl string
    Port  int
}

func GetConfig() *Config {
    once.Do(func() {
        // 只执行一次,即使并发调用
        instance = &Config{
            DBUrl: loadFromEnv("DB_URL"),
            Port:  8080,
        }
    })
    return instance
}

func loadFromEnv(key string) string {
    // 实际从环境变量或配置中心加载
    return "postgres://localhost:5432/mydb"
}

进阶:Go 1.21+ 的 OnceFunc

Go 1.21 新增了 sync.OnceFuncsync.OnceValuesync.OnceValues,用泛型简化常见模式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package config

import "sync"

var GetConfig = sync.OnceValue(func() *Config {
    return &Config{
        DBUrl: loadFromEnv("DB_URL"),
        Port:  8080,
    }
})

// 调用方式:cfg := GetConfig()
// 第一次调用执行初始化,后续调用直接返回缓存值

OnceValue 返回单个值,OnceValues 返回两个值(适合返回 (T, error) 模式):

1
2
3
4
5
var loadDB = sync.OnceValues(func() (*sql.DB, error) {
    return sql.Open("postgres", connStr)
})

db, err := loadDB()

避坑

  • Do 中 panic 仍会标记为已执行:如果 Once.Do 中的函数 panic,Once 会认为已初始化完成,后续调用不会重试。初始化逻辑要做 panic recovery。
  • Once 不可复制sync.Once 包含 uint32sync.Mutex,复制后状态不一致。必须用指针传递。

sync.Cond:条件变量,精准唤醒

什么时候用 Cond 而不是 channel?

Channel 擅长传递值,但不擅长「等待某个条件满足」的场景。比如一个队列,消费者需要等队列非空,生产者需要等队列非满。用 channel 可以实现,但 sync.Cond 更直接,且支持 Broadcast 唤醒所有等待者。

实战:有界队列的生产者-消费者

 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
package main

import (
    "sync"
)

type BoundedQueue struct {
    mu    sync.Mutex
    cond  *sync.Cond
    items []interface{}
    cap   int
}

func NewBoundedQueue(capacity int) *BoundedQueue {
    q := &BoundedQueue{
        items: make([]interface{}, 0, capacity),
        cap:   capacity,
    }
    q.cond = sync.NewCond(&q.mu)
    return q
}

// 生产者:队列满了就等
func (q *BoundedQueue) Put(item interface{}) {
    q.mu.Lock()
    for len(q.items) >= q.cap {
        q.cond.Wait() // 释放锁并等待,被唤醒后重新获取锁
    }
    q.items = append(q.items, item)
    q.cond.Broadcast() // 通知可能有消费者在等非空
    q.mu.Unlock()
}

// 消费者:队列空了就等
func (q *BoundedQueue) Get() interface{} {
    q.mu.Lock()
    for len(q.items) == 0 {
        q.cond.Wait()
    }
    item := q.items[0]
    q.items = q.items[1:]
    q.cond.Broadcast() // 通知可能有生产者在等非满
    q.mu.Unlock()
    return item
}

为什么用 for 循环而不是 if?

Cond.Wait() 被唤醒后,条件不一定满足——可能有其他等待者在你之前抢到锁消费了数据。这叫「虚假唤醒」(spurious wakeup)。所以必须用 for 循环重新检查条件:

1
2
3
4
5
6
7
8
9
// 正确
for len(q.items) == 0 {
    q.cond.Wait()
}

// 错误:可能从空队列读取
if len(q.items) == 0 {
    q.cond.Wait()
}

Signal vs Broadcast

  • Signal():唤醒一个等待者。适合只有一个消费者等一个资源的场景。
  • Broadcast():唤醒所有等待者。适合多种条件共享一个 Cond 的场景。
 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
// 多条件共享一个 Cond
type Store struct {
    mu     sync.Mutex
    cond   *sync.Cond
    stock  int
    orders int
}

// 等库存
func (s *Store) WaitStock() {
    s.mu.Lock()
    for s.stock == 0 {
        s.cond.Wait()
    }
    s.mu.Unlock()
}

// 等订单
func (s *Store) WaitOrders() {
    s.mu.Lock()
    for s.orders == 0 {
        s.cond.Wait()
    }
    s.mu.Unlock()
}

// 任何状态变更都唤醒所有等待者
func (s *Store) Restock(n int) {
    s.mu.Lock()
    s.stock += n
    s.cond.Broadcast() // 库存和订单的等待者都要检查
    s.mu.Unlock()
}

选型速查表

原语 核心能力 典型场景 替代方案
sync.Pool 对象复用 高频临时对象分配 预分配切片
sync.Once 一次性初始化 单例、配置加载 init() 函数
sync.Cond 条件等待+唤醒 生产者-消费者 channel + select

一句话总结:Pool 省内存,Once 保初始化,Cond 控时序。三个原语各自解决一个 channel 不够顺手的问题,配合 goroutine 使用,覆盖了 Go 并发编程 80% 的实战需求。