更新时间: 2020-03-31 14:07:01       分类: 学习笔记


背景

由于接触过的语言比较多,各种语言之间对并发任务的处理方式不尽相同,时常会令人迷惑。这次尝试系统的整理和总结一下常见的并发调度方式和编程模型。

使用的线程数

《并发之痛》中认为,并发编程最难解决的问题是,究竟要创建多少个线程(这里指内核线程)才最合适。不同的语言对于这个问题的理解和解决方案都不同,但大致可以从用户线程和内核线程的比值上做一个分类

用户线程:内核线程 = 1:1

参考下图:

一个使用编程语言创建的线程完整的映射到一个内核线程上去,Java就是这种实现。这么做线程的调度将主要依赖于操作系统本身的调度,如果创建的线程比较多,可能会因为上下文的切换问题导致性能低下。

这么做的好处在于,开发人员相当于直接在控制内核线程。通过合理的编程(如使用线程池),理论上可以最大限度的利用CPU性能。

缺点则是线程的创建和销毁成本比较高,同时编程模型也相对复杂一些,不好掌握。

用户线程:内核线程 = M:N

即是说,用户线程可以映射到任意一个内核线程上去。这种方式最为灵活,但编程语言本身的调度器实现难度会比较复杂一些,而且开发人员在开发时也就无法再直接去调度内核线程了。Go应该是目前为止实现M:N映射的语言中性能最好的一个。

Go的映射方案如下图:M为系统线程,P为调度器,G为routine也就是用户线程。

当内核线程数量 = CPU核心数的时候,这种映射模型可以很轻松的使程序的CPU利用率达到一个很高的水平。

优势就是用户线程可以变的很轻量,创建销毁和调度的成本都很低,大部分情况下也不必要刻意去维护线程池来提升性能了,编程难度降低了不少。

至于缺点就仁者见仁智者见智了,Go的调度器性能并不是最优的,也不能保证所有场景下的高效率,遇到这类场合可能还要在代码上下一番功夫。

用户线程:内核线程 = N:1

如下图

严格意义上这种映射不算完整的并发,因为无法完全利用多核的性能优势,而且任务也不能实现并行。

在脚本语言中用的比较多,现在最出名的应该就是JS了,而且偏偏使用了单线程的node性能还不差,因此现在也有不少人推崇这套方案。

编程模型上基本全部需要异步来实现,如果对异步编程方法了解的不够透彻写起来可能会比较头疼。

线程间通信的方法

引入多线程后,线程和线程之间的资源竞争和执行顺序依赖都成为令人头疼的问题,这时就必须让线程之间可以互相通信才能解决,而通信机制的编程模型不同,也会很大程度上影响程序员编写并发代码时的思路。

Lock共享资源

最基本的一种解决方式,就是通过多个线程同时读写某个变量的方法来实现。这么做很直观,不过如果锁使用不当,很有可能会造成严重的性能损耗,更不要说死锁这类的情况了。

由于非常常见,所以不加以赘述了,主要重点放在下面两种方式上:

CSP线程通信

传统的锁机制并不好驾驭,这时候CSP(Communicating Sequential Process)出现了,它的原则是“通过通信来共享内存,而不是用共享内存来通信”。

因此CSP模型里把通信方式抽象成了**管道(channel)**,所有线程间的通信都依赖于管道的读写来实现。

相比于单纯的内存锁,管道对内存的读写者分别提供了各自的等待队列,并且封装了调度的逻辑,这样就简化了多线程通信的复杂度,只要合理使用channel,就可以解决相当一部分的问题。

而go还为channel添加了缓存空间,使得channel的通信方式从单一的同步阻塞,变得可以在一定数量上支持异步。

CSP最大的亮点在于只关心消息的传输方式,而把消息的发送者和读写者给解耦了,这背后蕴含的逻辑是“读写者非常关心自己的数据有没有被处理”,如果不是这样的场景,可能使用CSP模型带来的改观就不是很大了。

Actor模型

这个模型相对来说比较新颖一些。目前已知的是Scala中有较大规模的应用。

和CSP不同,Actor把重点放在消息的处理单元而不是消息的传输方式上,发送者和读写者都必须事先知道对方是谁,才能够进行消息的收发。

由于没有完整的使用过Actor模型,在这里引用一下别人总结的对于Actor模型的优势:

相关参考:并发之痛


评论

还没有评论