原文地址:http://preshing.com/20120612/an-introduction-to-lock-free-programming
无锁编程是一项挑战,不仅仅是因为自身的复杂性所致,还与初次探索该课题的困难性相关。
我很幸运,我第一次介绍无锁(lock-free,也称为lockless)编程,是BruceDawson的出色而全面的白皮书《无锁编程注意事项》。和大多数人一样,我有机会将Bruce的建议用到无锁代码的编写和调试中,例如在Xbox360平台上的开发。
从那时起,写下了很多好的素材,包括抽象的理论、实例的正确性的证明以及一些硬件的细节。在脚注中会有一些引用。有时,来自一个源中的信息可能会和其它源是正交的。例如,一些材料假设了顺序一致性,这就回避了困扰c/c++无锁代码的内存排序问题。新的C++11的原子库标准提供了新的工具,这会挑战现有的无锁算法。
在本文中,我会重新介绍无锁编程,首先对其进行定义,然后从众多信息提炼出少数重要的概念。我会使用流程图来展示各个概念间的关系,然后我们将涉足一些细节问题。任何学习无锁编程的程序员,都应该能理解如何在多线程代码中使用互斥量,理解一些高级同步机制,例如信号和事件等。
无锁编程是什么
人们常常将无锁编程描述成不使用互斥量(一种锁的机制)的程序。这是事实,但又完全是。被广泛接受的基于学术报告的定义含有更广义的含义。从本质上来说,无锁编程是描述一些代码的一个属性,它并没有过多描述代码是如何写的。
基本上,你的代码的一些部分符合如下条件,即可被认为是无锁的。反之,如果你的代码一些部分不符合下述条件,则被认为不是无锁的。
在该场景中,“无锁”中的“锁”并不是直接指互斥量,而是指“锁定”整个应用的所有可能的方式,不论是死锁、活锁甚至可能是线程调度的方式都是你最大的敌人。最后一点听起来似乎很好笑,却是至关重要的一点。首先,共享互斥量被排除了,因为一旦一个线程获取了互斥量,你最大的敌人将永远无法再次调度那个线程。当然,真实的操作系统不是那样做的,只是我们是如此定义的。
下面这个简单的例子没有使用互斥量,但仍然不是无锁的。一开始,X=0.作为一个给读者的练习题,请考虑如何调度两个线程,才能使得两个都不退出循环?
while (X == 0) { X = 1 - X; }
没人期待整个大型的应用是完全无锁的。通常,我们可以从整个代码库中识别出一系列无锁操作。例如,在一个无锁队列中有少许的无锁操作,像push、pop,或许还有isEmpty等等。
《多处理器编程艺术》的作者Herlihy和Shavit,趋向于将这些操作表达成类的方法,并提出了一个简单的无锁的定义(PPT第150页):“在一个无限的执行过程中,会不停地有调用结束”。换句话说,程序能不断地调用这些无锁操作的同时,许多调用的也是不断地在完成。从算法上来说,在这些操作的执行过程中,系统锁定是不可能的。无锁编程的一个重要的特性就是,如果挂起一个单独的线程,不会阻碍其它线程执行。作为一组线程,他们使用特定的无锁操作来完成这个特性。这揭示了无锁编程在中断处理程序和实时系统方面的价值。因为在这些情况下,特定的操作必须在特定的时间内完成,不论程序的状态如何。
最后的精确描述:设计用于阻塞的操作不会是无锁算法失去其资格。例如在无锁队列的操作中,当队列为空时,队列的弹出操作会有意地阻塞。但其它的代码路径仍然被认为是无锁的。
无锁编程技术
事实证明,当你试图满足无锁编程的无阻塞条件时,会出现一系列技术:原子操作、内存屏障、避免ABA问题,仅列举几例。从这里开始,事情很快变得棘手了。
那么,这些技术间是如何相互关联的?我将它们放在下面的流程图中进行展示。下文将逐一阐述。
原子的Read-Modify-Write操作
所谓原子操作是指,通过一种看起来不可分割的方式来操作内存:线程无法看到原子操作的中间过程。在现代的处理器上,有很多操作本身就是的原子的。例如,对简单类型的对齐的读和写通常就是原子的。
Read-Modify-Write(RMW)操作更进一步,它允许你按照原子的方式,操作更复杂的事务。当一个无锁的算法必须支持多个写时原子操作会尤其有用,因为多个线程试图在同一个地址上进行RMW时,它们会按“一次一个”的方式排队执行这些操作。我已经在我的博客中涉及了RMW操作了,如实现轻量级互斥量、递归互斥量和轻量级日志系统。
RMW操作的例子包括:Win32上的_InterlockedIncrement
,iOS
上的OSAtomicAdd32
以及C++11
中的std::atomic
::fetch_add
。需要注意的是,C++11
的原子标准不保证其在每个平台上的实现都是无锁的,因此最好要清楚你的平台和工具链的能力。你可以调用std::atomic<>::is_lock_free
来确认一下。
不同的CPU
系列支持RMW
的方式也是不同的。例如,PowerPC
和ARM
提供load-link/store-conditional
指令,这实际上是允许你实现你自定义的底层RMW
操作。常用的RMW
操作就已经足够了。
正如上面流程图所表述那样,即使在单处理器系统上,原子的RMW
操作也是无锁编程的必要部分。没有原子性的话,一个线程的事务会被中途打断,这可能会导致一个错误的状态。
Compare-And-Swap
循环
或许,最常讨论的RMW
操作是compare-and-swap(CAS)
。在Win32
上,CAS
是通过如_InterlockedCompareExchange
等一系列指令来提供的。通常,程序员会在一个事务中使用Compare-And-Swap
循环。这个模式通常包括:复制一个共享的变量至本地变量,做一些特定的工作(改动),再试图使用CAS
发布这些改动。
void LockFreeQueue::push(Node* newHead)
{
for (;;)
{
// Copy a shared variable (m_Head) to a local.
Node* oldHead = m_Head;
// Do some speculative work, not yet visible to other threads.
newHead->next = oldHead;
// Next, attempt to publish our changes to the shared variable.
// If the shared variable hasn't changed, the CAS succeeds and we return.
// Otherwise, repeat.
if (_InterlockedCompareExchange(&m_Head, newHead, oldHead) == oldHead)
return;
}
}
这样的循环仍然有资格作为无锁的
,因为如果一个线程检测失败,意味着有其它线程成功—尽管某些架构提供一个较弱的
CAS
变种。无论何时实现一个
CAS
循环,都必须十分小心地避免
ABA
问题。
顺序一致性
顺序一致性意味着,所有线程就内存操作的顺序达成一致。这个顺序是和操作在程序源代码中的顺序是一致的。在顺序一致性的要求下,像我之前演示的那样的有意的内存重排序不再可能了。
达到顺序一致性,一个简单的(但显然不切合实际的)方式就是禁用编译器优化并强制你的所有线程在单个处理器上运行。一个处理器不会看到自己的内存效果失序,
有些编程语言,即使是在多处理器环境上运行优化过的代码也能提供顺序一致性。在C++11中,你可以具有默认内存保序特性的atomic类型变量。在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中atomic类型具有顺序一致性,因此r1=r2=0的结果是不可能的。为了达到此目标,编译器添加了额外的指令,通常是内存屏障或RMW操作。相对于让程序员直接处理内存保序的情况,这些额外的指令或许会使得程序效率降低。
内存保序
正如前面流程图所建议的那样,任何时候做多核(或者任何对称多处理器)的无锁编程,如果你的环境不能保证顺序一致性,你都必须考虑如何来防止内存重新排序。
在目前的架构中,增强内存保序性的工具通常分为三类,防止编译器重新排序和处理器重新排序:
1、一个轻型的同步或屏障指令,以后会详述;
2、一个完全的内存屏障指令,如之前所述;
3、内存操作提供获取或释放语义。
获取语义防止它之后的操作被重新排序,按编程顺序进行;释放语义防止之前的操作被重新排序。这些语义非常适合生产者-消费者关系,一个线程发布一些信息,另一个线程读取。之后会详细论述。
不同的处理器有不同的内存模型
在内存重新排序方面,不同的CPU族有不同的方式。每个CPU厂商都会在其文档中描述这些规则,相应的硬件严格遵守。例如,PowerPC和ARM处理器能自行修改存储在内存上的相关指令的顺序,而x86/64族的处理器(Intel和AMD)则不这么做。我们说前者有更宽松的内存模型。
有个模板来抽象这些平台特有的细节,尤其是C++11为我们提供了一个标准的方式来编写可移植的无锁代码。但现在,我认为大多数无锁编程者都至少要有一些平台差异方面的认识。如果只有一个重要的差异要记住的话,那就是,在x86/64指令级别,每个从内存加载(的操作)都带有获取语义,每个存入内存(的操作)都带有释放语义。至少对于non-SSE指令和non-write-combined内存是这样的。因此,过去常常发现在x86/64平台上可以工作的无锁代码,在其他处理器上却无法正常工作。
如果你对硬件如何处理内存重新排序的细节感兴趣,我建议看看附录C“Is Pararllel Programming Hard”。在任何情况下,请记住,内存重新排序也可能是因为编译器指令重新排序。
本文中,我没有叙述很多实践方面的无锁编程,例如:何时做?实际需要多少?我也没有提及验证你的无锁算法的重要性。我希望本文的介绍能为一些读者提供一个关于无锁概念的基本认识,这样你在进行进一步阅读时,就不必感到太奇怪。像往常一样,如果你发现任何不准确之处,请告之。
今天的文章无锁编程介绍_c++无锁编程分享到此就结束了,感谢您的阅读,如果确实帮到您,您可以动动手指转发给其他人。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/58648.html