基于暂停的自旋等待循环
从您的问题中我了解到,在您的情况下,等待时间非常长。在这种情况下,不建议使用自旋等待循环。但是,如果您正在使用一个自旋循环来检查内存中的值(例如一个字节大小的同步变量),请使用PAUSE
指令。请参阅Intel 64和IA-32体系结构优化参考手册第11.4.2节“短时间同步”。
您写道,您有一个“线程一直在扫描某些位置(例如队列)以检索新节点”的情况。
在这种情况下(即长时间等待),英特尔建议使用操作系统的同步API函数。例如,您可以在队列中出现新节点时创建一个事件,并使用WaitForSingleObject(Handle, INFINITE)
等待此事件。每当出现新节点时,队列将触发此事件。
根据英特尔优化参考手册第2.3.4节“Skylake客户端微架构中的暂停延迟”,
PAUSE指令通常与在同一处理器核心中执行的两个逻辑处理器上运行的软件线程一起使用,等待锁定被释放。这种短暂的等待循环通常持续十几个到几百个周期,因此从性能上讲,最好占用CPU等待而不是让出给操作系统。
从上述引文中,“十几个到几百个周期”我理解为20到500个CPU周期。
在4500 MHz英特尔酷睿i7 7700K处理器(基于Kaby-Lake-S微架构于2017年1月发布)上,500个CPU周期相当于0.0000001秒,即1/10000000秒:CPU每秒可以执行1000万次这个500-CPU周期循环。
这个由英特尔推荐的500次循环限制是理论上的,一切取决于具体的使用情况,即需要通过自旋等待循环进行同步的代码逻辑。像
Delphi的FastMM4-AVX内存管理器这样的一些场景在基准测试中使用5000的值效果更好。尽管如此,这些基准测试并不总是反映实际情况,应该测量真实的程序用例。
正如您所看到的,这个基于
PAUSE
的自旋等待循环是为了非常短的时间。
另一方面,每个调用API函数(如Sleep())都会经历昂贵的上下文切换成本,可能超过10000个周期;它还会遭受从第3到第0环的转换成本,可能超过1000个周期。
如果线程数超过处理器核心数(乘以超线程特性,如果有的话),并且一个线程在关键部分中被切换到另一个线程时,等待来自另一个线程的关键部分可能需要非常长的时间,至少需要10000个周期,因此基于
PAUSE
的自旋等待循环将是徒劳的。
除了英特尔优化参考手册的相关章节外,请参阅以下文章以获取更多信息:
当等待循环预计持续数千个周期或更长时间时,最好通过调用操作系统同步 API 函数之一(例如 Windows 操作系统上的
WaitForSingleObject
或
SwitchToThread
)让操作系统进行调度。
总之,在您的场景中,基于
PAUSE
的自旋等待循环不是最佳选择,因为您的等待时间很长,而自旋等待循环是为非常短的循环而设计的。
PAUSE
指令在基于Skylake微架构或更高版本的处理器上需要约140个CPU周期。例如,在2015年8月发布的Intel Core i7-6700K CPU(4GHz)上,它只需35.10ns,或者在2020年9月发布的移动设备Intel Core i7-1165G7 CPU上需要49.47ns。在早期处理器(Skylake之前),例如基于Haswell微架构的处理器上,它需要约9个周期。对于长循环,最好使用操作系统同步API函数将控制权交给其他线程,而不是占用CPU执行
PAUSE
循环,无论微架构如何。
测试、测试并设置
请注意,自旋等待循环也必须正确实现。英特尔推荐使用所谓的“测试、测试和设置”技术(请参见《英特尔64和IA-32体系结构优化参考手册》第11.4.3节“自旋锁优化”),以确定同步变量的可用性。根据这种技术,第一次“测试”是通过正常(非锁定)内存加载来完成的,以防止在自旋等待循环期间出现过多的总线锁定;如果在第一步(“测试”)的非锁定内存加载时变量可用,则继续进行第二步(“测试和设置”),该步骤通过总线锁定原子
xchg
指令完成。
但请注意,使用“测试”在“测试并设置”之前的这个两步方法可能会增加未竞争情况下的成本,与仅使用单步“测试并设置”相比。初始的只读访问可能仅获取共享状态的缓存行,因此像test-and-set (
xchg
)或compare-and-swap (
cmpxchg
)这样的原子操作仍需要进行“Read For Ownership” (RFO) 操作才能获得缓存行的独占所有权。
该操作由尝试写入处于共享状态的缓存行的处理器发出。