关于自旋锁和 sleep()

是的,通过移除一个对 sleep() 的调用,我们在一个垃圾收集基准测试中确实实现了 3.7 倍的加速。这个补丁redditHacker News 上引起了广泛关注,我想借此机会详细解释一下,以及关于 WebKit 项目的一些情况。

我们如何处理性能问题

WebKit 项目实践基准测试驱动的开发:选择一个基准测试,对其进行性能分析,优化,然后测量以验证改进。像这样专注于真实、可测量的问题可以帮助我们理清思路,避免陷入理论和猜测。

SunSpider JavaScript 基准测试是这种实践的一个很好的例子。在我们开始优化 JavaScript 引擎之前,我们编写了一个基准测试,其中包含了来自真实网站的内容,用于对引擎的不同部分施加压力。我们让基准测试驱动我们的开发决策,而不是反过来。这样做产生了许多有价值的见解——例如,SunSpider 表明启动时间和正则表达式性能至关重要,而跟踪编译器不适合我们的需求。

为什么 TCSpinLock 调用 sleep()

有了这些背景知识,让我们回到 TCSpinLock 及其对 sleep() 的调用。最初的 TCSpinLock 是针对在四个 CPU 核上运行的十个线程进行基准测试的。在这种环境下,一个线程可能会独占一个 CPU 核,而持有锁的线程根本没有运行。少量地 sleep 是一种简单的办法,可以确保持有锁的线程获得 CPU。

WebKit 使用此版本的 TCSpinLock 长达七年,没有任何问题。WebKit 中大多数对 malloc 的调用都是单线程的。这个锁没有出现在性能分析中。我们把时间花在了更重要的事情上。

基准测试发现了什么

当我们为垃圾收集器添加多核并行性时,情况发生了变化。我们运行了一个压力测试基准测试,旨在对收集器施加最大压力。性能分析显示,许多收集器线程正在等待标准库锁。尽管锁的竞争时间非常短,标准库会取消等待线程的调度,甚至让它们的 CPU 核空闲下来,而重新唤醒的延迟太高了。因此,我们开始在更多地方使用 TCSpinLock,以优化锁被短暂持有的情况。

然后,我们再次进行性能分析,看到了以下病态情况:所有线程会同时尝试获取一个 TCSpinLock,其中一个会成功,而其他线程会 sleep 2ms。在 24 核系统上,如果你让 23 个核 sleep 2ms,那么接下来的 2ms 里你的运行速度就会慢 24 倍!

解决方案很简单:不要那样做。WebKit 注意不创建比核心更多的线程,所以独占一个核心不会阻碍整体的向前进展。此外, 作为一种故障保护措施,我们仍然会向操作系统调度器让步,以避免独占一个核心——我们只是不再强制执行最小 sleep 时间。我们怎么知道这有效?因为基准测试变快了,而其他基准测试没有变慢。

什么会变快?

短期内:垃圾收集。在多核系统上,收集器可以支持更重的内存负载而不会增加暂停时间。长期来看:很多东西。收集器是我们最并行的系统,但随着时间的推移,我们正在转向更多并行的算法。拥有一个可靠的自旋锁实现是一个重要的基础构建模块。

这是“正确”的解决方案吗?

TCSpinLock 试图解决一个我们不存在的问题。对于在四个核心上运行十个线程的软件来说,正确的解决方案是什么?值得一提的是,TCSpinLock 也已经放弃了简单的 sleep(),并实现了一个复杂的队列和随机交错系统。当它出现在基准测试分析中时,我们才会去解决这个问题。

为什么不直接使用标准库锁?

我们有使用!但不是所有地方都用。我们的自旋锁利用了一个关键的优化:知道它们只会被持有很短的时间。由于自旋锁从不 sleep,它们能尽快地从竞争中恢复。正如性能分析所示,标准锁更加保守,它们甚至可能让 CPU 核空闲下来。

不要只听我的一面之词!

WebKit 拥有出色的性能,因为我们不断地测量和优化,并且不把任何事情视为理所当然。这种精神适用于我们所有的代码库,包括这个补丁。你可以使用每夜构建版本亲自验证这些结果。如果你认为有更好的做法,请提交 一份错误报告 并附上显示原因的基准测试——或者,更好的是,提交一份补丁