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