翻新并行程序设计的认知整理版(state of the art parallel)
首先,天地倒悬,结论先行:当你须要并行时。优先考虑不须要线程间共享数据的设计。其次考虑共享Immutable的数据。最糟情况是共享Mutable数据。
这个最糟选择,意味着最差的性能,最复杂啰嗦的代码逻辑,最easy出现难于重现的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语言里从一開始就添加了synchronizedkeyword来修饰函数。来表示该函数自己主动被包括在一个锁里,C#最開始也提供了相似的支持。但现在,不断被强调的是。不要使用它们。那是个错误的设计。
为什么呢?由于它根本保证不了并行的正确性(还有非常easy导致死锁的问题)。举例:
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的一种并行方式。由于能保证顺序,满足和非常多传统应用的须要,是比較easy採用的机制。当然,事实上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本来就是紧缺资源。分给两个线程共用,可能会导致互相竞争,而把对方须要的内存数据置换出去,导致内存等待反而增多。所以,不管怎样。内存訪问的优化总是非常重要的。