并发原语使用总结

这篇文章主要总结一些并发原语的适用注意事项

mutex


  • 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的问题,进而导致死锁

RWMutex


  • 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顺序为:

    1. 活跃的reader-1
    2. 活跃的reader-2
    3. 活跃的reader-3
    4. writer-1
    5. 新来的reader-1

    假设活跃的reader-1释放锁后,活跃的reader-2开始执行,但是方法内部又调用了【新来的reader-1】调用的方法,但是【新来的reader-1】在【writer-1】之后才触发的,那么【新来的reader-1】要等待【writer-1】释放锁 后才能执行,而【writer-1】又需要等待前面【活跃的reader】执行完才能执行,陷入了环形依赖

WaitGroup


  • Add方法设置计数器后计数应该大于0,否则panic

    Add方法的参数可以是负数,但是设置完要保证计数器的值是大于0,不建议传递负数,不是正确的使用方式,正确的使用是传递一个常量正数

  • 调用Done方法次数超过了计数器的值

    多余调用Done方法,调用Done方法的次数必须和计数器的值一样

  • 等待调用完Add方法后再调用Wait方法

    不要等到已经调用Wait方法了,再调用Add方法

  • Wait方法结束后再重用WaitGroup对象

    WaitGroup可以被重用,但是一定等到上一个Wait结束后再重用

  • 禁止拷贝

Cond


这个是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 和一个 Locker 关联,可以利用这个 Locker 对相关的依赖条件更改提供保护
  • Cond 可以同时支持 Signal 和 Broadcast 方法,而 Channel 只能同时支持其中一种
  • Cond 的 Broadcast 方法可以被重复调用。等待条件再次变成不满足的状态后,我们又可以调用 Broadcast 再次唤醒等待的 goroutine。这也是 Channel 不能支持的,Channel 被 close 掉了之后不支持再 open

Cond和WaitGroup也是有区别的

  • WaitGroup 是主 goroutine 等待确定数量的子 goroutine 完成任务
  • Cond 是等待某个条件满足,这个条件的修改可以被任意多的 goroutine 更新,Cond 的 Wait 不需要关心其他 goroutine 的数量,只关心等待条件
  • Cond 支持单个通知的机制,Signal 方法

Once


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,再一次加锁,从而导致死锁