Promising 2.0:宽松内存并发中的全局优化

本文由 Hana 根据论文解读视频整理所得,如有错误欢迎指正!

原视频内容较长,已经分上下两部分发布 B 站,欢迎点开学习!

论文解读 | Promising 2.0:宽松内存并发中的全局优化(上)_哔哩哔哩_bilibili论文解读 | Promising 2.0:宽松内存并发中的全局优化(上)https://www.bilibili.com/video/BV1Fq4y1V73D论文解读 | Promising 2.0:宽松内存并发中的全局优化(下)_哔哩哔哩_bilibili论文解读 | Promising 2.0:宽松内存并发中的全局优化(下)https://www.bilibili.com/video/BV1eF411e7f2分享嘉宾介绍:

冯新宇 博士 现任中央软件院编程语言实验室主任,编程语言首席专家,主导通用编程语言的设计和实现。加入华为前,先后于中国科学技术大学和南京大学担任教授、博士生导师,主要研究方向是程序设计语言理论和程序验证。


# 论文简介 #

内存模型是编程语言的语义定义的重要构成部分,是开发者和编译器构建者之间的重要接口。然而,由于处理器和编译器优化导致的并发程序的弱内存模型,一直没有很好的定义。

当前的业界共识是,Java 和 C++ 当前的内存模型都有严重的问题,特别是 C++ 的内存模型会允许 out of thin air 行为,导致程序的行为难以理解和推理。

本篇论文 [1] 是试图解决 C11 内存模型问题的一系列论文中非常重要的一篇, 采用了一种虚拟机模型,给出一个 C11 模型的改进方案,解决目前面临的各种问题。

本次分享更多的是给大家介绍一下内存模型的背景和相关领域的现状,并针对内存模型 1.0 说明一下存在的问题和 2.0 的大致方案。因为时间原因,我这里借用了作者们之前的分享材料,以及我自己之前的教学讲义,可能有些杂乱,大家包涵哈~

# 内存模型与弱内存模型 #

# 为什么需要内存模型?

先来介绍一下 内存模型 Memory Model,我们为什么关心内存模型,或者说内存模型是讲什么的?

如下图示,我们用 C1 || C2 表示一个并发程序,用 C1 和 C2 分别表示两个线程。那么,这个并发程序要执行的话,需要经过编译器(广义上的编译器,比如对 Java 来说 bytecode 是在虚拟机上运行的),再往下就是在硬件层上运行,实际上就是最基本的 读写内存 的行为,最后得到我们想要的结果。以上是一个并发程序真正的执行过程。

并发程序的执行过程

那么,作为普通的开发者,面对这样一个程序,我们关心什么?

我肯定不可能知道编译器的每一个细节,也不可能知道底层硬件的每个细节,但是我一定想知道这样一个程序写在这里,它最后的运行结果是什么,我们管这个叫 程序语义。这个问题最关键的地方就是程序里的 内存读写,我们要能够回答出最终在结果里应该 哪一个读 看到 哪一个写。这就是内存模型要回答的问题。

# 从 Sequentially Consistent Model 讲起

Leslie Lamport 在 1979 年 [2] 提出一种理解并发程序执行的内存模型,即 Sequentially Consistent Model(顺序一致性模型),如下图所示。

Sequentially Consistent (SC) Model [2]

我们可以将这个模型当做有多个处理器或者多个任务 ... ,每个任务你可以认为是在串行执行,即每次按照串行执行的顺序,往下执行下一条指令。当任务涉及到读写内存时,在任何时刻我们只能选其中一个任务,让其执行一个内存操作,这个就是 Sequentially Consistent (SC) Model,也是一个最简单的并发模型。

我们一般学并发的时候,可能老师教的往往都是这种模型。但在实际当中这种模型有些过于理想化了。我们如果一定要保证程序遵循这种模型的话,很多的优化就做不到了。

比如有下面这样一个例子,其中 x 和 y 可以看做是内存,r1 和 r2 是寄存器:

  • 在开始的时候,假设 x 和 y 为 0 x = y = 0;

  • 在第一个线程中,我们要往 x 里写 1 x = 1,然后 r1 读 y 的值 r1 = y;

  • 在第二个线程中,我们往 y 里写 1 y = 1,然后 r2 读 x 的值 r2 = x。

问题是,有没有可能 r1 和 r2 两个寄存器读到的值都为 0?

## r1=r2=0?

如果我们认为这个程序是交替执行的话,即在任何时刻选一个任务让他去执行,而且每个任务当成串行从前往后执行的话,这是不可能的。

至少有一个寄存器读到的一定是 1。

比如说 r1 如果读到 0(即 成立),就表示 r1 读 y(r1 = y)的时候,y 的写(y = 1)还没做,即 r1 的读发生在 y 写 1 之前。那么如果是这样的话, x = 1 发生在读 y(r1 = y)之前,因此这个时候 x 写 1(x = 1)已经做了。因此,这个顺序必然是,先执行 x 写 1(x = 1),然后再读 y(r1 = y),这个时候 y 还是 0;然后等这边的任务做完之后 y 再写 1(y = 1);然后 r2 再读 x(r2 = x),此时 x 必然已经是 1 了(即 ),不可能是 0。

类似地,如果 r2 读到的值是 0 的话,那么你可以用类似的办法推出来 y 必须得是 1。如果按照 SC Model, 是不可能成立的。

其实上面提到的推断方法已经被用在很多领域了。大家如果学并发或者操作系统的话,应该了解 Dekker's 算法 [3](即 实现互斥算法)实际上就是这么做的:当两个线程实现互斥,那我就让两边的代码块各自进入临界区。

回到原来的例子。第一个线程里,x 写 1(x = 1)表示 x 想进入临界区;第二个线程里,y 写 1(y = 1)表示自己也要进入临界区了;第一个线程中 r1 读 y(r1 = y)表示也要进入临界区了。这时,我们需要看一下第二个线程是不是想进临界区?如果 r2 是 0 的话,即没有要求进临界区,那第一个线程就可以继续执行下去。因为我们知道 r1 与 r2 不可能同时是 0,也就是说两个线程不可能同时进入临界区,因此我们能做到互斥。这个模型实际上已被用来实现 Dekker's Algorithm。

## r1=r2=0

实际上你会发现,真正的编译器编译之后,我们的实际效果是完全是有可能出现 r1=r2=0 的。之所以能导致这个结果,是什么原因呢?

编译器可能会发现这两个线程之间实际上 没有数据依赖关系(两个线程分别是访问 x,和访问 y),那么编译器可以根据某种需要,颠倒程序的执行顺序。因为编译器会认为每个任务都是串行程序,对于串行程序来说,如果没有数据依赖关系,那么颠倒顺序是不会影响最后结果的,因此编译器会做顺序重排的优化。

颠倒顺序执行后(如下图示),你会发现,r1 读 y 就会读到 0(当前 y 初始为 0),另一边线程里 y 写 1,然后 r2 读 x 读到 0(x 初始为 0),然后再 x 写 1。这种情况下,r1=r2=0 就成立了。

甚至这种结果的产生都不需要依赖于编译器。实际上在 x86 下面这个结果已经会出现了(x86 的内存模型相比 SC Model 会弱一些)。如果再考虑到编译器中间的各种优化,比如 Java 或者 C/C++,都有可能会产生这种行为。

这告诉我们什么呢?

其实,在编译器和硬件实现上,我们都会做各种各样的优化。这些优化叠加起来,就会导致可能实际的行为与最开始讲的 SC Model 不符。反过来讲,如果我们非要求程序执行的结果一定满足 SC Model 的话,那么我们现在很多编译器和处理器里的优化就不能像现在这么做了。随之可能会带来的结果就是,我们的程序性能就不会像现在这么好了。

这就是为什么说 SC Model 是一个比较理想化的内存模型,而真正我们程序展示出来的行为可能比我们 SC Model 要允许的行为还要多很多。这就是内存模型要回答的问题,我们管这个叫 弱内存模型 Weak Memory Model

弱内存模型相对于 SC Model 来说,它会比 SC Model 展示出更多的行为来。如果说我们必须得接受现在编译器和处理器做了各种优化的话,那当我们需要理解前面的程序时,我们就需要理解弱内存模型下面能产生的各种行为。

# 以 C/C++11 和 LLVM 为例说明

我从 Viktor Vafeiadis 的 Weak Memory Concurrency in C/C++11 and LLVM 讲义 [4] 上摘过来比较有意思的例子。(BTW,Viktor 是今天讲的 Promising Semantics 的主要作者之一,德国 MPI-SWS 软件研究所的研究员,曾是华为德累斯顿研究所顾问。)

我们看一下,下面两个例子是不是同时都能被允许?(例子中的 a 和 b 都可以看做是处理器的局部变量(或者可以看做是寄存器),x 和 y 是内存,我们关心内存模型实际上就是关心对 x 和 y 的访问。)

## 1# 在获取锁的过程中进行 CSE

CSE 即 公共子表达式消除 Common Subexpression Elimination [5]。

如下图示,我们在加锁 lock() 之前对 x 进行了读 a = x,在加锁之后再次读取 x b = x。在这个过程中,我们是否可以做这样一个优化,鉴于我们并没有在加锁前后对 x 进行修改,那么在临界区里我可以直接让 b 读 a 的值 b = a(这里做了程序变换),这样就可以减少一次内存读的操作。

你可能会觉得,并发程序怎么可以这么优化呢,这过程中有可能任务会被别人打断,两次对 x 的读有可能会读到不一样的值,怎么能直接优化为读 a 的值呢?

我们讲这个优化实际上是合理的。之所以这么说,是因为若存在被其他任务打断和修改 x 的值的情况,表示你的程序是有 数据竞争(data race) 的。对于 C/C++ 来说

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值