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

时间:2011-03-17 13:37:05

标签: c assembly locking x86 atomic

两种常见的锁定习语是:

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的基准测试。

5 个答案:

答案 0 :(得分:13)

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

有些linux kernel developers认为cmpxchg更快,因为锁定前缀不像xchg那样暗示。因此,如果您使用的是单处理器,多线程或者可以确保不需要锁定,那么使用cmpxchg可能会更好。

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

答案 1 :(得分:13)

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

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指令一样昂贵。

答案 2 :(得分:3)

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

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

答案 3 :(得分:2)

你确定你没有意思

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

第二个?

测试和测试以及设置锁(参见维基百科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/表明测试和测试以及设置锁定的性能更高。

答案 4 :(得分:1)

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

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

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

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

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

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架构采用英特尔的SSE和SSE2作为核心指令,而且,实际上,如果你运行64位代码,您已经确定SSE2,因此pause指令。但是,英特尔不鼓励依赖于在线特定功能的做法,并明确指出某些功能可能在未来的处理器中消失,应用程序必须始终通过CPUID检查功能。然而,SSE指令变得无处不在,并且许多64位编译器在没有检查的情况下使用它们(例如Delphi for Win64),因此在未来的某些处理器中没有SSE2,更不用说pause的可能性很小。 / p>

// 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: