在Golang中进行开发时,互斥锁在不断尝试获取永远无法获取的锁时会遇到饥饿问题。在本文中,我们将探讨影响Go 1.8的饥饿问题,该问题已在Go 1.9中解决。
饥饿
为了说明互斥锁的饥饿状况,我将以拉斯·考克斯(Russ Cox)提出的关于他们讨论互斥锁改进的问题为例:
func main() { done := make(chan bool, 1) var mu sync.Mutex // goroutine 1 go func() { for { select { case <-done: return default: mu.Lock() time.Sleep(100 * time.Microsecond) mu.Unlock() } } }() // goroutine 2 for i := 0; i < 10; i++ { time.Sleep(100 * time.Microsecond) mu.Lock() mu.Unlock() } done <- true }
starvation.go
此示例基于两个goroutine:
- goroutine 1长时间保持该锁并短暂释放它
- goroutine 2暂时持有该锁并释放很长时间
两者都具有100微秒的周期,但是由于goroutine 1一直在请求锁定,因此可以预期它将更频繁地获得锁定。
这是一个用Go 1.8进行的示例,该示例具有10次迭代的循环的锁分配:
Lock acquired per goroutine: g1: 7200216 g2: 10
该互斥锁已被第二个goroutine捕获了十次,而第一个则超过了700万次。让我们分析一下这里发生了什么。
首先,goroutine 1将获得锁定并睡眠100微秒。当goroutine 2尝试获取锁时,它将被添加到锁的队列(FIFO顺序)中,并且goroutine将进入等待状态:
Figure 1 — lock acquisition
然后,当goroutine 1完成工作时,它将释放锁定。此版本将通知队列唤醒goroutine 2。Goroutine 2将被标记为可运行,并正在等待Go Scheduler在 线程 上运行:
Figure 2— goroutine 2 is awoke
但是,在goroutine 2等待运行时,goroutine 1将再次请求锁定:
Figure 3— goroutine 2 is waiting to run
当goroutine 2尝试获取锁时,它将看到它已经具有保持状态并进入等待模式,如图2所示:
Figure 4— goroutine 2 tries again to get the lock
goroutine 2对锁的获取将取决于它在线程上运行所花费的时间。
现在已经确定了问题,让我们回顾可能的解决方案。
Barging vs Handoff vs Spinning
处理互斥量的方法有很多,例如:
- Barging 旨在提高吞吐量。释放锁后,它将唤醒第一个服务员,并将锁提供给第一个传入请求或此唤醒的服务员:
barging mode
Go 1.8就是这样设计的,它反映了我们之前看到的内容。
- Handoff 释放后,互斥体将持有该锁,直到第一个服务员准备好获取它为止。这会降低吞吐量,因为即使另一个goroutine准备获取它,该锁也被保留了:
handoff mode
我们可以在Linux内核的互斥体中找到此逻辑:
在我们的情况下,互斥锁切换会完美平衡两个goroutine之间的锁分配,但是会降低性能,因为这将迫使第一个goroutine即使未持有也要等待锁。
- Spinning 如果互斥锁与 自旋锁 不同,则它可以结合一些逻辑。当服务员的队列为空或应用程序大量使用互斥锁时,旋转很有用。Parking和unparking goroutine的成本较高,可能比仅旋转等待下一个锁获取要慢:
spinning mode
Go 1.8也使用此策略。当试图获取已经持有的锁时,如果本地队列为空且处理器数量大于一,则goroutine将旋转几次-如果仅使用一个处理器旋转就会阻塞程序。旋转后,goroutine将停放。如果程序大量使用锁,它可以作为快速路径。
有关如何设计锁的更多信息( 插入 ,越区切换,自旋锁),通常, Filip Pizlo撰写 了必读的文章“ WebKit中的锁定 ”。
饥饿模式
在Go 1.9之前,Go结合了插入和旋转模式。在1.9版中,Go通过添加新的饥饿模式解决了先前解释的问题,该模式将导致在解锁模式期间进行切换。
所有等待锁定时间超过一毫秒的goroutine,也称为有界等待,将被标记为饥饿。当标记为饥饿时,解锁方法现在将把锁直接移交给第一位服务员。这是工作流程:
starvation mode
由于进入的goroutine将不会获取任何为下一个服务员保留的锁,因此在饥饿模式下也将禁用旋转。
让我们使用Go 1.9和新的starvation模式运行前面的示例:
Lock acquired per goroutine: g1: 57 g2: 10
现在的结果更加公平。现在,我们想知道新的控制层是否会对互斥体不处于饥饿状态的其他情况产生影响。正如我们在该程序包的基准测试(Go 1.8与Go 1.9)中所看到的,在其他情况下,性能并没有下降(不同处理器数量下,性能会略有变化):
Cond32-6 10.9µs ± 2% 10.9µs ± 2% ~ MutexUncontended-6 2.97ns ± 0% 2.97ns ± 0% ~ Mutex-6 122ns ± 6% 122ns ± 2% ~ MutexSlack-6 149ns ± 3% 142ns ± 3% -4.63% MutexWork-6 136ns ± 3% 140ns ± 5% ~ MutexWorkSlack-6 152ns ± 0% 138ns ± 2% -9.21% MutexNoSpin-6 150ns ± 1% 152ns ± 0% +1.50% MutexSpin-6 726ns ± 0% 730ns ± 1% ~ RWMutexWrite100-6 40.6ns ± 1% 40.9ns ± 1% +0.91% RWMutexWrite10-6 37.1ns ± 0% 37.0ns ± 1% ~ RWMutexWorkWrite100-6 133ns ± 1% 134ns ± 1% +1.01% RWMutexWorkWrite10-6 152ns ± 0% 152ns ± 0% ~
翻译自: