无锁的并发编程
原著:KeirFraser, Tim Harris
原名:ConcurrentProgramming Without Locks
原文:http://research.microsoft.com/en-us/um/people/tharris/papers/2007-tocs.pdf
翻译:CoryXie <cory.xie@gmail.com>
摘要:互斥锁仍然是共享内存数据结构的并发控制机制事实上的标准。然而,他们明显的简单性是骗人的:很难设计可扩展的锁策略,因为锁机制可能隐藏问题,如优先级反转(priority inversion),死锁(deadlock)和锁护航(convoying)。此外,在构建复合操作时,可扩展的基于锁的系统是不容易组合的。为了寻找这些问题的解决方案,研究者的兴趣落到了非阻塞系统上,这样的系统通过避开互斥,从而具有可承诺的可扩展性和鲁棒性,同时还能确保安全。然而,现有的用于构建无阻塞系统的技术很少适合实际使用,强加巨大的存储开销,序列化非冲突的操作,或要求现在的CPU没有的指令。
在本文中,我们提出了三个API,这使得我们可以更容易开发任意数据结构的非阻塞实现。第一个API是一个多字的比较和交换操作(multi-wordcompare-and-swap,MCAS),可以原子地更新一组存储器位置。这可以被用来将一个数据结构从一个一致状态改变到另一个一致状态。第二个API是一个以单字为基础的软件事务内存(word-based software transactional memory,WSTM),它可以允许顺序代码被重新使用,比MCAS更直接,且当内存位置被读取而不是被更新时,提供了更好的可伸缩性。第三个API是一种基于对象的软件事务内存(object-based software transactional memory,OSTM)。 OSTM允许一个比WSTM更精简的实现,但在使用OSTM时需要付出代码重新设计的成本。
我们给出所有这些API的实际实现,基于所有现在的主要CPU家族都有的操作构建。我们通过使用它们来构建高度并行的跳表(skip-lists)和红黑树(red-black trees),说明了如何使用这些API。我们比较所得到的结果彼此之间的性能,也与高性能的基于锁的系统实现比较。这些结果表明,建立非阻塞有用的数据结构是可能的,与复杂的基于锁的设计相比,具有同等的或更好的性能。
分类和主题描述:D.4.1[操作系统]: 进程管理-并发;互斥;同步
通用条款:算法, 语言, 性能
更多的关键词和短语:并发,无锁系统,事务内存
1. 引言
互斥锁是使用最广泛的和基本的用于同步的抽象之一。这种普及主要是由于他们看似简单的编程模型和可用实现有效性和可扩展性。不幸的是,如果没有专门的编程注意,这些好处很少能在含有较多锁的系统中保持:
- 为了保证正确性(correctness),程序员必须确保线程持有必要的锁,以避免冲突的操作被同时执行。为了避免错误,程序员倾向于开发简单的锁策略,造成悲观地串行化一些非冲突的操作。
- 为了保证活动性(liveness),程序员必须小心,以避免引入死锁,因此,他们可能会导致持有锁的时间比其必要持有的时间更长。此外,如果没有调度的支持,程序员必须知道优先级反转问题。
- 为了获得高性能(high performance),程序员必须平衡锁的粒度以及应用程序获取和释放锁将花费的时间。
本文关注的是设计和实现软件,该软件是安全的可以在多线程多处理器共享内存的机器上使用,但它不涉及使用锁。相反,我们给出了三个不同的API,用于原子地访问一组内存位置。这些API可以使得能直接从顺序的数据结构开发并发数据结构。我们相信,这使得我们能更容易构建正确的多线程系统。此外,我们的实现是无阻塞的(这意味着,即使任何一组线程处于停顿状态,那么剩余的线程仍然可以取得进展);他们一般允许不相交的访问并行处理(disjoint-access parallelism)(意味着对非重叠位置的更新,能够同时执行)。
为了介绍这些API,我们将勾画其使用的代码片段,插入项目(items)到一个单链表,该单链表持有升序排列的整数。在每一种情况下,列表结构为哨兵头和尾节点,其键值分别小于和大于所有其他值。插入后,每个节点的键值保持不变。为了比较,图1示出了当实现为单线程使用时相应的插入操作。在该图中,正如在我们的每个例子中,通过识别节点prev和curr,在他们之间放置新节点,来实现插入操作。
我们的三个可替代的API都遵循一个共同的乐观风格[Kung和Robinson,1981],其中最核心的顺序代码被封装在一个循环中重试插入,直到成功提交对内存的更新。
我们的第一个API提供多字的比较和交换(multiwordcompare-and-swap,MCAS),推广了在许多处理器中都存在的单字CAS操作:它原子地将一个或多个内存位置从一组预期值更新到一组新值。图2显示了如何使用MCAS来表示插入。
相对于顺序算法,有两个基本的变化。首先,不是单独更新共享位置,代码必须调用MCAS来执行一套完整的内存访问,这一套内存访问需要原子地进行。在这个例子中,只有一个更新要用MCAS,但相应的删除操作将需要传递两个更新给MCAS:一是用来从列表中摘除节点,第二个用来将它的next字段清除为NULL,以防止在删除的节点后面并发插入新节点。其次,当代码读取的位置可能会受到另一个线程的MCAS并发更新时,就必须调用MCASRead。
MCAS和MCASRead提供了一个相当低级别的API,程序员必须小心地在必要时使用MCASRead,还必须记住,在随后的MCAS并不知道哪些地方已读。这可能会导致比较麻烦的代码,程序保持它读取过的位置的列表,以及它已经从这些地址中看到的值,然后将这些列表传递给MCAS,以确认这些值代表共享内存的一个一致的状态。
第二个抽象提供了一个单字为基础的软件事务内存(WSTM),通过允许了一系列的读和写操作被组合成一个可以原子地应用于堆的软件事务 [Harris和Fraser,2003],避免了这些问题中的某些问题。图3显示了使用WSTM的列表示例。相对顺序代码的变化是,队共享位置的读和写操作是通过WSTMRead和WSTMWrite函数进行的,而且这一整套的内存访问被封装在一个WSTMStartTransaction调用和一个WSTMCommitTransaction调用之间。
第三个抽象提供了一个基于对象的软件事务内存(OSTM),允许一个线程来“打开”一组对象的事务性访问,并再次原子地提交更新 [Fraser,2003]。
图4说明了这种编程风格:每一个对象的访问是通过一个OSTM句柄,必须采用OSTMOpenForReading或OSTMOpenForWriting调用,来获得基础数据的访问。在简短的例子,代码看起来比WSTM更详细,但OSTM实现更简单直接,且运行得更快。
虽然这些技术并没有提供一个银子弹来设计可扩展的并行数据结构,但是它们仍然代表责任从程序员转变过来了:该API的实现负责正确地确保冲突的操作不会同时进行,并负责在并行操作之间防止死锁和优先级倒置。API的调用者仍然有责任确保可扩展性,使得并发操作将不太可能需要修改一组重叠的位置。然而,这只是一个性能问题,而不是正确性或活动性问题,根据我们的经验,即使是直接从顺序代码开发来的简单数据结构,也能提供可竞争的性能,而且往往还能超越最先进的基于锁的设计。
【Work In Progress...】