Go 的并发编程有两套武器库:一套是 CSP 模型的 goroutine + channel,另一套是标准库 sync 包提供的底层原语。大多数教程把焦点放在 channel 上,但真正到生产环境里,sync.Pool、sync.Once 和 sync.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.OnceFunc、sync.OnceValue 和 sync.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 包含 uint32 和 sync.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% 的实战需求。