翻新并行程序设计的认知整理版(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本来就是紧缺资源。分给两个线程共用,可能会导致互相竞争,而把对方须要的内存数据置换出去,导致内存等待反而增多。所以,不管怎样,内存訪问的优化总是非常重要的。

原文地址:https://www.cnblogs.com/mqxnongmin/p/10548904.html