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

posted @ 2018-12-02 21:44  ldxsuanfa  阅读(126)  评论(0编辑  收藏  举报