【问题标题】:Why isn't CAS (Compare And Swap) equivalent to busy wait loops?为什么 CAS(比较和交换)不等同于繁忙的等待循环?
【发布时间】:2019-02-19 14:46:26
【问题描述】:

在过去的几天里,我读了一些关于无锁编程的文章,我遇到了 util.java.Random 类,它使用以下例程创建它的位:

protected int next(int bits) {
    long oldseed, nextseed;
    AtomicLong seed = this.seed;
    do {
        oldseed = seed.get();
        nextseed = (oldseed * multiplier + addend) & mask;
    } while (!seed.compareAndSet(oldseed, nextseed));
    return (int)(nextseed >>> (48 - bits));
}

根据this answer to a post "Spinlock vs Busy wait"

所谓的无锁算法倾向于使用紧忙等待 CAS指令,但在一般情况下争用如此之低 CPU 通常只需要迭代几次。

还有Wikipedia topic "Compare-and-Swap"

而不是在 CAS 操作失败后立即重试, 研究人员发现,整体系统性能可以提高 在多处理器系统中——许多线程不断更新一些 特定的共享变量——如果看到 CAS 的线程使用失败 指数退避——换句话说,在重试之前稍等片刻 CAS.[4]

Wikipedia 上的文章能看懂吗,已经查出来了,但是还没有使用,或者 CAS 指令失败后人为回退是常见的做法。这是因为这种循环在 CPU 使用率方面不被认为是危险的,还是因为 CAS 没有经常争用?

第二个问题:是否有任何特定原因创建了对 seed 的引用,或者我们也可以简单地使用类范围中的变量?

【问题讨论】:

标签: java concurrency lock-free compare-and-swap


【解决方案1】:

尝试 CAS 的多个线程是无锁的(但不是无等待的)。 每次尝试使用相同的old 值时,其中一个线程都会取得进展https://en.wikipedia.org/wiki/Non-blocking_algorithm.

(多个线程是否都读取相同的old 值,或者某些线程是否看到另一个线程的 CAS 的结果取决于时间,并且基本上决定了有多少争用。)

这与普通的忙等待循环不同,它只是等待一些未知长度的操作,如果持有锁的线程被取消调度,可能会无限期地卡住。在这种情况下,如果您的 CAS 未能获得锁,您肯定希望退出,因为您必须等待另一个线程执行某些操作才能成功。


通常在不需要复杂指数退避的低争用情况下使用无锁算法。这就是链接的 SO 答案所说的。

这是与 Wiki 文章中提到的情况的一个关键区别:许多线程不断更新某些特定的共享变量。这是一个高竞争的情况,所以最好让一个线程连续执行一堆更新,并在他们的 L1d 缓存中保持线路热。 (假设您使用 CAS 来实现硬件不直接支持的原子操作,例如原子双精度 FP 添加,您在其中 shared.CAS(old, old+1.0) 或其他东西。或者作为无锁队列或其他东西的一部分。)

如果您使用的是在实践中高度竞争的 CAS 循环,它可能有助于总吞吐量有所回落,例如在重试之前在失败时运行 x86 pause 指令,以减少在高速缓存行上敲击的内核。或者对于无锁队列,如果您发现它已满或为空,那么这基本上是等待另一个线程的情况,因此您绝对应该退出。


x86 以外的大多数架构都有LL/SC as their atomic RMW primitive,而不是直接的硬件 CAS。如果在 CAS 尝试期间其他线程甚至读取缓存行,从 LL/SC 构建 CAS 可能会虚假失败,因此可能保证至少一个线程成功.

希望硬件设计人员尝试使 CPU 能够使 LL/SC 抵抗来自争用的虚假故障,但我不知道细节。在这种情况下,退避可能有助于避免潜在的活锁。

(在 CAS 不会因争用而虚假失败的硬件上,对于 while(!shared.CAS(old, old<<1)){} 之类的东西,活锁是不可能的。)


Intel's optimization manual 有一个等待锁释放的示例,它们循环1 << retry_count 次(直到某个最大退避因子)请注意,这不是普通的 CAS 循环无锁算法的一部分;这是为了实现一个锁。

回退等待锁空闲,而不仅仅是为了争夺对包含锁本身的缓存行的访问。

  /// Intel's optimization manual
  /// Example 2-2. Contended Locks with Increasing Back-off Example

  /// from section 2.2.4 Pause Latency in Skylake Microarchitecture
  /// (~140 cycles, up from ~10 in Broadwell, thus max backoff should be shorter)
/*******************/
/*Baseline Version */
/*******************/
// atomic {if (lock == free) then change lock state to busy}
while (cmpxchg(lock, free, busy) == fail)
{
   while (lock == busy)
     _mm_pause();
}


/*******************/
/*Improved Version */
/*******************/
int mask = 1;
int const max = 64; //MAX_BACKOFF
while (cmpxchg(lock, free, busy) == fail)
{
   while (lock == busy)
   {
      for (int i=mask; i; --i){
         _mm_pause();
      }
      mask = mask < max ? mask<<1 : max;    // mask <<= 1  up to a max
   }
}

我通常认为在等待锁定时,您希望以只读方式旋转,而不是继续尝试使用 cmpxchg。我认为英特尔的这个示例展示了退避,而不是如何优化锁以避免延迟解锁线程的其他部分。

无论如何,请记住,该示例类似于我们正在讨论的无锁队列或原子添加或其他原语的 CAS 重试实现。它正在等待另一个线程释放锁,只是在读取旧值和尝试对新值进行 CAS 处理之间出现的新值失败。

【讨论】:

  • 有趣的是如何(基于什么)选择回退中的循环的初始值和最大值。或者这只是基于实验?在 Windows 系统中,私有 api RtlBackoff 使用了更大的值 - 从 64-128 循环(随机选择)开始,最多 8-16k。
  • @RbMm:我认为这只是一个例子。它专门针对 Skylake,其中pause 延迟约 140 个周期,而不是早期 uarches 中的约 5 或 10 个周期。我也许应该把这个从例子中去掉,因为它是关于使用 CAS 旋转等待另一个线程,而不是构建一个像 float++ 这样的原子 RMW 操作。
  • RtlBackoff 中使用的一个有趣的东西 - 尝试随机化循环计数(在当前实现中使用 __rdtsc)。考虑不同的线程,如果他们在并发尝试更新值,使用不同的暂停循环计数(比如 3+ 线程一次尝试 - 一次更新,2+ 开始退避,但使用不同的循环计数,因为不同时再次尝试更新值. 如果我正确理解__rdtsc 的想法
  • @RbMm: 是的,randomized 指数退避非常好,如果你要退避的话,以防多个线程开始他们的退避序列同时。否则很可能他们都在延迟相同的时间后踩踏并踩到对方,并继续这样做。
猜你喜欢
  • 2016-11-10
  • 2021-10-28
  • 2015-03-13
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2022-01-14
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多