Go语言死锁、活锁和饥饿概述
来源:云海天教程
时间:2022-12-31 09:35:25 423浏览 收藏
对于一个Golang开发者来说,牢固扎实的基础是十分重要的,golang学习网就来带大家一点点的掌握基础知识点。今天本篇文章带大家了解《Go语言死锁、活锁和饥饿概述》,主要介绍了并发,希望对大家的知识积累有所帮助,快点收藏起来吧,否则需要时就找不到了!
前面的部分都是关于程序正确性的讨论,如果这些问题得到正确的处理,那我们的程序将永远不会给出错误的答案。不幸的是,即使成功处理了这些问题,还有另一类问题需要解决:死锁、活锁和饥饿。所有这些问题都与你的程序密切相关,它们保证了你的程序在任何时候都在做着一些真正有用的事。如果没有正确处理,程序可能会进入一种完全停止正常工作的状态。死锁
死锁程序是所有并发进程彼此等待的程序。在这种情况下,如果没有外界的干预,这个程序将永远无法恢复。这听起来很严峻,那是因为的确如此!Go语言的运行时会尽其所能,检测一些死锁(所有的 goroutine 必须被阻塞,或者“asleep”),但是这对于防止死锁并没有太多的帮助。
为了帮助理解死锁是什么,我们先来看一个例子。同样,忽略任何你不知道的类型,函数,方法或是你不知道的包,只理解什么是死锁即可。
type value struct { mu sync.Mutex value int}var wg sync.WaitGroupprintSum := func(v1, v2 *value) { defer wg.Done() v1.mu.Lock() //在这里,我们尝试进入临界区来传入一个值。 defer v1.mu.Unlock() //在这里,我们使用defer语句在printSum返回之前退出临界区。 time.Sleep(2*time.Second) //在这里,我们休眠了一段时间来模拟一些工作(并触发死锁) v2.mu.Lock() defer v2.mu.Unlock() fmt.Printf("sum=%v", v1.value + v2.value)}var a, b valuewg.Add(2)go printSum(&a, &b)go printSum(&b, &a)wg.Wait()如果尝试运行此代码,可能会看到:
fatal error: all goroutines are asleep - deadlock!
为什么呢?如果仔细观察,就可以在此代码中看到时机问题。以下是运行时的图形表示。这些框表示函数,水平线表示调用这些函数,竖线表示图形头部的函数生存时间,如下图所示。图 :一个因时间问题导致死锁的演示
本质上,我们创建了两个不能转动的齿轮:第一次调用 printSum 锁定 a,然后试图锁定 b ,但在此期间,第二次调用 printSum 己锁定 b 并试图锁定 a。这两个 goroutine 都无限地等待着。
以图形的方式展示为什么会出现死锁似乎很明确,但是更严格的定义会给我们带来更多的好处。事实证明,出现死锁有几个必要条件。1971 年,Edgar Coffman 在一篇论文中列举了这些条件。这些条件现在被称为 Coffman 条件,是帮助检测、防止和纠正死锁的技术依据。
Coffman 条件如下:相互排斥:井发进程同时拥有资源的独占权。等待条件:并发进程必须同时拥有一个资源,并等待额外的资源。没有抢占:并发进程拥有的资掘只能被该进程释放,即可满足这个条件。循环等待:一个并发进程(P1)必须等待一系列其他井发进程(P2),这些并发进程同时也在等待进程(P2),这样便满足了这个最终条件。
注意:我们实际上并不能保证 goroutines 的运行顺序,或者需妥多长时间才能启动。虽然不太可能,但是一个 goroutine 可以在另一个 goroutine 开始之前获得和释放锁,从而避免死锁,这是有道理的。
来看看我们设计的程序,并确定它是否符合所有四个条件:
printSum 函数确实需要 a 和 b 的独占权,所以它满足了这个条件。因为 printSum 持有 a 或 b 并正在等待另一个,所以它满足这个条件。我们没有任何办怯让我们的 goroutine 被抢占。我们第一次调用 printSum 正在等待我们的第二次调用;反之亦然。
是的,我们写出来的无疑是一个死锁了。
这些规则也帮助我们防止死锁。如果确保至少有一个条件不成立,我们可以防止发生死锁。不幸的是,实际上这些条件很难推理,因此很难预防。网络上散布着诸如你和我这样的开发者的疑问,他们想知道为什么一小段代码是死锁的。通常情况下,一旦有人指出,这是非常明显的,但往往需要另一双眼睛。
活锁
店锁是正在主动执行并发操作的程序,但是这些操作无战向前推进程序的状态。你曾经在走廊走向另一个人吗?她移动到一边让你通过,但你也做了同样的事情。所以你转到另一边,但她也是这样做的。想象一下这个情形永远持续下去,你就明白了活锁。
我们实际上编写一些代码来演示这种情况。首先,我们将设置一些辅助函数来简化示例。为了有一个可以工作的例子,这里的代码利用了我们尚未涉及的几个主题。不建议试图了解它的细节,直到有把握可以使用好 sync 包。相反,建议遵循代码标注来理解强调的部分,然后将注意力转向包含示例核心的第二个代码块。
cadence := sync.NewCond(&sync.Mutex{})go func () { for range time.Tick(1*time.Millisecond) { cadence.Broadcast() }}()takeStep := func() { cadence.L.Lock() cadence.Wait() cadence.L.Unlock()}//tryDir 允许一个人尝试向一个方向移动,并返回是否成功。dir,每个方向都表示为试图朝这个方向移动的人数。tryDir := func(dirName string, dir *int32, out *bytes.Buffer) bool { fmt.Fprintf(out, " %v", dirName) //首先,我们宣布将要向这个方向移动一个距离。现在,只需要知道这个包的操作是原子操作。 atomic.Addint32(dir, 1) //为了演示活锁,每个人都必须以相同的速度或节奏移动。takeStep 模拟所有对象之间的一个不变的节奏。 takeStep() if atomic.LoadInt32(dir) == 1 { fmt.Fprint(out, ". Success!") return true } takeStep() //这里的人意识到他们不能向这个方向走而放弃。我们通过把这个方向减 1 来表示。 atomic.AddInt32(dir, -1) return false}var left, right int32tryLeft := func(out *bytes.Buffer) bool { return tryDir("left", &left, out)}tryRight := func(out *bytes.Buffer) bool { return tryDir("right", &right, out)}walk := func(walking *sync.WaitGroup, name string) { var out bytes.Buffer defer func() {fmt.Println(out.String()) }() defer walking.Done() fmt.Fprintf(&out, "%v is trying to scoot:", name) //对尝试次数进行了人为限制,以便此程序能结束。在一个有活锁的程序中,可能没有这个限制,这就是为什么它是一个问题! for i:=O; i输出如下:
Alice is trying to scoot: left right left right left right left right left right
Alice tosses her hands up in exasperation!
Barbara is trying to scoot: left right left right left right left right
left right
Barbara tosses her hands up in exasperation!
这个例子横示了使用活锁的一个卡分常见的原因:两个或两个以上的并发进程试图在没有协调的情况下防止死锁。这就好比,如果走廊里的人都同意,只有一个人会移动,那就不会有活锁:一个人会站着不动,另一个人会移到另一边,他们就会继续移动。
在我看来,活锁要比死锁更复杂,因为它看起来程序好像在工作。如果一个活锁程序在你的机器上运行,那可以通过查看 CPU 利用率来确定它是否在做处理某些逻辑,大家可能会认为它确实是在工作。根据活锁的不同,它甚至可能发出其他信号,让大家认为它在工作。然而,程序将会一直上演“hallway-shuffle”的循环游戏。
活锁是一组被称为“饥饿”的更大问题的子集。
饥饿
饥饿是在任何情况下,并发进程都无法获得执行工作所需的所有资源。当我们讨论活锁时,每个 goroutine 的资源是一个共享锁。
活锁保证讨论与饥饿是无关的,因为在活锁中,所有并发进程都是相同的,并且没有完成工作。更广泛地说,饥饿通常意味着有一个或多个贪婪的并发进程,它们不公平地阻止一个或多个井发进程,以尽可能有效地完成工作,或者阻止全部并发进程。
这里有一个程序的例子,有一个贪婪的 goroutine 和一个平和的 goroutine:
var wg sync.WaitGroupvar sharedLock sync.Mutexconst runtime = 1*time.SecondgreedyWorker := func() { defer wg.Done() var count int for begin := time.Now(); time.Since(begin) 输出如下:
Polite worker was able to execute 289777 work loops.
Greedy worker was able to execute 471287 work loops.
假设两种 worker 都有同样大小的临界区,而不是认为贪婪的 worker 的算法更有效(或调用 Lock 和 Unlock 的时候,它们也不是缓慢的),我们得出这样的结论,贪婪的 worker 不必要地扩大其持有共享锁上的临界区,井阻止(通过饥饿)平和的 worker 的 goroutine 高效工作。
请注意,我们这里的技术用于识别饥饿:一个 metric。饥饿会为记录和取样提供一个很好的 metric。一个发现和解决饥饿的方曲是,通过记录来确定进程工作速度是否和你预期的一样高。
值得一提的是,前面的代码示例还可以作为内存访问同步的性能影响的示例。因为同步访问内存是昂贵的,所以将我们的锁扩展到临界段之外是有利的。另一方面,这样做我们将冒着饿死其他并发进程的风险。
如果使用了内存访问同步,将不得不在扭拉皮同步和细粒反同步之间找到一个平衡点。当需要对应用程序进行性能调优时,强烈建议只将内存访问同步限制在关键部分;如果同步成为性能问题,可以一直扩展范围。走另一条路妥难得多。
所以,饥饿会导致你的程序表现不佳或不正确。前面的示例演示了低效场景,但是如果你有一个非常贪婪的并发进程,以至于完全阻止另一个并发进程完成工作,那么你就会遇到一个更大的问题。
我们还应该考虑到来自于外部过程的饥饿。请记住,饥饿也可以应用于 CPU、内存、文件句柄、数据库连接:任何必须共享的资源都是饥饿的候选者。
今天关于《Go语言死锁、活锁和饥饿概述》的内容就介绍到这里了,是不是学起来一目了然!想要了解更多关于golang的内容请关注golang学习网公众号!
-
417 收藏
-
328 收藏
-
367 收藏
-
221 收藏
-
109 收藏
-
444 收藏
-
311 收藏
-
266 收藏
-
188 收藏
-
317 收藏
-
430 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 542次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 507次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 497次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 484次学习