【专家坐堂】四种并发编程模型简介

概述

并发往往和并行一起被提及,但是我们应该明确的是“并发”不等同于“并行”

       并发 :同一时间 对待 多件事情 (逻辑层面)

       并行 :同一时间 (执行) 多件事情 (物理层面)

并发可以构造出一种问题解决方法,该方法能够被用于并行化,从而让原本只能串行处理的事务并行化,更好地发挥出当前多核CPU,分布式集群的能力。

但是,并发编程和人们正常的思维方式是不一样的,因此才有了各种编程模型的抽象来帮助我们更方便,更不容易出错的方式构建并发程序。下面将简单介绍一些常见的并发编程模型,希望能帮助大家对并发编程有更多的兴趣。这些模型都有各自的优势,需要根据应用场景挑选,而挑选的前提是能够深入地理解它们。

多线程编程模型

多线程模型是用于处理并发的最通用手段,在 C/C++/JAVA 等语言中广泛存在。主要特性有:

l  多个相互独立的执行流.

l  共享内存(状态).

l  抢占式的调度.

l  依赖锁,信号量等同步机制

多线程程序容易编写(因为写的是顺序程序),但是难分析,难调试,更容易出错,常见的有竞争条件,死锁,活锁,资源耗尽,优先级反转等等。

为了降低多线程模型编写难度,很多语言都一直在不断地引入并发编程方面新的特性,例如Java。从最早1996年的JDK1.0 版本起就已经有了ThreadRunnable类,确立了最基础的线程模型,这已经比直接调用POSIX接口构建多线程应用的方式有了很大的提高。然后在JDK5时引入了java.util.concurrent包,其中的线程池(Thread PoolExecutors)等类库,使得Java并发编程的易用性有了更好的提升。

到了JDK7 Fork/Join框架被引入,虽然底层一样是基于ExecutorService线程池的实现。但在编写并发逻辑时会比传统多线程方式更加直观,开发者可以将一个大的作业抽象为几个可以并发的子任务的结果整合;而每个子任务又可以继续按此逻辑继续划分,充分发挥现代多核CPU的性能。

同时,Fork/Join框架中还内置了Work-Stealing的任务调度机制,能够在尽量降低线程竞争的同时尝试自动均衡各工作线程之间的任务负载。如下图所示: 

Ø  4个线程每个都有独立的工作队列,避免单任务队列竞争

Ø  队列中的任务采用类似LIFO方式进出。由于整体作业都是按照一个大任务fork出多个子任务来抽象,因此可以视为越大粒度的任务会沉在队列的越底部。

Ø  当某个线程(示例中为线程D)的工作队列为空时,该线程就会自动尝试从另一个线程(示例中为线程A)的队列底部偷“一个任务过来执行。由于是从底部窃取的任务,可以假设这个任务将展开更多的子任务,从而减少窃取动作的产生,降低线程争用频率。

通过这些手段,Fork/Join框架能帮助开发者无需在考虑手动实现并发任务执行时的高效同步逻辑。

随后,JDK8中又引入了并行流(Parallel Streams)的概念, 该特性基于Fork/Join框架,但在易用性方面继续有所提升。并行流采用共享线程池的思路,从而连线程/线程池的配置逻辑都帮开发者简化了。当然,正是因为这个共享池( ForkJoinPool.commonPool() )是被JVM管理,同时被JVM内的所有线程共享,也导致了一些隐患,如果开发者并没有了解并行流的底层实现机制,则可能导致应用中利用到并行流的任务产生停滞现象。例如下面的代码示例:

由于 WS.url(url).get()会触发HTTP请求,因此执行到这一句代码时,线程池会被阻塞在IO操作上,结果导致了当前JVM中所有并行流中的任务全部被阻塞。

Callback编程模型

“回调”是一个很容易理解的名词。简单来说:某个函数(A)可以接受另一个函数(B)作为参数,在执行流程到某个点时作为参数的函数B就会被函数A调用执行,这个行为就被称为回调。

现实中,回调常常用于异步事件。即,函数A一般会在函数B没有被调用的情况下就先返回,而在某个异步事件发生时再触发调用函数B

但是滥用回调嵌套,就会导致著名的”callback hell”问题,代码难以阅读和维护。例如下面的片段:

为了避免此类大坑,我们可以参考以下几类解决方案:

l  Promises/A+规范: 它是一种用于管理异步回调的代码结构和流程,一种回调的语法糖。可以把原本嵌套的回调函数展平,使得代码逻辑更清楚。例如片段:

l  Generator: 生成器/半协程方式: 可以将一个函数执行暂停,并保存上下文, 将控制权交还给调用者;当再次被调用时,能够恢复当时的暂停状态继续执行。所以generator函数的行为表现和迭代器很类似,每次触发它的时候可以获取到新的结果,而不是像传统函数全部执行结束后一口气返回一系列值。 代码片段:

l  Async/Await:  可以视为Generator方式的语法糖,能够更好地展示异步调用的语义: async关键字用于表示该函数中有异步操作;await关键字表示需要等待(异步方式)后继表达式的结果。

Actor编程模型

Actor模型首先是由Carl Hewitt1973年提出定义, 随后由Erlang OTP (Open Telecom Platform) 推广开来。Actor属于并发组件模型, 通过组件方式定义并发编程范式的高级阶段,避免使用者直接接触多线程并发或线程池等基础概念,其消息传递更加符合面向对象的原始意图。

传统多数流行的语言并发是基于多线程之间的共享内存,使用同步机制来防止写争夺。而Actors使用消息模型,每个Actors在同一时间处理最多一个消息,可以发送消息给其他Actors,保证了单独写原则,从而巧妙避免了多线程的写争夺。

Actor模型不仅仅对于单机的并发应用开发有意义,对于分布式应用的开发也是一个可以大展手脚的场景: 节点之间互相独立,只能靠消息通讯,异步消息避免节点瓶颈等特性都非常贴合Actor模型的使用。

Actor模型的特点是:

l  万物皆是Actor

l  Actor之间完全独立,只允许消息传递,不允许其他任何共享

l  每个Actor最多同时只能进行一样工作

l  每个Actor都有一个专属的命名Mailbox(非匿名)

l  消息的传递是完全异步的;

l  消息是不可变的

Java中,可以利用Akka进行Actor编程模型的应用开发。Akka 将自身定义为一套用于构建JVM上高并发,容错式,分布式,消息驱动特性应用开发的工具包和运行环境。详细介绍可参见官网: http://akka.io/

下面用代码片段来展示下基于AKKA开发示例:

我们定义了两个Actor: HelloWorld Greeter.

l  HelloWorld会处理几个消息

n  启动消息(可以将preStart方法的调用视为收到一个专属启动事件的处理): 主动向Greeter(ActorRef可以视为对应Actor的专属Mailbox)发送一个Msg.GREET消息

n  Msg.Done消息: 接收完该消息后,停止当前Actor

n  其他消息: 调用unhandled() 处理

l  Greeter会处理这些消息:

n  Msg.GREET消息: System.out输出字符串, 并向消息的发送者回复一个Msg.Done消息

n  其他消息: 调用unhandled() 处理

HelloWorldGreeter可以根据需要实例化在多个线程中执行,编码过程中不需要考虑传统多线程中的Lock/Wait/Notify等同步手段就能让这两个Actor之间分别指示对方完成相应动作。


CSP编程模型

CSPCommunicating Sequential Processes)是由Tony Hoare1978的论文上首次提出的。 它是处理并发编程的一种设计模式或者模型,指导并发程序的设计,提供了一种并发程序可实践的组织方法或者设计范式。通过此方法,可以减少并发程序引入的其它缺点,减少和规避并发程序的常见缺点和bug,并且可以被数学理论所论证。

CSP将程序分成两种模块,Processor ChannelProcessor 代表了执行任务的顺序单元,它们内部没有并发,而Channel代表了并发流之间的信息交互,如共享数据的交换、修改、消息传递等等。

除了ChannelProcessor之间再无联系,这样就将并发同步作用缩小在Channel之处,使得问题得到了约束、集中。同步操作与争用并没有消失,只是聚焦在Channel之上。Processor之间的协作,Channel提供原语来支持,如Barrier等。

CSP 的好处是使得系统较为清晰,Processor 之间是解耦合的,职责也非常清楚,容易理解和维护。

l  工作者之间不直接进行通信

l  工作者向不同的通道中发布自己的消息(事件)。其他工作者们可以在这些通道上监听消息,发送者不知道具体谁在执行(匿名)

l  消息交互是同步方式

Java中对于CSP模型的实现库有JCSP。 同时在JDK中的SynchronousQueue,和CSP中的Channel有异曲同工之妙。Executors.newCachedThreadPool()中就利用到了SynchronousQueue,任务提交者是并不清楚底层哪个线程会处理提交的任务,并且当提交任务操作完成时必然已经有某个线程接受了该任务(并不代表线程开始执行),因此提交操作这次消息交互是同步的方式。这和Executors.newFixedThreadPool()之类创建的线程池是截然不同的,其他线程池在提交操作完成时,任务分配给线程这个动作是异步的。

此外,Go语言内置的goroutines & channels并发模型就是参考了CSP的思想,因此Go的并发编程强调不要利用共享内存来进行线程通讯,而应该依靠通讯来共享数据(Do not communicate by sharing memory; instead, share memory by communicating),尽量避免锁和线程争用。

参考资料

l  http://web.stanford.edu/~ouster/cgi-bin/papers/threads.pdf

l  https://en.wikipedia.org/wiki/Actor_model

l  https://en.wikipedia.org/wiki/Communicating_sequential_processes

l  https://talks.golang.org/2012/waza.slide#1

l  https://www.quora.com/What-are-the-differences-between-parallel-concurrent-and-asynchronous-programming

l  http://wiki.commonjs.org/wiki/Promises/A

l  http://www.ibm.com/developerworks/cn/java/j-csp1.html

l  http://blog.takipi.com/forkjoin-framework-vs-parallel-streams-vs-executorservice-the-ultimate-benchmark/

l  https://www.cs.kent.ac.uk/projects/ofa/jcsp/cpa2007-jcsp.pdf

l  http://tutorials.jenkov.com/java-concurrency/index.html

l  http://www.raychase.net/698

网易云新用户大礼包:https://www.163yun.com/gift

本文来自网易实践者社区,经作者邱晟授权发布。