【并发】Cond的使用

134 阅读5分钟

需求

现状:一个程序启动三份,分别通过不同的标识符,从远端Nas进行拷贝。

问题:由于局域网内存在近千台实例,程序存在三份,那么(3*1000)个程序在同时拷贝文件,Nas的出口带宽流量被撑爆,由于并发的关系,导致实例在限定时间内无法完成文件的拷贝。

最终需求:加速局域网内实例拷贝文件速度。

分析

通过代码,我们模拟出现状的使用场景:

const (
 StateInit = iota
 StateCopying
 StateCompleted
)

type Instance struct {
 id       int
 copyPath string
 state    int
}

func NewInstance(id int) *Instance {
 return &Instance{
  id:       id,
  copyPath: "default",
  state:    StateInit,
 }
}

func (i *Instance) ChangeState(state int) {
 i.state = state
}

func (i *Instance) Copy() {
 i.ChangeState(StateCopying)
 defer i.ChangeState(StateCompleted)
 time.Sleep(10 * time.Second)
 fmt.Printf("%d: exec copy form %s \n", i.id, i.copyPath)
}

func TestParallelCopy(t *testing.T) {
 var wg sync.WaitGroup
 for i := 0; i < 3; i++ {
  wg.Add(1)
  go func(j int) {
   defer wg.Done()
   instance := NewInstance(j)
   instance.Copy()
  }(i)
 }
 wg.Wait()
}


/*output:
2: exec copy form default 
0: exec copy form default 
1: exec copy form default 
*/

每个实例都会往default中拷贝。如果将三者合并成一个程序,那么上instance上需要存在一个管理类对象,这样通过管理类,才能捕获每个instance的实例状态(这里简单就用2个状态):copying,completed

const (
 StateCompleted = iota
 StateCopying
)

type InstanceMgr struct {
 sync.RWMutex
 instances map[int]*Instance
}

func NewInstanceMgr() *InstanceMgr {
 return &InstanceMgr{
  instances: make(map[int]*Instance),
 }
}

接下来,需要达成的要求,如图:

时间instance 1instances 2
t1开始进行拷贝-
t2-开始进行拷贝
t3-判断instance1正在拷贝,阻塞
t4完成拷贝-
t5通知其他进程,从我的目录进行拷贝-
t6结束从instance1目录进行拷贝

需要设计一种事件通知机制,该机制能够使当前协程进入阻塞状态,并在特定条件达成时,自动恢复并继续执行相应的逻辑处理。

第一反应:可以使用"锁" 来完成当前操作。instance1 需要能够通知instance2 进行拷贝,主动通知势必需要将instances2的锁提供给instance1操作,有两种方式:

  1. 将instance2的锁传给instance1让其可以进行解锁。
  2. 将锁对应的都存在instancesMgr对象中。

不管哪种方式,都不太符合使用直觉,#1 锁的复制会导致死锁问题的出现,#2 锁的操作没有跟instance进行绑定,存在滥用的可能。

还得是CSP理论,在instance中,新增以下几个字段:

type Instance struct {
    ...
 wait       chan int // 等待通道
 notifyList []chan int // 接收者列表
}

模拟测试场景:

type InstanceMgr struct {
 sync.RWMutex
 instances map[int]*Instance
}

func NewInstanceMgr() *InstanceMgr {
 return &InstanceMgr{
  instances: make(map[int]*Instance),
 }
}

func (i *InstanceMgr) ToCopying(ist *Instance) {
 // 判断是否存在其他的实例正在拷贝
 copyPath := ist.copyPath
 running, ok := i.RunningInstance(ist.id)
 if ok {
  // 增加通知连
  running.AddNotify(ist.wait)
  // 等待
  <-ist.wait
  copyPath = fmt.Sprintf("from %d", running.id)
 }
 ist.copyPath = copyPath
 ist.Copy()
 // 最好广播
 ist.Notify()
}

func (i *InstanceMgr) RunningInstance(curId int) (*Instance, bool) {
 i.RLock()
 defer i.RUnlock()
 for id, item := range i.instances {
  if id == curId {
   continue
  }
  if item.state == StateCopying {
   return item, true
  }
 }
 return nilfalse
}

func TestParallelMgr(t *testing.T) {
 instanceMgr := NewInstanceMgr()
 var wg sync.WaitGroup
 for i := 1; i <= 3; i++ {
  instanceMgr.instances[i] = NewInstance(i)
  wg.Add(1)
  go func(id int) {
   defer wg.Done()
   ist := instanceMgr.instances[id]
   instanceMgr.ToCopying(ist)
  }(i)
 }
 wg.Wait()
}
  • 创建3个instance。

  • 分别创建3个协程负责对应的instance的copy操作;

  • 在copy操作前,先判断是否存在正在拷贝的instance。

    • 存在,将wait放入到running的notifyList中。
    • 不存在, 直接进行copy操作
  • 在copy完成后,将notifyList中所有wait进行通知并清空列表。

以上完成了我们的需求,执行如下:

1exec copy form default 
2exec copy form from 1 
3exec copy form from 1 

在Golang中,存在这样的并发方法,能够直接使用:Sync.Cond

初步使用

func TestCond(t *testing.T) {
 c := sync.NewCond(&sync.Mutex{})
 total := 0
 var wg sync.WaitGroup
 for i := 0i <= 3i++ {
  wg.Add(1)
  go func() {
   defer wg.Done()
   a := 0
   for {
    c.L.Lock()
    c.Wait()
    a = total
    c.L.Unlock()
    if a > 10 {
     break
    }
   }
   fmt.Println(a)
  }()
 }
 go func() {
  for j := 0; j <= 15; j++ {
   total = j
   c.Broadcast()
   fmt.Println("boardcast: ", total)
   time.Sleep(1 * time.Second)
  }
 }()
 wg.Wait()
}

创建3个协程,当total等于10时,进行后续操作。

这里设计到比较重要的函数:

  • Broadcase:通知所有等待的协程,进行后续的操作。
  • Wait: 将当前协程放入到Cond的等待者列表中,知道被Signal或者Broadcase方法唤醒。
  • Signal((这里未出现,也提一下):移除Cond的wait中第一个协程并将其唤醒。

这里在初次使用的时候,一直有疑问:

  • 为什么Board无需加锁?
  • 为什么Wait前后需要加锁?

接下来,让我们刨根究底,看看golang的底层实现;

实现原理

源码如下:

type Cond struct {
 noCopy noCopy

 // L is held while observing or changing the condition
 L Locker

 notify  notifyList
 checker copyChecker
}

// NewCond returns a new Cond with Locker l.
func NewCond(l Locker) *Cond {
 return &Cond{L: l}
}

//* Wait 会以原子方式解锁 c.L,并暂停执行调用的 goroutine。
// 稍后恢复执行后,Wait 会在返回前锁定 c.L。
// 与其他系统不同,除非被 [Cond.Broadcast] 或 [Cond.Signal] 唤醒,否则 Wait 不能返回。
// 由于在 Wait 等待期间 c.L 没有被锁定,调用者通常不能假定 Wait 返回时条件为真。
// 相反,调用者应该在一个循环中等待:
// c.L.Lock()
// for !condition() {
//     c.Wait()
// }
// 资源条件的使用
// c.L.Unlock()
func (c *Cond) Wait() {
 c.checker.check()
 t := runtime_notifyListAdd(&c.notify)
 c.L.Unlock()
 runtime_notifyListWait(&c.notify, t)
 c.L.Lock()
}

// Signal 如果存在等待的goroutine,则唤醒一个
// 允许但不要求调用这在调用期间保持c.L
// Signal() 不会影响程序调度的优先级;
func (c *Cond) Signal() {
 c.checker.check()
 runtime_notifyListNotifyOne(&c.notify)
}

//广播唤醒所有等待条件的写成。
//允许但不要求调用c.L。
func (c *Cond) Broadcast() {
 c.checker.check()
 runtime_notifyListNotifyAll(&c.notify)
}

可以看到在golang中,cond的源码还是比较简单的。其中注释说明了其如何使用,以及一些注意点。

Wait:在sema.go/notifyListWait中,使用了链表的方式,将每个等待的goroutine连接起来,并调用gopark函数将goroutine挂起。

Broadcast: 在sema.go/notifyListNotifyAll中,循环等待链表,调用goready将goroutine唤起。

注意:

wait函数进入后解锁,释放会加锁。

总结

Sync.Cond在理解上还是存在比较难理解的地方。

个人觉得第一种通过Channel的方式对于代码的理解,维护方面会比较好。