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

已标记关键词 清除标记
相关推荐
©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页