技术干货

LockFree编程简介(译)

2017-07-07  本文已影响0人  有效栈

无锁编程是一个挑战。不光是因为任务本身的复杂性,即使是理解这个话题本身也比较困难。

我比较幸运,我的第一个有关Lock—Free(或者可以称之为无锁)编程的介绍是 Bruce Dawson所著的一本优秀的和全面的白皮书:Lock-Free编程的思考以及类似于像许多这样的文章。值此,我开始按照Bruce的建议,在一台Xbox 360上开发和调试Lock-Free代码。

时至如今,已经出现了许多优秀的文章资料,从抽象的理论到实例证明的正确性和硬件细节。在文章的注脚部分,我会列出这些参考文章。有时,一篇文章的信息与其他的文章是正交的:比如一些文章假定了顺序一致性,从而回避了像瘟疫一样折磨着C/C++的Lock-Free编程的内存排序问题。新的C++11标准原子库提供了一种新的解法,向我们多数使用的人的Lock—Free算法提出了挑战。

在这篇文章中,我想要重新介绍无锁编程。首先是定义它,然后将它蒸馏分解成一系列关键的概念。

我会使用流程图来表示这些概念是如何与其他的概念相关联的。然后我们再尝试深入一下其中的细节。

任何一个编程人员在投入到无锁编程之前应该理解如何使用互斥量写出正确的多线程并发程序。或者使用其他的高级别的同步对象,比如信号量或者事件。这是最基本的要求。

人们经常将Lock-Free编程认为是没有互斥的编程,或者说没有的编程。这是对的,但是只是这个故事的一部分。在学术文献上通常被接收的概念是无锁是描述某些代码一个属性,但没有说太多关于这段代码是如何写成的。

基本上,如果你的部分代码满足以下的条件,就可以被认为是无锁的,相对的,如果你的代码不满足这些条件,那么这部部分就不是无锁的。

Markdown

在这种情况下,“无锁”中的“锁”并不直接是互斥量,而是看起来有能力以某种方式阻塞住应用程序,不论是锁本身是死锁还是活锁。甚至是由与你为敌的线程调度问题(made by your worst enemy)。最后一点可能看起来比较搞笑,但是却很重要。共享的互斥量只是简单的具有排他性,因为一但被一个线程获得,线程调度有可能永远不能够再调度该线程。当然,实际情况不会允许这个情况发生,我们只不过是定义这个术语。

这里有一个简单的例子,这个操作不包含基本的互斥,但仍然不是Lock-Free的。初始值X=0。作为读者们的一个练习,思考一下两个线程执行这段代码,是否没有一个线程能退出这个循环。


while (X == 0)
{
    X = 1 - X;
}

没有人期望一个大型的应用程序全都是无锁的。通常,我们在整个代码中确定一组特定的无锁操作。举个例子,一个Lock-Free queue,也只有少部分的无锁操作,比如push,pop,或者isEmpty。荷里希和(以)谢菲特,多处理器编程的艺术的作者,表述了在一个类方法中,提供了lock-free 如下简结的定义:
  “一个方法总能在有限次调用内完成。换言之,完成调用的数量不断增加,无论它是什么。”
  这些操作在系统中,在算法上是无法被阻塞的。

另外一个重要的概念就是如果你挂起了一个单独的线程,他不会阻止其他线程处理任务。总体来讲,每个线程有自己的Lock-Free操作。这就是暗示了Lock-Free编程的价值。当写一个中断处理和实时系统的时候,它可以确保在限定的时间内完成不管什么程序的其余部分处于什么状态。

最后一个说明:某些操作被设计为阻塞的并不意味是这就不是Lock-Free的。一个队列的的pop操作当队列为空的时估计被设置为阻塞,剩余的代码仍然被认为是lock-free的。

Lock-Free编程技巧

事实证明,当您试图在无锁编程中满足非阻塞条件时:原子操作,内存屏障,避免了ABA的问题,等等。这就是事情很快成为恶魔。(译者:Lock—free并非是无阻塞的,而是不使用互斥来达到“锁”的目的。)

所以我下面用了一个图来来描述了技术相关的关系,并且会在下面详细讲述。

Markdown

原子的读-改-写操作

原子操作(Atomic Operations)在操作内存时可以被看做是不可分割的(Indivisible),其他线程不会打断该操作,没有一个线程可以看到执行的中间状态。在现代的 CPU 处理器上,很多操作已经被设计为原子的,比如对齐读(Aligned Read)和对齐写(Aligned Write)等。

Read-Modify-Write更进一步。允许你使用更复杂的原子事务。这在支持多写者的情况下开发Lock-Free 算法尤其有用。因为当多线程试图读-改-写一个同样地址的时候,他们会有效地排成一列并且同时只有有一个操作。本文涉及到的读-改-写的操作,在轻量级互斥锁 递归锁轻量级日志系统中有体现。

;常见的RMW操作包括Win32的_InterlockedIncrement ,IOS的OSAtomicAdd32以及C++的std::atomic<int>::fetch_add 。不过C++的标准原子操作并不保证支持每一个平台,所以最好知道你的平台和工具链的是否满足这个特性,可以使用std::atomic<>::is_lock_free来确认。

不同的cpu家族以不同的方式支持RMW。
比如POWERPC和ARM通过公开load-link/store-conditional指令集,允许你在低级别的有效实现你自己的原始RMW。尽管并不经常这样做。通常的RMW已经足够。
  流程图说明了,原子的RWM操作在lock free编程中是非常必要的一部分,甚至是在单处理器中,一个线程在执行事务的过程中可能在半路被中断,有可能会导致状态的不一致。

Compare-And-Swap Loops

或许讨论最广泛的操作就是compare-and-swap (CAS)操作了,在 Win32 平台上,CAS 操作有一组原生的实现,例如 _InterlockedCompareExchange 等。对 RMW 操作最常见的讨论可能就是,使用 CAS Loops 来完成对事务的原子处理。

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;
    }
}

Sequential Consistency(顺序一致性)

顺序一致性意味着所有的线程都同意在内存操作时都按照顺序发生。
并且这个顺序是鱼程序代码的顺序有关的。
在顺序一致性下,不会出现如我在讲到的内存重排序问题。
  一个简单的(但明显不能用于实践)方式就是禁止编译器优化并且强制所有的线程在一个处理器下面运行。一个处理器绝不会看见自己的内存乱序,甚至是处理器在空闲的时间。一些编程语言提供了顺序一致性来阻止在多处理器器程序的代码优化。
  在 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是不可能发生的。为了达到这种效果,编译器输出了额外的指令-典型的内存栅栏或者RWM操作。

这种额外的指令导致了没有直接内存排序的程序高效。

内存重排序

如流程图上面所示,你的无锁程序在多核或者多处理器(或者对称处理器)上任何时间都不能完全的保证顺序一致性。你必须思考如何阻止内存排序

在如今的架构中,强制保证正确内存排序的工具主要有三个级别,来保证阻止编译器重排序处理器重排序

请求内存的操作语义阻止了按照程序的编写顺序之后内存重排序,释放内存阻止了其操作之前的重排序。这些程序非常适合生产者/消费者关系。我会在后续的文章讲到这一点

不同的处理器有不同的内存模型

当我们谈到内存排序的时候,不同的处理器有不同的内存模型。每个CPU供应商都会明确地写明规则并且严格按照硬件来编排。比如,PowerPC 和 ARM的处理器通过自身的指令改变内存屏障的顺序,但英特尔和AMDx86/64指令集则不会。我们说,更早的处理器拥有更宽松的内存模型
抽象出各个平台的细节是非常诱人的。尤其是C++11提供了我们标准的方式去方便地编写Lock-Free代码。但就目前来看,我认为大部分Lock-Free的程序员们还是需要了解一些不同平台的细节。记住,有一个关键区别,
X86/64指令级的每个从内存加载必须有有获取语义,每个从内存释放都必须有释放语义(否则内存重排序)——至少非SSE指令和非写联合内存是这样的,但是在其他处理器又不一样

如果你对处理器需要内存排序的硬件细节感兴趣,我推荐C语言附录的并行编程困难吗?请记住在任何情况下,由于编译器指令重新排序会导致内存重新排序也会发生。

在这篇博客中,我并没有说太多有关编程的实际运用的方面。比如:
什么时候我们要使用它?我们到底有多需要他?我也没有提及到验证你的无锁算法的重要性。尽管如此,我还是希望某些读者,这篇简要的介绍能够带来关于无锁基本概念的提高。所以你在进行额外的阅读,的时候不会感到太困惑。
如果你发现任何错误,那么请在评论中告知我吧!

参考资料

原文链接:
http://preshing.com/20120612/an-introduction-to-lock-free-programming/

上一篇下一篇

猜你喜欢

热点阅读