Golang: 互斥锁和饥饿模式

用Go语言进行开发时,互斥锁在不断尝试获取永远无法获取的锁时会遇到饥饿问题。在本文中,我们将探讨影响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
}

此示例基于两个协程:

  • 协程 1长时间持有该锁并短暂释放它
  • 协程 2暂时持有该锁并释放了很长时间

两者都具有100微秒的周期,但是由于协程1一直在请求该锁,因此可以预期它将更频繁地获得该锁。

这是一个用Go 1.8编写的示例,该示例具有10次迭代的循环的锁分配:

Lock acquired per goroutine:
g1: 7200216
g2: 10

互斥锁已被第二个协程捕获了十次,而第一个的捕获则超过了700万次。让我们分析一下这里发生的事情。

首先,协程1将获得锁并睡眠100微秒。当协程2尝试获取锁时,它将被添加到锁的队列(FIFO顺序)中,并且该协程将进入等待状态:

图1 — 获取锁

然后,当协程1完成工作时,它将释放锁。此释放将通知队列唤醒协程2。 协程2将被标记为可运行,然后等待Go Scheduler在线程上运行:

图2 — 协程2被唤醒

但是,在协程2等待运行时,协程1将再次请求锁:

图3 — 协程 2正在等待运行

当协程2尝试获取锁时,会发现它已经持有锁并将进入等待模式,如图2所示:

图4-协程2再次尝试获取锁

协程2对锁的获取将取决于它在线程上运行所花费的时间。 既然已经确定了问题,那么让我们回顾一下可能的解决方案。

Barging vs Handoff vs Spinning

处理互斥锁的方法有很多,例如:

  • **Barging.**旨在提高吞吐量。释放锁后,它将唤醒第一个waiter,并将锁提供给第一个传入的请求或这个被唤醒的waiter:

barging 模式

Go 1.8就是这样设计的,它反映了我们之前看到的内容。

  • **Handoff.**释放后,互斥锁将持有该锁,直到第一个waiter准备好获取它为止。这会降低吞吐量,因为即使另一个协程准备获取它,该锁也被保留了:

handoff 模式

我们可以在Linux内核的互斥锁中找到以下逻辑:

发生锁饥饿是可能的,因为Mutex_lock()允许锁窃取,其中正在运行(或乐观旋转)的任务击败了唤醒的waiter进行获取。

锁窃取是一项重要的性能优化,因为等待waiter醒来并获取runtime可能会花费大量时间,在此期间,everyboy都将耗在锁上。

[…]这重新引入了一些等待时间,因为一旦我们进行了切换,我们就必须等待waiter再次醒来。

在我们的示例中,互斥锁切换会完美地平衡两个协程之间的锁分配,但是会降低性能,因为这将迫使第一个协程即使未持有也要等待锁。

  • **Spinning. **如果互斥锁与自旋锁不相同,则它可以结合一些对方的逻辑。当waiter的队列为空或应用程序大量使用互斥锁时,Spinning(旋转)很有用。停放和未停放的协程的成本较高,可能比仅spinning(旋转)等待下一个锁的获取要慢:

spinning 模式

Go 1.8也使用此策略。当试图获取已经持有的锁时,如果本地队列为空并且处理器数量大于一,则协程将旋转几次-如果仅使用一个处理器spinning就会阻塞程序。Spinning 后,协程将停放。如果程序大量使用锁,这个方法可以作为快速路径。

有关锁的设计方式的更多信息(barging(插入),handoff(切换),spinlock(自旋锁)), 一般来说,Filip Pizlo撰写了必读文章“ WebKit中的锁”。

饥饿模式

在Go 1.9之前,Go结合了barging(插入)和spinning(旋转)模式。在1.9版中,Go通过添加新的饥饿模式解决了先前解释的问题,该模式将导致在解锁模式期间进行handoff(切换)。

所有等待锁的时间超过一毫秒的协程,也称为有界等待,将被标记为饥饿。当标记为饥饿时,解锁方法现在将把锁直接移交给第一位waiter。这是工作流程:

饥饿模式

由于进入的协程将不会获取任何为下一个waiter保留的锁,因此在饥饿模式下也将禁用spinning(旋转)。

让我们使用Go 1.9和新的饥饿模式运行上一个示例:

Lock acquired per goroutine:
g1: 57
g2: 10

现在的结果更加公平。现在,我们想知道新的控制层是否会对互斥锁不处于饥饿状态时的其它情况产生影响。正如我们在该软件包的基准测试(Go 1.8 vs. 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%     ~

#go

Golang: 互斥锁和饥饿模式
16.15 GEEK