Go语言精进之路读书笔记第35条——了解sync包的正确用法
Go语言通过标准库的sync包提供了针对传统基于共享内存并发模型的基本同步原语。
35.1 sync包还是channel
在下面一些场景下,我们依然需要sync包提供的低级同步原语
- (1) 需要高性能的临界区同步机制场景
- (2) 不想转移结构体对象所有权,但又要保证结构体内部状态数据的同步访问的场景。基于channel的并发,需要在goroutine间通过channel转移数据对象的所有权。只有拥有数据对象所有权(从channel接收到该数据)的goroutine才可以对该数据对象进行状态变更。
sync.Mutex和channel各自实现的临界区同步机制的一个简单性能对比,sync.Mutex实现的同步机制的性能要比channel实现的高出两倍多
var cs = 0 // 模拟临界区要保护的数据
var mu sync.Mutex
var c = make(chan struct{}, 1)
func criticalSectionSyncByMutex() {
mu.Lock()
cs++
mu.Unlock()
}
func criticalSectionSyncByChan() {
c <- struct{}{}
cs++
<-c
}
func BenchmarkCriticalSectionSyncByMutex(b *testing.B) {
for n := 0; n < b.N; n++ {
criticalSectionSyncByMutex()
}
}
func BenchmarkCriticalSectionSyncByChan(b *testing.B) {
for n := 0; n < b.N; n++ {
criticalSectionSyncByChan()
}
}
35.2 使用sync包的注意事项
sync包中定义的结构类型首次使用后不应对其进行复制操作
g3阻塞在加锁操作上了,而g2则按预期正常运行
- g2是在互斥锁首次使用之前创建的
- g3则是在互斥锁执行完加锁操作并处于锁定状态之后创建的,并且创建g3的时候复制了foo的实例(包含sync.Mutes的实例),并在之后使用了这个副本
type foo struct {
n int
sync.Mutex
}
func main() {
f := foo{n: 17}
go func(f foo) {
for {
log.Println("g2: try to lock foo...")
f.Lock()
log.Println("g2: lock foo ok")
time.Sleep(3 * time.Second)
f.Unlock()
log.Println("g2: unlock foo ok")
}
}(f)
f.Lock()
log.Println("g1: lock foo ok")
// 在mutex首次使用后复制其值
go func(f foo) {
for {
log.Println("g3: try to lock foo...")
f.Lock()
log.Println("g3: lock foo ok")
time.Sleep(5 * time.Second)
f.Unlock()
log.Println("g3: unlock foo ok")
}
}(f)
time.Sleep(1000 * time.Second)
f.Unlock()
log.Println("g1: unlock foo ok")
}
分析:
- Mutex由state和sema两个字段组成
- state int32:表示当前互斥锁的状态
- sema uint32:用于控制锁状态的信号量
- 对Mutex实例的复制即是对两个整型字段的复制,初始状态下Mutex实例处于Unlocked状态,state和sema均为0
- g2复制了处于初始状态的Mutex实例,副本的state和sema均为0,这与g2自定义一个新的Mutex实例无异,因此g2后续可以按预期正常运行
- 后续主程序调用了Lock方法,Mutex实例变为Locked状态,state字段值为sync.mutex-Locked
- g3复制了处于Locked状态的Mutex实例,副本的state为sync.mutex-Locked,因此g3再对其实例副本调用Lock方法将会导致进入阻塞状态(也就是死锁状态,因为没有任何其他机会再调用该副本的Unlock方法了,并且Go不支持递归锁)
- 总结
- sync包中类型的实例在首次使用后被复制得到的副本一旦再被使用将导致不可预期的结果
- 在使用sync包中类型时,推荐通过闭包方式或传递类型实例(或包裹该类型的类型实例)的地址或指针的方式进行
35.3 互斥锁还是读写锁
- 在并发量较小的情况下,互斥锁性能更好;随着并发量增大,互斥锁的竞争激烈,导致加锁和解锁性能下降
- 读写锁的读锁性能并未随并发量的增大而发生较大变化,性能始终恒定在40ns左右
- 在并发量较大的情况下,读写锁的写锁性能比互斥锁、读写锁的读锁都差,并且随着并发量增大,其写锁性能有继续下降的趋势
- 总结:读写锁适合应用在具有一定并发量且读多写少的场合
var cs1 = 0 // 模拟临界区要保护的数据
var mu1 sync.Mutex
var cs2 = 0 // 模拟临界区要保护的数据
var mu2 sync.RWMutex
func BenchmarkReadSyncByMutex(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
mu1.Lock()
_ = cs1
mu1.Unlock()
}
})
}
func BenchmarkReadSyncByRWMutex(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
mu2.RLock()
_ = cs2
mu2.RUnlock()
}
})
}
func BenchmarkWriteSyncByRWMutex(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
mu2.Lock()
cs2++
mu2.Unlock()
}
})
}
35.4 条件变量
一个条件变量可以理解为一个容器,这个容器中存放着一个或者一组等待着某个条件成立的goroutine。当条件成立时,这些处于等待状态的goroutine将得到通知并被唤醒以继续后续的工作。
如果没有条件变量,我们可能需要在goroutine中通过连续轮询的方式检查是否满足条件。连续轮询非常消费资源,因为goroutine在这个过程中处于活动状态但其工作并无进展。下面是用sync.Mutex实现对条件的轮询等待的例子。
type signal struct{}
var ready bool
func worker(i int) {
fmt.Printf("worker %d: is working...\n", i)
time.Sleep(1 * time.Second)
fmt.Printf("worker %d: works done\n", i)
}
func spawnGroup(f func(i int), num int, mu *sync.Mutex) <-chan signal {
c := make(chan signal)
var wg sync.WaitGroup
for i := 0; i < num; i++ {
wg.Add(1)
go func(i int) {
for {
mu.Lock()
if !ready {
mu.Unlock()
time.Sleep(100 * time.Millisecond)
continue
}
mu.Unlock()
fmt.Printf("worker %d: start to work...\n", i)
f(i)
wg.Done()
return
}
}(i + 1)
}
go func() {
wg.Wait()
c <- signal(struct{}{})
}()
return c
}
func main() {
fmt.Println("start a group of workers...")
mu := &sync.Mutex{}
c := spawnGroup(worker, 5, mu)
time.Sleep(5 * time.Second) // 模拟ready前的准备工作
fmt.Println("the group of workers start to work...")
mu.Lock()
ready = true
mu.Unlock()
<-c
fmt.Println("the group of workers work done!")
}
sync.Cond为goroutine在上述场景下提供了另一种可选的、资源消耗更小、使用体验更佳的同步方式。下面是使用sync.Cond改造后的例子。
type signal struct{}
var ready bool
func worker(i int) {
fmt.Printf("worker %d: is working...\n", i)
time.Sleep(1 * time.Second)
fmt.Printf("worker %d: works done\n", i)
}
func spawnGroup(f func(i int), num int, groupSignal *sync.Cond) <-chan signal {
c := make(chan signal)
var wg sync.WaitGroup
for i := 0; i < num; i++ {
wg.Add(1)
go func(i int) {
groupSignal.L.Lock()
for !ready {
groupSignal.Wait()
}
groupSignal.L.Unlock()
fmt.Printf("worker %d: start to work...\n", i)
f(i)
wg.Done()
}(i + 1)
}
go func() {
wg.Wait()
c <- signal(struct{}{})
}()
return c
}
func main() {
fmt.Println("start a group of workers...")
groupSignal := sync.NewCond(&sync.Mutex{})
c := spawnGroup(worker, 5, groupSignal)
time.Sleep(5 * time.Second) // 模拟ready前的准备工作
fmt.Println("the group of workers start to work...")
groupSignal.L.Lock()
ready = true
groupSignal.Broadcast()
groupSignal.L.Unlock()
<-c
fmt.Println("the group of workers work done!")
}
分析:
- sync.Cond初始化需要一个满足实现了sync.Lock接口的类型实例,通常使用sync.Mutex
- 条件变量需要这个互斥锁来同步临界区,保护用作条件的数据
- 各个等待条件成立的goroutine在加锁后判断条件是否成立,如果不成立,则调用sync.Cond的Wait方法进入等待状态。Wait方法在goroutine挂起前会进行Unlock操作
- 调用sync.Cond的Broadcast方法后,各个阻塞的goroutine将被唤醒并从Wait方法中返回
- 在Wait方法返回前,Wait方法会再次加锁让goroutine进入临界区,接下来goroutine会再次对条件数据进行判定,如果条件成立,则解锁并进入下一个工作阶段;如果条件依旧不成立,那么再次调用Wait方法挂起等待
35.5 使用sync.Once实现单例模式
sync.Once保证任意一个函数在程序运行期间只被执行一次,常用于初始化和资源清理,避免重复执行初始化或资源关闭操作
type Foo struct {
}
var once sync.Once
var instance *Foo
func GetInstance(id int) *Foo {
defer func() {
if e := recover(); e != nil {
log.Printf("goroutine-%d: caught a panic: %s", id, e)
}
}()
log.Printf("goroutine-%d: enter GetInstance\n", id)
once.Do(func() {
instance = &Foo{}
time.Sleep(3 * time.Second)
log.Printf("goroutine-%d: the addr of instance is %p\n", id, instance)
panic("panic in once.Do function")
})
return instance
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(i int) {
inst := GetInstance(i)
log.Printf("goroutine-%d: the addr of instance returned is %p\n", i, inst)
wg.Done()
}(i + 1)
}
time.Sleep(5 * time.Second)
inst := GetInstance(0)
log.Printf("goroutine-0: the addr of instance returned is %p\n", inst)
wg.Wait()
log.Printf("all goroutines exit\n")
}
分析:
- once.Do会等待f执行完毕后才返回,这期间其他执行once.Do函数的goroutine将会阻塞等待
- Do函数返回后,后续的goroutine再执行Do函数将不再执行f并立即返回
- 即便在函数f中出现panic,sync.Once原语也会认为once.Do执行完毕,后续对once.Do的调用将不再执行f
35.6 使用sync.Pool减轻垃圾回收压力
sync.Pool是一个对象缓存池,有如下特点
- 它是goroutine并发安全的,可以被多个goroutine同时使用
- 放入该缓存池中的数据对象的生命是暂时的,随时都可能被垃圾回收掉
- 缓存池中的数据对象是可以重复利用的,这样可以在一定程度上降低数据对象重新分配的频度,减轻GC压力
- sync.Pool为每个P(GMP中的P)单独建立一个local缓存池,进一步降低高并发下对锁的争抢
通过sync.Pool来复用数据对象的方式可以有效降低内存分配频率,减轻垃圾回收压力,从而提高性能
var bufPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}
func writeBufFromPool(data string) {
b := bufPool.Get().(*bytes.Buffer)
b.Reset()
b.WriteString(data)
bufPool.Put(b)
}
func writeBufFromNew(data string) *bytes.Buffer {
b := new(bytes.Buffer)
b.WriteString(data)
return b
}
func BenchmarkWithoutPool(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
writeBufFromNew("hello")
}
}
func BenchmarkWithPool(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
writeBufFromPool("hello")
}
}
典型应用:建立像bytes.Buffer这样类型的临时缓存对象池
var bufPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}
可能带来的问题:
- sync.Pool的Get方法从缓存池中挑选bytes.Buffer数据对象时并未考虑数据对象是否满足调用者的需求
- 可能出现bytes.Buffer刚扩容,但被长期用于处理小数据,导致扩容后的大内存长时间得不到释放,给应用带来沉重的内存消耗负担
解决方式:
- (1) 限制要放回缓存池中的数据对象大小,参考Go标准库fmt包的代码
if cap(p.buf) > 64<<10 {
和issue#23199 - (2) 建立多级缓存池,标准库的http包在处理http2数据时,预先建立了多个不同大小的缓存池,这样就可以根据要处理的数据的大小从最合适的缓存池中获取Buffer对象,并在完成数据处理后将对象归还到对应的池中,避免大材小用、浪费内存的情况