如何编写无锁结构?

时间:2008-09-18 13:18:51

标签: multithreading multicore lock-free

在我的多线程应用程序中,我发现其中存在严重的锁争用,从而阻碍了跨多个内核的良好可伸缩性。我决定使用无锁编程来解决这个问题。

如何编写无锁结构?

21 个答案:

答案 0 :(得分:44)

简短回答是:

你不能。

答案很长:

如果您提出这个问题,您可能不太了解能够创建无锁结构。创建无锁结构非常困难,只有该领域的专家才能做到这一点。而不是编写自己的,搜索现有的实现。当你找到它时,检查它的使用范围,记录的程度,是否经过充分证明,有什么限制 - 甚至其他人发布的一些无锁结构也会被破坏。

如果找不到与您当前使用的结构相对应的无锁结构,请调整算法以便使用现有结构。

如果您仍然坚持创建自己的无锁结构,请务必:

  • 从非常简单的事情开始
  • 了解目标平台的内存模型(包括读/写重新排序约束,什么操作是原子的)
  • 研究了很多其他人在实现无锁结构时遇到的问题
  • 不要猜测它是否会起作用,证明它
  • 严重测试结果

更多阅读:

Lock free and wait free algorithms at Wikipedia

Herb Sutter: Lock-Free Code: A False Sense of Security

答案 1 :(得分:16)

使用诸如Intel's Threading Building Blocks之类的库,它包含相当多的无锁结构和算法。我真的不建议你自己尝试编写无锁代码,这非常容易出错并且难以正确使用。

答案 2 :(得分:12)

编写线程安全的无锁代码很难;但是this article from Herb Sutter会让你开始。

答案 3 :(得分:12)

正如 sblundy 指出的那样,如果所有对象都是不可变的,只读,则不需要担心锁定,但这意味着您可能需要经常复制对象。复制通常涉及malloc和malloc使用锁定来跨线程同步内存分配,因此不可变对象可能比你想象的少得多(malloc本身扩展性很差,malloc ;如果你做了很多malloc在性能关键部分,不要期望良好的性能)。

当你只需要更新简单变量(例如32或64位int或指针)时,对它们执行简单的加法或减法操作或只交换两个变量的值,大多数平台为此提供“原子操作”(进一步GCC也提供这些)。 Atomic与线程安全不同。但是,原子确保,如果一个线程将64位值写入内存位置,例如另一个线程从中读取,则读取操作将在写入操作之前或写入操作之后获取值,但绝不会是写入操作之间的值(例如,前一个32位已经是新的,最后一个32位仍然是旧值!如果你不在这样的情况下使用原子访问,就会发生这种情况变量)。

但是,如果你有一个C结构有3个值,想要更新,即使你用原子操作更新所有三个,这些是三个独立的操作,因此读者可能会看到一个值已经更新的结构和两个没有更新。如果你必须保证,你需要一个锁,读者要么看到结构中的所有值都是旧值或新值。

使锁定扩展得更好的一种方法是使用R / W锁。在许多情况下,对数据的更新很少(写操作),但访问数据非常频繁(读取数据),想到集合(哈希表,树)。在这种情况下,R / W锁将为您带来巨大的性能提升,因为许多线程可以同时保持读锁(它们不会相互阻塞)并且只有当一个线程想要写锁时,所有其他线程在执行更新时被阻止。

避免线程问题的最佳方法是不跨线程共享任何数据。如果每个线程在大多数情况下处理其他线程无法访问的数据,则根本不需要锁定该数据(也没有原子操作)。因此,尝试在线程之间尽可能少地共享数据。然后,如果你真的需要(ITC,Inter Thread Communication),你只需要一种快速的方法在线程之间移动数据。根据您的操作系统,平台和编程语言(遗憾的是您没有告诉我们这些),可能存在各种强大的ITC方法。

最后,使用共享数据但没有任何锁定的另一个技巧是确保线程不访问共享数据的相同部分。例如。如果两个线程共享一个数组,但是一个只能访问偶数,另一个只有奇数索引,你不需要锁定。或者如果两者共享相同的内存块而一个只使用它的上半部分,另一个只使用下一个,则不需要锁定。虽然没有说,但这将导致良好的表现;尤其不适用于多核CPU。将一个线程写入此共享数据(运行一个核心)的操作可能会强制为另一个线程(在另一个核心上运行)刷新缓存,并且这些缓存刷新通常是在现代多核CPU上运行的多线程应用程序的瓶颈。

答案 4 :(得分:10)

正如我的教授(来自“多处理器编程艺术”的Nir Shavit)告诉全班同学:请不要。主要原因是可测试性 - 您无法测试同步代码。你可以运行模拟,你甚至可以进行压力测试。但它充其量只是粗略的近似。你真正需要的是数学正确性证明。很少有人能够理解它们,更不用说写它们了。 因此,正如其他人所说:使用现有的库。 Joe Duffy's blog调查了一些技巧(第28节)。你应该尝试的第一个是树分裂 - 打破较小的任务并组合。

答案 5 :(得分:7)

不变性是避免锁定的一种方法。请参阅Eric Lippert's discussion以及诸如不可变堆栈和队列之类的实现。

答案 6 :(得分:6)

重新。 Suma的回答是,Maurice Herlithy在多处理器编程的艺术中表明,实际上任何都可以在没有锁的情况下编写(见第6章)。 iirc,这主要涉及将任务拆分为处理节点元素(如函数闭包),并将每个元素排入队列。线程将通过跟踪最新缓存的节点中的所有节点来计算状态。显然,在最坏的情况下,这可能会导致顺序性能,但它确实具有重要的无锁属性,可以防止线程在持有锁时长时间安排线程的情况。 Herlithy也实现了理论上的等待性能,这意味着一个线程不会永远等待赢得原子入队(这是很多复杂的代码)。

多线程队列/堆栈非常难(请查看ABA problem)。其他事情可能很简单。习惯于while(true){atomicCAS直到我交换它}块;他们非常强大。对CAS的正确性的直觉可以帮助开发,尽管你应该使用好的测试和可能更强大的工具(可能SKETCH,即将到来的MIT Kendospin?)来检查正确性你可以将它简化为一个简单的结构。

请发布有关您的问题的更多信息。没有细节,很难给出一个好的答案。

编辑不可移植性很好,但如果我理解正确,它的适用性是有限的。它并没有真正克服读写后的危害;考虑执行“mem = NewNode(mem)”的两个线程;他们都可以读取mem,然后都写出来;对于经典增量函数来说不正确。此外,由于堆分配(必须跨线程同步),它可能很慢。

答案 7 :(得分:5)

无法改变会产生这种影响。对对象的更改会导致新对象。 Lisp以这种方式工作。

Effective Java的第13项解释了这种技术。

答案 8 :(得分:4)

Cliff Click通过利用有限状态机对无锁数据结构进行了一些主要研究,并发布了许多Java实现。您可以在他的博客上找到他的论文,幻灯片和实现:http://blogs.azulsystems.com/cliff/

答案 9 :(得分:2)

使用现有的实施,因为这个工作领域是领域专家和博士的领域(如果你想做得好!)

例如,这里有一个代码库:

http://www.cl.cam.ac.uk/research/srg/netos/lock-free/

答案 10 :(得分:1)

如果你看到锁争用,我会首先尝试在你的数据结构上使用更细粒度的锁,而不是完全无锁的算法。

例如,我目前正在处理多线程应用程序,它具有自定义消息传递系统(每个线程的队列列表,队列包含要处理的线程的消息)以在线程之间传递信息。这种结构存在全局锁定。在我的情况下,我不需要那么多的速度,所以它并不重要。但是,如果这个锁会成为一个问题,例如,它可以被每个队列中的单个锁替换。然后在特定队列中添加/删除元素不会影响其他队列。仍然会有一个用于添加新队列等的全局锁,但它不会那么争议。

即使是单个多产品/消费者队列也可以在每个元素上使用粒度锁定编写,而不是具有全局锁定。这也可以消除争用。

答案 11 :(得分:1)

无锁同步的基本原则是:

  • 无论何时阅读结构,都要按照阅读进行测试,看看结构是否在您开始阅读后发生了变异,然后重试,直到您成功阅读,而没有其他东西出现并且您正在变异这样做;

  • 每当你改变结构时,你都会安排你的算法和数据,这样就会有一个原子步骤,如果采取这个步骤,会导致整个变化对其他线程可见,并安排事情使得没有除非采取该步骤,否则变更是可见的。您可以使用平台上存在的任何无锁原子机制来执行该步骤(例如,比较和设置,加载链接+存储条件等)。在该步骤中,您必须检查是否有任何其他线程已经突变对象,因为变异操作开始,如果没有则提交,如果有则重新启动。

网上有很多无锁结构的例子;在不了解您正在实施的内容以及在哪个平台上更难以更具体的情况下。

答案 12 :(得分:1)

大多数无锁算法或结构都是以某些原子操作开始的,即一旦线程开始的某些内存位置的更改将在任何其他线程执行相同操作之前完成。你的环境中有这样的操作吗?

有关此主题的规范性论文,请参阅here

另请参阅此wikipedia article文章以获取更多想法和链接。

答案 13 :(得分:1)

如果您正在为多核cpu编写自己的无锁数据结构,请不要忘记内存障碍!另外,请考虑研究Software Transaction Memory技术。

答案 14 :(得分:0)

如果您阅读了有关该主题的多个实施和论文,您会注意到以下共同主题:

1)共享状态对象是lisp / clojure样式inmutable :也就是说,所有写入操作都是在新对象中复制现有状态,对新对象进行修改然后尝试更新共享状态(从可以使用CAS原语更新的对齐指针获得)。换句话说,您永远不会修改可能被当前线程读取的现有对象。可以使用Copy-on-Write语义为大型复杂对象优化Inmutability,但那是另一棵坚果树

2)你清楚地指明允许当前和下一个状态之间的转换有效:然后验证算法是否有效变得更容易

3)处理每个线程的危险指针列表中丢弃的引用。参考对象安全后,尽可能重用

查看我的另一篇相关文章,其中一些用信号量和互斥量实现的代码以无锁的方式(部分)重新实现: Mutual exclusion and semaphores

答案 15 :(得分:0)

查看我的link ConcurrentLinkedHashMap,了解如何编写无锁数据结构的示例。它不是基于任何学术论文,也不像其他人所暗示的那样需要多年的研究。它只需要仔细的工程。

我的实现使用ConcurrentHashMap,它是一种每桶锁定算法,但它不依赖于该实现细节。它可以很容易地被Cliff Click的无锁实现取代。我从Cliff借用了一个想法,但更明确地使用的是用状态机对所有CAS操作进行建模。这大大简化了模型,因为你会看到我通过'ing状态得到了psuedo锁。另一个技巧是允许懒惰并根据需要解决。你会经常看到回溯或让其他线程“帮助”清理。就我而言,我决定允许列表中的死节点在到达头部时被驱逐,而不是处理从列表中间删除它们的复杂性。我可能会改变这一点,但我并不完全信任我的回溯算法,并希望推迟一项重大改变,例如采用3节点锁定方法。

“多处理器编程的艺术”一书是一本很好的入门读物。但总的来说,我建议在应用程序代码中避免使用无锁设计。通常情况下,在其他更不容易出错的技术更适合的情况下,它只是过度杀伤。

答案 16 :(得分:0)

您能澄清一下结构的含义吗?

现在,我假设你的意思是整体架构。您可以通过不在进程之间共享内存,以及为进程使用actor模型来实现它。

答案 17 :(得分:0)

在Java中,使用JDK 5+中的java.util.concurrent包而不是编写自己的包。如上所述,这对于专家来说真的是一个领域,除非你有一两年的闲暇时间,否则不能选择自己。

答案 18 :(得分:0)

减少或消除共享的可变状态。

答案 19 :(得分:0)

如前所述,这实际上取决于您所谈论的结构类型。例如,您可以编写有限的无锁队列,但不能编写允许随机访问的队列。

答案 20 :(得分:0)

嗯,这取决于结构的类型,但你必须使结构能够小心谨慎地检测和处理可能的冲突。

我怀疑你可以制作一个100%无锁的产品,但同样,这取决于你需要构建什么样的结构。

您可能还需要对结构进行分片,以便多个线程处理单个项目,然后再进行同步/重新组合。