Go sync 包深度实战——Mutex、RWMutex、WaitGroup、Once 的正确使用
Go sync 包深度实战——Mutex、RWMutex、WaitGroup、Once 的正确使用
适读人群:Go并发开发者、需要在多goroutine间协调状态的工程师 | 阅读时长:约17分钟 | 核心价值:sync包是Go并发编程的基础设施,每一个原语都有精准的适用边界
一、老孙的「双重检查锁」翻车记
老孙从Java转Go大概一年,经验丰富。有次他要实现一个配置热更新功能:多个goroutine读配置,配置更新时只有一个goroutine写。他写了一段代码,看起来很优雅——用map缓存配置,读的时候先不加锁判断是否存在,不存在再加锁初始化。
就是Java里经典的「双重检查锁」(Double-Checked Locking)模式。
在Java里,这个模式需要 volatile 关键字配合才安全。Go里没有 volatile,但他觉得「应该没问题的」。
上线两周后,生产环境偶发一种怪异的panic:map里的某个值在被读取时已经是半初始化的状态,触发了nil指针解引用。根本原因是:Go内存模型不保证在没有同步原语的情况下,一个goroutine对内存的写入对另一个goroutine是可见的。他的双重检查逻辑里,第一次不加锁的检查读到了「幻象状态」。
这件事让他彻底重新审视Go的sync包。我们来系统过一遍。
二、Mutex:最基础的互斥锁
基本用法
package main
import (
"fmt"
"sync"
)
type SafeCounter struct {
mu sync.Mutex
count int
}
func (c *SafeCounter) Increment() {
c.mu.Lock()
defer c.mu.Unlock() // defer确保即使panic也会解锁
c.count++
}
func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}
func main() {
counter := &SafeCounter{}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter.Increment()
}()
}
wg.Wait()
fmt.Println("最终计数:", counter.Value()) // 应该是1000
}坑1:锁的复制导致失效
package main
import (
"fmt"
"sync"
)
type Counter struct {
mu sync.Mutex
count int
}
// 错误:值接收者会复制Counter,包括mutex!
func (c Counter) BadIncrement() {
c.mu.Lock() // 锁的是副本,不是原本
defer c.mu.Unlock()
c.count++ // 操作的也是副本
}
// 正确:指针接收者
func (c *Counter) GoodIncrement() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func main() {
c := &Counter{}
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c.GoodIncrement()
}()
}
wg.Wait()
fmt.Println("count:", c.count)
}规则:sync.Mutex(以及所有sync包的类型)绝对不能复制,必须用指针传递。
坑2:锁的粒度太粗导致性能问题
锁的粒度要刚好保护临界区,不要把不需要保护的耗时操作也包进去:
package main
import (
"fmt"
"sync"
"time"
)
type DataStore struct {
mu sync.Mutex
data map[string]string
}
// 错误:把耗时的IO操作也放在锁里
func (ds *DataStore) BadUpdate(key, value string) {
ds.mu.Lock()
defer ds.mu.Unlock()
// 这是个外部调用,耗时可能几百毫秒
// 整个时间段其他goroutine都被阻塞了
result := expensiveCompute(value) // 不应该在锁里
ds.data[key] = result
}
// 正确:只在真正需要的地方加锁
func (ds *DataStore) GoodUpdate(key, value string) {
result := expensiveCompute(value) // 先计算,不需要锁
ds.mu.Lock()
ds.data[key] = result // 只有这一步需要锁
ds.mu.Unlock()
}
func expensiveCompute(s string) string {
time.Sleep(10 * time.Millisecond)
return s + "_processed"
}
func main() {
ds := &DataStore{data: make(map[string]string)}
var wg sync.WaitGroup
start := time.Now()
for i := 0; i < 10; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
ds.GoodUpdate(fmt.Sprintf("key%d", i), fmt.Sprintf("val%d", i))
}(i)
}
wg.Wait()
fmt.Printf("GoodUpdate耗时: %v\n", time.Since(start))
}三、RWMutex:读多写少场景的性能利器
sync.RWMutex 允许多个goroutine同时持有读锁,但写锁是独占的。适合配置、缓存等读多写少的场景。
Java对比: 类似 java.util.concurrent.locks.ReadWriteLock,但Go的语法更简洁。
package main
import (
"fmt"
"sync"
"time"
)
type RWCache struct {
mu sync.RWMutex
cache map[string]string
}
func NewRWCache() *RWCache {
return &RWCache{cache: make(map[string]string)}
}
// 读:加读锁,允许并发读
func (c *RWCache) Get(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.cache[key]
return v, ok
}
// 写:加写锁,独占
func (c *RWCache) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.cache[key] = value
}
func main() {
cache := NewRWCache()
// 初始化数据
for i := 0; i < 100; i++ {
cache.Set(fmt.Sprintf("key%d", i), fmt.Sprintf("value%d", i))
}
var wg sync.WaitGroup
// 并发读(90%流量)
start := time.Now()
for i := 0; i < 900; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
cache.Get(fmt.Sprintf("key%d", i%100))
}(i)
}
// 并发写(10%流量)
for i := 0; i < 100; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
cache.Set(fmt.Sprintf("key%d", i), fmt.Sprintf("new_value%d", i))
}(i)
}
wg.Wait()
fmt.Printf("RWMutex耗时: %v\n", time.Since(start))
}RWMutex的一个坑:写锁饥饿
当写锁等待时,Go的RWMutex会阻止新的读锁申请(防止写锁永远等不到),这意味着:如果写操作频繁,读操作可能被频繁阻塞。高写入频率场景下,RWMutex的优势会大幅减小。
四、WaitGroup:协调goroutine的完成
sync.WaitGroup 是Go里等待一批goroutine全部完成的标准工具,类似Java的 CountDownLatch。
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var wg sync.WaitGroup
tasks := []string{"任务A", "任务B", "任务C", "任务D"}
for _, task := range tasks {
wg.Add(1) // 必须在goroutine启动前Add,而不是在goroutine内部
task := task
go func() {
defer wg.Done() // goroutine完成时Done
fmt.Printf("开始: %s\n", task)
time.Sleep(100 * time.Millisecond)
fmt.Printf("完成: %s\n", task)
}()
}
wg.Wait() // 阻塞,直到所有goroutine调用了Done
fmt.Println("所有任务完成")
}坑:Add必须在goroutine启动前
package main
import (
"fmt"
"sync"
)
func wrongWaitGroup() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
go func(i int) {
wg.Add(1) // 错误!Add在goroutine内部,可能在Wait之后才执行
defer wg.Done()
fmt.Println(i)
}(i)
}
wg.Wait() // 可能在所有Add之前就返回了!
}
func correctWaitGroup() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1) // 正确:在启动goroutine之前Add
go func(i int) {
defer wg.Done()
fmt.Println(i)
}(i)
}
wg.Wait()
}
func main() {
fmt.Println("=== 正确用法 ===")
correctWaitGroup()
}WaitGroup + 错误收集模式
package main
import (
"fmt"
"sync"
)
func runParallelTasks(tasks []func() error) []error {
var (
wg sync.WaitGroup
mu sync.Mutex
errs []error
)
for _, task := range tasks {
wg.Add(1)
task := task
go func() {
defer wg.Done()
if err := task(); err != nil {
mu.Lock()
errs = append(errs, err)
mu.Unlock()
}
}()
}
wg.Wait()
return errs
}
func main() {
tasks := []func() error{
func() error { fmt.Println("task1 done"); return nil },
func() error { return fmt.Errorf("task2 failed") },
func() error { fmt.Println("task3 done"); return nil },
}
errs := runParallelTasks(tasks)
for _, err := range errs {
fmt.Println("错误:", err)
}
}五、Once:只执行一次的保证
sync.Once 保证某个函数只被执行一次,即使在多个goroutine中同时调用。这是单例模式的完美Go实现,也是解决老孙那个双重检查锁问题的正确方案。
Java对比: 类似 @Lazy + synchronized 的单例模式,但更简洁,没有DCL的陷阱。
package main
import (
"fmt"
"sync"
)
type Config struct {
DBHost string
DBPort int
}
var (
once sync.Once
config *Config
)
func GetConfig() *Config {
once.Do(func() {
// 这个函数保证只执行一次,即使并发调用
fmt.Println("初始化配置(只执行一次)")
config = &Config{
DBHost: "localhost",
DBPort: 5432,
}
})
return config
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
cfg := GetConfig()
fmt.Printf("goroutine获取到config: %+v\n", cfg)
}()
}
wg.Wait()
// "初始化配置"只打印一次
}Once的坑:panic不会重置Once
package main
import (
"fmt"
"sync"
)
var initOnce sync.Once
func initWithPanic() {
initOnce.Do(func() {
fmt.Println("初始化中...")
panic("初始化失败") // Once执行完了(即使panic),不会再执行
})
}
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recover:", r)
}
}()
initWithPanic()
// 第二次调用,Do函数不会再执行,即使第一次panic了
initOnce.Do(func() {
fmt.Println("这行永远不会执行")
})
}最佳实践:Once的初始化函数里不要panic,如果初始化可能失败,用带error返回值的初始化函数:
package main
import (
"fmt"
"sync"
)
type Service struct {
once sync.Once
db interface{} // 假设是数据库连接
err error
}
func (s *Service) Init() error {
s.once.Do(func() {
// 把错误存在结构体里,不panic
s.db, s.err = connectDB()
})
return s.err
}
func connectDB() (interface{}, error) {
fmt.Println("连接数据库...")
return struct{}{}, nil // 模拟成功
}
func main() {
svc := &Service{}
if err := svc.Init(); err != nil {
fmt.Println("初始化失败:", err)
return
}
fmt.Println("服务就绪")
}六、sync.Map:并发安全的map
Go 1.9引入了 sync.Map,专门为并发读写优化,但API和普通map不同:
package main
import (
"fmt"
"sync"
)
func main() {
var m sync.Map
// 存储
m.Store("name", "老张")
m.Store("age", 30)
// 读取
if val, ok := m.Load("name"); ok {
fmt.Println("name:", val)
}
// 读取或存储(原子操作:不存在则存储,返回实际值)
actual, loaded := m.LoadOrStore("city", "北京")
fmt.Println("city:", actual, "已存在:", loaded)
// 删除
m.Delete("age")
// 遍历
m.Range(func(k, v interface{}) bool {
fmt.Println(k, "=", v)
return true // 返回false则停止遍历
})
}sync.Map适用场景:
- key只写入一次,但读取多次(类似缓存)
- 多个goroutine读写不同的key集合(几乎无竞争)
不适合: 频繁更新同一批key,此时自己维护 map + RWMutex 性能更好。
七、sync.Pool:对象复用池
sync.Pool 是临时对象的复用池,减少GC压力。类似Java的对象池,但Go的Pool不保证对象一直存在(GC时会清空):
package main
import (
"bytes"
"fmt"
"sync"
)
var bufPool = sync.Pool{
New: func() interface{} {
// 当Pool里没有可用对象时,调用New创建新的
fmt.Println("创建新的buffer")
return new(bytes.Buffer)
},
}
func processRequest(data string) string {
// 从Pool取出对象
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset() // 重用前必须Reset
defer bufPool.Put(buf) // 用完放回Pool
buf.WriteString("处理: ")
buf.WriteString(data)
return buf.String()
}
func main() {
for i := 0; i < 5; i++ {
result := processRequest(fmt.Sprintf("请求%d", i))
fmt.Println(result)
}
}八、各原语适用场景速查
| 场景 | 推荐原语 |
|---|---|
| 保护共享状态(读写均衡) | sync.Mutex |
| 保护共享状态(读多写少) | sync.RWMutex |
| 等待一批goroutine完成 | sync.WaitGroup |
| 初始化只执行一次 | sync.Once |
| 并发安全的map(读多/key固定) | sync.Map |
| 临时对象复用 | sync.Pool |
| goroutine间通信/信号 | channel |
九、总结
sync包是Go并发的基础设施,但每个原语都有精准的边界。核心原则:
- Mutex/RWMutex:保护临界区,最小粒度,不能复制
- WaitGroup:Add在goroutine外,Done在goroutine内,Wait等全完成
- Once:单例初始化的标准做法,初始化函数不要panic
- sync.Map:读多写少/key稳定时才有优势,否则不如手动map+锁
老孙那个问题,用 sync.Once 包裹初始化逻辑,三行代码搞定,既正确又简洁。这就是「用对工具」的价值。
