这篇文章主要总结一些并发原语的适用注意事项
Lock和Unlock要成对出现
避免漏掉任何一个,如果业务场景允许,尽量按照如下方式写
Lock()
defer Unlock()
禁止拷贝
mutex是有状态的,如果允许拷贝那么也就意味着新对象自带状态,检查是否有拷贝,可以使用vet,在编译脚本使用go vet 来检测
vet 会对实现了Lock接口和Unlock接口的类型进行分析
实际生产中如果想对某个类型实施禁止拷贝的限制,可以按照如下方式实现
type noCopy struct{
}
func (*noCopy) Lock(){
}
func (*noCopy) Unlock(){
}
type LimitCopy struct{
noCopy
}
不可以递归使用
mutex不是递归锁,go 并没有提供递归锁,可以基于mutex实现递归锁
循环等待
A等待B释放锁,B等待A释放锁,ABBA的问题,进而导致死锁
Lock和Unlock要成对出现
和mutex一样
禁止拷贝
和mutex一样,RWMutex是基于mutex实现的,不能拷贝一个带有状态mutex
读锁可以递归使用,但是写锁不可以
避免读操作调用写操作,或者写操作调用读操作
避免读操作再调用读操作,而在调用的过程又有其他协程调用写操作
这个死锁场景很隐蔽,这和RWMutex的实现有关,Go 标准库中的 RWMutex 设计是 Write-preferring 方案。一个正在阻塞的 Lock 调用会排除新的 reader 请求到锁
当一个 writer 请求锁的时候,例如已经3个活跃的 reader,它会等待这些活跃的 reader 完成,才有可能获取到锁, 但是,如果之后活跃的 reader 再依赖新的 reader 的话,这些新的 reader 就会等待 writer 释放锁之后才能继续执行, 这就形成了一个环形依赖: writer 依赖活跃的 reader -> 活跃的 reader 依赖新来的 reader -> 新来的 reader 依赖 writer
等待锁的reader和writer顺序为:
假设活跃的reader-1释放锁后,活跃的reader-2开始执行,但是方法内部又调用了【新来的reader-1】调用的方法,但是【新来的reader-1】在【writer-1】之后才触发的,那么【新来的reader-1】要等待【writer-1】释放锁 后才能执行,而【writer-1】又需要等待前面【活跃的reader】执行完才能执行,陷入了环形依赖
Add方法设置计数器后计数应该大于0,否则panic
Add方法的参数可以是负数,但是设置完要保证计数器的值是大于0,不建议传递负数,不是正确的使用方式,正确的使用是传递一个常量正数
调用Done方法次数超过了计数器的值
多余调用Done方法,调用Done方法的次数必须和计数器的值一样
等待调用完Add方法后再调用Wait方法
不要等到已经调用Wait方法了,再调用Add方法
Wait方法结束后再重用WaitGroup对象
WaitGroup可以被重用,但是一定等到上一个Wait结束后再重用
禁止拷贝
这个是Go语言提供的条件变量,像C++里的std::condition_variable,Cond提供三个方法——Signal、Broadcast、Wait,这三个方法和C++里的condition_variable的notify_one、notify_all、wait的用法基本一致
Signal 如果 Cond 等待队列中有一个或者多个等待的 goroutine,则需要从等待队列中移除第一个 goroutine 并把它唤醒
Wait方法的内部实现是:
func (c *Cond) Wait() {
c.checker.check()
// 增加到等待队列中
t := runtime_notifyListAdd(&c.notify)
c.L.Unlock()
// 阻塞休眠直到被唤醒
runtime_notifyListWait(&c.notify, t)
c.L.Lock()
}
使用Cond时要注意下面几点
禁止拷贝
调用Wait方法的前后一定要有加锁和解锁的调用
Wait方法的内部实现是:
func (c *Cond) Wait() {
c.checker.check()
// 增加到等待队列中
t := runtime_notifyListAdd(&c.notify)
c.L.Unlock()
// 阻塞休眠直到被唤醒
runtime_notifyListWait(&c.notify, t)
c.L.Lock()
}
从实现c.L.Unlock()
得知,如果不加锁必然导致释放一个未加锁的锁,这里如果不释放锁,其他 Wait 的调用者就没有机会加入到 notify 队列中
从这段代码c.L.Lock()
得知,内部又加锁,所以外部必须释放这个锁
所以外部调用Wait方法时一定加锁和解锁,像下面这样:
c.L.Lock()
c.Wait()
c.L.Unlock()
唤醒后一定再次检查条件
waiter goroutine 被唤醒不等于等待条件被满足,只是有 goroutine 把它唤醒了而已,等待条件有可能已经满足了,也有可能不满足,需要进一步检查。也可以理解为,等待者被唤醒,只是得到了一次检查的机会而已
具体原理可以参考这篇文章
Signal不存在虚假唤醒
Go语言的Cond并不像C++语言里的condition_variable,Signal不存在虚假唤醒,而C++语言里的condition_variable会有虚假唤醒
Cond的特性是Channel代替不了
Cond和WaitGroup也是有区别的
Once的语义是只做一次,对外只提供一个方法Do(f func()),只执行一次函数对象f,使用时需要注意以下几点
禁止拷贝
Once的结构里包含mutex,所以禁止拷贝
注意未初始化
使用Once肯定是对一个资源的初始化,那么就有可能初始化失败,对于Once而言,可以保证只执行一次函数对象f,但是不保证函数对象f内部执行情况,所以如果函数对象f内部 对一个资源的初始化失败了,当获取资源时取到的是个无效值
可以参考标准库里的Once实现,自己实现一个Once,如果函数f执行失败,当外部再次调用Do方法时还可以执行函数f,代码如下:
type Once struct {
mu sync.Mutex
done uint32
}
func (this *Once) Do(f func() error) error {
if this.Done() {
return nil
}
return this.slowDo(f)
}
func (this *Once) Done() bool {
return atomic.LoadUint32(&this.done) == 1
}
func (this *Once) slowDo(f func() error) error {
this.mu.Lock()
defer this.mu.Unlock()
var err error
if this.done == 0 {
err = f()
if err == nil {
atomic.StoreUint32(&this.done, 1)
}
}
return err
}
禁止递归调用Do方法
上面提到,Once是基于Mutex而实现的,所以当外部调用Do方法时,传递的函数参数f内部不能再次调用这个Once的Do方法,例如这样:
once.Do(func(){
once.Do(...)
})
当第一次执行Do时已经加锁了,执行函数对象f时,由于内部再次调用方法Do,再一次加锁,从而导致死锁