翻译自博客《An Introduction to Lock-Free Programming》,链接为http://preshing.com/20120612/an-introduction-to-lock-free-programming。
无锁编程是一个不简单的挑战。它的难度不仅仅是因为这个任务本身的复杂度,也因为第一眼你并不知道为了能够达到无锁编程到底有多复杂。
我很幸运我第一次接触到关于无锁编程的介绍是Bruce Dawson的伟大而又易于理解的白皮书——《无锁编程理解》。同时,与其他人一样,我也会在很多的地方将Bruce书中所讲的建议应用到开发和调试无锁代码中,比如说Xbox360开发平台。
从那时候起,市面上出版了许多优秀的无锁编程材料,这些材料涉及从抽象的理论与可用性证明到实用的例子和硬件的细节。我在文章底部也列了一些参考文献。但是,我发现,很多无锁类型不是在所有地方都同时介绍的很清楚:比如说,一些材料中假设顺序一致性因而回避一些内存顺序的问题,而这些问题真是代表性的影响C/C++无锁代码的问题。而最新的C++ 11原子类型库标准则给我们带来了另一个难题,甚至改变了我们许多人对无锁算法的理解。
在这篇文章中,我将重新介绍来无锁编程。首先,我会对它进行定义,然后我会将无锁编程归纳成几个重要的概念,并利用流程图来展示这些概念的相互关系,然后一一对它们进行解释。在开始讲之前,我希望任何一个希望使用无锁编程的程序员都最好已经理解怎样使用互斥锁或其他高级的同步工具(比如信号量和消息队列)来编写正确的多线程代码。
什么是无锁编程
人们常常将无锁编程理解为没有互斥锁的编程方式。这是正确的,但是只说了一个方面。基于学术论文中普遍被接受的定义比这要全面一些。从它的本质来说,无锁编程是一种描述代码的特性,而不是说代码是如何编写出来的。
基本上,如果你的程序的满足下图的情况,则可以认为这段程序是无锁。相反,如果你的代码不满足这些情况,则说明这些代码不是无锁的。
这里举一个没有互斥锁操作的简单例子,这个例子没有互斥锁但是也不是无锁的。刚开始,x=0,。作为读者的一个练习,考虑一下两个线程将怎么来调度使得它们都不会退出这个循环
- while (X == 0)
- {
- X = 1 – X;
- }
没有人期望一个大的应用是完全无锁的。一般来说,我们只是在整个基础代码中指出一些特殊的无锁操作集合。比如,在一个无锁队列中,它可能会有一个无锁的操作,比如入队列、出队列、可能是否为空,等等。
Herlihy和Shavit,《多核编程艺术》的作者,倾向于把这些操作表示为方法 ,然后得出了关于无锁的如下简洁定义:在一个有限的执行中,一些方法的调用一定会有限的结束。换句话说,只要程序能够保持调用这些无锁的操作,结束方法的调用数量不管怎样都将保持增加。所以在进行那些操作时,系统从算法角度来说不可能把系统锁住。
无锁编程的一个重要结果是如果你阻塞了某一个线程,它将不会阻止其他线程的操作,因为它们有自己的无锁操作。这也暗示着在写中断处理函数和实时系统中无锁编程的重要特点是:不管程序处在什么样的状态,一定的任务一定能够在一定的时间范围内完成,。
最后纠正一个概念是:被设计来阻塞线程的操作并不会降低算法的质量。比如说,一个队列的出队列操作在队列为空时可能会倾向于阻塞,但是剩余的代码同样可以认为是无锁的。
无锁编程技术
如果你尝试在无锁编程中满足无阻塞情况,有一系列的技术可以实现,比如说:原子操作,内存屏障,避免ABA问题。这也是为什么事情会快速的变得复杂。
那这些技术是怎样相互联系的呢?为了展示它们的关系,我将它们都画在了下面这样流程图中。然后我将详尽的介绍下面的每一种技术。
原子RMW(Read-Modify-Write)操作
原子操作是一种不可分割的对内存进行操作的方式——没有线程可以得到一半完成的操作。在现代处理器中,许多线程已经是原子的了,比如说简单数据类型的读写操作一般都是原子的。
RMW操作更进一步的允许你去原子的进行更复杂的数据操作。如果一个无锁的算法需要支持多个写操作时,它们将显得更加的有用。因为当多个线程同时在同一个内存地址中尝试RMW操作时,它们将能很有效率的排成一队,并分时的分别执行这些操作。我已经在之前的博客中讲过RMW操作的应用,比如说轻量级互斥锁,递归互斥锁和轻量级的日志系统。
RMW操作的例子包括在Win32下的 _InterlockedIncrement、iOS下的 OSAtomicAdd32
和C++ 11下的
std::atomic<int>::fetch_add。值得注意的是在C++ 11中,原子操作标准不保证这个操作在所有平台下都是无锁的,所以最好能够知道了解你使用的平台是否符合。你可以通过调用 std::atomic<>::is_lock_free 来确认。
不同的CPU家族有不同的方式来支持RMW操作。像PowerPC和ARM这样的处理器有 load-link/store-conditional这样的操作,这将能有效的帮助你在底层实现自己的RMW操作,虽然它不是经常用到。同时,通用的RMW操作一般也是够用的。
就像在流程图中显示的那样,即使在单核系统中,原子的RMW操作也是无锁编程的一个必要组成部分。没有原子性,一个线程可以在中途被中断,而这可能会导致不一致的状态。
CAS循环
可能讨论最多的RMW操作就是CAS。在Win32中,CAS通过一系列的内联函数来提供,比如
InterlockedCompareExchange
。通常,程序员在一个循环中使用
CAS
来重复尝试一个操作。这个模型典型的操作包括:将一个共享变量复制到一个局部变量中,进行一些特殊的操作,然后尝试使用
CAS
来修改个共享变量:
- void LockFreeQueue::push(Node* newHead)
-
- {
- for (;;)
- {
- // 复制共享变量(m_Head) 到局部
- Node* oldHead = m_Head;
-
- // 做一些特殊的工作,这些工作不能被其他线程感知
- newHead->next = oldHead;
-
- // 然后尝试将我们的改动发送到共享变量中
- // 如果共享内存没有改变,则CAS成功,返回
- // 否则,重复
- if (_InterlockedCompareExchange(&m_Head, newHead, oldHead) == oldHead)
- return;
- }
- }
这些循环仍然被认为是无锁的,因为如果一个线程的测试失败了,这就意味着它一定在另一个线程中成功了——即使一些体系结构提供了一个差的CAS变体。当进行CAS循环时,特殊需要注意的地方是操作必须防止出现ABA问题。
顺序一致性
顺序一致性表示所有的线程都具有相同的内存操作顺序,同时这个顺序与程序源代码中的执行循序保持一致。在顺序一致性的前提下,之前博客中出现过的内存重新排序的问题就不会出现。
一个简单但不切实际的实现顺序一致性的方法是关掉编译器优化同时强制所有的线程跑在同一个核中。在一个核中,即使线程先得到或者随意的线程调度的情况下也不会出现内存乱序的情况。
一些编程语言即使在多核环境下也提供了包括顺序一致性的优化代码。在C++ 11中,你可以将共享的变量声明为C++ 11中有默认内存顺序限制的的原子类型。在JAVA中,你可以将共享变量指定为volatile类型。这里是用C++ 11的方式重写的之前博客的例子:
- std::atomic X(0), Y(0);
- int r1, r2;
-
- void thread1()
- {
- X.store(1);
- r1 = Y.load();
- }
- void thread2()
- {
- Y.store(1);
- r2 = X.load();
- }
因为C++ 11的原子类型保证了顺序一致性,所以结果不可能为r1=r2=0。为了实现这种情况,编译器必须增加一条额外的指令——内存屏障和RMW操作。相比于那些程序员直接处理内存的顺序来说,这些额外的指令让应用的执行效率更低。
内存顺序
就像像在流程图中所建议的,任何时候你使用多核做无锁编程时,你的环境都不能保证顺序一致性,所以你必须考虑如何防止内存乱序。
在今天的体系结构下,保证正确的内存顺序一般有三个方向,它们能同时防止编译器乱序和处理器乱序:
(1) 一个轻量级的同步或内存屏障指令,这个我将在以后的博客中解释;
(2) 一个全内存屏障指令,我之前博客已经描述了;
(3) 提供Acquire和release semantics的内存操作
Acquire semantics能够防止操作指令后面的内存操作乱序,而释放语义能够防止操作指令前面的内存操作乱序。这些操作尤其适合于出现生产者/消费者关系的情况,也就是一个线程修改一些信息而另一个线程获得信息。我将会在以后的博客中讨论这个问题。
不同的处理器有不同的内存模型
当出现内存乱序时,不用的CPU都有不同的习惯。规则是由每一个CPU供应商制定的,同时该CPU在硬件上严格的服从这个规则。比如,PowerPC和ARM的处理器自己可以改变和指令相关的内存存储的顺序,但是,通常X86/64系列的CPU从英特尔系列到AMD系列都不能实现。所以我们说前者的CPU拥有一个更加自由的内存模型。
这里有一些方案来抽象这些硬件平台相关的细节,尤其是C++ 11给我们一种标准的方法来写可移植的无锁代码。但是现在,我认为大多数的无锁程序员都至少有些还在意平台的不同。如果这里有一个最重要的平台不同点需要被记住的话,那一定是在X86/64的指令集中,每一次从内存中获得数据会产生一次Acquire semantic,每一次将数据存到内存会产生一次release semantics——至少对于无SSE的指令集和没有写操作结合的内存操作来说如此。所以,在以前的无锁代码可以很好的在X86/64中执行,但是在其他机器中则可能会失败的。
如果你对硬件怎么实现内存乱序和为什么要内存乱序有兴趣的话,我建议你看一下《并行编程难么?》这本书。而在其他情况下,注意内存乱序也会由于编译器将指令乱序而出现。
在过去,我很少讲无锁编程的实际应用层面:比如什么时候使用它?我们有多需要使用它?我也很少提到提高验证无锁代码有效性的重要性。然而,我希望读者们明白的是,这个介绍只是提供了一些基本的无锁编程的概念,所以你阅读一些其他读物来进行更加深入的研究。和往常一样,如果你有任何疑问,可以在下面写评论。