typevaluestruct{musync.Mutexvalueint}varwgsync.WaitGroupprintSum:=func(v1,v2*value){deferwg.Done()v1.mu.Lock()//在这里,我们尝试进入临界区来传入一个值。deferv1.mu.Unlock()//在这里,我们使用defer语句在printSum返回之前退出临界区。time.Sleep(2*time.Second)//在这里,我们休眠了一段时间来模拟一些工作(并触发死锁)v2.mu.Lock()deferv2.mu.Unlock()fmt.Printf("sum=%vn",v1.value+v2.value)}vara,bvaluewg.Add(2)goprintSum(&a,&b)goprintSum(&b,&a)wg.Wait()
如果尝试运行此代码,可能会看到:fatalerror:allgoroutinesareasleep-deadlock!
为什么呢?如果仔细观察,就可以在此代码中看到时机问题。以下是运行时的图形表示。这些框表示函数,水平线表示调用这些函数,竖线表示图形头部的函数生存时间,如下图所示。
图:一个因时间问题导致死锁的演示
本质上,我们创建了两个不能转动的齿轮:第一次调用printSum锁定a,然后试图锁定b,但在此期间,第二次调用printSum己锁定b并试图锁定a。这两个goroutine都无限地等待着。
以图形的方式展示为什么会出现死锁似乎很明确,但是更严格的定义会给我们带来更多的好处。事实证明,出现死锁有几个必要条件。1971年,EdgarCoffman在一篇论文中列举了这些条件。这些条件现在被称为Coffman条件,是帮助检测、防止和纠正死锁的技术依据。
Coffman条件如下:
相互排斥:井发进程同时拥有资源的独占权。
等待条件:并发进程必须同时拥有一个资源,并等待额外的资源。
没有抢占:并发进程拥有的资掘只能被该进程释放,即可满足这个条件。
循环等待:一个并发进程(P1)必须等待一系列其他井发进程(P2),这些并发进程同时也在等待进程(P2),这样便满足了这个最终条件。
注意:我们实际上并不能保证goroutines的运行顺序,或者需妥多长时间才能启动。虽然不太可能,但是一个goroutine可以在另一个goroutine开始之前获得和释放锁,从而避免死锁,这是有道理的。
来看看我们设计的程序,并确定它是否符合所有四个条件:
printSum函数确实需要a和b的独占权,所以它满足了这个条件。
因为printSum持有a或b并正在等待另一个,所以它满足这个条件。
我们没有任何办怯让我们的goroutine被抢占。
我们第一次调用printSum正在等待我们的第二次调用;反之亦然。
是的,我们写出来的无疑是一个死锁了。
这些规则也帮助我们防止死锁。如果确保至少有一个条件不成立,我们可以防止发生死锁。不幸的是,实际上这些条件很难推理,因此很难预防。网络上散布着诸如你和我这样的开发者的疑问,他们想知道为什么一小段代码是死锁的。通常情况下,一旦有人指出,这是非常明显的,但往往需要另一双眼睛。
活锁
店锁是正在主动执行并发操作的程序,但是这些操作无战向前推进程序的状态。
你曾经在走廊走向另一个人吗?她移动到一边让你通过,但你也做了同样的事情。所以你转到另一边,但她也是这样做的。想象一下这个情形永远持续下去,你就明白了活锁。
我们实际上编写一些代码来演示这种情况。首先,我们将设置一些辅助函数来简化示例。为了有一个可以工作的例子,这里的代码利用了我们尚未涉及的几个主题。不建议试图了解它的细节,直到有把握可以使用好sync包。相反,建议遵循代码标注来理解强调的部分,然后将注意力转向包含示例核心的第二个代码块。
cadence:=sync.NewCond(&sync.Mutex{})gofunc(){forrangetime.Tick(1*time.Millisecond){cadence.Broadcast()}}()takeStep:=func(){cadence.L.Lock()cadence.Wait()cadence.L.Unlock()}//tryDir允许一个人尝试向一个方向移动,并返回是否成功。dir,每个方向都表示为试图朝这个方向移动的人数。tryDir:=func(dirNamestring,dir*int32,out*bytes.Buffer)bool{fmt.Fprintf(out,"%v",dirName)//首先,我们宣布将要向这个方向移动一个距离。现在,只需要知道这个包的操作是原子操作。atomic.Addint32(dir,1)//为了演示活锁,每个人都必须以相同的速度或节奏移动。takeStep模拟所有对象之间的一个不变的节奏。takeStep()ifatomic.LoadInt32(dir)==1{fmt.Fprint(out,".Success!")returntrue}takeStep()//这里的人意识到他们不能向这个方向走而放弃。我们通过把这个方向减1来表示。atomic.AddInt32(dir,-1)returnfalse}varleft,rightint32tryLeft:=func(out*bytes.Buffer)bool{returntryDir("left",&left,out)}tryRight:=func(out*bytes.Buffer)bool{returntryDir("right",&right,out)}walk:=func(walking*sync.WaitGroup,namestring){varoutbytes.Bufferdeferfunc(){fmt.Println(out.String())}()deferwalking.Done()fmt.Fprintf(&out,"%vistryingtoscoot:",name)//对尝试次数进行了人为限制,以便此程序能结束。在一个有活锁的程序中,可能没有这个限制,这就是为什么它是一个问题!fori:=O;i<5;i++{//首先,这个人会试图向左走,如果失败了,他们会尝试向右走iftryLeft(&out)||tryRight(&out){return}}fmt.Fprintf(&out,"n%vtossesherhandsupinexasperation!",name)}//这个变量为程序提供了一个等待直到两个人都能够相互通过或放弃的方式varpeopleInHallwaysync.WaitGrouppeopleInHallway.Add(2)gowalk(&peopleInHallway,"Alice")gowalk(&peopleInHallway,"Barbara")peopleInHallway.Wait()
输出如下:Aliceistryingtoscoot:leftrightleftrightleftrightleftrightleftright
Alicetossesherhandsupinexasperation!
Barbaraistryingtoscoot:leftrightleftrightleftrightleftright
leftright
Barbaratossesherhandsupinexasperation!
可以看到,Alice和Barbara在最终退出之前,会持续竞争。
这个例子横示了使用活锁的一个卡分常见的原因:两个或两个以上的并发进程试图在没有协调的情况下防止死锁。这就好比,如果走廊里的人都同意,只有一个人会移动,那就不会有活锁:一个人会站着不动,另一个人会移到另一边,他们就会继续移动。
在我看来,活锁要比死锁更复杂,因为它看起来程序好像在工作。如果一个活锁程序在你的机器上运行,那可以通过查看CPU利用率来确定它是否在做处理某些逻辑,大家可能会认为它确实是在工作。根据活锁的不同,它甚至可能发出其他信号,让大家认为它在工作。然而,程序将会一直上演“hallway-shuffle”的循环游戏。
活锁是一组被称为“饥饿”的更大问题的子集。
饥饿
饥饿是在任何情况下,并发进程都无法获得执行工作所需的所有资源。
当我们讨论活锁时,每个goroutine的资源是一个共享锁。
活锁保证讨论与饥饿是无关的,因为在活锁中,所有并发进程都是相同的,并且没有完成工作。更广泛地说,饥饿通常意味着有一个或多个贪婪的并发进程,它们不公平地阻止一个或多个井发进程,以尽可能有效地完成工作,或者阻止全部并发进程。
这里有一个程序的例子,有一个贪婪的goroutine和一个平和的goroutine:
varwgsync.WaitGroupvarsharedLocksync.Mutexconstruntime=1*time.SecondgreedyWorker:=func(){deferwg.Done()varcountintforbegin:=time.Now();time.Since(begin)<=runtime;{sharedLock.Lock()time.Sleep(3*time.Nanosecond)sharedLock.Unlock()count++}fmt.Printf("Greedyworkerwasabletoexecute%vworkloopsn",count)}politeWorker:=func(){deferwg.Done()varcountintforbegin:=time.Now();time.Since(begin)<=runtime;{sharedLock.Lock()time.Sleep(1*time.Nanosecond)sharedLock.Unlock()sharedLock.Lock()time.Sleep(1*time.Nanosecond)sharedLock.Unlock()sharedLock.Lock()time.Sleep(1*time.Nanosecond)sharedLock.Unlock()count++}fmt.Printf("Politeworkerwasabletoexecute%vworkloops.n",count)}wg.Add(2)gogreedyWorker()gopoliteWorker()wg.Wait()
输出如下:Politeworkerwasabletoexecute289777workloops.
Greedyworkerwasabletoexecute471287workloops.
贪婪的worker会贪婪地抢占共享锁,以完成整个工作循环,而平和的worker则试图只在需要时锁定。两种worker都做同样多的模拟工作(sleeping时间为3ns),但是你可以看到,在同样的时间里,贪婪的worker工作量几乎是平和的worker工作量的两倍!
假设两种worker都有同样大小的临界区,而不是认为贪婪的worker的算法更有效(或调用Lock和Unlock的时候,它们也不是缓慢的),我们得出这样的结论,贪婪的worker不必要地扩大其持有共享锁上的临界区,井阻止(通过饥饿)平和的worker的goroutine高效工作。
请注意,我们这里的技术用于识别饥饿:一个metric。饥饿会为记录和取样提供一个很好的metric。一个发现和解决饥饿的方曲是,通过记录来确定进程工作速度是否和你预期的一样高。
值得一提的是,前面的代码示例还可以作为内存访问同步的性能影响的示例。因为同步访问内存是昂贵的,所以将我们的锁扩展到临界段之外是有利的。另一方面,这样做我们将冒着饿死其他并发进程的风险。
如果使用了内存访问同步,将不得不在扭拉皮同步和细粒反同步之间找到一个平衡点。当需要对应用程序进行性能调优时,强烈建议只将内存访问同步限制在关键部分;如果同步成为性能问题,可以一直扩展范围。走另一条路妥难得多。
所以,饥饿会导致你的程序表现不佳或不正确。前面的示例演示了低效场景,但是如果你有一个非常贪婪的并发进程,以至于完全阻止另一个并发进程完成工作,那么你就会遇到一个更大的问题。
我们还应该考虑到来自于外部过程的饥饿。请记住,饥饿也可以应用于CPU、内存、文件句柄、数据库连接:任何必须共享的资源都是饥饿的候选者。