【问题标题】:Is an efficient C++ conditional spinlock possible?高效的 C++ 条件自旋锁可能吗?
【发布时间】:2018-12-03 01:29:21
【问题描述】:

我在多线程 C++ 代码中遇到了一种情况,我需要将一些非常快速的操作原子化(出现序列化),以便我可以使用自旋锁,例如:

lock mutex: while (lock.test_and_set(std::memory_order_acquire))
unlock mutex: lock.clear(std::memory_order_release);

但是我认为很聪明,并以数据结构当前是否由多个线程共享为条件进行锁定:

lock mutex: if(lockneeded) while (lock.test_and_set(std::memory_order_acquire))
unlock mutex: if(lockneeded)lock.clear(std::memory_order_release);

最初,数据结构仅由一个线程拥有,但可以所有者将访问权限授予另一个线程,此时它必须设置需要锁的变量(必须是atomic bool 本身)。

这行得通吗?


编辑:一些上下文。我有一个调度协程的系统。一个挂起的协程队列由单个线程一次运行一个,直到它挂起或完成,然后运行下一个。该系统最初是为单线程设计的,因为按照规范,协程是顺序编程结构。上下文切换时间非常快,因为协程使用堆分配的链表作为堆栈,而不是机器堆栈。所以上下文切换基本上只是一个指针交换。

然后我决定允许多个线程处理列表,因此协程成为进程。现在指针交换必须以原子方式完成。交换速度非常快,因此自旋锁似乎是保护操作的正确方法。

我有一个测试用例,我在其中连续运行一组作业,然后使用额外的辅助线程再次执行。我有一个问题,我现在已经解决了,结果与日程安排无关。现在,4 个线程运行该进程的速度大约是 1 个线程的 3.5 倍。

性能目标很简单:我想将 Go-lang 从地球上抹去。我的系统兼容 C/C++ ABI(Go 不兼容),它使用正确的流处理模型(Go 不兼容),而且它也是一种非常优秀的语言。

我不知道 Go 的上下文切换速度有多快。但是我的测试用例的当前未调整版本,我们不能忘记作业计数到 100K 以产生延迟(并确保锁上的争用接近零),在 5 秒内处理 200 万个进程,这是上下文切换速率每秒大约 400K 开关。我希望如果我用空作业(什么都不做协程)替换慢速作业,速度将超过每秒 100 万次开关。那是运行 200 万个进程。现实世界的速度会更低,实验试图找到性能的上限。

【问题讨论】:

  • 正确格式化帖子并提供minimal reproducible example,如果您想询问特定场景。在处理多线程时,拥有一个完整的示例尤为重要。
  • 鉴于bool 需要是原子的,我不确定它是否会在您添加额外指令时提高性能。有没有在单线程的情况下测量过这3种情况(无锁、锁和锁+布尔)的性能,看看实际的影响?如果不是,也许是过早的优化?
  • 所以你已经到了正确的做法是让 lockneeded 成为具有访问权限的线程的计数器......所以应该根据这个计数器来实现自旋锁...... = > 这几乎是一个互斥锁!
  • @Phil1970:不,我还没有测量性能,我在问它是否会工作。当前代码使用互斥锁。最新型号 Mac 上的并发版本比单线程版本慢 10 倍。它使用条件锁的等价物。在这种情况下,条件测试的成本与使用互斥锁的成本相比是很小的。我计划用自旋锁替换互斥锁。之后我可以尝试不带条件。该应用程序实际上是一个用户空间调度程序。
  • @Oliv:不,它不是计数,它不是 1 个线程或更多。目前的计划是即使其中一个线程消失也不会恢复。所以它从 false 开始,并且可以在第二个线程开始之前的单个线程中变为 true 一次,这就是它的结束。我想这就够了。至少目前可以,还有更多关键问题需要解决。

标签: c++ multithreading


【解决方案1】:

不,很遗憾,这不起作用。

假设线程A看到lockneeded为假,没有获取lock就进入了临界区,那么在临界区中间发生了上下文切换。线程 B 请求访问数据结构。数据结构不知道线程 A 处于临界区,因此线程 B 被授予访问权限。 lockneeded 设置为 true,但线程 A 已经在其临界区中。线程 B 然后获取 lock... 你可以很容易地看到这是未定义的行为。

除非你能保证lockneeded 在临界区不会改变,否则它不能工作。保证lockneeded 不会改变的一种方法是使用锁来保护它。因此,您需要为lockneeded 的每个访问添加一个锁,因此首先破坏了变量的用途。

高效的 C++ 自旋锁

自旋锁在概念上非常简单,但有多种口味可供选择。需要考虑的重要因素是性能要求(真的需要那么高效吗?)、架构、线程库、所需的可伸缩性、预期的争用量(如果争用很少,您可以针对非争用情况),使用相同锁的关键部分不对称(防止线程饥饿),读写比率......你可以看到,如果你需要它超级高效,你需要做很多性能测试去做。因此,如果您真的不需要性能,您应该只使用您拥有的自旋锁并将时间花在其他地方。

但我们是计算机科学家,我们喜欢最有效的解决方案,因为我们是问题解决者。对于高度争议、高度可扩展的自旋锁,请查看 MCS 锁。对于一个普遍良好的自旋锁,我不久前进行了一些测试,发现 pthreads 的自旋锁具有相当大的可扩展性。

还有另一种方法可以保证线程 A 不在临界区,而线程 A 不必写任何东西。它被称为 rcu_synchronize,简单地说,它需要线程 B 设置 lockneeded 并等待足够长的时间来保证临界区中的任何线程都会完成它。

由于锁变量的缓存未命中(全局写入会使其他也在旋转的内核无效)导致总线流量不足,因此幼稚自旋锁的可伸缩性很差。

您可以做的一个简单优化是“spin on read”自旋锁:

lock mutex:   while (lock.load(std::memory_order_acquire) || lock.test_and_set(std::memory_order_acquire)) {}
unlock mutex: no change

所以如果另一个线程有锁,这个线程不会打扰 TSL(由于 OR 短路),但是当另一个线程释放锁时,线程尝试 TSL,这可能成功也可能不成功.不幸的是,这种锁在高规模场景中的性能与朴素自旋锁一样差,但在低规模、中等争用情况下,与朴素自旋锁相比,它可能会不时为您节省一些周期。

【讨论】:

  • 我认为您错过了一些重要信息。 Lockneeded 一开始是假的,只有一个线程。该线程在生成第二个线程之前将 lockneeded 设置为 true。所以你描述的场景不可能发生。这里唯一的问题是产生的线程必须看到 lockneeded 的值为真,它在产生第二个线程之前。在我的系统中,生成机器使用互斥锁来强制生成器等待,直到生成器告诉它它实际上已经开始运行,这应该是足够的内存排序。
  • 所以,你是对的,需要进行性能测试。但对我来说,第一步是得到一些半合理的东西,而且清楚明确地正确。这样可以设置和测量一些测试用例,并且可以比较调整尝试的性能和正确性。您展示的短路读取优先优化绝对值得考虑。
  • 所以目前在 OSX 上仅供参考,测试代码的并发版本比单线程版本慢 10 倍。在 Linux 上,它更好,只有 13 秒。竞争接近于零,有 200 个作业循环递增计数器很多次。 100,000 个什么都不做的工作的结果是相似的。所以锁的效率很高。似乎缓存和调度问题是杀手。我需要更多的实验。
  • 感谢您的反馈。每次你需要做一份工作时,你都会产生一个新线程吗?产生一个线程来处理一项工作就像雇用一个职员来检查一个客户。生成线程的初始成本很容易超过执行操作的成本。你考虑过线程池吗?
  • 系统从一个线程开始运行队列中的作业。它实际上是一个同步协程调度器,没有并发。当前的设计允许其中一个协程在运行时生成一个新的 pthread 作为助手。目前,这个选择是由协程的作者而不是系统做出的。一旦新的 pthread 启动,它就会与运行生成它的协程的调度程序共享作业队列。然后协程(逻辑上)提升到进程的状态。
猜你喜欢
  • 2012-08-09
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2020-06-09
  • 2013-11-14
  • 1970-01-01
相关资源
最近更新 更多