无锁编程介绍

原文地址: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;
}

没人期待整个大型的应用是完全无锁的。通常,我们可以从整个代码库中识别出一系列无锁操作。例如,在一个无锁队列中有少许的无锁操作,像pushpop,或许还有isEmpty等等。

《多处理器编程艺术》的作者HerlihyShavit,趋向于将这些操作表达成类的方法,并提出了一个简单的无锁的定义PPT150):“在一个无限的执行过程中,会不停地有调用结束”。换句话说,程序能不断地调用这些无锁操作的同时,许多调用的也是不断地在完成。从算法上来说,在这些操作的执行过程中,系统锁定是不可能的。无锁编程的一个重要的特性就是,如果挂起一个单独的线程,不会阻碍其它线程执行。作为一组线程,他们使用特定的无锁操作来完成这个特性。这揭示了无锁编程在中断处理程序和实时系统方面的价值。因为在这些情况下,特定的操作必须在特定的时间内完成,不论程序的状态如何。



最后的精确描述:设计用于阻塞的操作不会是无锁算法失去其资格。例如在无锁队列的操作中,当队列为空时,队列的弹出操作会有意地阻塞。但其它的代码路径仍然被认为是无锁的。

无锁编程技术

事实证明,当你试图满足无锁编程的无阻塞条件时,会出现一系列技术:原子操作、内存屏障、避免ABA问题,仅列举几例。从这里开始,事情很快变得棘手了。

那么,这些技术间是如何相互关联的?我将它们放在下面的流程图中进行展示。下文将逐一阐述。



原子的Read-Modify-Write操作

所谓原子操作是指,通过一种看起来不可分割的方式来操作内存:线程无法看到原子操作的中间过程。在现代的处理器上,有很多操作本身就是的原子的。例如,对简单类型的对齐的读和写通常就是原子的。

Read-Modify-WriteRMW)操作更进一步,它允许你按照原子的方式,操作更复杂的事务。当一个无锁的算法必须支持多个写时原子操作会尤其有用,因为多个线程试图在同一个地址上进行RMW时,它们会按“一次一个”的方式排队执行这些操作。我已经在我的博客中涉及了RMW操作了,如实现轻量级互斥量递归互斥量轻量级日志系统

RMW操作的例子包括:Win32上的_InterlockedIncrementiOS上的OSAtomicAdd32以及C++11中的std::atomic<int>::fetch_add。需要注意的是,C++11的原子标准不保证其在每个平台上的实现都是无锁的,因此最好要清楚你的平台和工具链的能力。你可以调用std::atomic<>::is_lock_free来确认一下。

不同的CPU系列支持RMW的方式也是不同的。例如,PowerPCARM提供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”。在任何情况下,请记住,内存重新排序也可能是因为编译器指令重新排序。
    本文中,我没有叙述很多实践方面的无锁编程,例如:何时做?实际需要多少?我也没有提及验证你的无锁算法的重要性。我希望本文的介绍能为一些读者提供一个关于无锁概念的基本认识,这样你在进行进一步阅读时,就不必感到太奇怪。像往常一样,如果你发现任何不准确之处,请告之。

  • 3
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值