深入探索并发编程系列(七)-内存屏障:资源控制操作

当你使用资源控制时, 那么你肯定在试图理解内存执行顺序。不管你是用C,C++还是其它语言,这都是在编写无锁(lock-free)代码时需要重点考虑的。

在上一篇文章中,我们介绍了编译期间的内存乱序,这一部分内容构成内存执行顺序问题的一部分。这篇文章讲述另一部分:处理器本身在运行期间的内存执行顺序。与编译器乱序一样,处理器乱序对于单线程来说也是不可见的。只有在使用无锁(lock-free)技术时-也就是说,当共享内存在线程之间不是互斥量时,乱序现象才会变得显露无疑。然而,与编译器乱序不同的是,处理器乱序只在多核和多处理器系统中才可见.

你可以使用任意能充当memory barrier的指令来确保执行正确的内存顺序。某种程度上来说,这是你需要了解的唯一技术,因为当你使用这类指令时,能自动处理好编译器执行顺序问题。充当memory barrier的指令包括(但不局限于)以下情况注1

  • GCC中的内联汇编指令,比如PowerPC平台下的 asm volatile(“lwsync” ::: “memory”)
  • 除Xbox 360平台以外,任意的Win32 Interlocked操作,
  • C++11原子类型操作,比如load(std::memory_order_acquire)
  • POSIX 互斥量操作,比如 pthread_mutex_lock

正因为有许多指令可以充当memory barrier,我们需要去了解许多不同类型的memory barrier. 实际上,上述的所有指令产生的memory barrier都属于不同类型,这会写lock-free代码时给你带来困惑。为了试图把事情说清楚,我会把那些有助于理解大部分(但不是全部)可能的memory barrier类型做一个类比。

首先,考虑一种典型的多核系统结构。双核,每个核上有32KiB的L1数据缓存,两个核之间有1MiB的L2共享缓存, 主内存512MiB注2.

多核系统就有点像是一群程序员通过一种怪异的资源控制策略来合作一个项目。举个例子,上面的双核系统对应两个程序员合作的场景。将这两个程序员分别取名为Larry与Sergey.

右边有个共享的中心仓库-代表主内存和共享L2缓存的结合体。Larry和Sergey在本地机器中都有仓库的工作副本,分别是每个CPU核中的L1缓存。每台机器上有个临时存储区,用来记录寄存器和本地变量的值。现在两个程序员坐在那里,富有激情的编辑他们的工作副本和临时存储区,根据他们看见的数据决定下一步该做什么–就像是一个线程就在那个CPU核上工作一样。

在这开始引入资源控制策略。在这个类比里,资源控制策略实际上是非常奇怪的。由于Larry和Sergey修改了仓库的工作副本,他们的修改在背后不断地从仓库中来回传播(leaking),这种情况发生的次数都是随机的。只要larry编辑文件X,对文件的修改都会泄露到仓库中,但不能保证什么时候才会发生。有可能会立即发生,有可能会发生很多次,也有可能之后才发生。这时,他可能会继续编辑其它的文件,比如Y和Z,这些改变可能会在X泄露之前就已经传播了。这样一来,写操作在写进仓库的过程中就很容易发生乱序。

类似的,在Sergey的机器上,不能保证那些修改从仓库到工作副本中来回传播的时间间隔和顺序。这样一来,从仓库读数据的过程中就很容易发生乱序。

现在,如果每个程序员分别独自工作在仓库的隔离区域,他们都不会意识到背后的传播过程,甚至不知道另一个程序员的存在。这就好比两个正在运行的独立的单线程。在这个例子中,内存执行顺序的基本原则还是能保证的。

当程序员开始在仓库的同一区域工作时,上述的类比就显得更加重要了。再来看看之前文章中的例子。 X和Y是全局变量,且都初始化为0.

把X和Y想象成是Larry的工作副本,Sergey的工作副本以及仓库本身中的文件。Larry将1写入工作副本中的X,sergey同时将1写进工作副本中的Y。 如果每个程序员在查询工作副本中的其它文件之前,两者的修改都能传回到仓库中,最后都会得到这个结果:r1 = 0 和 r2 = 0
。起初可能会觉得这种结果与直觉上相反,但对照下资源控制的类比方式,就觉得很明显了。

Memory Barrier类型

幸运的是,Larry和Sergey不完全是对这种随机性与在背后发生的不可预计的传播无能为力。他们能发出一些特殊指令,调用fence指令来充当memory barrier). 对于上述类比方式,可以定义四种memory barrier,因此也对应四种fence指令。每种memory barrier都是以阻止不同类型的内存乱序能力来命名,比如,StoreLoad用来阻止写读类型。

就像Doug指出的那样,这四种类型可能并不能很好的对应真实CPU中的特殊指令。大部分情况,一条CPU指令能充当上述几种memory barrier类型的组合,可能也会附带一些其它的效果。不论在什么情况下,只要你以资源控制的类比方式理解了这四种memory barrier,就容易理解真实CPU中的大多数的指令与一些高级编程语言的构造。

LoadLoad

一个LoadLoad barrier能有效地阻止在barrier之前执行的读与在barrier之后执行的读造成的乱序。

在我们的类比中,LoadLoad fence 指令基本等价于仓库的的pull操作。想象git pull, hg pull, p4 sync, svn update 或者cvs update, 所有操作都在仓库工作。如果本地的修改有任何的merge冲突,我们就说他们被随机的决议(resolved)。

要提醒你的是,不能保证LoadLoad会pull整个仓库的最近(或HEAD)的修订版本。只要HEAD版本至少是从中心仓库传播到本地机器的最新值,就能pull比HEAD版本更老的版本。

这听起来可能像一个较弱的保证,但仍然是一个完美的方案来阻止读取过时的数据。考虑一个经典的例子,Sergey检查共享flag来看Larry是不是发布了一些数据。如果flag为真,在读取发布的数据之前,Sergey发出一个LoadLoad barrier 。

1
2
3
4
5
if (IsPublished)                   // Load and check shared flag
{
LOADLOAD_FENCE(); // Prevent reordering of loads
return Value; // Load published value
}

显然,这个例子依赖于IsPublished标志位是否传播到了Sergey的工作副本。不用去关心这些是什么时候发生的。只要发现了传播的flag,它就发出一个LOadLoad fence来阻止读取Value的值(这个值比flag本身还要老).

StoreStore

一个StoreStore barrier能有效的阻止在barrier之前的写操作与在barrier之后的写操作之间的乱序。

在我们的类比中,StoreStore fence指令对应仓库的push操作。想象git push, hg push, p4 submit, svn commit or cvs commit 都发生在整个仓库中。

跟绕口令一样,假设StoreStore指令不是即时的,而是以异步的方式延后执行。因此,尽管Larry执行了StoreStore指令,我们对于他之前所有的写操作什么时候能再在仓库中出现不能做任何的假设。

这听起来可能也像是弱的保证,但是,已经足够来阻止Sergey收到Larry发布的任何过时的数据。 回到上面同样的例子, 这时Larry只需要发布一些数据到共享内存,发出一个 StoreStore barrier,然后将共享flag设置为true

1
2
3
Value = x;                         // Publish some data
STORESTORE_FENCE();
IsPublished = 1; // Set shared flag to indicate availability of data

再说一次,我们依赖从Larry的工作副本中传播到Sergey的Ispublished值. 一旦Sergey检测到,他相信自己看到了Value的正确值。有趣的是,在这种工作模式中,Value不用是原子类型,也可以是有许多元素的大结构体。

LoadStore

不像LoadLoad和StoreStore,就资源控制操作来看,LoadStore没有比较合适的类比。 理解LoadStore的最好方法很简单,就是考虑指令乱序。

想象Larry有一系列指令要执行。某些指令让他从自己的工作副本读取数据到寄存器中,以及某些指令从寄存器中写数据到工作副本中。Larry具备欺骗指令的能力,但只限于一些特殊场合。只要他遇到读操作时,他就会先检测读操作之后的任何写操作。如果写操作和当前的读操作完全不相关,他会先略过读操作,先进行写操作,结束后在进行读操作。在这种场景下,内存执行顺序的基本原则–绝不修改单程序的行为–仍然是遵守了的。

对于真实CPU,如果某些处理器中有一个缓存错过了读操作(紧接着缓存命中的写操作),这种指令乱序就可能会发生。为了理解这个类比,硬件细节并不重要。我们只当做Larry的工作很繁琐,而这正是他能创新的机会(这种机会很有限)。 不管他是否选择这么做都是完全不可预计的。幸运的是,阻止这种乱序类型的开销并不大。当Larry遇到一个LoadStore barrier, 他就能简单的避免barrier附近的乱序。

在我们的类比中,尽管在读写之间有个LoadLoad或者StoreStore barrier时,Larry执行这种类型的LoadStore乱序也是有效的。 然而,在真实的CPU中,充当LoadStore barrier的指令至少能充当其它两种类型的barrier。

StoreLoad

StoreLoad barrier能确保其它处理器遇到barrier之前执行所有的写操作,另外,barrier之后执行的所有读操作能收到最近能被barrier可见的值。 .换句话说,它能在barrier处理所有的读操作之前有效地阻止所有的写操作乱序,尊重顺序一致性多处理器执行那些操作的方式。

StoreLoad是唯一的。这是唯一的一种memory barrier类型可以阻止前文提到的这种结果:r1 = r2 = 0,这个例子我在前面的文章中提到过很多次了。

如果你一路仔细地读下来,可能会有个疑惑: StoreLoad和 StoreStore再紧接着一个LoadLoad有什么不一样呢?毕竟,StoreStore 将修改push到仓库中,然而LoadLoad 把远程的修改pull回来。然而,那两种barrier类型是不够的。记住,push操作可能会因为任意数量的指令而延迟, pull操作可能不会从HEAD版本中pull数据。 这也解释了为什么owerPC的lwsync指令–充当所有LoadLoad, LoadStore和StoreStore memory barrier,但不是StoreLoad–是不足够来阻止例子中的r1 = r2 = 0 这种结果的。

拿类比来说,StoreLoad barrier能通过push所有局部修改到仓库中来实现,等待那个操作完成,然后pull 仓库中绝对的、最近的主干修订版本。在大部分处理器上,充当StoreLoad barrier的指令比充当其它类型barrier的指令开销更大。

如果在那个操作中放置一个LoadStore barrier,也没什么大不了的, 之后我们得到的是一个完整的memory fence–一次性充当所有四种barrier 类型。 正如Doug指出的那样,在目前所有处理器上都是这样,每个充当StoreLoad barrier的指令也充当完整的memory fence.

类比给你带来了什么?

正如我之前提到的那样,在处理内存执行顺序时,每个处理器都有不同的特点。具体来说,在x86/64家族中有一种强内存模型。这是为了让内存乱序的发生降到最低。PowerPC和ARM有着弱的内存模型。Alpha因自成一派而出名。幸运的是,这篇文章的类比对应一种弱的内存模型。 如果你能用心对待它,并使用这里提供的fence指令来实施正确的内存执行顺序,你就能处理大部分的CPU。

这种类比同样对应针对C++11和C11的抽象机器。因此,如果你使用那些语言的标准库写无锁(lock-free)代码,同时将上述的类比记在脑里,就更有可能在任何平台下正确执行。

在这种类比中,我说过,每个程序员代表在一个核心中正在运行的单线程。在一个真实的操作系统中,线程更倾向在生命周期中在不同的核心里移动,这时上述的类比仍然有效。我也在机器语言和C/C++语言不断变换来举例。显然,我们更倾向使用C/C++,或者另外更高级的语言。这是有可能的,因为任何充当memory barrier的操作也能阻止编译器乱序。

我还没有写关于每种memory barrier类型的文章。例如,也存在数据依赖(data dependency )barriers注3。 我会在以后的文章中讲这些内容。然而,上面给出的四种类型仍是最重要的。

如果你对CPU在底层是如何工作的很感兴趣–像写缓存,缓存一致性协议(cache coherency protocol)以及硬件实施细节相关的东西–以及它们为什么会发生内存乱序注4,我推荐Paul McKenney & David Howell的工作。 实际上,我认为能成功写好无锁(lock-free)代码的大部分程序员都至少都对这种硬件细节有点熟悉。

译者注

注1:gcc+x86下,编译器级别的memory barrier和CPU级别的memory barrier可以如下实现:

1
2
define COMPILER_BARRIER() __asm__ __volatile__("" : : : "memory")
define CPU_BARRIER() __sync_synchronize

其中,CPU_BARRIER()可以防止CPU写读、写写、读写、读读乱序。如你所知,CPU级别的memory barrier同时约束CPU和编译器的乱序;而编译器级别的memory barrier只约束编译器的乱序,不影响CPU。

注2:现在用的很多的cpu一般有三级cache。

注3:为了演示data dependency barrier,考虑以下例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
初始化
int A = 1;
int B = 2;
int C = 3;
int *P = &A;
int *Q = &B;
//cpu1
B = 4;
CPU_BARRIER();
P = &B;
//cpu2
Q = P;
D = *Q

从直觉上说,Q最后要么等于&A,要么等于&B。也就是说:

Q == &A, D == 1

或者

Q == &B, D == 4

但是,让人吃惊的是,在某些CPU体系结构例如DEC Alpha下,可能出现

Q == &B, D == 2的情形,原因是,虽然CPU1按照顺序执行完两条语句先后对B和P进行了修改,但CPU2可能先感知到P的变化,读到了P的新值,然后才是B的新值。因此,为了防止这样的问题,需要加一个data dependency barrier。

注4:为什么CPU乱序只在多核多线程下才可能会暴露出问题?为什么X86体系结构的Intel CPU要对写读进行乱序?

要明白这两个问题,我们首先得知道cache coherency,也就是所谓的cache一致性。

在现代计算机里,一般包含至少三种角色:cpu、cache、内存。一般说来,内存只有一个;CPU Core有多个;cache有多级,cache的基本块单位是cacheline,大小一般是64B-256B。

每个cpu core有自己的私有的cache(有一级cache是共享的,如文中所示),而cache只是内存的副本。那么这就带来一个问题:如何保证每个cpu core中的cache是一致的?

在广泛使用的cache一致性协议即MESI协议中,cacheline有四种状态:Modified、Exclusive、Shared、Invalid,分别表示修改、独占、共享、无效。

当某个cpu core写一个内存变量时,往往是(先)只修改cache,那么这就会导致不一致。为了保证一致,需要先把其他core的对应的cacheline都invalid掉,给其他core们发送invalid消息,然后等待它们的response。

这个过程是耗时的,需要执行写变量的core等待,阻塞了它后面的操作。为了解决这个问题,cpu core往往有自己专属的store buffer。

等待其他core给它response的时候,就可以先写store buffer,然后继续后面的读操作,对外表现就是写读乱序。

因为写操作是写到store buffer中的,而store buffer是私有的,对其他core是透明的,core1无法访问core2的store buffer。因此其他core读不到这样的修改。

这就是大概的原理。MESI协议非常复杂,背后的技术也很有意思。

Acknowledgement

本文由 Diting0x睡眼惺忪的小叶先森 共同完成,在原文的基础上添加了许多精华注释,帮助大家理解。

感谢好友小伙伴-小伙伴儿 阅读了初稿,并给出宝贵的意见。

原文:http://preshing.com/20120710/memory-barriers-are-like-source-control-operations/

本文遵守Attribution-NonCommercial-NoDerivatives 4.0 International License (CC BY-NC-ND 4.0)
仅为学习使用,未经博主同意,请勿转载
本系列文章已经获得了原作者preshing的授权。版权归原作者和本网站共同所有

攒点碎银娶媳妇