翻新并行程序设计的认知整理版(state of the art parallel)

近几年,业内对并行和并发积累了丰富的经验,有了较深刻的理解。但之前积累的大量教材,在当今的软硬件体系下,反而都成了负面教材。所以,有必要加强宣传,翻新大家的认知。

首先,天地倒悬,结论先行:当你需要并行时,优先考虑不需要线程间共享数据的设计,其次考虑共享Immutable的数据,最糟情况是共享Mutable数据。这个最糟选择,意味着最差的性能,最复杂啰嗦的代码逻辑,最容易出现难于重现的bug,以及不能测试预防的死锁可能性。在代码实现上,优先考虑高抽象级别的并行库(如C++11的future,PPL,OpenMP,OpenAcc,Intel的TBB,.NET的TPL,等等),最后考虑使用low-level的thread。同步机制上,优先考虑使用高级的并发集合类库(C++11,.NET和Java里都有并发集合类),其次考虑使用同步锁,最后如果你是精通并行并充分了解硬件的超级专家,那么考虑使用atom实现lock-free的同步。

一下子就已经涉及好多问题了。且听下贴分解……

首先,程序并不是按我们写的代码一行一行地执行的。程序会被编译成机器指令(废话)。由于机器指令和汇编指令基本一一对应,以下用汇编指令替代。编译器编译时,是会调整汇编指令顺序的。理由很简单,因为优化需要,调整后速度会快很多。而且不但可能调整顺序,还可能合并重复的指令序列,如公共子表达式合并优化。

而且,不只编译器会调整顺序,CPU也会。在Pentium那代处理器的时代,就已经出现了乱序执行(Out of Order)和分支预测。因为一个指令不会用到所有的计算单元,总会有些计算单元闲置浪费。为了充分利用,处理器会积极的获取后面的指令,并尽可能安排提前执行,以提高处理速度。其次,在多核处理器上,多层级的Cache也会导致处理核心观测到的内存数据变化时间发生变化,其效果可等价于指令顺序变化。

所以,实际执行的程序代码的顺序,根本不是我们写的顺序。

或曰:君其戏言欤?吾编程十数载,未曾见其乱也。

那是当然,因为这些顺序调整有个前提保证:不改变单线程执行时的程序含义。也就是说,对于单线程程序,程序行为会和你预期的一样。但是,这只限于单线程……

一个非常常见的错误类型:(伪码)

Thread A:

bool flag = false;

loop {

...

if (flag) doSomeThing();

...

}

Thread B:

...

flag = true;

...

幸运的是,这种错误的并行程序大多在“正确”地运行着,所以很多人并不认为有错。

在保证单线程执行效果不变的前提下,Thread A可以被编译成这样:

bool flag = false;

loop {

...

bool temp = flag;

...

if (temp) doSomeThing();

...

}

也可以成这样:

bool flag = false;

bool temp = flag;

loop {

...

if (temp) doSomeThing();

...

}

或者优化成这样(因为推断flag总是false):

loop {

...

...

}

好在这种情况万一出现了,立刻就能发现并行运行结果不对。但很多可能出问题的地方是比较隐晦的,可能百分之一、甚至百万分之一的概率下出错,便成了莫名其妙的bug,连续加班而不能有所斩获。

或曰:若吾之编译器未做此优化,则其无误乎?

不,它仍然不可靠。前面已经说过,CPU也会优化调整指令执行顺序,内存缓存架构也会导致等效的指令重排序效果。所以,是否出错,和机器有关,和CPU有关,和内存有关,和执行时的各种状态有关。

或曰:吾知矣。加volatile可解之矣。

方向对了,但不尽然。volatile在不同的编程语言里作用并不相同。对C、C++而言,标准只规定编译器不可缓存变量的值,而必须每次直接访问内存,而并没有对顺序的要求(这个在多核处理器出现之前是足够的)。而编译器的具体实现,多支持更严格一点的volatile。即使同一编译器,对不同的平台(x86/x64/ARM)的volatile提供的保证也可能不同,但至少保证标准的要求。如VC++对x86/x64平台,还额外保证volatile read不能被时间后移,volatile write不能被时间前移;但对ARM平台则没有。很多并不是很正确的程序却能正确运行,正是因为编译器大多提供了更多,然而不能假设这种额外奖励是可移植的。

.NET的volatile明确定义了其行为。除了不会被缓存外,还规定了volatile read不能被时间后移,volatile write不能被时间前移。即使是在ARM平台上也一样。当然,在ARM上支持这个保证是有代价的,必须使用代价相对较高的memory barrier指令以获得硬件上的顺序保证。

说道这里就又出来了个memory barrier的概念,也叫fence。其实就是用来人工指定顺序保证的机制。C++11最重要的部分之一就是明确定义了内存模型,引入了很多相关的函数来细粒度地控制顺序。有兴趣的可以自己研究。有鉴于过于难懂,不适合科普,这里就不讲了。

或曰:呜呼。并行必难如此哉?

非也。正因为lock-free机制非常复杂,所以才推荐使用同步锁。同步锁其实不仅仅是锁,它还提供了双向的顺序保证:锁的开始和结束是指令移动的硬性边界,任何指令移动都不能跨越这两条边界。所以,你将得到你所指定的顺序,这个是最强力的保证。

或曰:善。然则何以前文不推荐用锁?

因为会有性能损失。首先,我们都知道锁阻塞时会导致等待,而降低并行效果,这是锁的性能问题的主要来源。(一个次要的性能影响:如前所述,锁提供了双向顺序保证,这也意味着编译器不得不牺牲一些可能的优化。)为了提高效率,自然是锁的粒度越小越好。但是,问题并不如此简单。人们曾经认为并行可以如此简单,所以Java语言里从一开始就加入了synchronized关键字来修饰函数,来表示该函数自动被包含在一个锁里,C#最开始也提供了类似的支持。但如今,不断被强调的是,不要使用它们,那是个错误的设计。

为什么呢?因为它根本保证不了并行的正确性(还有很容易导致死锁的问题)。举例:

userB.borrow(100);

userA.loan(100);

虽然Borrow()和Load()两个函数都是synchronized,但两个函数调用中间是一个不完整交易状态,而其他线程有可能介入到这个不完整交易状态之中。

所以,为保证锁的正确性,锁必须包含一个完整的transaction。当要减小锁的粒度时,问题就变得愈发复杂,编程时就需要愈发小心,而且存在transaction这个粒度的下限。

另一方面,减小粒度的优化通常也需要增加锁的数量,以便减少阻塞的频率。但锁的数量增多,意味着死锁的风险大大增加。尤其是无法预测的死锁,比如:

lock (obj) {

...

CallAVirtualFunction();

...

}

在锁里调用虚函数、回调函数、事件等,都是非常危险的,因为它们可能含有任意未知的代码,可能导致直接或间接的锁重入,而引发死锁。尤其是对于Library API或支持plug时,你都无法预先测试。这个经验教训,是业内一些公司以很大的代价总结出来的。

以上所讲的锁的问题,本质上是因为同步锁没有composability。锁的效果不具有局部性,无法封装到局部,虽然锁的使用是你的函数内部的实现细节,但它的效果会leak到函数之外。在软件设计上,它破坏封装,导致耦合性。

或曰:吾知矣。若吾尽学其理,可自编线程而有最佳性能乎?

不推荐。自己管理线程未必能获得最佳性能,往往更差。

首先,线程创建是很expensive的,这个大家都知道。其次,线程切换是很expensive的,具体切换所花时间和OS相关,一般几十毫秒,这代价通常已经远比同步锁还大了。过多的线程会over-subscribe处理器,导致性能大幅变差,甚至比单线程执行还慢得多。再次,同一Core上的线程切换可能导致cache-trashing,因为内存远比CPU慢(差异可达到两个数量级),后果可想而知。这些优化都非常底层,要做得好需要对底层非常的熟悉,甚至需要系统内核的辅助。

其次,在高层设计方面,讲一下k-thread和n-thread。一般传统游戏引擎的并行,都是k-thread。也就是需要几个线程、每个线程干什么,都是程序固定写好了的。可能在4核CPU上性能最好,在8核上就浪费4个核,在单核上反而比单线程慢。就是说,没有scalability(规模扩展性)。这虽然是并行了,有收获,但肯定不是最佳性能。

n-thread是根据CPU核数,性能可以线性增长的设计。单核就一个线程,8核就8个,100核就100个。这才是理想的设计。当然,我这里已经省略了很多细节。比如4核加超线程的CPU,应该几个线程呢?比如有些线程处理IO,阻塞了,是不是要再多补几个线程呢。比如实时.NET程序是不是要给GC留点处理能力做concurrent垃圾回收呢?

所以,一般不推荐自己从线程开始实现自己的并行机制。

问曰:如此则毋用线程耶?

也不能这么绝对。虽然高级并行库一般够用,偶尔还是需要。一个比较实用的例子是Active Object Pattern。简单的说,就是一个worker thread,有一个任务队列,需要它干活就往队列里加任务,它会主动从任务队列里取得任务执行,并能保证任务按顺序执行。所以叫主动对象。它本身是k-thread的一种并行方式,因为能保证顺序,满足和很多传统应用的需要,是比较容易采用的机制。当然,其实n-thread的并行任务管理的底层,差不多也就是n个Active Object的线程池。

只要语言有Lambda或匿名函数的支持,这个Pattern可以重构成一个Utility class,专门来处理并行,而业务逻辑中不再需要考虑并行(伪码):

class Active {

Thread thread;

BlockingConcurrentQueue<Func> taskQueue;

public Active() {

thread.Start(threadMain);

}

public Shutdown() {

taskQueue.Add(null);

thread.Join();

}

public void Run(Func action) {

taskQueue.Add(action);

}

public Future<T> Run<T>(Func<T> func) {

Promise<T> promise = new Promise<T>();

Func action = lambda {

try {

promise.SetResult(func());

} catch (Exception ex) {

promise.SetException(ex);

}

};

Run(action);

return promise.Future;

}

private void ThreadMain() {

while ((func = taskQueue.Take()) != null)

func();

}

}

问曰:何为Future?何为Promise?

Future是一个提供“可能未来才能取得的结果”的对象,是异步程序会常用的对象,是只读的。Promise用于实现Future提供者一方,是相对底层一点的代码才会用到的对象,是Future的生产者,是只写的。.NET里使用的类名比较通俗,future叫Task,而promise叫TaskCompletionSource。试看:

C++ 11:

future<int> f = async(func);

...

int v = f.get();

C#:

Task<int> t = Task.Run(func);

...

int v = t.Result;

Future(这里指纯概念上的,并非指特定实现)是high-level并行、异步处理的基本构件。未来主流的异步API,将会都以future作为返回值。但目前C++ 11的future功能还非常有限,可以考虑boost或微软的ppl。

或问曰:如此则future尽能勘用乎?

也不能一概而论。future最合适的操作,是运行时间大约在1毫秒到30秒的函数。这里给出的时间只是方便理解的大约范围,不要当成规则。其原因是,如果函数本身执行时间太短,则future调度本身的开销相对总时间的比例会偏大,变得不是很划算;反之,如果执行时间过长,则会长期占用线程池里的线程,影响其他future的调度效率,以致线程池不得不分配新线程来补偿。有些future的实现,如.NET的Task,提供Hint选项,可以指示该任务会执行很长时间,系统会提供相应的底层优化,其实也就是为它创建一个专用的线程。

问曰:执行时间过短又若何?

用更经济的并行方法。比如Parallel For。几乎各种并行库都提供了这种支持,简单明了,且效率远比自己创建n个线程的效率高。

C#例:

Parallel.For(0, 10000, i =>

{

result[i] = Foo(i);

});

如上,其形式和传统for循环非常类似,不会增加复杂度,但能提高n倍速度。

通常,能应用Parallel for的代码都和集合操作相关,而集合操作,在支持列表推导式(List comprehension)或查询推导式(Query comprehension)的语言里,通常使用这些更便捷的语法来实现。如.NET的LINQ。这些代码可以被自动并行化(对函数式语言)或显示指定(对非函数式语言)并行化。

C#例:

var result = from r in records.AsParallel()

let t = Foo(r)

where t.Bar > 100

select t;

并行与否的唯一区别就是是否调用AsParallel()。当然,要并行,程序员要自己保证LINQ表达式里的操作没有side effect。

问曰:何为side effect?

简单解释的话,就是说,其中用到的所有变量,除了赋初值之外,没有任何其他的修改,也就是说都是immutable的。这是函数编程语言的基本特征之一。只要保证了这一点,无论怎么并行,都不会导致运行结果出错,所以非常适合编译器自动优化并行,这也是函数式语言在并行领域的先天优势。所以电信领域里,在高实时性和高并行性的高要求下,Erlang独领风骚,而不是C++。

或曰:苟能精学斯理,可得尽CPU之力也哉。

不,其实还远不及。当我们看到系统CPU占用率(或单核占用率)达到100%时,这个100%只是假象。即使完全不考虑并行,你的单线程程序并非100%地利用了CPU的性能。

问曰:汝欲言SIMD乎?

SIMD指令固然是提高性能之法。但我要说的,是更一般的情况。当今的CPU,速度远快于内存,其速度差异可以达到两个数量级。一条计算指令,无论是整形还是浮点,甚至是SIMD,都不过一个时钟周期,而一次cache miss,则可能数百时钟周期。能充分利用cache的程序,其实极少。(但cache miss导致的计算单元等待,并不会被报告为空闲。)所以,如今的low-level优化,更注重内存的使用和布局,而不像以前更关注指令的使用。

或曰:如此则不并行亦能提速哉?

没错。但此时更有并行的必要。试想,当cache miss时,计算单元傻等,不如做点别的。于是,Intel搞了个HyperThreading,就是常说的超线程技术。本质就是一个核提供两组执行状态的寄存器,也就是提供了两个硬件线程,当一个线程等待内存时,切换至另一个线程,以此掩盖内存延迟。

类似的,GPU上也有类似的设计。而且因为GPU和存储器的速度差异更大,一般一个计算单元要配四个硬件线程。

但是,超线程的实际效果很难说,有时可以提高速度,有时也会反而降低速度。为什么会降低呢,因为cache trashing。cache本来就是紧缺资源,分给两个线程共用,可能会导致互相竞争,而把对方需要的内存数据置换出去,导致内存等待反而增多。所以,无论如何,内存访问的优化总是非常重要的。

翻新并行程序设计的认知整理版(state of the art parallel),布布扣,bubuko.com

时间: 2024-10-08 22:11:08

翻新并行程序设计的认知整理版(state of the art parallel)的相关文章

赠书《JavaScript高级程序设计(第三版)》5本

本站微博上正在送书<JavaScript高级程序设计>走过路过的不要错过,参与方式,关注本站及简寻网+转发微博:http://weibo.com/1748018491/DoCtp6B8r 本站联合简寻网#寻找千里码# 正在送书<javascript高级程序设计>第三版 5本,对这本书期待的朋友可以去参与哦. 关于简寻网: 我们是一群年轻的创业者,我们关注互联网发展,追逐技术的进步.互联网时代的到来,我们希望能通过技术的手段解决生活中的问题.招聘行业是一个传统而又新兴的行业,传统的流

JavaScript高级程序设计(第三版)学习,第一次总结

Array类型 var arr = []; arr.length; //返回数组元素个数 改变length可以动态改变数组大小 检测数组 instanceof可以检测某个对象是否是数组,限制:只能是一个网页或一个全局作用域 Array.isArray(arr); //最终确定某个值到底是不是数组,没有限制 转换方法 arr.toString(); //返回由数组每个值的字符串形式拼接而成的以逗号分隔的字符串 arr.valueOf(); //与toString方法一致 arr.toLocalSt

个项目涉及到的50个Sql语句(整理版)

/*标题:一个项目涉及到的50个Sql语句(整理版)作者:爱新觉罗.毓华(十八年风雨,守得冰山雪莲花开)时间:2010-05-10地点:重庆航天职业学院说明:以下五十个语句都按照测试数据进行过测试,最好每次只单独运行一个语句.问题及描述:--1.学生表Student(S#,Sname,Sage,Ssex) --S# 学生编号,Sname 学生姓名,Sage 出生年月,Ssex 学生性别--2.课程表 Course(C#,Cname,T#) --C# --课程编号,Cname 课程名称,T# 教师

《VB语言程序设计(第3版)》总结

我之前因学习昆仑通态的组态软件MCGS,用并学习过VB,还买了一本书<VB语言程序设计(第3版)>.现在在某公司实习,最近接触老的项目,又要用到VB.我就又把那本书大体看了一遍,并对其进行了总结.之所以总结这个,主要是书太多了,想把那本书丢了,呵呵,但又得留下点东西吧. 下面一张图概括了VB的大部分基础知识点,看了这个图基本就不用看书了,哈哈. 我学习VB主要是在VB6.0的环境下学习的.下面介绍一下VB的一些基本语句. (1)声明语句 Dim score As Integer, temp A

OpenMP并行程序设计——for循环并行化详解

转载请声明出处http://blog.csdn.net/zhongkejingwang/article/details/40018735 在C/C++中使用OpenMP优化代码方便又简单,代码中需要并行处理的往往是一些比较耗时的for循环,所以重点介绍一下OpenMP中for循环的应用.个人感觉只要掌握了文中讲的这些就足够了,如果想要学习OpenMP可以到网上查查资料. 工欲善其事,必先利其器.如果还没有搭建好omp开发环境的可以看一下OpenMP并行程序设计--Eclipse开发环境的搭建 首

字符串HASH模板 取自挑战程序设计竞赛(第2版)

/*===================================================* 从b串中寻找和a串长度相同的子串,返回开始位置 不保证绝对正确,发生冲突概率为O(sqrt(n)), n为哈希函数的最大值 \*===================================================*/ #define ull unsigned long long const ull B = 1e8+7; /*according to the book*/

LAMP搭建--未整理版

[[email protected] ~]#yum search  关键字   //安装过程中提示少哪个程序就搜关键字找包名 [[email protected] httpd-2.2.25]# ./configure --prefix=/usr/local/httpd --enable-so --enable-rewrite --enable-cgi --enable-charrset-lite --enable-ssl [[email protected] ~]#useradd -M -s /

[转] AOJ 0525 Osenbei《挑战程序设计竞赛(第2版)》练习题答案

来自 码农场 ? AOJ 0525 Osenbei<挑战程序设计竞赛(第2版)>练习题答案 只把代码复制过来,原博的其他分析请看链接. 1 #include <iostream> 2 #include <bitset> 3 #include <algorithm> 4 5 using namespace std; 6 7 bitset<10000> cookie[10]; 8 9 ///////////////////////////SubMai

【CUDA并行程序设计系列(1)】GPU技术简介

http://www.cnblogs.com/5long/p/cuda-parallel-programming-1.html 本系列目录: [CUDA并行程序设计系列(1)]GPU技术简介 [CUDA并行程序设计系列(2)]CUDA简介及CUDA初步编程 [CUDA并行程序设计系列(3)]CUDA线程模型 [CUDA并行程序设计系列(4)]CUDA内存 [CUDA并行程序设计系列(5)]CUDA原子操作与同步 [CUDA并行程序设计系列(6)]CUDA流与多GPU 关于CUDA的一些学习资料