Go: 互斥锁与饥饿
本文使用Go版本:1.13
在使用Golang开发时,当尝试获取一个永远也得不到的互斥锁时,它就可能发生饥饿问题。本文我们将研究Go1.8版本饥饿问题。该问题在Go1.9版本已经解决。
饥饿
为了更好解释互斥锁饥饿情况,我们将以以下示例讨论并演示其改进。
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
}
本示例使用了两个协程:
- 协程1长时间持有锁并迅速释放
- 协程2短暂持有锁并长时间释放
二者都有100微妙的周期,但协程1不断请求获取锁,可以预见的是其将不断持有该锁。
下面时使用Go1.8完成的示例,并打印协程2迭代10次之后锁分配情况:
每个goroutine获取锁情况:
g1: 7200216
g2: 10
可以看的第二个协程获取10次锁的时候,第一个协程获取锁的次数远超10次。我们来分析发生了什么。
首先,协程1获取锁并休眠100微秒。当第二个协程尝试获取锁时,将被加入锁队列(FIFO),且该协程将进入等待状态:
然后,当协程1完成它的工作时,将释放锁。并通知队列唤起协程2。协程2将被标记为可运行状态并等待调度器调起:
然而,当协程2正等待执行时,协程1将再次请求锁:
当协程2尝试获取锁时,将发现该锁已被持有并进入等待模式,如下图:
故而协程2能获取锁将取决于其在线程上运行所花费的时间。
现在的问题已经确定,让我们尝试解决它可行的方案。
桥接 VS 切换 && 自旋
有会多方式可以解决互斥锁,例如:
- 桥接。这是为提高吞吐量儿设计的。当锁释放时,它将唤醒第一个等待着,并且将锁给第一个传入的请求者或者当前等待着。
这就是Go1.8的设计方式,即我们最开始示例所看到。
- 切换。释放时,互斥锁将持有该锁,直到第一位等待着准备获取它。这种方式会减少吞吐量,因为即使有协程已经准备获取锁,锁也会互斥锁被持有。
我们能在Linux内核锁中找到这种逻辑。
饥饿状态是可能会出现的,因为该方式运行窃取锁。正在运行的任务会优先于即将被唤醒或已被唤 醒的协程获得锁。
窃取锁是一项重要的性能优化,因为等待唤醒的协程并获得锁可能会花费很多时间。在此期间,每个协程都会在该锁上停顿。
这会重新引入新的等待时间,因为一旦我们进行协程切换,其他协程就只能等待唤醒。
这种方式下,锁切换很好平衡两个协程之前锁的分配,但它也降低了性能,因为它会强制第一个协程等待锁即使它没有被持有。
- 自旋。互斥锁不同于自旋锁,它必须结合一些逻辑。当等待队列为空或当应用程序大量使用互斥锁时,自旋在这个时候是有用的。取消或唤起协程都有成本,并且可能比自旋等待下一个锁获取更慢。
Go1.8便使用了这种策略。当尝试获取一个已持有的锁时,如果本地队列为空并且处理器数量大于1,则协程将会自旋几次。当使用一个处理器处理自旋时,程序会处于阻塞状态。自旋过后,该协程将会停止。在程序大量使用锁的情况下,这是一种有效的方式。
饥饿模式
在Go1.9之前,Go结合了桥接于自旋模式。而Go1.9版本,Go通过添加新的饥饿模式来解决之前的问题,该模式将导致解锁模式期间的切换。所以等待的协程超过1毫秒,也成为有界等待,将被标记为饥饿。被标记为饥饿的协程,解锁时会将该锁交给第一位进入等待的协程。工作模式如下:
在饥饿模式下,自旋也可能会停止。因为传入的协程没有机会获取锁。
使用Go1.9版本在饥饿模式下运行之前的用例:
每个goroutine获取锁情况:
g1: 57
g2: 10
当前结果就比较公平了。
原文地址:medium.com/a-journey-with-go/go-mu...
译文地址:博客:Go: 互斥锁与饥饿
本作品采用《CC 协议》,转载必须注明作者和本文链接