Appearance
sync 包:WaitGroup、Once、Mutex
前几篇我们聚焦于 channel,但 Go 并不只有 channel 一种同步工具。标准库 sync 包提供了一系列传统同步原语:WaitGroup、Once、Mutex、RWMutex、Cond、Map、Pool。它们与 channel 互补,在保护共享状态、对象复用等场景下往往比 channel 更直接高效。本篇我们将逐一掌握这些工具的正确用法和最佳实践。
一、sync.WaitGroup 详解
我们在第二章已经接触过 WaitGroup 的基础用法,这里做更深入的讲解。WaitGroup 用于等待一组 goroutine 完成,它内部维护一个计数器。
1. 三个核心方法
Add(n int):计数器加 n(正数增加,负数减少)。Done():计数器减 1,等价于Add(-1)。Wait():阻塞直到计数器归零。
2. 完整示例:并发处理任务并等待
go
package main
import (
"fmt"
"sync"
"time"
)
func main() {
tasks := []string{"下载", "解析", "存储", "通知", "清理"}
var wg sync.WaitGroup
for _, task := range tasks {
wg.Add(1) // 必须在启动 goroutine 前 Add
go func(name string) {
defer wg.Done() // 用 defer 确保 Done 一定执行
fmt.Printf("开始执行: %s\n", name)
time.Sleep(time.Second) // 模拟工作
fmt.Printf("完成: %s\n", name)
}(task)
}
wg.Wait() // 等待所有任务完成
fmt.Println("所有任务执行完毕")
}3. 批量 Add 优化
如果知道任务总数,可以一次性 Add,减少调用次数:
go
package main
import (
"fmt"
"sync"
)
func main() {
const N = 5
var wg sync.WaitGroup
wg.Add(N) // 一次性加 N
for i := 0; i < N; i++ {
go func(id int) {
defer wg.Done()
fmt.Printf("worker %d 完成\n", id)
}(i)
}
wg.Wait()
fmt.Println("全部完成")
}4. WaitGroup 不能复制
WaitGroup 内部有状态,不能按值传递,必须用指针。这也是为什么把 WaitGroup 传给 goroutine 时要用 &wg:
go
package main
import (
"fmt"
"sync"
)
// ✅ 正确:传指针
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Println("worker", id)
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
go worker(1, &wg) // 传指针
wg.Wait()
}如果把 WaitGroup 按值传给函数,每个函数拿到的是副本,Done 修改的是副本,主 goroutine 的计数器不会减——程序会死锁。Go 的 go vet 工具能检测出这种错误。
二、WaitGroup 的常见误用
1. 误用一:在 goroutine 内部 Add
go
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
go func(id int) {
wg.Add(1) // ❌ 错误:在 goroutine 内 Add
defer wg.Done()
fmt.Println("worker", id)
}(i)
}
wg.Wait() // 可能直接返回(计数器还是 0),也可能死锁
fmt.Println("完成")
}问题在于 Add 和 Wait 之间存在竞态:主 goroutine 调用 Wait 时,可能 goroutine 还没来得及 Add,计数器是 0,Wait 直接返回;或者部分 Add 了部分没 Add,行为不确定。Add 必须在启动 goroutine 之前调用。
2. 误用二:忘记 Done
go
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
// 忘记 wg.Done(),或者提前 return 跳过 Done
fmt.Println("working")
// 没有调用 Done
}()
wg.Wait() // 永远阻塞,死锁
fmt.Println("完成")
}用 defer wg.Done() 可以彻底避免这个问题——无论函数怎么退出,Done 都会执行。
3. 误用三:计数器变负数
go
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
wg.Add(1)
// 不小心 Done 两次
wg.Done()
wg.Done() // 计数器变成 -1,panic: sync: negative WaitGroup counter
wg.Wait()
fmt.Println("完成")
}计数器不能为负,否则 panic。这通常是因为逻辑错误导致 Done 被调用多次。
4. 误用四:Wait 期间 Add
go
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
wg.Add(1) // ❌ Wait 期间 Add,panic
go func() {
defer wg.Done()
}()
}()
wg.Wait()
fmt.Println("完成")
}在某个 goroutine 已经 Wait 之后又 Add,会触发 panic。需要新一轮任务就新建一个 WaitGroup。
三、sync.Once:单例模式与初始化
sync.Once 保证某个操作只执行一次,即使多个 goroutine 同时调用。它是实现单例模式、懒加载、全局初始化的标准工具。
1. 基本用法
go
package main
import (
"fmt"
"sync"
)
var (
once sync.Once
config string
)
func loadConfig() string {
once.Do(func() {
fmt.Println("加载配置(只执行一次)")
config = "应用配置"
})
return config
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
cfg := loadConfig()
fmt.Printf("goroutine %d 拿到配置: %s\n", id, cfg)
}(i)
}
wg.Wait()
}输出中「加载配置」只出现一次,但所有 goroutine 都拿到了配置。
2. 实现单例模式
go
package main
import (
"fmt"
"sync"
)
type Database struct {
name string
}
var (
dbInstance *Database
dbOnce sync.Once
)
func GetDB() *Database {
dbOnce.Do(func() {
fmt.Println("初始化数据库连接")
dbInstance = &Database{name: "MySQL"}
})
return dbInstance
}
func main() {
for i := 0; i < 3; i++ {
db := GetDB()
fmt.Printf("获取到: %s\n", db.name)
}
}3. Once 的注意点
Do接收的函数即使 panic,Once 也会认为「已执行」,后续调用不会再执行。- Once 不能复制(和 WaitGroup 一样要用指针或作为结构体字段)。
- 如果初始化逻辑可能失败,单纯用 Once 不够(失败后无法重试),需要配合其他逻辑。
go
package main
import (
"fmt"
"sync"
)
func main() {
var once sync.Once
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
once.Do(func() {
if id == 1 {
panic("模拟初始化失败") // panic 后 once 仍认为已执行
}
fmt.Println("初始化成功")
})
}(i)
}
// 需要处理 panic 否则整个程序崩溃
// 这里仅演示概念,实际要 recover
defer func() {
if r := recover(); r != nil {
fmt.Println("捕获 panic:", r)
}
}()
wg.Wait()
}四、sync.Mutex:互斥锁
sync.Mutex 是最基本的互斥锁,用来保护临界区——同一时刻只允许一个 goroutine 访问某段代码或数据。
1. 基本用法
go
package main
import (
"fmt"
"sync"
)
type Counter struct {
mu sync.Mutex
count int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock() // 用 defer 确保解锁
c.count++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}
func main() {
c := &Counter{}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c.Inc()
}()
}
wg.Wait()
fmt.Println("最终计数:", c.Value()) // 1000
}2. 为什么需要锁
如果不加锁,1000 个 goroutine 同时 c.count++ 会发生数据竞争——++ 不是原子操作(读-改-写三步),最终结果会小于 1000。加锁后保证每次自增完整执行。
3. 锁的正确姿势
- Lock 后立即 defer Unlock:这是最安全的模式,避免任何分支忘记解锁。
- 不要在持锁时做耗时操作:如 I/O、网络请求、长时间计算,会严重降低并发度。
- 锁不要嵌套:嵌套锁容易死锁,如果必须嵌套要仔细分析顺序。
go
package main
import (
"fmt"
"sync"
)
type SafeMap struct {
mu sync.Mutex
data map[string]int
}
func NewSafeMap() *SafeMap {
return &SafeMap{data: make(map[string]int)}
}
func (m *SafeMap) Set(key string, val int) {
m.mu.Lock()
defer m.mu.Unlock()
m.data[key] = val
}
func (m *SafeMap) Get(key string) (int, bool) {
m.mu.Lock()
defer m.mu.Unlock()
v, ok := m.data[key]
return v, ok
}
func (m *SafeMap) Delete(key string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.data, key)
}
func main() {
m := NewSafeMap()
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
key := fmt.Sprintf("k%d", i)
m.Set(key, i)
}(i)
}
wg.Wait()
fmt.Println("map 大小:", len(m.data))
v, ok := m.Get("k50")
fmt.Println("k50:", v, ok)
}4. Mutex 不可重入
Go 的 Mutex 不是可重入的——同一个 goroutine 二次 Lock 会死锁:
go
package main
import (
"fmt"
"sync"
)
type Service struct {
mu sync.Mutex
}
func (s *Service) Outer() {
s.mu.Lock()
defer s.mu.Unlock()
fmt.Println("Outer 持有锁")
s.Inner() // ❌ Inner 又会 Lock,但当前 goroutine 已持锁,死锁
}
func (s *Service) Inner() {
s.mu.Lock()
defer s.mu.Unlock()
fmt.Println("Inner")
}
func main() {
s := &Service{}
s.Outer()
}这与 Java 的 synchronized(可重入)不同。Go 的设计哲学是:可重入锁容易隐藏设计问题,应该重构代码避免在持锁时调用其他需要锁的方法。
五、sync.RWMutex:读写锁
sync.RWMutex 是读写锁:允许多个读操作并行,但写操作独占。适合读多写少的场景。
1. 基本用法
go
package main
import (
"fmt"
"sync"
"time"
)
type Cache struct {
mu sync.RWMutex
data map[string]string
}
func NewCache() *Cache {
return &Cache{data: make(map[string]string)}
}
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock() // 读锁
defer c.mu.RUnlock() // 释放读锁
v, ok := c.data[key]
return v, ok
}
func (c *Cache) Set(key, val string) {
c.mu.Lock() // 写锁
defer c.mu.Unlock() // 释放写锁
c.data[key] = val
}
func main() {
cache := NewCache()
cache.Set("name", "Go")
var wg sync.WaitGroup
// 多个并发读
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 3; j++ {
if v, ok := cache.Get("name"); ok {
fmt.Printf("reader %d: %s\n", id, v)
}
time.Sleep(100 * time.Millisecond)
}
}(i)
}
// 偶尔写
for i := 0; i < 2; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
cache.Set("name", fmt.Sprintf("Go-%d", id))
fmt.Printf("writer %d 写入\n", id)
}(i)
}
wg.Wait()
fmt.Println("最终 name:", func() string {
v, _ := cache.Get("name")
return v
}())
}2. RWMutex 的规则
- 多个
RLock可以同时持有(并发读)。 Lock是独占的,持有写锁时其他读锁和写锁都要等待。- 写锁会等待所有已持有的读锁释放后才能获取。
- 写锁等待期间,新的读锁也会被阻塞(防止写饥饿)。
3. 何时用 RWMutex
- 读远多于写:比如缓存、配置,RWMutex 比 Mutex 并发度更高。
- 读写相当或写多读少:RWMutex 的开销(维护读者计数)反而比 Mutex 大,直接用 Mutex 更好。
- 临界区很短:RWMutex 优势不明显,Mutex 更简单。
实测中,临界区很短时 Mutex 往往比 RWMutex 快,因为 RWMutex 的元数据维护成本更高。不要盲目用 RWMutex,要 benchmark。
六、sync.Cond:条件变量
sync.Cond 是条件变量,用于 goroutine 之间「等待某个条件成立」。它通常和 Mutex 配合使用:goroutine 持锁检查条件,条件不满足就 Wait(自动释放锁并阻塞),其他 goroutine 改变条件后 Signal 或 Broadcast 唤醒等待者。
1. 基本示例:生产者-消费者
go
package main
import (
"fmt"
"sync"
"time"
)
type Queue struct {
mu sync.Mutex
cond *sync.Cond
items []int
maxLen int
}
func NewQueue(max int) *Queue {
q := &Queue{maxLen: max}
q.cond = sync.NewCond(&q.mu) // 关联一个 Mutex
return q
}
func (q *Queue) Put(item int) {
q.mu.Lock()
defer q.mu.Unlock()
for len(q.items) >= q.maxLen { // 用 for 防止虚假唤醒
q.cond.Wait() // 释放锁并等待,被唤醒时重新获取锁
}
q.items = append(q.items, item)
q.cond.Signal() // 通知一个等待的消费者
}
func (q *Queue) Get() int {
q.mu.Lock()
defer q.mu.Unlock()
for len(q.items) == 0 {
q.cond.Wait()
}
item := q.items[0]
q.items = q.items[1:]
q.cond.Signal() // 通知一个等待的生产者
return item
}
func main() {
q := NewQueue(3)
var wg sync.WaitGroup
// 生产者
wg.Add(1)
go func() {
defer wg.Done()
for i := 1; i <= 10; i++ {
q.Put(i)
fmt.Printf("生产 %d\n", i)
time.Sleep(100 * time.Millisecond)
}
}()
// 消费者
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 10; i++ {
item := q.Get()
fmt.Printf(" 消费 %d\n", item)
time.Sleep(200 * time.Millisecond)
}
}()
wg.Wait()
fmt.Println("完成")
}2. Signal vs Broadcast
Signal():唤醒一个等待的 goroutine。Broadcast():唤醒所有等待的 goroutine。
通常单个资源可用用 Signal,状态变化影响所有等待者用 Broadcast。
3. Cond 的使用要点
Wait必须在持锁状态下调用,它会释放锁、阻塞、被唤醒后重新获取锁。- 条件检查必须用
for循环,不能用if——因为可能被虚假唤醒。 - Cond 在 Go 中用得相对少,因为很多场景 channel 更直观。但在「等待条件满足」这类场景,Cond 比 channel 更合适。
七、sync.Map:并发安全 Map
普通 map 不是并发安全的,多个 goroutine 同时读写会 panic。sync.Map 是 Go 提供的并发安全 Map,适合读多写少、key 相对稳定的场景。
1. 基本用法
go
package main
import (
"fmt"
"sync"
)
func main() {
var m sync.Map
// 存储
m.Store("name", "Go")
m.Store("version", "1.22")
// 读取
v, ok := m.Load("name")
fmt.Println("name:", v, ok) // Go true
v, ok = m.Load("age")
fmt.Println("age:", v, ok) // <nil> false
// 不存在则存储
actual, loaded := m.LoadOrStore("name", "Rust")
fmt.Println("name:", actual, "已存在:", loaded) // Go true
// 删除
m.Delete("version")
v, ok = m.Load("version")
fmt.Println("version:", v, ok) // <nil> false
// 遍历
m.Range(func(key, value any) bool {
fmt.Printf("遍历: %v = %v\n", key, value)
return true
})
}2. sync.Map vs Mutex+Map
| 特性 | sync.Map | Mutex + map |
|---|---|---|
| 读多写少 | 优(读无锁) | 一般(每次读要锁) |
| 写多 | 一般 | 优 |
| key 稳定 | 优(内部缓存 key) | 一般 |
| 类型安全 | 弱(用 any) | 强(泛型/具体类型) |
| API 友好度 | 较繁琐 | 简洁 |
经验法则:
- 读多写少 + key 集合稳定:用 sync.Map。
- 写多或 key 频繁变化:用 Mutex + 普通 map。
- 需要类型安全:用 Mutex + 泛型 map(Go 1.18+)。
3. Go 1.24 的泛型同步 map
Go 1.24 引入了 sync.Map 的泛型版本(实际上叫 synctesting 或类似,具体看版本)。更通用的做法是自封装:
go
package main
import (
"fmt"
"sync"
)
// 泛型并发安全 map
type ConcurrentMap[K comparable, V any] struct {
mu sync.RWMutex
data map[K]V
}
func NewConcurrentMap[K comparable, V any]() *ConcurrentMap[K, V] {
return &ConcurrentMap[K, V]{data: make(map[K]V)}
}
func (m *ConcurrentMap[K, V]) Set(k K, v V) {
m.mu.Lock()
defer m.mu.Unlock()
m.data[k] = v
}
func (m *ConcurrentMap[K, V]) Get(k K) (V, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
v, ok := m.data[k]
return v, ok
}
func (m *ConcurrentMap[K, V]) Delete(k K) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.data, k)
}
func main() {
m := NewConcurrentMap[string, int]()
m.Set("a", 1)
m.Set("b", 2)
v, ok := m.Get("a")
fmt.Println("a =", v, ok) // 1 true
v, ok = m.Get("c")
fmt.Println("c =", v, ok) // 0 false
}八、sync.Pool:对象复用
sync.Pool 是对象池,用于复用临时对象,减少 GC 压力。典型场景:复用 bytes.Buffer、json.Encoder 等临时对象。
1. 基本用法
go
package main
import (
"bytes"
"fmt"
"sync"
)
var bufPool = sync.Pool{
New: func() any {
fmt.Println("创建新 buffer")
return new(bytes.Buffer)
},
}
func process(data string) string {
// 从池中借一个 buffer
buf := bufPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset() // 重置以便复用
bufPool.Put(buf) // 还回池中
}()
buf.WriteString("prefix-")
buf.WriteString(data)
return buf.String()
}
func main() {
for i := 0; i < 5; i++ {
result := process(fmt.Sprintf("data%d", i))
fmt.Println(result)
}
}「创建新 buffer」只在第一次(或被 GC 回收后)打印,后续都复用已有对象。
2. Pool 的特点
- Get 可能返回新对象(池为空时调用
New)。 - Put 后对象可能被随时回收:Pool 中的对象会在 GC 时被清理,所以 Pool 不保证对象一直存在。
- 不适合做连接池:因为对象可能被 GC 清掉,连接池应该用专门的实现。
- 适合短生命周期、高频创建的对象。
3. 实战:复用 bytes.Buffer
go
package main
import (
"bytes"
"encoding/json"
"fmt"
"sync"
)
var bufferPool = sync.Pool{
New: func() any {
return bytes.NewBuffer(make([]byte, 0, 1024))
},
}
func toJSON(v any) (string, error) {
buf := bufferPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset()
bufferPool.Put(buf)
}()
encoder := json.NewEncoder(buf)
if err := encoder.Encode(v); err != nil {
return "", err
}
// Encode 会加一个换行,去掉
s := buf.String()
if len(s) > 0 && s[len(s)-1] == '\n' {
s = s[:len(s)-1]
}
return s, nil
}
func main() {
for i := 0; i < 3; i++ {
data := map[string]int{"id": i, "score": i * 10}
s, _ := toJSON(data)
fmt.Println(s)
}
}在 JSON 序列化、字符串拼接等高频场景,用 Pool 复用 Buffer 能显著减少内存分配和 GC 压力。
九、锁的最佳实践
1. 减小临界区
只锁真正需要保护的部分,不要把无关代码也放进锁里:
go
package main
import (
"fmt"
"strings"
"sync"
)
type Logger struct {
mu sync.Mutex
logs []string
}
// ✅ 好:先做耗时操作,再持锁写入
func (l *Logger) Log(msg string) {
formatted := strings.ToUpper(msg) // 耗时操作放锁外
l.mu.Lock()
l.logs = append(l.logs, formatted)
l.mu.Unlock()
}
func main() {
logger := &Logger{}
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
logger.Log(fmt.Sprintf("event %d", i))
}(i)
}
wg.Wait()
fmt.Println("日志数:", len(logger.logs))
}2. 避免锁嵌套
go
package main
import "sync"
type A struct {
mu sync.Mutex
}
type B struct {
mu sync.Mutex
a *A
}
// ❌ 危险:先锁 B 再锁 A,如果别处先锁 A 再锁 B,死锁
func (b *B) BadMethod() {
b.mu.Lock()
defer b.mu.Unlock()
b.a.mu.Lock() // 嵌套锁
defer b.a.mu.Unlock()
}
// ✅ 好:拆分,先获取 A 的数据再操作
func main() {}如果必须用多把锁,要保证全程序获取锁的顺序一致(比如总是先 A 后 B)。
3. 用 defer Unlock 防止遗漏
go
package main
import "sync"
type SafeCounter struct {
mu sync.Mutex
n int
}
func (c *SafeCounter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}4. 不要持锁做 I/O
I/O 涉及系统调用和阻塞,持锁期间会卡住所有等待该锁的 goroutine。应该先在锁内拷贝数据,释放锁后再做 I/O。
十、Mutex vs Channel:何时用锁何时用 channel
这是 Go 开发者常问的问题。Go 官方的建议是:
不要默认认为一种比另一种好。它们是互补的工具。
1. 适合用 channel 的场景
- 传递数据所有权:一个 goroutine 把数据交给另一个。
- 协调多个 goroutine:通知、等待、扇入扇出。
- 并发任务的结果收集:worker 把结果发到 channel。
- 需要明确的生命周期:channel 的关闭天然表达「结束」。
go
package main
import "fmt"
// channel 适合:传递数据、协调
func main() {
results := make(chan int)
go func() {
// 计算
results <- 42
}()
fmt.Println("结果:", <-results)
}2. 适合用锁的场景
- 保护共享状态:缓存、计数器、配置。
- 简单的读写保护:不需要复杂协调,就是「别同时改」。
- 性能敏感的临界区:锁的开销通常比 channel 小。
go
package main
import (
"fmt"
"sync"
)
// 锁适合:保护共享状态
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Inc() {
c.mu.Lock()
c.mu.Unlock()
c.n++
}
func main() {
c := &Counter{}
c.Inc()
fmt.Println(c.n)
}3. 选择建议
- 如果是「传递数据」用 channel,如果是「保护数据」用锁。
- 不确定时,先用最直观的方式实现,再根据需要优化。
- 不要为了用 channel 而把简单的状态保护搞复杂。
十一、小结
本篇我们系统学习了 sync 包的同步原语:
WaitGroup:等待一组 goroutine 完成。
Add必须在 goroutine 启动前调用,Done用 defer,不能复制(用指针),不能在 Wait 期间 Add。Once:保证操作只执行一次,用于单例和初始化。panic 后也认为已执行,不能复制。
Mutex:互斥锁,保护临界区。Lock 后立即 defer Unlock,不可重入(二次 Lock 会死锁),不要在持锁时做耗时操作。
RWMutex:读写锁,读多写少时提高并发度。但临界区短时 Mutex 可能更快,要 benchmark。
Cond:条件变量,等待条件成立。Wait 必须持锁,条件检查用 for 循环防虚假唤醒。Signal 唤醒一个,Broadcast 唤醒全部。
Map:并发安全 Map,适合读多写少、key 稳定场景。写多时用 Mutex+map 更好。Go 1.18+ 可用泛型自封装类型安全的并发 map。
Pool:对象复用,减少 GC 压力。对象可能在 GC 时被清理,不适合做连接池。适合 bytes.Buffer 等短命高频对象。
最佳实践:减小临界区、避免锁嵌套、用 defer Unlock、不要持锁做 I/O。
Mutex vs Channel:传递数据用 channel,保护状态用锁。它们是互补的,不要教条地只用一种。
下一篇我们将把这些工具组合起来,学习经典并发模式:Worker Pool、Pipeline、Fan-out/Fan-in 等。