【问题标题】:Do lock-free algorithms really perform better than their lock-full counterparts?无锁算法真的比全锁算法性能更好吗?
【发布时间】:2011-04-15 18:28:42
【问题描述】:

Raymond Chen一直在做hugeseriesonlockfreealgorithms。除了InterlockedXxx 函数的简单案例之外,所有这些函数的流行模式似乎是它们实现自己的锁。当然,没有处理器锁,但在每个 CPU 上反复循环以确保一致性的概念非常类似于自旋锁。作为自旋锁,它们的效率将低于操作系统附带的通用锁,因为它们在等待其他线程时不会产生对它们的量子的控制。因此,每当有人来找我说“但我的算法是无锁的”时,我的一般反应是“所以”?​​p>

我很好奇 - 是否有 基准 可以显示无锁算法比全锁算法具有优势?

【问题讨论】:

标签: multithreading synchronization lock-free


【解决方案1】:

除了 InterlockedXxx 函数的简单案例之外,看起来像 所有这些的普遍模式是他们实施他们的 自己的锁。

这里的答案似乎都没有真正触及“无锁”CAS 循环与互斥锁或自旋锁之间区别的核心。

重要的区别在于 无锁 算法保证自行取得进展 - 无需其他线程的帮助。使用锁或自旋锁,任何无法获得锁的可怜线程完全受拥有锁的线程的支配。无法获取锁的可怜线程除了等待(通过忙等待或操作系统辅助睡眠)之外什么都不做。

使用在 CAS 上循环的无锁算法,无论其他竞争线程在做什么,都可以保证每个线程都能取得进展。从本质上讲,每个线程都在控制自己的命运。是的,它仍然可能需要循环很多次,但是它循环的次数受到竞争线程数的限制。在大多数情况下,它不能无限循环。 (在实践中,可能会发生活锁,例如 LL/SC 循环由于错误共享而不断失败) - 但是线程本身可以再次采取措施来处理这个问题 - 它不是摆布另一个持有锁的线程。

至于性能,这取决于。我见过无锁算法的明显例子,即使在高线程争用情况下,它们的锁定算法也完全胜过它们。在运行 Debian 7 的 x86-64 机器上,我比较了 C++ Boost.Lockfree 队列(基于 Michael/Scott 算法)和由 std::mutex 包围的普通旧 std::queue 之间的性能。在高线程争用下,无锁版本的速度几乎是原来的两倍。

那是为什么呢?好吧,无锁算法的性能最终归结为实现细节。算法如何避免ABA?它如何实现安全的内存回收?有很多变种...标记指针、基于 epoch 的回收、RCU/静止状态、危险指针、一般进程范围的垃圾收集等。所有这些策略都有性能影响,有些还限制了您的应用程序的一般方式可以设计。一般来说,根据我的经验,引用计数方法(或标记指针方法)往往表现不佳。但替代方案的实现可能要复杂得多,并且需要更多基于线程本地存储或通用垃圾收集的内存回收基础设施。

【讨论】:

    【解决方案2】:

    一般来说,无锁算法每个线程的效率较低 - 正如您所提到的,为了实现无锁算法而不是简单的锁,您需要做更多的工作。

    但是,面对争用,它们确实倾向于显着提高整个算法的整体吞吐量。 Thread switching latencycontext switches,在许多线程上速度很快,会显着降低应用程序的吞吐量。无锁算法有效地实现了它们自己的“锁”,但它们这样做的方式是防止或减少上下文切换的次数,这就是为什么它们倾向于执行锁定对应物。

    话虽如此 - 这大部分取决于所讨论的算法(和实现)。例如,我有一些例程,我设法切换到 .NET 4 的新并发集合,而不是使用以前的锁定机制,并且测量到我的总算法速度提高了近 30%。话虽如此,与基本锁相比,您可以发现许多基准测试表明使用其中一些相同的集合会降低性能。与所有性能优化一样 - 在您测量之前您真的不知道。

    【讨论】:

    • +1 - 当然这将取决于特定的算法,并且应该进行基准测试 - 我只是想挑战“无锁”等同于“更好”的看法案例。 :) 我想每个人都会同意,一个非常好的使用锁的算法可能会胜过一个非常糟糕的无锁算法,就像一个非常好的无锁算法会胜过一个非常糟糕的使用锁的算法一样。
    • @Billy:是的,但是“好的”算法可能会更好地锁定或无锁 - 这实际上取决于您必须锁定的内容、锁定的频率等。通常,锁定-free 在面对高并发的情况下往往会更好(并发越多,它就越有帮助)......
    【解决方案3】:

    无锁不一定更快,但它可以消除死锁或活锁的可能性,因此您可以保证您的程序总是会朝着完成的方向前进。使用锁,很难做出这样的保证——很容易错过一些可能导致死锁的执行顺序。

    过去,一切都取决于。至少根据我的经验,速度的差异往往更多地取决于实施中部署的技能水平,而不是是否使用锁。

    【讨论】:

    • 嗯.. 我不确定我是否认同这个论点。这些算法之一完全有可能出现死锁——你正在实现自己以锁定方式工作的东西!但是,即使这消除了所有死锁和活锁错误,这些错误也比您可以从有缺陷的无锁算法中获得的潜在数据一致性错误更容易处理、调试和消除。 -- 但为最后一段 +1。
    • @Billy:仔细看:我没有说它保证什么,只是它可以。有了锁,即使在最好的情况下,也很难保证任何事情。
    • @Jerry: 嗯....我会反对。争论死锁或活锁更容易,但争论数据一致性要困难得多(恕我直言,这是更重要的约束)。
    • @Billy:哦,别误会我的意思:我并不是说完成很多事情或类似的事情更容易。我只是说,当您解除锁定时,有几种特定类型的事情更容易证明。
    • “无锁”的官方定义不仅仅是手动使用原子操作而不是互斥锁。它实际上意味着forward progress is always possible for at least some threads.。使用互斥锁,如果持有锁的一个线程阻塞,其他线程就无法取得进展。 “免等待”是一个更强有力的保证:每个操作最多需要有限数量的步骤。我认为这排除了 cmpxchg 循环(我认为这是@BillyONEal 担心的类似自旋的循环)。
    【解决方案4】:

    在 x64 上的 Windows 下,简单的(在 freelist 前面没有组合数组)无锁 freelist 比基于 mutex 的 freelist 快大约一个数量级。

    在我的笔记本电脑 (Core i5) 上,对于单线程、无锁,我每秒获得大约 3100 万次 freelist 操作,而对于 mutex,我每秒获得大约 230 万次操作。

    对于两个线程(在不同的物理内核上),在无锁的情况下,每个线程可以进行大约 1240 万次 freelist 操作。使用互斥锁,我每秒可以进行大约 80 THOUSAND 次操作。

    【讨论】:

    • Mutex 是在这里使用的错误工具;互斥锁仅用于跨进程通信。如果您正在做这种事情,您可能应该使用关键部分...
    • 我注意到无锁并不关心你是否跨进程;它的性能将保持不变。如果 CS 因为它是一个进程而比互斥锁快,那么无锁具有 CS 所没有的能力。
    • @Blank:是和不是。是的,不需要修改跨进程算法,但它需要一个共享内存段,这在访问方面不是免费的。 (也就是说,典型的无锁轮询循环会执行得更慢)
    • 我的印象是共享内存的访问速度并不比非共享内存慢。你有什么开销?基准测试的代码尚未发布——它是 liblfds 第 7 版的一部分,将在一两个月内发布。当前版本 6 可用。 liblfds.org
    • 基准测试非常简单;它是一个弹出,然后是一个推送,从/到一个空闲列表,在一个 while 循环中,其中那些(和一个计数器)是循环中唯一的操作。这将运行十秒钟。使用了各种逻辑核心的组合。
    【解决方案5】:

    真正无锁算法的主要优点是即使任务被搁置,它们也很健壮(请注意,无锁是比“不使用锁”(*)更严格的条件)。虽然避免不必要的锁定具有性能优势,但性能最佳的数据结构通常是那些在许多情况下可以操作锁定,但可以使用锁定来最小化抖动的数据结构。

    (*)我看到了一些“无锁”多生产者队列的尝试,其中一个生产者在错误的时间被阻止会阻止消费者看到任何新项目,直到它完成它的工作);这种数据结构不应该真正被称为“无锁”。一个被阻塞的生产者不会阻塞其他生产者取得进展,但可能会任意阻塞消费者。

    【讨论】:

      【解决方案6】:

      无锁算法绝对比阻塞算法更快。但当然反过来也是如此。假设实现比锁定计数器部分执行得更好,唯一的限制因素是争用。

      以两个 Java 类为例,ConcurrentLinkedQueue 和 LinkedBlockingQueue。在适度的现实世界竞争下,CLQ 的表现要好于 LBQ。在竞争激烈的情况下,使用暂停线程将使 LBQ 性能更好。

      我不同意 user237815。 synchronized 关键字不需要像以前那样多的开销,但相对于无锁算法,与单个 CAS 相比,它确实有大量相关的开销。

      【讨论】:

        【解决方案7】:

        最近在 [JavaOne Russia][1] 上,Oracle 员工(专门研究 Java 性能和基准测试)展示了使用 CAS(无锁、高level spinlock 实际上)和经典锁(java.util.concurrent.locks.ReentrantLock)。

        据此,自旋锁只有在少数线程尝试访问监视器时才具有更好的性能。

        【讨论】:

        • Hmm.. 这对 Java 语言来说是非常具体的,但实际上并没有说任何关于无锁或无锁的事情。并且没有像无锁自旋锁这样的想法——自旋锁就是锁。
        • 无论您做什么,int 计数器都无法扩展,因为您有很多线程访问相同的内存位置。你需要一个计数漏斗。
        【解决方案8】:

        至少在 Java 中,锁定本身可以非常快。 synchronized 关键字不会增加很多开销。您只需在循环中调用同步方法即可自行对其进行基准测试。

        只有在发生争用时锁定才会变慢,并且被锁定的进程不是即时的。

        【讨论】:

        • +1 - 是的——但我认为我们可以假设两种算法都存在争用(因为如果这不是你的问题中的一个有争议的点,那么你不太可能花时间写一个它的无锁实现)。
        • 问题是关于基准,而不是关于锁和无锁算法的理论。我的部分回答提出了一种获得基准的方法。
        【解决方案9】:

        无锁还具有不休眠的优点。内核中有一些地方是不允许你睡觉的——Windows 内核有很多地方——这极大地限制了你使用数据结构的能力。

        【讨论】:

        • 自旋锁也不休眠。
        【解决方案10】:

        是的,无锁可确保进度,但除非您手动中止线程,这在某些平台上是可能的,或者在关键部分分配并出现内存不足异常,或者任何类似的愚蠢行为,否则您不需要那样做。 正确实现的自旋锁几乎总能胜过无锁方法,因为通常你会在第一次或尝试不成功后做更多的工作。 如果您保持旋转时间很短并且通过比较交换指令使 cpu 不堪重负和/或在一段时间后不退缩,将线程时间片提供给其他线程(这使计划外的线程有机会唤醒和释放锁),那么无锁代码可以执行得更好.除此之外,我认为这是不可能的。我不感兴趣,也不对自旋锁不适合的复杂数据类型感兴趣,但我仍然觉得设计合理的基于锁的算法几乎总是更好。不过我可能是错的。

        【讨论】:

        • 我认为这个问题的意思是询问无锁代码,无论它是无等待/无锁/无阻塞。 (en.wikipedia.org/wiki/Non-blocking_algorithm)。正如您所说,实际实现这些属性通常是不值得的。但是可以阻塞的无锁代码(但在实践中几乎从来没有,而且不会持续很长时间)可以击败锁定。例如无锁队列可以支持同时读/写。例如Lock-free Progress Guarantees 很好地分析了在极少数情况下可以阻塞的良好无锁队列。
        • 你疯了吗?他要求比较无锁代码和锁定代码。并且在您的链接中,在无争议的情况下它可能是单个 cmpx,但对于自旋锁也是如此。那么等待能够写入同步内存而不是做很多工作并尝试以前没有人这样做有什么不好?我再次假设你不是傻瓜以意想不到的方式杀死你的线程或贪婪地旋转..
        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 2012-06-17
        • 1970-01-01
        • 2015-12-29
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2010-12-09
        相关资源
        最近更新 更多