无锁同步总是优于使用锁的同步吗?

Sou*_*a B 45 c++ synchronization lock-free stdatomic

在 C++ 中,有一种原子类型std::atomic<T>。该原子类型可能是无锁的,也可能不是,具体取决于类型 T 和当前平台。如果某个类型的无锁实现在类型 T 的平台上可用,那么大多数编译器都会提供无锁atomic<T>。在这种情况下,即使我想要非无锁atomic<T>我也无法拥有它。

C++ 标准决定只保留一个,std::atomic<T>而不是一std::atomic<T>加一std::lock_free<T>(部分针对特定类型实现)。这是否意味着“在任何情况下,当后者可用时,使用非无锁原子类型都会比使用无锁原子类型更好”?(主要是在性能方面而不是易用性方面)。

Dav*_*rtz 55

这是否意味着“在任何情况下,当后者可用时,使用非无锁原子类型都会比使用无锁原子类型更好”?(主要是在性能方面而不是易用性方面)。

不。总的来说,这不是真的。

假设您有两个核心和三个可供运行的线程。假设线程 A 和 B 正在访问相同的集合,并且会发生显着的争用,而线程 C 正在访问完全不同的数据,并且会最小化争用。

如果线程 A 和 B 使用锁,其中一个线程将很快被取消调度,而线程 C 将在一个内核上运行。这将允许调度的线程 A 或 B 几乎没有争用地运行。

相比之下,使用无锁集合时,调度程序永远没有机会重新调度线程 A 或 B。线程 A 和 B 完全有可能在其整个时间片中同时运行,从而在其 L2 之间对相同的缓存行进行乒乓操作。全程缓存。

一般来说,锁比无锁代码更有效。这就是为什么在线程代码中更频繁地使用锁的原因。然而,std::atomic类型通常不用于这样的上下文中。std::atomic在您有理由认为锁会更有效的上下文中使用类型可能是错误的。

  • @SouravKannanthaB:只有一条规则=&gt; **有疑问,使用锁**。锁定算法具有相对可预测的性能曲线,并且不会导致CPU崩溃/线程饥饿。无锁算法如果实施不当或使用不当,可能会导致糟糕的性能——这是专家的领域,甚至专家也经常犯错。如果您不认为自己是该领域的专家,请不要在生产中实施。 (28认同)
  • 这个答案似乎更多地是关于抢占式调度和协作式调度之间的区别,而不是锁定和无锁同步。它们是相关的,并且抢占式通常不是无锁的,但它们是不同的概念。 (5认同)
  • @rtpax 使用锁的同步创建了可以取消调度线程的定义点。无锁同步则不然。 (3认同)
  • @mpoeter因为该线程只能通过与当前正在运行的另一个线程竞争来取得进展,从而导致两个线程运行非常缓慢,甚至可能通过拥塞共享资源(例如核心之间的连接)来减慢不相关的线程和进程的速度。最好安排一个不会竞争的线程并让所有线程全速运行。无锁算法允许同时调度访问相同数据的线程,从而增加争用。锁鼓励非竞争线程并发运行。 (2认同)
  • @mpoeter 锁强制上下文切换的唯一时间是当两个线程同时运行且存在争用且另一个线程不会争用且可以调度时。如果当前的争用场景可能会重复数千次(如果没有上下文切换来调度一些非争用线程),那么这将是一个“巨大”的胜利。当然,这需要系统做其他工作。但如果没有其他事情可做,您可能不会承受重负载,而且性能也不是那么重要。 (2认同)

Jer*_*fin 39

除了大卫·施瓦茨(David Schwartz)的出色回答之外,我要指出的是,很大程度上取决于整个系统中稀缺的东西。

如果准备运行的线程多于运行它们的核心,那么您通常想要做的是尽快检测到对某些资源的争用,并将除其中一个争用线程之外的所有线程置于睡眠状态,这样您就可以可以将其他线程调度到这些核心上。

无锁往往在或多或少相反的情况下效果更好:在任何给定时间,可用的硬件多于要运行的线程。在这种情况下,当资源空闲时,具有无锁代码的忙等待可以非常快速地做出反应,进行修改并继续前进。

第二个问题是当争用确实发生时可能会持续多长时间。如果您有许多线程不断地“争夺”一些资源,那么您几乎肯定最好让大多数线程进入睡眠状态,让少数线程(通常只有一个)尽快取得进展,然后切换到另一个线程并重复。

但是让一个线程进入睡眠状态并调度另一个线程意味着进入内核模式和调度程序。如果争用预期是短暂的,则线程之间的不断切换会增加大量开销,因此整个系统会减慢很多。


Mat*_* M. 26

这是否意味着“在任何情况下,当后者可用时,使用非无锁原子类型都会比使用无锁原子类型更好”?

是的,在这个具体案例中。

无锁实现总是优于锁定实现的原因std::atomic<T>很简单,这些操作是由硬件本身支持的。

也就是说,std::atomic_uint32_t::load(std::memory_order::relaxed)在 x86_64 上将归结为:

mov     eax, DWORD PTR [rsp-4]
Run Code Online (Sandbox Code Playgroud)

这只是常规的内存读取,因为 x86 默认情况下已经具有强大的内存模型。

当然,这是无与伦比的。

因此,没有必要同时拥有锁定std::locking<std::uint32_t>和无锁std::lock_free<std::uint32_t>:没有任何情况std::locking<std::uint32_t>会更好,它总是会成为性能陷阱。

然而,不要将此视为无锁算法必然更可取的认可。std::atomic无锁的优势来自于直接映射到硬件指令,这是一个相当特殊的情况。正如 @David Schwartz 和 @Jerry Coffin 所解释的,当涉及更复杂的数据结构和更复杂的算法时——尤其是多指令算法——那么无锁和锁定哪个更好就更加微妙。

  • @SouravKannanthaB:您认为互斥锁是如何实现的?硬件中通常没有神奇的互斥指令,因此互斥是通过原子操作和适当的内存屏障(有时组合)来实现的。无论原子操作是什么,执行一个原子操作总是比锁定(&gt;= 1 个原子操作)、读/写/递增/递减和解锁(&gt;= 1 个原子操作)更便宜。所以,是的,原子操作在构造上_总是_比互斥体更快。 (7认同)
  • @JerryCoffin,内存顺序很重要,这是正确的,但另一方面,互斥锁也需要内存顺序,因此它不会影响无锁原子操作与锁定原子操作的相对性能。 (4认同)
  • @JerryCoffin:ISO C++ 指定锁定互斥锁相当于对互斥锁对象进行获取操作。例如`m.exchange(1, std::memory_order_acquire)`。所以这是允许的弱值,但它必须是 RMW,这意味着在 x86 上它并不比 seq_cst 便宜。释放锁可以像释放纯存储一样便宜,但是如果更复杂的互斥体使用锁来记录可能需要通过系统调用唤醒其他等待者的事实,则更复杂的互斥体可能需要原子 RMW。因此,实际上,在 x86 上,您在获取和释放互斥锁方面存在完整的障碍。 (4认同)

sup*_*cat 12

无锁数据结构在没有争用时通常表现得很好,即使存在争用也能正确执行,但争用的存在可能会导致其性能严重下降。如果 100 个线程都尝试同时更新无锁数据结构,则一个线程在第一次尝试时会成功,至少两个线程在第一次和第二次尝试中会成功,并且至少三个线程在三次尝试中会成功,等等。但最糟糕的是 -在这种情况下,所需的更新尝试总数可能超过 5,000 次。相比之下,如果 100 个线程都尝试更新基于锁的数据结构,则将允许其中一个线程立即执行更新,而其他 99 个线程将被阻止,但这 99 个线程中的每一个只有在能够执行更新时才会被唤醒。立即访问数据结构。

基于锁的数据结构的缺点是,持有锁的线程将阻塞所有其他线程,除非或直到它完成所需的操作并释放锁。相比之下,当使用无锁数据结构时,被拦截的竞争线程将为其他线程提供比其他线程更少的障碍。