并发系统可以使用不同的并发模型去实现。一个并发模型指定着线程在系统协作中是如何完成被给与的任务。不同的并发模型使用不同的方式分解工作,以及线程之间可能用不同的方式沟通和协作。这个并发模型教程将会深入的讲解在我写的时候的使用的最广泛的并发模型。
并发模型和分布式系统类似
在这个文字中讲到的并发模型跟在分布式系统中使用的不同的框架是类似的。在一个并发系统中不同的线程之间互相沟通。在一个分布式系统中不同的进程进行交流(可能是不同的计算机)。本质上线程和进程是非常相似的。这个就是为什么不同的并发模型经常看起来跟不同的分布式框架相似的原因。
当然分布式系统也有额外的挑战,例如网络可能出现故障,或者远程计算机以及进程当掉等等。但是运行在一个大的服务器的并发系统中,如果一个CPU出现故障,网卡故障,磁盘故障等等也可能出现相似的问题。这种故障的可能性虽然可能比较低,但是理论上也有可能发生。
因为并发模型跟分布式系统框架是类似的,所以他们之间经常可以借鉴一些思想。例如,在工作者(线程)之间分配工作的模型跟在分布式系统中的负载均衡是类似的。处理错误的技术像日志,容错等等也是相同的。
并行工作者
第一个并发模型,我们称之为并行工作者模型。进来的任务分配给不同的工作者。这里有一个示意图:
在并行工作者并发模型中,一个代理分配进来的工作到不同的工作者。每一个工作者完成整个的任务。整个工作者是并行工作的,运行在不同的线程中,以及可能是在不同的CPU中。
如果一个并行工作者模型在一个汽车工厂中被实现,每一辆车将会被一个工作者生产。这个工作者将会得到说明书去建造,并且将要构建从开始到结束的每一件事情。
这个并行工作者并发模型是在java应用中使用的最广泛的并发模型(虽然那个正在改变)。在java.util.concurrent的Java包中的许多并发工具类被设计使用这个模型。你也可以在Java企业级应用中看到这个模型的痕迹。
并行工作者的优势
并行工作者并发模型的优势在于理解起来比较简单。为了增加应用的并行计算,你只是需要添加更多的工作者就可以了。
例如,你正在实现一个网页爬虫的功能,你将会使用不同数量的工作者去抓取一定数量的页面,以及看看哪个工作者将会花费更短的抓取时间(意味着更高的性能)。因为网页抓取是一个IO密集型工作,你可能是以计算机上的每个CPU/内核多个线程结束。一个CPU一个线程太少了,因为它在等待数据下载的时候将会空闲很长时间。
并行工作者的劣势
并行工作者并发模型有一些劣势潜伏在表面。我将会在下面的部分解释大部分的劣势。
共享状态获取复杂
在现实中并行工作者并发模型比上面说明的更加复杂。这个共享的工作者经常需要访问一些共享数据,或者在内存中或者在共享的数据库中。下面的图显示了并行工作者并发模型的复杂性。
这个共享状态的一些是处在像工作队列的通信机制里面。但是这个共享状态的一些是业务数据,数据缓存,数据库连接池等等。
只要共享状态悄悄的进入并行工作者并发模型,它就开始变得复杂了。这个线程在某种程度上需要访问共享的数据以确保被一个线程改变的对其他线程也是可见的(推向主内存,并且不只是陷入到执行这个线程的CPU的CPU缓存)。线程需要避免竞态条件,死锁,以及许多其他的共享状态并发问题。
此外,当访问共享的数据结构的时候,当线程之间互相等待,并行计算的部分丢失了。许多并发的数据结构正在堵塞,意味着一个或者一组有限的线程集合可以在任何给予的时间访问他们。这个就可能在这些共享的数据结构上导致竞争。高竞争将会本质上导致访问共享的数据结构代码部分执行的一定程度上的串行化。
现代的非堵塞的并发算法可能会降低竞争,以及提高性能,但是非堵塞算法很难被实现。
持久化的数据结构是另外一个可供选择的。一个持久化的数据结构当被修改的时候总是会保存它自己之前的版本。此外,如果多个线程指向相同的持久化数据结构,并且其中一个线程修改它,正在修改的这个线程得到一个新的结构的引用。所有其他的线程将会保持对老的结构的引用,这些老的结构仍然是没有改变的。这个Scala编程语言包含了几个持久化的数据结构。
当持久化的数据结构对于共享的数据结构的并发修改一个优雅的简练的解决方案的时候,持久化的数据结构不会执行的很好。
例如,一个持久化的列表将会添加所有新的元素到这个列表的头部,并且返回对于新添加元素的一个引用(这个然后执行这个列表的剩余部分)。所有其他的线程仍然保持一个对列表中前面第一个元素的引用,并且对于其他线程这个列表显示未改变的。他们不能看到这个新添加的元素。
这样的一个持久化列表作为一个链表实现。不幸的是链表在现代软件中不会执行的很好。在列表中的每一个元素都是分开的对象,以及这些对象可以被传播到所有的计算机内存中。当代CPU在顺序的访问数据会更快的,以至于在现代硬件上在一个数组的顶层不是列表实现将会得到一个更高的性能。一个数组顺序的存储数据。这个CPU缓存可以一次加载更大的块进入缓存,并且在这个CPU缓存一旦加载完就可以直接访问数据。这个如果用链表实现是不可能的,因为在链表里面的元素将会分散到所有的RAM上。
无状态的工作者
在系统中共享的状态可以被其他的线程修改。因此工作者每次需要它的时候都要重新读取这个状态,去确认是否工作在最新的拷贝上。这是真的,不管这个共享状态是在内存中还是在外部的数据库中。一个在内部没有保持状态的工作者称之为无状态的(但是需要每次重新读取)。
每次都需要重新读数据会变慢的。尤其是如果这个状态存储在外部的数据库中。
任务顺序是不能确定的
另外一个并行工作者模型的劣势就是执行任务的顺序是不能确定的。这里没有办法保证那个任务先执行,那个任务最后执行。任务A可能会在任务B之前给予一个工作者,然而任务B可能在任务A之前执行。
并行工作者模型自然地不确定使得很难去推论在某个时间点上系统的状态。它也会很难去保证一个任务在另外一个任务之前发生(基本上是不可能的)。
流水线(Assembly Line)
第二个并发模型,我称之为流水线并发模型。我选择这个名字只是为了适合“并行工作者”比喻的更简单。其他的开发者使用其他的名字(例如,反应系统,或者事件驱动系统)依赖平台或者社区。下面有个示例图进行说明:
这个工作者就像是一个工厂里的流水线上的工人一样。每一个工人只是执行全部工作的一部分。当那个部分完成之后,这个工人就会把这个任务转给下一个工人。
每一个工作者都是运行在他们自己的线程里面,并且工作者之间没有共享的状态。所以这个有时候也会作为一个无共享的并发模型被提及。
使用流水线并发模型的系统通常会使用非堵塞IO来设计。非堵塞IO意味着当一个工作者开始一个IO操作的时候(例如读文件或者来自网络连接的数据),这个工作者不会等到IO调用结束。IO操作是慢的,以至于等待IO操作完成是浪费CPU的。这个CPU可以同时做一些其他的事情。当这个IO操作结束的时候,这个IO操作的结果(例如数据读取或者数据写的状态)会传递给另外一个工作者。
对于非堵塞IO,这个IO操作决定着工作者之间的边界范围。一个工作者做他能做的直到它不得不开始一个IO操作。然后它放弃控制这个任务。当这个IO操作结束的时候,这个流水线上的下一个工作者继续在这个任务上工作,直到那个也不得不开始一个IO操作等等。
实际上,这些任务不一定流动在一个生产线上。因为大部分的系统不只是执行一个任务,工人与工人之间的任务流动依赖于需要被做的那个任务。实际上,这里将会有多个不同的虚拟流水线同时在进行。下面的图示就是真正的流水线系统中的任务是如何流动的。
任务甚至为了并发运行可能会执行不止一个工作者。例如,一个工作可能同时指向一个任务执行者和一个任务日志。这个图示说明了所有的三个流水线是怎样通过把他们的任务指向相同的工作者而结束的(最后的工作者在中间的流水线上):
流水线甚至可以得到的比这个更加复杂的。
反应系统,事件驱动系统
使用一个流水线并发模型的系统通常也被称之为反应系统,事件驱动系统。这个系统的工作者对系统中正在发生的事件作出反应,或者从外部接受或者被其他的工作者发出。事件的例子可以是一个HTTP请求,或者是某一个文件结束加载到内存等等。
在写的时候,这里有许多有趣的反应/事件驱动平台可用,并且在将来会有更多的。更普遍的一些看起来如下:
- Vert.x
- Akka
- Node.JS(JavaScript)
翻译地址:http://tutorials.jenkov.com/java-concurrency/concurrency-models.html