如果你在 macOS 上运行 Firefox,你可能会注意到其响应速度在 103 版本中有了显著提升,特别是当你的标签页很多,或者你的机器同时运行其他应用程序时。这一提升是通过对 Firefox 内存分配器中锁定机制的小改动实现的。
Firefox 在所有架构上都使用高度定制版本的 jemalloc 内存分配器。我们已经与上游 jemalloc 大幅偏离,以确保 Firefox 在性能和内存使用方面达到最佳效果。
内存分配器必须是线程安全的,并且为了提高性能,需要能够处理来自不同线程的大量并发请求。为了实现这一点,jemalloc 在其内部结构中使用了锁,这些锁通常只会被短暂地持有。
分配器中的锁定实现方式与代码库中的其他部分不同。具体来说,创建互斥锁和使用互斥锁不能发出新的内存分配请求,因为这会导致分配器本身的无限递归。为了实现这一点,分配器倾向于使用底层操作系统的原生轻量级锁。在 macOS 上,我们很长时间以来一直依赖于 OSSpinLock 锁。
顾名思义,这些锁不是常规的互斥锁,如果另一个线程已经获取了这些锁,这些锁不会将尝试获取这些锁的线程置于休眠状态。尝试锁定已锁定 OSSpinLock 实例的线程将忙等该锁,而不是等待该锁被释放,这通常被称为自旋锁定。
这似乎有悖常理,因为自旋会消耗 CPU 周期和电量,并且通常在现代代码库中是不受欢迎的。但是,将线程置于休眠状态会带来重大的性能影响,因此并不总是最佳选择。
特别是,将线程置于休眠状态然后唤醒它需要两次上下文切换,以及将线程状态保存到内存和从内存中恢复。根据 CPU 和工作负载的不同,线程状态的大小可以从几百字节到几千字节不等。将线程置于休眠状态还会产生间接的性能影响。
例如,与线程运行的内核相关联的缓存可能包含有用的数据。当线程被置于休眠状态时,另一个来自无关工作负载的线程可能会被选中运行以代替它,用新数据替换缓存中的数据。
当原始线程恢复时,它可能最终会运行在不同的内核上,或者运行在同一个内核上,但缓存却变冷,充满了无关数据。无论哪种情况,线程执行速度都会比保持不受干扰地运行慢。
由于以上所有原因,如果线程尝试获取的锁只被短暂地持有,那么让线程短暂自旋可能是有利的。因为它可以带来更高的性能和更低的功耗,因为自旋的成本低于休眠。
然而,自旋有一个重大缺陷:如果自旋持续时间过长,就会有损性能,因为只会浪费周期。更糟糕的是,如果机器负载很重,自旋可能会给系统带来额外的负载,可能减慢拥有锁的线程的速度,从而增加其他线程需要锁的可能性,进而进行更多自旋。
正如你现在可能猜到的那样,OSSpinLock 在轻负载系统上表现非常出色,但在负载增加时表现很差。更重要的是,它有两个根本缺陷:它在用户空间中自旋,并且从不休眠。
在用户空间中自旋通常是一个坏主意,因为用户空间不知道系统当前正在经历多少负载。在内核空间中,锁可能会做出明智的决策,例如,如果负载很高,则完全不进行自旋,但OSSpinLock 没有这样的规定,也没有进行任何调整。
但更重要的是,当它无法真正获取锁时,它会让步 而不是休眠。这尤其糟糕,因为内核不知道让步的线程正在等待锁,因此它可能会唤醒另一个正在争抢同一锁的线程,而不是唤醒拥有该锁的线程。
这将导致更多自旋和让步,最终导致用户体验极差。在负载很重的系统上,这可能会导致接近死锁,从而使 Firefox 实际上挂起。有关OSSpinLock 的这个问题是已知 的,并且在Apple 中已被弃用。
于是出现了os_unfair_lock,这是 Apple 对OSSpinLock 的官方替代品。如果你仍然使用OSSpinLock,你将收到明确的警告,建议你改为使用它。
所以,我尝试使用它,但结果很糟糕。我们的一些自动化测试的性能下降了多达 30%。 os_unfair_lock 的行为可能比OSSpinLock 好,但它很糟糕。
事实证明,os_unfair_lock 在出现争用时不会自旋,而是让调用线程立即休眠,因为它发现锁有争用。
对于内存分配器来说,这种行为并非最佳,并且性能下降是不可接受的。在某种程度上,os_unfair_lock 与OSSpinLock 存在相反的问题:它过于乐意休眠,而自旋将是更好的选择。在这一点上,值得一提的是,pthread_mutex 锁在 macOS 上更慢,因此它们也不是选项。
但是,当我深入研究 Apple 的库和内核时,我注意到确实存在一些自旋锁,并且它们在内核空间中进行自旋,在那里它们可以针对负载和调度做出更明智的选择。这些锁将非常适合我们的用例。
那么,你如何使用它们呢?好吧,事实证明它们没有文档记录。它们依赖于一个非公开函数和标志,我不得不在 Firefox 中复制这些函数和标志。
该函数是os_unfair_lock_with_options(),我使用的选项是OS_UNFAIR_LOCK_DATA_SYNCHRONIZATION 和OS_UNFAIR_LOCK_ADAPTIVE_SPIN。
后者要求内核使用内核空间自适应自旋,而前者阻止它在 Apple 库使用的线程池中生成额外的线程。
它们有效吗?是的!在轻负载系统上的性能与OSSpinLock 相当,但在负载很重的系统上,它们提供了更好的响应速度。它们还为笔记本电脑用户做了一些非常有用的事情:它们降低了功耗,因为 CPU 浪费在无法获取锁的自旋上的周期更少了。
不幸的是,我的痛苦还没有结束。 OS_UNFAIR_LOCK_ADAPTIVE_SPIN 标志仅在 macOS 10.15 或更高版本中受支持,但 Firefox 还在更旧的版本上运行(一直到 10.12)。
作为一种过渡方案,我最初在旧系统上回退到了 OSSpinLock。后来,我通过使用 os_unfair_lock 以及 用户空间手动自旋 成功地完全摆脱了它。
这不是理想的解决方案,但仍然比依赖 OSSpinLock 好,尤其是因为这只在 x86-64 处理器上需要,在那里我可以使用 pause 指令 在循环中,这应该能够减少无法获取锁时的性能和功耗影响。
当两个线程运行在同一个物理内核上时,其中一个使用 pause 指令会使内核的大部分资源可供另一个线程使用。不幸的是,如果两个线程在同一个内核上自旋,它们仍然会消耗很少的能量。
此时,你可能会想知道 os_unfair_lock - 可能与未公开的标志结合使用 - 是否适合你的代码库。我的答案可能是肯定的,但你使用它时必须小心。
如果你使用的是未公开的标志,请务必定期在 macOS 的新测试版上测试你的软件,因为它们可能会在未来的版本中失效。即使你只使用 os_unfair_lock 公共接口,也要注意它与 fork() 的兼容性不好。这是因为锁在内部存储了 mach 线程 ID,以确保一致的获取和释放。
这些 ID 在调用 fork() 后会发生改变,因为线程在复制你的进程的线程时会创建新的线程 ID。这可能会导致子进程崩溃。如果你的应用程序使用 fork(),或者你的库需要是 fork() 安全的,你需要使用 pthread_atfork() 注册 at-fork 处理程序,以便在 fork 之前获取父进程中的所有锁,然后在 fork 之后释放它们(也位于父进程中),并在子进程中重置它们。
一条评论