x86上交换与比较和交换锁的相对性能

两种常见的锁定习语是:

if (!atomic_swap(lockaddr, 1)) /* got the lock */ 

和:

 if (!atomic_compare_and_swap(lockaddr, 0, val)) /* got the lock */ 

其中val可以简单地为锁的新预期所有者的常量或标识符。

我想知道的是x86(和x86_64)机器上两者之间是否存在任何显着的性能差异。 我知道这是一个相当广泛的问题,因为单个cpu模型之间的答案可能差异很大,但这是我要求的原因之一,而不仅仅是我可以访问的几个cpus的基准测试。

我假设atomic_swap(lockaddr,1)被转换为xchg reg,mem指令和atomic_compare_and_swap(lockaddr,0,val)被转换为cmpxchg [8b | 16b]。

一些Linux内核开发人员认为cmpxchg更快,因为锁定前缀不像xchg那样暗示。 因此,如果您使用的是单处理器,multithreading或者可以确保不需要锁定,那么使用cmpxchg可能会更好。

但是很可能你的编译器会将它转换为“锁定cmpxchg”,在这种情况下它并不重要。 另请注意,虽然此指令的延迟较低(1个周期没有锁定,大约20个带锁定),但如果您碰巧使用的是两个线程之间的公共同步变量,这很常见,将强制执行一些额外的总线周期,永远与指令延迟相比。 这些很可能完全隐藏在200或500个CPU周期长缓存监听/同步/内存锁/总线锁等等。

我找到了这份英特尔文档,声明在实践中没有区别:

http://software.intel.com/en-us/articles/implementing-scalable-atomic-locks-for-multi-core-intel-em64t-and-ia32-architectures/

一个常见的误解是使用cmpxchg指令的锁比使用xchg指令的锁更便宜。 使用它是因为cmpxchg不会尝试以独占模式获取锁,因为cmp将首先通过。 图9显示cmpxchg与xchg指令一样昂贵。

在x86上,任何带有LOCK前缀的指令都会将所有内存操作作为读 – 修改 – 写周期。 这意味着XCHG(带有隐式LOCK)和LOCK CMPXCHG(在所有情况下,即使比较失败)也总是在高速缓存行上获得独占锁。 结果是性能基本没有差异。

请注意,在同一个锁上旋转的许多CPU都会在此模型中导致大量总线开销。 这是自旋锁循环应该包含PAUSE指令的一个原因。 其他一些架构对此有更好的操作。

你确定你不是故意的吗?

  if (!atomic_load(lockaddr)) { if (!atomic_swap(lockaddr, val)) /* got the lock */ 

对于第二个?

测试和测试以及设置锁(参见Wikipedia https://en.wikipedia.org/wiki/Test_and_test-and-set )是许多平台的一种非常常见的优化。

根据比较和交换的实现方式,它可能比测试和测试更快或更慢。

由于x86是一个相对较强的有序平台,因此可能不太可能使测试和测试以及更快地设置锁定的硬件优化。

图8来自Bo Persson发现的文件http://software.intel.com/en-us/articles/implementing-scalable-atomic-locks-for-multi-core-intel-em64t-and-ia32-architectures/ shows测试和测试和设置锁具有卓越的性能。

就英特尔处理器的性能而言,它是相同的,但为了简单起见,让事情更容易理解,我更喜欢你给出的例子的第一种方式。 如果您可以使用xchg执行此操作,则没有理由使用cmpxchg获取锁定。

根据奥卡姆的剃刀原则,简单的事情会更好。

除此之外,使用xchg锁定function更强大 – 您还可以检查软件逻辑的正确性,即您没有访问未明确分配用于锁定的内存字节,或者您没有解锁两次。

关于释放锁是否只是普通商店或lock商店没有达成共识。 例如,Windows 10下的LeaveCriticalSection使用lock -ed store来释放锁定,即使在单插槽处理器上也是如此; 在具有非统一内存访问(NUMA)的多个物理处理器上,如何释放锁定的问题:正常存储与lock存储可能更为重要。

请参阅此安全锁定函数示例,该函数检查数据的有效性,并捕获尝试释放未获取的锁:

 const cLockAvailable = 107; // arbitrary constant, use any unique values that you like, I've chosen prime numbers cLockLocked = 109; cLockFinished = 113; function AcquireLock(var Target: LONG): Boolean; var R: LONG; begin R := InterlockedExchange(Target, cLockByteLocked); case R of cLockAvailable: Result := True; // we've got a value that indicates that the lock was available, so return True to the caller indicating that we have acquired the lock cLockByteLocked: Result := False; // we've got a value that indicates that the lock was already acquire by someone else, so return False to the caller indicating that we have failed to acquire the lock this time else begin raise Exception.Create('Serious application error - tried to acquire lock using a variable that has not been properly initialized'); end; end; end; procedure ReleaseLock(var Target: LONG); var R: LONG; begin // As Peter Cordes pointed out (see comments below), releasing the lock doesn't have to be interlocked, just a normal store. Even for debugging we use normal load. However, Windows 10 uses locked release on LeaveCriticalSection. R := Target; Target := cLockAvailable; if R <> cLockByteLocked then begin raise Exception.Create('Serious application error - tried to release a lock that has not been actually locked'); end; end; 

你的主要应用程序在这里:

 var AreaLocked: LONG; begin AreaLocked := cLockAvailable; // on program initialization, fill the default value .... if AcquireLock(AreaLocked) then try // do something critical with the locked area ... finally ReleaseLock(AreaLocked); end; .... AreaLocked := cLockFinished; // on program termination, set the special value to catch probable cases when somebody will try to acquire the lock end. 

您也可以使用以下代码作为自旋循环,它在旋转时使用正常负载以节省资源,如Peter Cordes所建议的那样。 在5000个周期后,它调用Windows API函数SwitchToThread()。 这个5000循环的值是我的经验。 从500到50000的值似乎也没问题,在某些情况下,较低的值更好,而在其他情况下更高的值更好。 请注意,您只能在支持SSE2的处理器上使用此代码 – 您应该在调用pause指令之前检查相应的CPUID位 – 否则只会浪费电源。 在没有pause处理器上,只需使用其他方法,如EnterCriticalSection / LeaveCriticalSection或Sleep(0),然后在循环中使用Sleep(1)。 有人说在64位处理器上你可能不会检查SSE2以确保实现pause指令,因为原来的AMD64架构采用Intel的SSE和SSE2作为核心指令,而且,实际上,如果运行64位代码,你已经确定SSE2,因此pause指令。 但是,英特尔不鼓励依赖于在线特定function的做法,并明确指出某些function可能在未来的处理器中消失,应用程序必须始终通过CPUID检查function。 然而,SSE指令变得无处不在,并且许多64位编译器在没有检查的情况下使用它们(例如Delphi for Win64),因此在未来的某些处理器中没有SSE2,更不用说pause可能性非常小。

 // on entry rcx = address of the byte-lock // on exit: al (eax) = old value of the byte at [rcx] @Init: mov edx, cLockByteLocked mov r9d, 5000 mov eax, edx jmp @FirstCompare @DidntLock: @NormalLoadLoop: dec r9 jz @SwitchToThread // for static branch prediction, jump forward means "unlikely" pause @FirstCompare: cmp [rcx], al // we are using faster, normal load to not consume the resources and only after it is ready, do once again interlocked exchange je @NormalLoadLoop // for static branch prediction, jump backwards means "likely" lock xchg [rcx], al cmp eax, edx // 32-bit comparison is faster on newer processors like Xeon Phi or Cannonlake. je @DidntLock jmp @Finish @SwitchToThread: push rcx call SwitchToThreadIfSupported pop rcx jmp @Init @Finish: