需求
现状:一个程序启动三份,分别通过不同的标识符,从远端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 1 | instances 2 |
|---|---|---|
| t1 | 开始进行拷贝 | - |
| t2 | - | 开始进行拷贝 |
| t3 | - | 判断instance1正在拷贝,阻塞 |
| t4 | 完成拷贝 | - |
| t5 | 通知其他进程,从我的目录进行拷贝 | - |
| t6 | 结束 | 从instance1目录进行拷贝 |
需要设计一种事件通知机制,该机制能够使当前协程进入阻塞状态,并在特定条件达成时,自动恢复并继续执行相应的逻辑处理。
第一反应:可以使用"锁" 来完成当前操作。instance1 需要能够通知instance2 进行拷贝,主动通知势必需要将instances2的锁提供给instance1操作,有两种方式:
- 将instance2的锁传给instance1让其可以进行解锁。
- 将锁对应的都存在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 nil, false
}
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进行通知并清空列表。
以上完成了我们的需求,执行如下:
1: exec copy form default
2: exec copy form from 1
3: exec 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 := 0; i <= 3; i++ {
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的方式对于代码的理解,维护方面会比较好。