Go 语言中的 sync 包
本文最后更新于 2025年8月6日 16:47
在并发编程中,同步原语(也就是我们通常说的锁)的主要作用是保证多个线程或者 goroutine在访问临界资源时不会出现线程安全问题,Go 语言中的 sync 包就提供了常用的同步源语,这里简单梳理一下不同同步源语的使用场景。
sync.Mutex
sync.Mutex 实现了对临界资源的加锁访问:
1 | |
sync.Mutex是一个 不可复制 的对象。你不能对它进行直接的赋值或传递。- 如果结构体包含
sync.Mutex,你应该传递结构体的指针,而不是结构体的副本。这样可以确保锁的状态不会丢失,并且在多个地方操作的是同一个互斥锁。
sync.RWMutex
sync.RWMutex 是一种 读写互斥锁,它不仅实现了 sync.Mutex 的 Lock 和 Unlock 方法(因为两者都实现了 sync.Locker 接口),还提供了 RLock 和 RUnlock 方法,用于支持并发读取。
1 | |
与 sync.Mutex 只能同时允许 一个 读锁或 一个 写锁不同,sync.RWMutex 允许 多个 读锁并行存在,或者 一个 写锁独占。它特别适用于 频繁读取、少量写入 的场景。
这里引用网上关于 sync.Mutex 和 sync.RWMutex 锁定性能的基准测试结果:
1 | |
从测试结果可以看出
sync.Mutex的锁定/解锁速度较快。sync.RWMutex的写锁操作 (Lock()/Unlock()) 比sync.Mutex慢。sync.RWMutex的读锁操作 (RLock()/RUnlock()) 比sync.Mutex更快。
因此 sync.Mutex 更适合用于 频繁写入、少量读取 的场景,而 sync.RWMutex 在 频繁读取、少量写入 的场景下能够提供更好的性能,特别是当多个 goroutine 需要并发读取时。
sync.WaitGroup
sync.WaitGroup 是 Go 中常用的同步原语,它用于协调多个 goroutine 的执行,确保一个 goroutine 等待一组其他 goroutine 执行完成后再继续执行。
sync.WaitGroup 通过内部计数器来跟踪正在执行的 goroutine 数量。它的工作原理如下:
- **
Add(int)**:用来增加计数器,指定有多少个goroutine要等待。 - **
Done()**:每当一个goroutine完成时,调用Done()将计数器减 1。(其实是调用Add(-1)) - **
Wait()**:让当前goroutine阻塞,直到计数器的值变为 0,表示所有等待的goroutine执行完毕。
在以下示例中,我们将启动八个 goroutine,并等待他们完成:
1 | |
如果我们事先知道需要启动的 goroutine 数量,可以在循环外直接调用 wg.Add({num}),而不必在每次迭代中都调用 Add(1)。
sync.Map
sync.Map是一个并发版本的 map,我们可以:
- 使用
Store(interface {}, interface {})添加元素。 - 使用
Load(interface {}, interface {})获取元素(类比 Get 操作)。 - 使用
Delete(interface {})删除元素。 - 使用
LoadOrStore(interface {}, interface {}) (interface {}, bool)检获取或添加之前不存在的元素。如果键之前在map中存在,则返回的布尔值为true。 - 使用
Range遍历元素。
1 | |
上面的程序会输出:
1 | |
如你所见,sync.Map 的 Range 方法接收一个类型为 func(key, value interface{}) bool 的函数参数。如果该函数返回 false,则会停止迭代。一个有趣的事实是,即使我们在迭代过程中返回了 false,最坏情况下的时间复杂度仍然是 O(n),因为 sync.Map 仍然需要遍历所有元素。
何时使用 sync.Map 而不是使用 map 和 **sync.Mutex**?
频繁读取,少量写入的场景:
当你的代码有频繁的读取操作,但写入操作较少时,
sync.Map是一个不错的选择。与使用sync.Mutex锁住普通的map不同,sync.Map在处理并发读取时能够提供更高的效率,因为它对读取操作进行了优化。多个
goroutine同时进行读取、写入和覆盖不相交的键:例如,假设你有一个分片(sharding)机制,包含 4 个
goroutine,每个goroutine负责 25% 的键,这些键之间没有冲突。在这种情况下,sync.Map更为合适,因为它内部使用了细粒度的锁机制(每个分片锁定不同的区域),从而能够在不发生冲突的情况下允许多个goroutine并发地操作不同的键。
sync.Pool
sync.Pool是一个并发池,负责安全地保存一组对象。它有两个导出方法:
Get() interface{}用来从并发池中取出元素。Put(interface{})将一个对象加入并发池。
1 | |
输出:
1 | |
需要注意的是Get()方法会从并发池中随机取出对象,无法保证以固定的顺序获取并发池中存储的对象。
假设我们需要编写一个函数,将数据写入文件。在这个函数中,我们需要使用缓冲区来处理数据,而缓冲区在多次调用中是可以复用的。通过使用 sync.Pool,我们可以重用已分配的缓冲区,从而避免频繁的内存分配。
- 获取缓冲区:通过
sync.Pool.Get()获取一个缓冲区对象。如果是第一次调用,它会创建一个新的缓冲区。 - 重置缓冲区:调用
buf.Reset()重置缓冲区,确保之前的内容不会影响当前操作。 - 使用
defer归还缓冲区:当操作完成后,使用defer将缓冲区放回sync.Pool,以便下一次使用。
1 | |
sync.Once
sync.Once 是一个简单而强大的原语,可确保一个函数仅执行一次。在下面的示例中,只有一个 goroutine 会显示输出消息:
1 | |
我们使用了 Do(func ()) 方法来指定只能被调用一次的部分。
sync.Cond
sync.Cond 是 sync 包中的同步原语,它通常用于在多个 goroutine 之间发出信号。通过 sync.Cond,你可以实现 一对一信号(Signal())或 一对多信号(Broadcast())的机制,用于通知其他 goroutine 继续执行。
sync.Cond 适合在你需要一个 goroutine 等待另一个 goroutine 的某个事件发生时,比如一个 goroutine 等待共享数据的变化。
假设我们有一个共享切片,goroutine 需要等待它的第一个元素被更新。
创建 **
sync.Cond**:sync.Cond需要一个sync.Locker类型的对象(如sync.Mutex或sync.RWMutex)来保护共享数据。1
cond := sync.NewCond(&sync.Mutex{})等待信号并处理:
创建一个函数,使用
cond.Wait()等待信号,直到另一个goroutine更新共享数据。1
2
3
4
5
6func printFirstElement(s []int, cond *sync.Cond) {
cond.L.Lock() // 锁定共享资源
cond.Wait() // 等待信号
fmt.Printf("%d\n", s[0]) // 打印第一个元素
cond.L.Unlock() // 解锁
}发出信号:
main goroutine更新共享切片的第一个元素,并通过cond.Signal()发出信号通知等待的goroutine。1
2
3
4
5
6
7
8
9
10s := make([]int, 1) // 创建一个共享切片
for i := 0; i < runtime.NumCPU(); i++ {
go printFirstElement(s, cond) // 启动多个 goroutine
}
i := get() // 获取要更新的值
cond.L.Lock()
s[0] = i // 更新共享切片
cond.Signal() // 发出信号
cond.L.Unlock()Signal()会解除一个阻塞的goroutine,它会打印s[0]中的值。广播信号:
如果希望所有等待的
goroutine都收到信号,可以使用cond.Broadcast(),而不是Signal()。1
2
3i := get()
cond.L.Lock()
s[0] = i