【问题标题】:Relative performance of swap vs compare-and-swap locks on x86x86 上交换与比较和交换锁的相对性能
【发布时间】:2011-07-17 10:07:10
【问题描述】:

两种常见的锁定习语是:

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

和:

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

其中val 可以简单地是一个常量或锁定新潜在所有者的标识符。

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

【问题讨论】:

  • +1 只是为了勇敢地“配置它!”并且“除非它是一个被证明的瓶颈,否则不要考虑它!” cmets :-)
  • 更糟的是,我不会对可用并行性(一个多核处理器或多个处理器)的争用和性质可能也是一个重要因素感到惊讶。
  • 如果快速路径未能获得锁,您的自旋循环应在重试xchgcmpxchg 之前检查只读,以避免所有服务员敲击缓存行并延迟尝试解锁的线程。 (在自旋循环中使用_mm_pause()atomic_load_explicit(lockaddr, memory_order_relaxed)。避免在快速路径中使用_mm_pause())。 stackoverflow.com/a/37246263/224132stackoverflow.com/a/11980693/224132.

标签: c assembly locking x86 atomic


【解决方案1】:

我假设 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 并感谢您的链接。我想接受一个答案,但我不确定该选择哪一个 - 两者都没有完全涵盖该主题,但都有很好的信息。
【解决方案2】:

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

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

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

【讨论】:

  • 链接+1。不过,这个神话毫无意义。我会天真地假设xchg 会更便宜,只是因为(1)它的功能不那么强大,并且(2)cmpxchg 在总线锁(或任何机制)被持有时还有“更多工作要做”。
  • 这个神话完全有道理。 xchg 总是锁定总线,所以它通常(没有锁定前缀)较慢。但是对于原子操作,您需要使用与“lock xchg”一样慢的“lock cmpxchg”。 CPU 并没有更多的事情要做,不是真的,两者都是“加载保存”,缓慢的部分是加载和保存,一些东西(交换或比较和交换)在 CPU 中根本不需要时间。事实上,如果 cmpxchg 比较失败,它可能会更快,例如它不需要保存。
【解决方案3】:

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

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

【讨论】:

  • 如果快速路径发现锁已锁定,则重试的自旋循环应在尝试@987654322 之前使用只读检查(由PAUSE 指令分隔) @。将pause 放在一个循环中,用xchg 不断敲击缓存行,这只是一个小改进(尤其是在英特尔之前的Skylake 上,pause 只休眠约5 个周期而不是~100 个周期)。请注意,Skylake 强调不要在无争用快速路径中使用 pause,因此请使用单独的循环。
【解决方案4】:

你确定你不是故意的

 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/ 表明 Test 和 Test 和 Set 锁在性能上更胜一筹。

【讨论】:

  • 是的,在只读检查上旋转很好。许多使用xchg 敲击同一高速缓存行的内核将延迟解锁线程。更好的是在自旋循环中有PAUSE (_mm_pause)(但不在快速路径中)。
  • 他可能没有。旧的 386 版本的锁是你换了一个 1,但只有当你得到一个零后才拥有锁。
【解决方案5】:

使用 xchg vs cmpxchg 获取锁

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

根据奥卡姆剃刀原理,越简单越好。

除此之外,使用xchg 锁定更强大 - 您还可以检查软件逻辑的正确性,即您没有访问尚未明确分配用于锁定的内存字节。因此,您将检查您是否使用了正确初始化的同步变量。除此之外,您将能够检查您没有解锁两次。

使用普通内存存储来释放锁

对于在释放锁时写入同步变量是否应该只使用普通内存存储(mov)或总线锁定内存存储(即具有隐式或显式lock的指令)没有达成共识-前缀,如xchg

使用普通内存存储释放锁的方法是 Peter Cordes 推荐的,详情参见下面的 cmets。

但是在某些实现中,获取和释放锁都是通过总线锁定内存存储完成的,因为这种方法似乎简单直观。例如,Windows 10 下的 LeaveCriticalSection 使用总线锁定存储即使在单插槽处理器上也能释放锁定;而在具有非统一内存访问 (NUMA) 的多个物理处理器上,这个问题更加重要。

我在单插槽 CPU (Kaby Lake) 上对执行大量内存分配/重新分配/释放的内存管理器进行了微基准测试。当没有争用时,即线程数少于物理内核时,锁定释放测试完成速度会慢约 10%,但当物理内核有更多线程时,锁定释放测试完成速度会快 2%。因此,平均而言,释放锁的普通内存存储优于锁定内存存储。

检查同步变量有效性的锁定示例

查看这个更安全的锁定函数示例(Delphi 编程语言),它检查同步变量的数据的有效性,并捕获释放未获取的锁的尝试:

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 的建议,此代码在旋转时使用正常的内存负载来节省资源。这种技术被称为“测试、测试和设置”。您可以在https://stackoverflow.com/a/44916975/6910868

了解有关此技术的更多信息

迭代次数

本例中基于暂停的自旋等待循环首先尝试通过读取同步变量来获取锁,如果它不可用,则在 5000 个循环的循环中使用pause 指令。 5000 次循环后,它调用 Windows API 函数 SwitchToThread()。 5000 次循环的这个值是经验值。它基于我的测试。从 500 到 50000 的值似乎也可以,但在某些情况下,较低的值更好,而在其他情况下,较高的值更好。您可以在我在上一段中提供的 URL 上阅读更多关于基于暂停的自旋等待循环的信息。

暂停指令的可用性

请注意,您只能在支持 SSE2 的处理器上使用此代码 - 您应该在调用 pause 指令之前检查相应的 CPUID 位 - 否则只会浪费功率。在没有pause 的处理器上,只需使用其他方法,例如 EnterCriticalSection/LeaveCriticalSection 或 Sleep(0),然后在循环中使用 Sleep(1)。有人说,在 64 位处理器上,您可能不会检查 SSE2 以确保执行 pause 指令,因为原始 AMD64 架构采用 Intel 的 SSE 和 SSE2 作为核心指令,实际上,如果您运行 64-位代码,您肯定已经拥有 SSE2 以及 pause 指令。但是,英特尔不鼓励依赖存在特定功能的做法,并明确指出某些功能可能会在未来的处理器中消失,应用程序必须始终通过 CPUID 检查功能。然而,SSE 指令变得无处不在,许多 64 位编译器在不检查的情况下使用它们(例如用于 Win64 的 Delphi),因此在某些未来的处理器中没有 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:

【讨论】:

  • 您不需要xchg 来释放锁。只需一个发布存储(即普通的 x86 存储)就足够了。即使是为了调试,在存储 cLockByteAvailable 之前对锁进行正常读取以查看它是否已经解锁可能就足够了。
  • 对性能而言重要的是旋转只读检查以查看锁是否可用,而不是旋转InterlockedExchange。许多内核使用xchg 敲击同一缓存行会延迟解锁线程。
  • 是的。我不久前在another SO answer 中写了一个简单的asm 自旋锁示例,它在竞争情况下使用了暂停+加载自旋循环。它完全避免存储到缓存行,直到它看到锁可用。 (真正的实现需要某种退避来防止线程踩踏,所有线程都试图在锁定可用时立即获取。)
  • xchg 释放一个锁可能会稍微早一点让它可见,但在释放线程中肯定会有更大的开销。不过,一家普通的商店不可能长期隐形。 NUMA 机器仍然是缓存一致的, 您所描述的(从一个核心中的 L1 读取陈旧值,而另一个核心中的 L1 包含不同的脏值)违反了缓存一致性。对于要提交到 L1D 的存储,所有套接字的所有内核上的所有其他缓存行副本都必须是无效的。多插槽系统在它们自己的 L3 中丢失时会窥探另一个插槽。
  • glibc pthreads uses a normal x86 store in pthread_spin_unlock。 CPU 确实会尝试尽快将存储提交到 L1D,以释放存储缓冲区中的空间。我认为使用xchg 所得到的只是在临界区之后阻止代码中不相关的存储/加载的执行,直到存储变得全局可见。但是如果xchg 正在运行时另一个线程试图读取锁,xchg 可能会使解锁线程拒绝释放缓存行
猜你喜欢
  • 2013-10-27
  • 2011-05-10
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2019-04-16
  • 2011-04-09
  • 1970-01-01
  • 2012-03-10
相关资源
最近更新 更多