统一并发 III - 跨基准测试
“统一并发”(Unified Concurrency)的目标是,通过一种模式和两个分别用于常规和 async/await 方法的接口,以面向对象的方式统一对不同同步原语的访问。
系列文章
- 统一并发 I - 简介
- 统一并发 II - 基准测试方法学
- 统一并发 III - 交叉基准测试
- 统一并发 IV - 实现跨平台 (.NET Core 2.1 / .NET Standard 2.0)
引言
前两篇文章为“统一并发”下所有已实现的同步原语的基准测试,以及为了理解不同同步原语在不同场景和情况下的优缺点、成本和行为而进行的交叉基准测试做好了铺垫。
这里研究的第一组基准测试将分别涵盖每种同步原语在“高强度竞争”(Heavy Contention)和“坏邻居”(Bad Neighbor)两种场景下的表现。
Monitor
(C# lock) - 来自前一篇文章LockUC
SpinLockUC
TicketSpinLockUC
AsyncLockUC
AsyncSpinLockUC
和 AsyncTicketSpinLockUC
将不被涵盖,因为它们的行为与其对应的常规线程版本 SpinLockUC
和 TicketSpinLockUC
完全相同。AsyncSpinLockUC
和 AsyncTicketSpinLockUC
总是返回一个已完成的、作为 struct
(而非 class)实现的 awaiter,因此避免了内存分配和等待。这样一来,其行为显然与非 async
版本相同,这一点也得到了测量数据的支持。
第二组将涵盖每种同步原语在“高强度竞争”和“坏邻居”两种场景下的交叉基准测试。
- Monitor (C# lock)、
SpinLockUC
和TicketSpinLockUC
- Monitor (C# lock)、
LockUC
- Monitor (C# lock)、
LockUC
和AsyncLockUC
- Monitor (C# lock)、
LockUC
和AsyncLockUC
、SpinLockUC
、TicketSpinLockUC
统一并发框架在开源的 GreenSuperGreen 库中实现,该库可在 GitHub 和 Nuget 上获取(包含 3 个包:库、单元测试、基准测试)。
- http://github.com/ipavlu/GreenSuperGreen
- https://nuget.net.cn/packages/GreenSuperGreen/
- https://nuget.net.cn/packages/GreenSuperGreen.Test/
- https://nuget.net.cn/packages/GreenSuperGreen.Benchmarking/
基准测试:Monitor (C# Lock) - 高强度竞争
Monitor
(C# lock)的吞吐周期表现得非常好,其单调性与顺序吞吐周期类似,没有意外情况。但是,所花费的 CPU 资源量令人担忧。吞吐周期越短,在高强度竞争场景下浪费的 CPU 资源就越多!这实际上很值得注意,因为它与常识相悖!通常,我们期望短时间的吞吐周期会带来较低的 CPU 负载,这是基于线程挂起/恢复技术的预期,然而 Monitor
(C# lock)在这种情况下却受其混合特性的驱动。如果竞争足够激烈,或者峰值负载有可能造成高强度竞争,那么 Monitor
(C# lock)就会成为一个严重的瓶颈。这有两种可能的情况:
- 高强度竞争是正常发生的,代码库专为高吞吐周期而设计,因此会很快被识别和处理。
- 高强度竞争偶尔在高峰期发生,由于复杂的代码库难以重现,那么代码库的性能问题很容易被忽视,并可能长期隐藏。
解释非常简单。如前所述,Monitor
(C# lock)是一种混合锁。在尝试获取访问权时,如果另一个线程已经拥有访问权,当前线程并不会立即挂起,而是会进行自旋等待,试图更快地获取访问权,以期减少线程挂起/恢复的成本。如果这种方法不起作用,线程才会被挂起。这种混合方法是在早期多核处理器时代引入的,当时它确实加快了吞吐周期,但现在对于 24 核或更多的处理器来说,它带来了沉重的代价,并且每增加一个核心,情况就变得更糟。
在 Monitor(C# lock)中只做少量工作但请求数量众多的场景下,会损失大量 CPU 资源!显然,我们不能再忽视落后于硬件架构的遗留代码库和老化的软件架构了。
根据图表,在给定的机器上,应避免低于 64 毫秒的吞吐周期,因为超过 60% 的 CPU 资源将被浪费在发热上!随着线程数量的减少,这个数字可能会降低。避免高并发总是一个很好的检查点,看看我们是否在顺序问题上投入了过多的并行处理。但是,浪费 60% 或更多 CPU 资源的吞吐周期水平会随着 CPU 核心数的增加而提高,这也是当前未来的趋势。
基准测试:Monitor (C# Lock) - 坏邻居
请记住,“坏邻居”场景关注的是多个不相关的线程对(每对只有两个线程,竞争最小)的累积效应。如上文在高强度竞争场景中对 Monitor(C# lock)的报告所述,在低竞争情况下,通过 Monitor(C# lock)可达到的吞吐周期可以显著降低,而不会造成大量 CPU 浪费。但即便如此,我们也可以看到,Monitor(C# lock)在 CPU 浪费中扮演了角色,在低于 16 毫秒的吞吐周期下,其浪费是预期的两倍。因此,Monitor(C# lock)确实是一个“坏邻居”,几个不相关的服务/线程在低竞争情况下,累积起来可能将超过 50% 的 CPU 资源浪费在发热上,而只有 48% 的工作是有效的。这一结论是基于图表中的理想顺序趋势线得出的。
基准测试:LockUC - 高强度竞争
LockUC 是一种基于 TaskCompletionSource<object>
的新型同步原语,用法与 Monitor
(C# lock)非常相似,但它避免了自旋等待,即 Monitor
(C# lock)的混合部分。访问是公平的。LockUC
的设计旨在超越 Monitor
(C# lock),在线程挂起/恢复技术仍然有用的领域避免其混合特性,并适用于那些不需要在广泛吞吐周期范围内实现超高吞吐周期的遗留代码,同时又不会无谓地将 CPU 资源浪费在发热上。无法获得锁访问的线程会立即被挂起!对于所有我们不期望高竞争甚至不关心竞争的情况,这都应是鼓励使用的场景。但是,我们应该关心的是,在服务的其他部分难以复现的边缘情况下,一个被忽视的同步原语所带来的影响。我们应该选择最简单、影响最小且能保证其行为的工具。对于没有 async
/await
选项的经典线程模型,LockUC
是一个不错的选择。LockUC
明显优于 Monitor
(C# lock),它浪费的 CPU 资源更少,在给定机器上,吞吐周期在 1.5 毫秒以内时甚至更好。低于 1.5 毫秒的吞吐周期时,Monitor
(C# lock)开始追赶 LockUC
,但此时两者都已耗尽几乎所有可用的 CPU 资源,实际上在同步上消耗的 CPU 资源比做有用功还要多!这表明,仅靠线程和线程挂起/恢复方法将无法超越这一界限,必须考虑其他方法。
基准测试:LockUC - 坏邻居
请记住,“坏邻居”场景关注的是多个不相关的线程对(每对的竞争都最小)的累积效应。LockUC
在吞吐周期上略逊于 Monitor
(C# lock),但换来的是 CPU 性能的显著提升。在吞吐周期成为问题的场景下,应使用其他同步原语,比如 SpinLockUC
。
基准测试:SpinLockUC/AsyncSpinLockUC - 高强度竞争
SpinLockUC
是对 .NET SpinLock struct
的一个薄封装,旨在为以下情况提供最佳吞吐周期:访问线程在受保护区域相遇的概率很低,且避免公平性有助于提高吞吐周期。如果受保护区域内的操作非常短,即使有 24 个线程,也很难造成多个线程长时间自旋等待以获取访问权的情况。更可能的情况是,在新的访问请求到来之前,前一个访问请求大部分时间已经处理完毕。这是一种理想情况,但上图显示,在给定的机器上,24 个线程在吞吐周期超过 324 微秒时达到临界点,此后在高强度竞争下,一些线程实际上会将时间花费在自旋/等待获取访问权上,如果同时有更多线程尝试获取访问权,等待时间甚至可能长达数秒,因为访问不是公平的。这种情况在 NUMA 架构的机器上是个大问题,因为根据特定内存与 CPU 核心的 NUMA 节点距离度量,某些线程访问某些内存比其他线程更容易,这加剧了缺乏公平性的问题。跨 NUMA 节点访问成本的影响可以通过 Mark Russinovich, Microsoft 的工具 CoreInfo 进行相对测量:https://docs.microsoft.com/en-us/sysinternals/downloads/coreinfo。为 SpinLock 寻求最高吞吐周期的设计要求付出了这个代价。请理解,这些限制并非 SpinLock 实现中留下的某些错误!这些问题是基于原子指令的同步原语的内在属性,其中访问公平性无法保证!未来的趋势是走向更多的 CPU 核心和同一台机器中更多的 NUMA 节点。这预示着,未来的机器将把吞吐周期的临界点推向远低于 324 微秒。我们应该做好准备并考虑到这一点,因为这些问题正是红帽企业版 Linux 已经实现 TicketSpinLock
的原因。它的吞吐周期稍差,但作为交换,TicketSpinLock
能够保证公平访问,这对于更好的负载均衡很重要,并使系统更接近实时操作系统的特性。
不公平访问可能在无法解释的吞吐周期瞬时下降中扮演重要角色,我们应该牢记如何避免它!我们必须使 SpinLock
入口内的执行非常快,理想情况下,只有内存更新指令,避免慢速计算,并严格避免因 I/O 或内核调用导致的线程挂起,如果可能的话,还要避免内存分配。
SpinLockUC
的不公平性在图表 5 中清晰可见,因为它导致了吞吐周期统计数据上的峰值,因为不同线程的表现截然不同。在吞吐周期超过 324 微秒时,一些线程获得了大量入口机会,而另一些在十秒内几乎没有或完全没有入口机会。
图表 6 仅显示了吞吐周期,特别是中位数和最大吞吐周期,从中我们可以看到两组不同线程的行为:一组几乎总能进入,其吞吐周期接近理想值;另一组则接近 10 秒的最大吞吐周期,这些线程获得的入口机会很少或根本没有!必须指出,试图在 SpinLock
中执行长时间运行的代码是个坏主意,图表 5 和 6 显示了这样做是多么毫无意义。
基准测试:SpinLockUC/AsyncSpinLockUC - 坏邻居
基于 .NET SpinLock struct
的 SpinlockUC
在“坏邻居”场景中表现得相当不错,因为竞争最小,但仍有一对线程在并发执行。SpinLock
设计用于竞争不频繁且锁内执行时间极短的场景。通过平衡代码,使锁外执行路径比锁内执行路径花费更长的时间,可以进一步降低 CPU 浪费。
基准测试:TicketSpinLockUC/AsyncTicketSpinLockUC - 高强度竞争
TicketSpinLockUC
是一种通用的 TicketSpinLock
实现,它会一直争用原子操作,直到根据到达顺序的票号获得访问权,因此访问是公平的。它没有像 .NET Spinlock
那样的自旋等待、让步或休眠,为此,它付出了原子操作争用的沉重代价,并占用了大量 CPU 资源。由于公平性,它在给定机器上吞吐周期低于 200 微秒时会损失吞吐性能,但这并不重要!它专为锁入口内极短的执行路径而设计,理想情况下只有几条指令或更新一些变量,并确保公平访问(FIFO 顺序)。这是一种有用的同步原语,适用于需要确保线程饥饿保护的负载均衡算法。
基准测试:TicketSpinLockUC/AsyncTicketSpinLockUC - 坏邻居
在“坏邻居”场景中,当每个同步原语的线程数降低到只有两个时,TicketSpinLockUC
的实现在给定机器上显示出吞吐周期低于 200 微秒的改进。这是一个很好的例子,说明在顺序问题上投入过多并行性是个坏主意,并激励我们在线程数量与锁内执行时间和锁外执行时间(后者应花费更长时间)的比率之间寻求正确的平衡。
基准测试:AsyncLockUC - 高强度竞争
AsyncLockUC
是唯一真正意义上的 async
/await
同步原语,它实际上会返回未完成的 awaiter,其计时和调度基于 ThreadPool
。显然,如果 ThreadPool
的线程耗尽,同步性能将会下降,但让我们面对现实,这也意味着软件设计不佳,在这种情况下,由于大量活动线程争夺 CPU 时间片,挂起/恢复同步技术同样会性能下降。
AsyncLockUC
似乎具有有趣的性能,特别是 CPU 浪费非常低。访问是公平的。其内部数据结构的锁定是基于无锁的,并且尽可能快和短,以避免任何竞争。这是本报告中唯一不会导致 CPU 死锁的同步原语 => 即 CPU 在空转,但实际完成的工作量非常小,因为同步成本占用了几乎所有的 CPU 资源。
AsyncLockUC
表明它可以处理任何工作负载,包括峰值的高强度竞争,它不会无谓地浪费 CPU 资源,并且机器上的其他服务仍有可用资源来继续其工作。这里的 CPU 使用率最高低于 42%!
AsyncLockUC 尽可能地接近理想同步原语,因为对于超过 200 微秒的吞吐周期,其行为迅速接近理想同步原语的性能!
基准测试:AsyncLockUC - 坏邻居
在“坏邻居”场景中,AsyncLockUC
的行为似乎也相当合理。
交叉基准测试:Monitor (C# Lock) & SpinLockUC & TicketSpinLockUC - 高强度竞争
我们终于来到了第一个交叉基准测试!Monitor(C# lock)具有混合特性,因此很适合与 SpinLockUC
和 TicketSpinLockUC
进行交叉检验。
Monitor(C# lock)是一种线程挂起/恢复同步原语,通过混合启发式方法,使用自旋等待来避免线程挂起,但这并非没有代价!我们可以看到,在低于 1 毫秒的吞吐周期下,SpinLockUC
和 Monitor
(C# lock)在 CPU 和吞吐周期方面的行为非常相似。而具有公平访问的 TicketSpinLockUC
在吞吐周期上则输给了它们。
我相信这个图表很好地展示了它们之间的关系,尤其是在何处使用何种原语。
- 显然,对于任何超过 324 微秒的吞吐周期,都应避免使用
SpinLockUC
,因为线程饥饿的可能性很高。 TicketSpinLockUC
仅应在需要确保公平性的情况下使用,例如负载均衡算法,并且仅适用于锁定区域内非常短的操作。我建议在低于 100 微秒的吞吐周期下使用,否则 CPU 浪费不合理。- 这就留下了一个悬而未决的问题:在 324 微秒到 32 毫秒的吞吐周期之间,什么可以替代
Monitor
(C# lock)?
交叉基准测试:Monitor (C# lock) & SpinLockUC & TicketSpinLockUC - 坏邻居
Monitor
(C# lock)在“坏邻居”场景下的交叉基准测试清楚地显示了 SpinLockUC
的最佳应用场景。在吞吐周期短且竞争最小的算法中,SpinLockUC
能够节省大量 CPU 资源,因为在这些情况下,Monitor
(C# lock)的混合特性显然浪费了大量 CPU 资源。
交叉基准测试:Monitor (C# lock) & LockUC - 高强度竞争
在高强度竞争场景下,Monitor(C# lock)与其合理替代品 LockUC
的交叉基准测试回答了在 324 微秒到 32 毫秒吞吐周期之间应使用哪种同步原语的问题。LockUC
显然可以很好地作为替代品,因为其 CPU 浪费更低,并且其性能恶化点比 Monitor(C# lock)来得晚得多。
交叉基准测试:Monitor (C# lock) & LockUC - 坏邻居
在竞争最小的“坏邻居”场景下,对 Monitor(C# lock)和 LockUC 的交叉基准测试显示了 Monitor(C# lock)的性能问题,因为即使在 LockUC 的 CPU 浪费下降的极短执行路径中,Monitor(C# lock)仍在消耗 CPU!
交叉基准测试:Monitor (C# lock) & LockUC & AsyncLockUC - 高强度竞争
最后,我们可以检验 Monitor(C# lock)、LockUC 和客观上最佳的同步原语 AsyncLockUC
在高强度竞争场景下的交叉基准测试。
如果必须停留在常规线程环境中,最好用 LockUC 替换 Monitor(C# lock),因为它浪费的 CPU 资源更少,并且在某些配置下,实际上在相同时间内能完成更多工作。
如果我们能将代码重写为 Async
/Await
环境,我们就可以选择 GreenSuperGreen/统一并发框架下的最佳选项,即 AsyncLockUC
,它尽可能地接近理想同步原语,并且避免了其他同步原语的 CPU 死锁问题!
在某些情况下,Monitor
(C# lock)被用于需要低吞吐周期的场景,而不考虑 CPU 浪费。我希望我已经通过基准测试充分说明,在这种情况下使用 Monitor
(C# lock)的 CPU 浪费高得离谱。这样的吞吐周期可以通过 SpinLockUC
/AsyncSpinLockUC
、TicketSpinLockUC
/AsyncTicketSpinLockUC
实现,所有基准测试都表明,这些代码部分需要不同的思维和设计,否则它们会成为瓶颈。
交叉基准测试:Monitor (C# lock) & LockUC & AsyncLockUC - 坏邻居
“坏邻居”场景的结果应该不足为奇。建议与高强度竞争场景完全相同。
如果必须停留在常规线程环境中,最好用 LockUC
替换 Monitor(C# lock),因为它浪费的 CPU 资源更少,并且在某些配置下,实际上在相同时间内能完成更多工作。
如果我们能将代码重写为 Async
/Await
环境,我们就可以选择 GreenSuperGreen/统一并发框架下的最佳选项,即 AsyncLockUC
,它尽可能地接近理想同步原语,并且避免了其他同步原语的 CPU 死锁
问题!
交叉基准测试:Monitor (C# lock) & LockUC & AsyncLockUC & SpinLockUC & TicketSpinLockUC - 高强度竞争
最后,我们可以检验 Monitor(C# lock)、LockUC
和客观上最佳的同步原语 AsyncLockUC
在高强度竞争场景下的交叉基准测试。
如果必须停留在常规线程环境中,最好用 LockUC 替换 Monitor(C# lock),因为它浪费的 CPU 资源更少,并且在某些配置下,实际上在相同时间内能完成更多工作。
如果我们能将代码重写为 Async
/Await
环境,我们就可以选择 GreenSuperGreen/统一并发框架下的最佳选项,即 AsyncLockUC
,它尽可能地接近理想同步原语,并且避免了其他同步原语的 CPU 死锁
问题!
在某些情况下,Monitor
(C# lock)被用于需要低吞吐周期的场景,而不考虑 CPU 浪费。我希望我已经通过基准测试充分说明,在这种情况下使用 Monitor
(C# lock)的 CPU 浪费高得离谱。这样的吞吐周期可以通过 SpinLockUC
/AsyncSpinLockUC
、TicketSpinLockUC
/AsyncTicketSpinLockUC
实现,所有基准测试都表明,这些代码部分需要不同的思维和设计,否则它们会成为瓶颈。
交叉基准测试:Monitor (C# lock) & LockUC & AsyncLockUC & SpinLockUC & TicketSpinLockUC - 坏邻居
“坏邻居”场景的结果应该不足为奇。建议与高强度竞争场景完全相同。
如果必须停留在常规线程环境中,最好用 LockUC
替换 Monitor(C# lock),因为它浪费的 CPU 资源更少,并且在某些配置下,实际上在相同时间内能完成更多工作。
如果我们能将代码重写为 Async
/Await
环境,我们就可以选择 GreenSuperGreen/统一并发框架下的最佳选项,即 AsyncLockUC
,它尽可能地接近理想同步原语,并且避免了其他同步原语的 CPU 死锁问题!
摘要
本文涵盖了在 GreenSuperGreen
库的 UnifiedConcurrency
框架下实现的同步原语,在“高强度竞争”和“坏邻居”两个不同场景下的基准测试和交叉基准测试。
我们基于场景和具体情况,分别并比较地讨论了同步原语的优缺点、成本、弱点、优点和行为。
修订历史
- 2018年6月28日:完整的交叉基准测试:Monitor (C# lock)、
LockUC
和AsyncLockUC
、SpinLockUC
、TicketSpinLockUC