Lock-Free编程简介——An Introduction to Lock-Free Programming

原文地址:https://preshing.com/20120612/an-introduction-to-lock-free-programming/

Lock-free编程很有挑战性,这不光是因为(实现)『Lock-free编程』的复杂性(就很高),更麻烦的是:一开始就明晰『Lock-free编程』这一课题,这件事很有难度。幸运的是我一开始是从Bruce Dawson完美且专业的白皮书接触『Lock-free编程』(也叫lockless编程):Lockless编程思考。和很多人一样,我也有幸将Bruce先生的建议运用到Xbox 360这样的平台上的lock-free代码的实际开发、调试。从那以后,很多从抽象理论、正确性证明到实用示例、硬件细节的优秀资料问世。 我会在文末列出相关参考。。有时候一份资料中的信息可能和另外的资料看上去正交的(相违背):例如,一些资料假设sequential consistency(顺序一致性),因此回避了折磨lock-free C/C++代码的memory ordering issues(内存乱序问题) 新的C++11原子库标准向这些作品抛出难题,挑战我们中一些人的 lock-free算法。

本文中,我会重新介绍lock-free编程,首先是重新定义lock-free编程,然后提取主要信息并归纳为一些关键概念。我将通过流程图展示这些概念是如何彼此关联的,然后我将简单介绍一些细节。至少,任何想深入探索lock-free编程的应该已经知道如何用互斥量(mutexes)或者其他高级同步对象如信号量、事件来编写正确的多线程代码。本质上,lock-free是一种用来描述一些代码的特征,并不需要过多讲(关注)这些代码是如何编写出来的细节。

Lock-free编程是什么

人们通常把lock-free编程描述成不使用互斥量(也叫锁)的编程。这么说是对的,但是这只是lock-free的一部分。普遍被接受的、基于学术文献的定义则更丰富一些。

基本上,如果你的代码中的一部分满足以下条件,我们就可以认为这部分代码是lock-free的。相反地,如果你代码中的一部分不满足这些条件,那这部分就不是lock-free。

从这个意义上讲,lock-free中的锁并不是直接指互斥量,而是以某种方式让整个程序锁起的可能性,无论是因为死锁、活锁——甚至可以是因为你憎恶的敌人假定的线程调度决策。最后一点听起来很有趣,但确是关键。共享互斥量被排除的原因是,一旦线程持有互斥量,你最憎恶的敌人会简单的选择不再调起这个线程。当然,真实的操作系统不是这样运转的,我们纯粹是定义一些术语。

下面有一个简单的例子,这段代码不持有互斥量,但它也不是lock-free的。一开始X=0。这里给读者留一个练习:如何调度两个执行下面代码的线程,让他们都不能退出循环。

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

 一个简单的参考答案

没有人期待一个大的应用(可以做到)完全的lock-free。通常,我们会从代码库中识别出一些特定的lock-free操作的代码段。比如,在一个lock-free队列中,应该有几个lock-free操作像push、pup,或许还有isEmpty等等。


The Art of Multiprocessor Programming的作者Herlihy & Shavit倾向于用类的成员函数来表达这些操作,并简练地定义lock-free(见slide 150):『在无限的执行中,通常一些函数调用无限地完成』。换句话说,只要程序可以不断的调用这些lock-free操作,无论因为什么原因,完成的调用数量不断增加。在这这些操作执行期间,系统在算法上不可能锁起。

Lock-free编程的一个重要结果就是:如果你挂起一个线程,这不会让其他线程通过调用lock-free操作而来的有效执行(make progress)总体来看受到影响(而不能继续有效执行)。(吐槽下,老外的从句,真的很难搞)。这暗示了lock-free编程的价值,如在写中断处理模块和实时系统这些无论其他程序处于何种状态都必须在指定时间内完成的特定任务时。

最后明确:设计为阻塞的操作并不会影响算法被认为是lock-less。例如,一个队列的出队操作可能在队列为空时故意阻塞线程。剩下的代码路径(其他线程)仍然可以是lock-free的。

Lock-Free 编程技术

当你尝试满足lock-free编程的非阻塞条件时,往往会出现一堆相关技术:原子操作,内存屏障,避免ABA问题等等。这个时候事情会很快变得很残忍。

所以,这些技术是如何和其他的相互关联的?为了说明他们的关系,我将他们都画进了下面这张图里。我会在之后逐一说明。

原子Read-Modify-Write操作

 原子操作是一些以不可分割的方式操作内存的操作:没有线程可以感知(观测)到(已开始但)未完成的原子操作。在现代的处理器中,很多操作已经是原子的了。例如,对齐(aligned)读写简单类型数据通常是原子的(没有内存对齐的数据,该数据的读取不具有原子性)。

Read-modify-write (RMW)操作更进一步,允许原子地实现更复杂的事务。当一个lock-free算法必须支持多个写入方时,RMW尤为重要。因为当多个线程试图对同一个地址进行RMW,他们将有效地排成一行,然后一次一个地执行这些操作。我已经在本博客中触及了RMW,如实现一个lightweight mutex,一个recursive mutex 和一个lightweight logging system

RMW操作的例子包括WIN32平台的 _InterlockedIncrement ,IOS平台的OSAtomicAdd32,以及C++11中的std::atomic<int>::fetch_add。注意C++11原子标准不保证其实现在所有平台上都是lock-free的,因此,你最好要了解你使用的平台的能力和工具链。你可以通过调用std::atomic<>::is_lock_free来确认这一点。

不同CPU系列对RMW的支持不尽相同。PowerPC、ARM等处理器提供load-link/store-conditional指令,有效地让你可以在一个比较低的层面实现自己原生的RMW,虽然这些一般都已经被实现了(你不用自己实现)。通用的RMW操作通常就足够了。

如流程图所示,原子的RMWs是lock-free编程的一个必须组成,即使在单处理器系统中也是如此。没有原子机制的话,一个线程可能在一个事务的中途被打断,这很可能导致产生不一致状态。

Compare-And-Swap循环

或许讨论最多的RMW操作就是compare-and-swap (CAS)。在Win32平台,CAS通过一系列内在函数如 _InterlockedCompareExchange来实现。通常,程序员通过在一个循环中进行CAS操作来不断尝试进入一个事务。这种模式通常包括:复制一个共享变量到一个本地变量,进行一些投机操作(speculative work,不知道咋翻),然后通过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;
    }
}

这种循环仍然可以认为是lock-free的,因为如果这种试探操作在一个线程中失败,那表示其他某个线程的相同的试探已经成功了-即使一些架构提供的是 CAS的一种弱化变体 :那(哪个?)不必一定为true。不论何时,实现一个CAS循环时必须要进行特殊处理,避免出现ABA问题 。

顺序一致性Sequencial Consistency

顺序一致性指所有线程对内存操作发生的顺序达成一致,同时这个顺序和操作在源码中的顺序一致。在顺序一致性这个前提下就不会遇到内存指令重排的坑,作者另外的这篇文章有讲述:参考

实现顺序一致性的一个简单(但是明显不切实际)的方式就是:禁用编译器优化同时强制自己的线程在同一个处理器上运行。一个处理器不会看到自己的内存乱序带来的影响,即使多个线程都预先清空( pre-empted)并且在任意时间被调度。

一些编程语言为编译优化过的并且在多处理器环境中执行的代码也提供顺序一致性。在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 atomic类型保证了顺序一致性,r1==r2==0这种结果不可能出现。为了实现顺序一致性,编译器偷摸输出了额外的的指令:通常是内存屏障(memory fences)和/或 RMW操作。这些额外的指令可能导致程序的执行效率比直接处理内存指令重排的低一些。

内存定序Memory Ordering

如流程图所示,任何时候你要在多核(或者其他任何 对称对处理器)环境进行lock-free编程,同时你的环境不保证顺序一致性,你一定要思考如何防止存储指令重排memory reordering

在如今的架构中,实现正确的内存访问顺序的工具通常有3类,他们都可以同时防止编译器重新排序(compiler reordering)和 处理器重新排序(processor reordering):

  • 一个轻量同步或者屏障指令,作者在future posts中有讨论;
  • 一套完整的全内存屏障指令,作者在 demonstrated previously中有讨论;
  • 提供acquire or release 语义的内存指令.

Acquire 语义防止操作的内存指令重排,使操作执行顺序按照代码的顺序进行,release语义防止在它之前的操作发生内存指令重排。这两个语义特别适合带有生产者/消费者关系的场景,其中一个线程发布一些信息,剩余线程去读这些信息。Acquire and Release Semantics 【】这里有对此的进一步讨论。

不同处理器使用不同内存模型Different Processors Have Different Memory Models

在对待内存指令重排时 不同的CPU系列有不同的特性 。CPU供应商会给出文档说明它的硬件遵循的具体规则。比如,PoweerPC和ARM处理器会改变和指令相关的内存存储顺序(PowerPC and ARM processors can change the order of memory stores relative to the instructions themselves),但通常Intel和AMD的x86/64系列处理器就不会这样(改变存储顺序) 我们认为前者的处理有一个更宽松存储模型relaxed memory model.

通过抽象摆脱这种平台相关细节很具诱惑,尤其C++11提供了一个写可移植的lock-free代码的标准方式。但是,我认为大多数lock-free程序员多少有一些得益于平台差异。如果要记住一个关键的差异,那就是x86/64指令级别,每次从内存加载数据带有acquire 语义,每次写内存操作都提供release 语义,至少对于non-SSE指令和non-write-combined内存是如此。因此,以前在x86/64平台写lock-free代码很常见, 但是对于其他处理器则不然

如果你对硬件如何以及为何进行内存指令重排的细节感兴趣,作者推荐 Is Parallel Programming Hard的附录C。任何时候,记住内存指令重排也可能因为编译器对指令重新排序而发生。

本文中作者没有谈太多lock-free编程的实践,如:我们何时进行lock-free编程?我们真正需要多少lock-free编程?作者也没有提及验证lock-free算法的重要性。但是作者希望这篇介绍可以让一些读者对lock-free的概念有一个基本的认识,因此你继续看附加阅读时不会感到困惑。

As usual, if you spot any inaccuracies, let me know in the comments.

[This article was featured in Issue #29 of Hacker Monthly.]

参考

   

  没有英汉互译结果
  请尝试网页搜索

 

 

 

posted @ 2019-01-04 19:40  hujichen  阅读(85)  评论(0)    收藏  举报