sync 包:Go 的同步工具箱
前两篇文章我们学了 goroutine 和 channel——Go 并发编程的两大利器。但现实中,不是所有并发问题都适合用 channel 来解决。
有时候你只需要保护一小段临界区代码,用 channel 反而显得笨重。这时候,传统的同步原语——互斥锁、读写锁、等待组等——就是你的好朋友。
Go 的 sync 包提供了这些工具。它虽然不大,但每个工具都精心设计,覆盖了并发编程中最常见的场景。今天我们就来逐个拆解这个工具箱。
sync.Mutex:互斥锁
为什么需要互斥锁?
先看一个问题:
package main
import (
"fmt"
"sync"
)
func main() {
counter := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++
}()
}
wg.Wait()
fmt.Println("结果:", counter)
}
你觉得这段代码的输出是多少?1000?
不,每次运行的结果都可能不同,而且几乎不会是 1000。可能是 987、993、976……
这是因为 counter++ 不是一个原子操作。它实际上包含三个步骤:
- 读取 counter 的当前值
- 把值加 1
- 写回 counter
当多个 goroutine 同时执行这三步时,就会发生竞态条件(race condition):
Goroutine A: 读取 counter = 100
Goroutine B: 读取 counter = 100 ← 读到了相同的旧值
Goroutine A: 写入 counter = 101
Goroutine B: 写入 counter = 101 ← 覆盖了 A 的结果!
两次 counter++,但 counter 只增加了 1。这就是并发编程中最经典的 bug。
用 Mutex 保护临界区
sync.Mutex 提供两个方法:
Lock():获取锁。如果锁已被其他 goroutine 持有,会阻塞等待。Unlock():释放锁。
package main
import (
"fmt"
"sync"
)
func main() {
counter := 0
var mu sync.Mutex
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock() // 获取锁
counter++ // 临界区代码
mu.Unlock() // 释放锁
}()
}
wg.Wait()
fmt.Println("结果:", counter) // 1000(总是正确的)
}
现在 counter++ 被锁保护起来了。同一时刻只有一个 goroutine 能执行这段代码,结果就正确了。
用 defer 确保释放
实际开发中,推荐用 defer 来确保锁一定会被释放:
mu.Lock()
defer mu.Unlock()
// 临界区代码...
即使临界区代码发生 panic,defer 也会确保锁被释放。否则,其他 goroutine 会永远等待——这就是死锁。
封装成线程安全的结构体
把 Mutex 和它保护的数据封装在一起是一个好习惯:
type SafeCounter struct {
mu sync.Mutex
value int
}
func (c *SafeCounter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}
func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
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
}
⚠️ 重要:sync.Mutex 不能被复制。如果你需要传递包含 Mutex 的结构体,传指针而不是值:
// ❌ 不好:按值传递会复制 Mutex
func process(c SafeCounter) { ... }
// ✅ 好:按指针传递
func process(c *SafeCounter) { ... }
sync.RWMutex:读写锁
sync.RWMutex 是 Mutex 的增强版。它区分读锁和写锁:
- 读锁(共享锁):多个 goroutine 可以同时持有读锁
- 写锁(排他锁):只有一个 goroutine 能持有写锁,且与读锁互斥
这非常适合读多写少的场景:
type Cache struct {
mu sync.RWMutex
items map[string]string
}
func NewCache() *Cache {
return &Cache{
items: make(map[string]string),
}
}
// Get 读取数据(使用读锁)
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
value, ok := c.items[key]
return value, ok
}
// Set 写入数据(使用写锁)
func (c *Cache) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = value
}
func main() {
cache := NewCache()
// 多个 goroutine 同时读取
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
cache.Get(fmt.Sprintf("key%d", i))
}(i)
}
// 一些 goroutine 同时写入
for i := 0; i < 10; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
cache.Set(fmt.Sprintf("key%d", i), fmt.Sprintf("value%d", i))
}(i)
}
wg.Wait()
}
读写锁的规则
| 操作 | 互斥 |
|---|---|
| 读锁 vs 读锁 | ❌ 不互斥(可以并发读) |
| 读锁 vs 写锁 | ✅ 互斥 |
| 写锁 vs 写锁 | ✅ 互斥 |
简单说:读读并发,读写互斥,写写互斥。
性能对比
让我们看看读写锁和互斥锁在读多写少场景下的性能差异:
package main
import (
"sync"
"testing"
)
type MutexMap struct {
mu sync.Mutex
data map[string]int
}
func (m *MutexMap) Get(key string) int {
m.mu.Lock()
defer m.mu.Unlock()
return m.data[key]
}
type RWMap struct {
mu sync.RWMutex
data map[string]int
}
func (m *RWMap) Get(key string) int {
m.mu.RLock()
defer m.mu.RUnlock()
return m.data[key]
}
func BenchmarkMutexGet(b *testing.B) {
m := &MutexMap{data: map[string]int{"a": 1}}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
m.Get("a")
}
})
}
func BenchmarkRWMapGet(b *testing.B) {
m := &RWMap{data: map[string]int{"a": 1}}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
m.Get("a")
}
})
}
在高并发读的场景下,读写锁的性能可以比互斥锁快几倍甚至几十倍。
sync.WaitGroup:等待一组 goroutine
sync.WaitGroup 是我们已经用过好几次的老朋友了。它用来等待一组 goroutine 完成工作。
三个方法:
Add(delta int):增加计数Done():减少计数(等价于Add(-1))Wait():阻塞,直到计数变为 0
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1) // 增加计数
go func(id int) {
defer wg.Done() // 确保计数减 1
fmt.Printf("Task %d 开始\n", id)
time.Sleep(time.Second)
fmt.Printf("Task %d 完成\n", id)
}(i)
}
fmt.Println("等待所有任务完成...")
wg.Wait()
fmt.Println("所有任务已完成!")
}
⚠️ 注意事项:
Add必须在 goroutine 启动之前调用,否则可能和Wait竞态Done最好用defer调用,确保一定会执行- 不要把 WaitGroup 传给 goroutine 后,在外部再调用
Add
sync.Once:只执行一次
sync.Once 确保某段代码只会被执行一次,即使在多个 goroutine 中并发调用。
典型用途是延迟初始化(lazy initialization):
package main
import (
"fmt"
"sync"
)
type Config struct {
DatabaseURL string
Port int
}
var (
config *Config
once sync.Once
)
func loadConfig() *Config {
once.Do(func() {
fmt.Println("加载配置文件...") // 这行只会打印一次
config = &Config{
DatabaseURL: "postgres://localhost/mydb",
Port: 8080,
}
})
return config
}
func main() {
var wg sync.WaitGroup
// 多个 goroutine 同时请求配置
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
cfg := loadConfig()
fmt.Printf("Goroutine %d: port = %d\n", id, cfg.Port)
}(i)
}
wg.Wait()
}
输出:
加载配置文件...
Goroutine 0: port = 8080
Goroutine 1: port = 8080
...
“加载配置文件…“只打印了一次。sync.Once 保证了初始化的唯一性。
实现单例模式
sync.Once 是 Go 中实现单例模式的标准方式:
type Database struct {
conn *sql.DB
}
var (
instance *Database
once sync.Once
)
func GetDatabase() *Database {
once.Do(func() {
db, err := sql.Open("postgres", "dsn")
if err != nil {
panic(err)
}
instance = &Database{conn: db}
})
return instance
}
sync.Pool:对象池
sync.Pool 是一个临时对象的池子,用来缓存和复用频繁创建和销毁的对象,减少 GC 压力。
package main
import (
"bytes"
"fmt"
"sync"
)
// 创建一个 Buffer 池
var bufPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}
func processRequest(data string) {
// 从池中获取一个 Buffer
buf := bufPool.Get().(*bytes.Buffer)
// 使用完毕后清空并放回池中
buf.Reset()
buf.WriteString(data)
fmt.Printf("处理: %s (len=%d)\n", buf.String(), buf.Len())
bufPool.Put(buf)
}
func main() {
for i := 0; i < 5; i++ {
processRequest(fmt.Sprintf("request-%d", i))
}
}
sync.Pool 的关键方法:
Get():从池中获取一个对象(如果池为空,调用New创建新的)Put(obj):把对象放回池中
⚠️ 注意:池中的对象会在 GC 时被自动清理。所以 sync.Pool 适合缓存短生命周期的临时对象,不适合做长期缓存。
sync.Pool 在 Go 标准库中被广泛使用,比如 fmt 包就用它来缓存打印时用到的缓冲区。
sync.Map:并发安全的 Map
在之前的文章中我们知道,普通 map 不是并发安全的。sync.Map 是 Go 1.9 引入的并发安全版本。
package main
import (
"fmt"
"sync"
)
func main() {
var m sync.Map
// 存储
m.Store("name", "张三")
m.Store("age", 25)
// 读取
if value, ok := m.Load("name"); ok {
fmt.Println("name:", value)
}
// 读取或存储(如果 key 不存在则存储)
value, loaded := m.LoadOrStore("city", "北京")
fmt.Printf("city: %v (loaded: %v)\n", value, loaded)
// 删除
m.Delete("age")
// 遍历
m.Range(func(key, value interface{}) bool {
fmt.Printf("%v: %v\n", key, value)
return true // 返回 false 停止遍历
})
}
sync.Map 适合以下场景:
- 读多写少:多个 goroutine 读取,偶尔有写入
- 键空间不重叠:不同的 goroutine 操作不同的键
对于其他场景,用 sync.RWMutex + 普通 map 通常性能更好。
条件变量:sync.Cond
sync.Cond 用来协调多个 goroutine 之间的等待和通知。它比 channel 更灵活,但使用也更复杂。
package main
import (
"fmt"
"sync"
"time"
)
type Queue struct {
mu sync.Mutex
cond *sync.Cond
items []string
maxSize int
}
func NewQueue(maxSize int) *Queue {
q := &Queue{
items: make([]string, 0),
maxSize: maxSize,
}
q.cond = sync.NewCond(&q.mu)
return q
}
func (q *Queue) Push(item string) {
q.mu.Lock()
defer q.mu.Unlock()
// 等待队列有空间
for len(q.items) >= q.maxSize {
q.cond.Wait() // 阻塞等待,同时释放锁
}
q.items = append(q.items, item)
fmt.Printf("入队: %s (size: %d)\n", item, len(q.items))
q.cond.Signal() // 通知等待的消费者
}
func (q *Queue) Pop() string {
q.mu.Lock()
defer q.mu.Unlock()
// 等待队列有数据
for len(q.items) == 0 {
q.cond.Wait()
}
item := q.items[0]
q.items = q.items[1:]
fmt.Printf("出队: %s (size: %d)\n", item, len(q.items))
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.Push(fmt.Sprintf("item-%d", i))
time.Sleep(100 * time.Millisecond)
}
}()
// 启动消费者
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 10; i++ {
time.Sleep(300 * time.Millisecond)
q.Pop()
}
}()
wg.Wait()
}
sync.Cond 在实际开发中用得不多,因为大多数场景用 channel 更简洁。但了解它的存在还是很有用的。
锁 vs Channel:如何选择?
这是很多 Go 开发者纠结的问题。这里给出一些指导原则:
用 Channel 的场景
- 传递数据的所有权:一个 goroutine 生产数据,另一个消费
- 协调多个 goroutine 的执行顺序
- 超时和取消操作
- 分发任务给多个 worker
用锁的场景
- 保护内部状态:比如缓存、计数器
- 细粒度的锁操作:只锁住几行代码
- 性能敏感的临界区:锁的开销比 channel 小
一个简单的原则
用 channel 做 goroutine 之间的通信,用锁做 goroutine 内部的同步。
// ✅ 好:用锁保护内部状态
type Cache struct {
mu sync.RWMutex
items map[string]string
}
// ✅ 好:用 channel 在 goroutine 之间传递数据
func process(jobs <-chan Job, results chan<- Result) {
for job := range jobs {
results <- doWork(job)
}
}
用 -race 检测竞态条件
Go 提供了一个强大的工具:竞态检测器(race detector)。
go run -race main.go
go test -race ./...
go build -race
它能在运行时检测出数据竞态,即使你的程序"看起来工作正常"也能发现问题。
// race_test.go
package main
import (
"sync"
"testing"
)
func TestRace(t *testing.T) {
data := make(map[string]int)
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
key := fmt.Sprintf("key%d", i)
data[key] = i // 竞态条件!
}(i)
}
wg.Wait()
}
运行 go test -race 会报告竞态条件的位置。
⚠️ 最佳实践:在 CI/CD 中加入 go test -race,确保每次提交都没有竞态条件。
小结
今天我们全面学习了 sync 包:
- Mutex:互斥锁,保护临界区代码
- RWMutex:读写锁,读多写少场景性能更好
- WaitGroup:等待一组 goroutine 完成
- Once:确保代码只执行一次
- Pool:对象池,减少 GC 压力
- Map:并发安全的 map
- Cond:条件变量,复杂场景下的协调工具
最后,记住一个重要的原则:能用 channel 解决的,就不用锁;能用锁解决的,就不用 channel。选择最合适的工具,而不是最复杂的。
练习时间
- 安全队列:实现一个线程安全的队列,支持 Push、Pop、Size 操作
- 并发缓存:用
sync.RWMutex实现一个带过期时间的缓存 - 资源池:用
sync.Pool实现一个数据库连接池 - 单例模式:用
sync.Once实现一个线程安全的配置加载器 - 竞态检测:故意写一段有竞态条件的代码,用
-race检测出来
下一篇预告
下一篇文章,我们将学习 Context 包——Go 并发编程的另一个重要工具。Context 用来控制 goroutine 的取消、超时和传值,是构建大型 Go 应用不可或缺的一部分。
我们下篇见!👋
参考资料:
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。