sync 包:Go 的同步工具箱

全面掌握 Go 语言 sync 包:Mutex、RWMutex、WaitGroup、Once、Pool 和 Map

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++ 不是一个原子操作。它实际上包含三个步骤:

  1. 读取 counter 的当前值
  2. 把值加 1
  3. 写回 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.RWMutexMutex 的增强版。它区分读锁写锁

  • 读锁(共享锁):多个 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("所有任务已完成!")
}

⚠️ 注意事项

  1. Add 必须在 goroutine 启动之前调用,否则可能和 Wait 竞态
  2. Done 最好用 defer 调用,确保一定会执行
  3. 不要把 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 包:

  1. Mutex:互斥锁,保护临界区代码
  2. RWMutex:读写锁,读多写少场景性能更好
  3. WaitGroup:等待一组 goroutine 完成
  4. Once:确保代码只执行一次
  5. Pool:对象池,减少 GC 压力
  6. Map:并发安全的 map
  7. Cond:条件变量,复杂场景下的协调工具

最后,记住一个重要的原则:能用 channel 解决的,就不用锁;能用锁解决的,就不用 channel。选择最合适的工具,而不是最复杂的。

练习时间

  1. 安全队列:实现一个线程安全的队列,支持 Push、Pop、Size 操作
  2. 并发缓存:用 sync.RWMutex 实现一个带过期时间的缓存
  3. 资源池:用 sync.Pool 实现一个数据库连接池
  4. 单例模式:用 sync.Once 实现一个线程安全的配置加载器
  5. 竞态检测:故意写一段有竞态条件的代码,用 -race 检测出来

下一篇预告

下一篇文章,我们将学习 Context 包——Go 并发编程的另一个重要工具。Context 用来控制 goroutine 的取消、超时和传值,是构建大型 Go 应用不可或缺的一部分。

我们下篇见!👋


参考资料:

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页