原文出自: preshing.com/20120612/an…
人们经常认为无锁编程就是在编写代码的过程中,不使用互斥体。这是正确的,但是这仅仅是整个无锁编程中的一部分。从本质上讲,无锁表示的是代码的某种属性,而不是过分强调代码应该如何编写。
简单地说,如果你的代码满足下面的情况,那么该部分就可以认为是无锁。相反,如果你的代码不满足下列情况,这部分就不是无锁。
从这个意义上讲,无锁中的锁并不是直接指mutexes,而是指以某种方式"锁定"整个应用程序的可能性,无论是死锁、活锁——甚至是线程调度都可能成为你最大的敌人。最后一点听起来很有趣,但这就是关键。据此,共享的互斥锁很容易地从无锁编程中排除,因为一旦一个线程获得了mutex,线程调用就有可能不再会调度其他线程。当然,真实的操作系统不会这么做。
这里给出一个不包含互斥体但仍不是无锁的操作。初始情况下,X = 0。作为给读者的练习,考虑两个线程如何被调度,使得两个线程都不退出循环。
while(X == 0)
{
X = 1 - X;
}
没有人期望大型应用程序完全无锁。通常我们在代码只需要一组特定的无锁操作。例如,在一个无锁队列中,可能会有一些无锁操作,例如push、pop,isEmpty等等。
The Art of Multiprocessor Programming的作者Herlihy & Shavit,倾向于将此类操作表示为类方法,并提供以下简洁的无锁定义:“In an infinite execution, infinitely often some method call finishes." 换句话说,只要程序保持调用这些无锁操作,同时完成调用的数量会不断增加。在这些操作期间,系统在算法上是不可能被锁定的。
无锁编程的一个重要结果是,如果你暂停了一个线程,它永远不会阻止其他线程作为一个组,通过它们自己的无锁操作取得进展。这暗示了在编写中断处理程序和实时系统时,无锁编程的价值,其中某些任务必须在一定的时间限制内完成,而不管程序的其余部分处于什么状态。
最后,被设计为阻塞的操作不会成为判断当前代码是否是无锁的标准。例如,当队列为空时,队列的弹出操作可能会故意阻塞。剩余的代码路径仍然可以被认为是无锁的。
无锁编程的技术
事实证明,当您尝试满足无锁编程的非阻塞条件时,会出现一系列技术:原子操作、内存屏障、避免ABA问题等等。这就使得问题变得很困难。
那么这些技术是如何相互关联呢?为了说明,我整理了一下流程图。
Atomic Read-Modify-Write操作
原子操作是以不可分割的方式操纵内存的操作:没有线程可以观察到操作的中间状态。在现代处理器上,很多操作早已是原子的。例如,简单类型的对齐读写通常是原子的。
Read-modify-write(RMW)操作更进一步,允许您原子地执行更复杂的操作。当无锁算法必须支持多个writers时,它们尤其适用,因为当多个线程尝试对同一地址执行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也是无锁编程的必要部分。如果没有原子性,线程可能在事务的中途被中断,从而导致不一致的状态。
Compare-And-Swap Loops
最经常被讨论的RMW操作是compare-and-swap(CAS)。在Win32,CAS通过一系列内置函数来进行实现,例如_InterlockedCompareExchange。通常,程序员在循环中不断执行compare-and-swap指令以达到重复尝试事务的目的。该模式的一个典型例子,拷贝一个共享变量到局部变量,执行一些具有风险性的工作,并且使用CAS去发布这些改变。
void LockFreeQueue::push(Node* newHead)
{
for(;;)
{
// Copy a shared variable to a local.
Node* oldHead = m_Head;
// Do some speculative work, not yet visible to other threads.
newHead->next = oldHead;
if(_InterlockedCompareExchange(&m_Head, newHead, oldHead) == oldHead)
return;
}
}
这样的循环仍然符合无锁的条件,因为如果一个线程的测试失败,这意味着它一定在另一个线程上成功——尽管一些架构提供一个较弱的CAS变体(使得结果不一定是正确的)。无论何时实现一个CAS Loop,特别需要注意避免ABA问题。
顺序一致性
顺序一致性是指所有线程对内存操作发生的顺序一致,并且该顺序与程序源代码中的操作顺序一致。在顺序一致性下,不可能像我上一篇文章中展示的那样体验内存重新排序的恶作剧。
实现顺序一致性的一种简单(但显然不切实际)的做法是禁用编译器优化并强制所有线程在单个处理器上运行。在这种情况下,即使线程被强占并且在任意时间发生调度,处理器也不会看到内存中乱序的影响。
一些编程语言甚至为在多处理器环境中运行的优化代码提供顺序一致性。在C++11中,您可以将所有共享变量声明为具有默认内存排序约束的C++11原子类型。在Java中,您可以将所有共享变量标记为volatile,这是我上一篇文章中的示例,用C++11重写:
std::atomic<int> 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操作。与程序员直接处理内存排序的指令相比,这些附加指令可能使得效率降低。
内存顺序
正如流程图所示,任何时候您为多核(或任何对称多处理器)进行无锁编程,并且您的环境不能保证顺序一致性,您必须考虑如何防止内存重新排序。
在当今的架构中,强制执行正确内存排序的工具通常分为三类(避免编译重排和处理器重排):
- 轻量级的同步或屏障指令,将在未来讨论;
- 一个完整的屏障指令,在之前的文章中已经演示过了;
- 提供获取,释放语义的内存操作。
获取语义防止在程序顺序中跟随它操作的内存重新排序,并且释放语义防止它之前操作的内存重新排序。这些语义特别适用于存在生产者/消费者关系的情况,其中一个线程发布一些信息,另一个线程读取它。这部分在后续的帖子中会进一步讨论。
不同的处理器有不同的内存模型
在内存重新排序方面,不同的CPU有着不同的习惯。这些规则由每个CPU供应商记录并由硬件严格遵守。例如,PowerPC和ARM处理器可以相对于指令本身更改内存存储的顺序,但通常Intel和AMD的x86/64系列处理器不会。我们说之前的处理器有一个更加relaxed memory model。
为了针对不同的平台进行抽象,C++11提供了一种编写可移植无锁代码的标准方法。但是目前,我认为大多数无锁程序员需要对不同平台间差异有一定的了解。如果有一个关键的区别需要记住,那就是在x86/64指令级别上,每次从内存中加载都伴随获取语义,并且每次存储到内存中伴随着释放语义——至少对于非SSE指令和非写合并内存。因此,过去编写可在x86/64上运行,但在其他处理器上失败的无锁代码时很常见的。
如果您对处理器如何以及为何执行内存重新排序的硬件细节感兴趣,我建议您阅读一下Is Parallel Programming Hard的附录C。在任何情况下,请记住,由于编译器对指令的重新排序,也会发生内存重新排序。