大家好,我是煎鱼。
除了上次跟大家提到的 map 使用 Swiss Table 来替换 Hashmap 的原始实现以外。本次 Go1.24 新版本还带来了更多的有效优化。
今天这篇文章将继续和大家一起学习自旋互斥 lock2 优化。
背景
提案作者 @Rhys Hiltner 在 2024 年提出了改进互斥锁的性能优化诉求:
其个人对于 runtime.mutex 值的部分经验是:整个进程会因为对单个 mutex 的需求使得整个程序缓慢运行。
我不认为这一点会让人感到意外,尽管速度减慢的程度超出了我的预期。主要的惊喜在于,程序一旦跌落性能悬崖,就很难再恢复过来。
性能测试
在基准测试 ChanContended 中,作者发现随着 GOMAXPROCS 的增加,mutex 的性能明显下降。
- Intel i7-13700H (linux/amd64):
- 当允许使用 4 个线程时,整个进程的吞吐量是单线程时的一半。
- 当允许使用 8 个线程时,吞吐量再次减半。
- 当允许使用 12 个线程时,吞吐量再次减半。
- 在 GOMAXPROCS=20 时,200 次通道操作平均耗时 44 微秒,平均每 220 纳秒调用一次 unlock2,每次都有机会唤醒一个睡眠线程。
- M1 MacBook Air (darwin/arm64):
- 当允许使用 5 个线程时,吞吐量不到单线程时的一半。
另一个角度是考虑进程的 CPU 占用时间。
下面的数据显示,在 1.78 秒的挂钟时间内,进程的 20 个线程在 lock2 调用中总共有 27.74 秒处于 CPU 上。
如下测试报告:
$ go test runtime -test.run='^$' -test.bench=ChanContended -test.cpu=20 -test.count=1 -test.cpuprofile=/tmp/p
goos: linux
goarch: amd64
pkg: runtime
cpu: 13th Gen Intel(R) Core(TM) i7-13700H
BenchmarkChanContended-20 26667 44404 ns/op
PASS
ok runtime 1.785s
$ go tool pprof -peek runtime.lock2 /tmp/p
File: runtime.test
Type: cpu
Time: Jul 24, 2024 at 8:45pm (UTC)
Duration: 1.78s, Total samples = 31.32s (1759.32%)
Showing nodes accounting for 31.32s, 100% of 31.32s total
----------------------------------------------------------+-------------
flat flat% sum% cum cum% calls calls% + context
----------------------------------------------------------+-------------
27.74s 100% | runtime.lockWithRank
4.57s 14.59% 14.59% 27.74s 88.57% | runtime.lock2
19.50s 70.30% | runtime.procyield
2.74s 9.88% | runtime.futexsleep
0.84s 3.03% | runtime.osyield
0.07s 0.25% | runtime.(*lockTimer).begin
0.02s 0.072% | runtime.(*lockTimer).end
----------------------------------------------------------+-------------
关键问题之一:这些 lock2 相关的线程并没有休眠,而是一直在自旋!
新提案:增加 spinning 状态
发现问题
通过上述的分析,原作者发现当前的 lock2 实现虽然理论上允许线程睡眠,但实际上导致所有线程都在自旋,自旋的线程至少与(并且可能也导致)更慢的锁传递有关,带来了不少的性能损耗。
@Rhys Hiltner 进而提出了新的设计方案《Proposal: Improve scalability of runtime.lock2[1]》。大家有兴趣的可以认真看下。下面提及主要优化部分。
核心优化点
核心的观点在于:扩展互斥锁的 mutex 状态字,加入一个新的标志位,称为 “spinning”(旋转)。
使用这个 “spinning” 位来表示是否有一个等待的线程处于 “醒着并循环尝试获取锁” 的状态。线程之间会互相排除进入 “spinning” 状态,但它们不会因为尝试获取这个标志位而阻塞。
只有持有 “spinning” 位的线程可以循环重新加载 mutex 状态字。这个线程在进入休眠之前会释放 “spinning” 位。其他等待线程则会直接进入休眠,而不会尝试争夺 “spinning” 位。
当某个线程解锁互斥锁时,如果发现已经有线程处于 “醒着并旋转” 的状态,就可以避免唤醒其他线程。在 Go 运行时的背景下,这种设计被称为 “spinbit”(旋转位)。
简单来说,这个设计的核心目的是:通过让一个线程负责 “旋转尝试获取锁”,避免所有线程都同时竞争资源,从而减少争用和不必要的线程切换。
兼容性和多平台
本次对于兼容性有保障,导出 API 没有变化。所以我们只需要升级到新版本 Go1.24 就可以白嫖这个优化点了!
目前该优化支持 futex 和 Xchg8 系统调用两个类型。futex 专门用于 GOOS=linux 平台。futex 是主要实现,整体综合表现会好一些。
在已支持的平台上会默认打开 GOEXPERIMENT=spinbitmutex 以此应用该实验性规则。如果大家不需要可以进行关闭。
参考资料
[1]Proposal: Improve scalability of runtime.lock2: https://github.com/golang/proposal/blob/master/design/68578-mutex-spinbit.md