Skip to content

32|sync 包与锁

多个 goroutine 同时读写同一个变量时,需要同步保护。普通 map 并发写会触发 fatal error: concurrent map writes,多个 goroutine 同时自增计数器会得到错误的结果。sync 包提供了等待、加锁和单次执行等原语,是 Go 并发编程的基础工具。

一、sync.WaitGroup

sync.WaitGroup 用来等待一组 goroutine 全部结束。它只负责等待,不负责传数据:

go
package main

import (
	"fmt"
	"sync"
	"time"
)

func check(name string) {
	time.Sleep(1 * time.Second)
	fmt.Println(name, "ok")
}

func main() {
	hosts := []string{"web01", "web02", "db01"}

	var wg sync.WaitGroup
	for _, host := range hosts {
		wg.Add(1)
		go func(name string) {
			defer wg.Done()
			check(name)
		}(host)
	}

	wg.Wait()
	fmt.Println("all checks finished")
}

WaitGroup 内部可以近似看成一个计数器。Add(1) 表示多一个任务要等,Done() 表示一个任务结束,Wait() 会一直等到计数归零。

Done() 少调用一次,程序会一直卡在 Wait()Done() 调多了,计数变成负数会触发 panic。循环里启动 goroutine 时,把循环变量作为参数传进去更稳——旧代码里如果直接在闭包里使用外层循环变量,容易出现所有任务读到同一个值的问题。

二、sync.Mutex

多个 goroutine 同时读写同一个变量时,需要同步保护:

go
package main

import (
	"fmt"
	"sync"
)

func main() {
	counts := map[string]int{}
	var mu sync.Mutex
	var wg sync.WaitGroup

	for _, service := range []string{"nginx", "nginx", "mysql"} {
		wg.Add(1)
		go func(name string) {
			defer wg.Done()
			mu.Lock()
			counts[name]++
			mu.Unlock()
		}(service)
	}

	wg.Wait()
	fmt.Println(counts)
}

Mutex 是互斥锁,同一时间只有一个 goroutine 能持有锁。LockUnlock 之间的代码叫临界区,应该尽量短——锁住的代码越长,并发性能越差。

defer mu.Unlock() 比手动 Unlock 更安全,避免中间提前 return 时忘记释放锁:

go mu.Lock()
		defer mu.Unlock()
		counts[name]++

三、sync.RWMutex

读多写少时用 sync.RWMutex,它区分读锁和写锁。多个 goroutine 可以同时持有读锁,但写锁独占:

go
var cache = make(map[string]string)
var rwmu sync.RWMutex

func get(key string) string {
	rwmu.RLock()
	defer rwmu.RUnlock()
	return cache[key]
}

func set(key, value string) {
	rwmu.Lock()
	defer rwmu.Unlock()
	cache[key] = value
}

读操作远多于写操作时,RWMutex 的并发性能比 Mutex 好得多。但如果写操作很频繁,读锁不断被写锁阻塞,性能反而不如 Mutex

数据能通过 channel 汇总时,通常比共享 map 再加锁更清楚。channel 把状态流转显式化,而锁隐藏了状态变更的时机。需要频繁读写公共状态时,再考虑 Mutex

四、sync.Once

sync.Once 保证某个操作只执行一次,常用于初始化:

go
var once sync.Once
var config *Config

func GetConfig() *Config {
	once.Do(func() {
		config = loadConfig()
	})
	return config
}

无论 GetConfig 被多少 goroutine 同时调用,loadConfig 只执行一次。sync.Once 比手动用 Mutex + bool 标志更简洁,且内部处理了并发竞争。

五、sync.Map

Go 1.9 引入的 sync.Map 是并发安全的 map,但 API 和普通 map 不同,不适合所有场景。它适合两种场景:

  1. 键值对只写一次、读很多次(如缓存)
  2. 多个 goroutine 读写不同的 key,互不重叠
go
var m sync.Map

m.Store("key", "value")
val, ok := m.Load("key")
m.Delete("key")
m.Range(func(key, value interface{}) bool {
	fmt.Println(key, value)
	return true // 继续遍历
})

普通 map 用 Mutex 保护更直观,sync.Map 只在特定场景下性能更好。

六、常见错误

WaitGroup 计数不匹配

go
wg.Add(1)
go func() {
	// 忘了调用 wg.Done()
}()
wg.Wait() // 永远阻塞

defer wg.Done() 在 goroutine 开头注册,能避免所有退出路径遗漏。

复制 sync 类型

go
func process(mu sync.Mutex) { // 错误:复制了 Mutex
	mu.Lock()
	defer mu.Unlock()
}

sync.Mutexsync.WaitGroup 等类型不应该被复制。函数参数应该传指针:func process(mu *sync.Mutex)

锁的粒度太粗

go
mu.Lock()
result := heavyComputation()
data[name] = result
mu.Unlock()

heavyComputation 不需要锁保护,把它放到锁外面,临界区只保护 map 写入:

go
result := heavyComputation()
mu.Lock()
data[name] = result
mu.Unlock()

死锁

go
mu1.Lock()
mu2.Lock() // 如果另一个 goroutine 先锁了 mu2 再请求 mu1,死锁

多个锁的获取顺序不一致会导致死锁。需要多个锁时, always 按固定顺序获取。