并发系统可以由不同的并发模型来实现。并发模型规定了系统中线程的协作方式,从而能够共同完成指定任务。不同的并发模型会将任务按不同的方式分割,而线程之间也通过不同的方式来通信和协作。本并发教程将更深入一点的讨论时下(2015 - 2019)使用最普遍的并发模型。
并发模型和分布式系统的相似之处
本文所描述的并发模型和以不同架构实现的分布式系统很相似。在并发系统中不同线程之间彼此通信。在分布式系统中进程之间彼此通信(进程可能在不同的计算机上)。实际上线程和进程大同小异,这也正是为什么不同的并发模型和不同的分布式系统往往看上去很像。
当然,分布式系统要面对更多的挑战,比如网络失败,远程计算机或进程故障等。而对于运行在大型服务器上的并发系统来说,存在类似的问题,比如CPU故障,网卡故障,硬盘故障等。故障的几率可能较低,但是理论上仍会发生故障。
由于并发模型和分布式系统架构很相似,所以它们之间常常可以互相借鉴。例如,工作线程之间分配工作的模型常常类似于分布式系统的负载均衡模型。类似的还有错误处理技术,比如日志记录,故障转移,任务的幂等性等。
共享状态 VS 分离状态
并发模型的一个重要方面是,该让组件和线程在线程之间共享状态呢?还是各自拥有独立的状态,从不在线程之间共享呢?
并行工作机模型(Parallel Workers)
假如在汽车工厂中实现这个并行工作机模型,每辆车会由某个工人生产。工人会按照汽车的生产规范,从头到尾把这辆车制造出来。
并行工作机并发模型是java程序中最常用的并发模型,虽然这已经在变化了。在java.util.concurrent包中的很多并发工具类是以此模型而设计的。你也能在企业版java应用服务器中看到该模型的踪迹。
并行工作机模型的优点
并行工作机并发模型的优点是容易理解。你只需增加更多的工作机就可以增加应用的并行能力。
例如你要实现一个网络爬虫,你可以用不同数量的工作机来爬取一定量的页面,看用多少数量的工作机爬取的时间最短(也就意味着性能最好)。由于网络爬取是IO密集型作业,最终很可能每个CPU或核心只需开几个线程。一个CPU只开一个线程会太少,因为CPU大部分时间在等待下载数据,处于空闲状态。
并行工作机模型的缺点
虽然表面上简单,但是并行工作机并发模型也存在一些缺点。我会在下面的章节中阐明最明显的缺点。
共享状态会变得更复杂
一旦并行工作机并发模型中引入了共享状态,它就开始变得复杂。线程在访问共享数据时,需要确保一个线程所做的修改对其他线程可见(推送到主内存,而不是仅仅停留在执行线程的CPU缓存中)。线程需要避免竞态条件、死锁和许多其他共享状态并发问题。
此外,当线程因访问共享数据结构而彼此等待时,将会丢失部分并行能力。许多并发数据结构是阻塞的,这意味着一个或有限的线程集可以在任意时间访问它们。这可能导致对这些共享数据结构的争用。激烈的争用本质上将导致访问共享数据结构的部分代码要顺序执行。
现代非阻塞并发算法可以减少竞争,提高性能,但非阻塞算法很难实现。
另一种选择是持久化数据结构。持久化数据结构在修改时始终保留其以前的版本。因此,如果多个线程指向同一个持久性数据结构,并且某个线程对其进行了修改,则该线程将获得新结构的引用。所有其他线程都保留对旧结构的引用,该结构仍然保持不变,因此保持一致。Scala编程包含了几个持久化数据结构。
虽然持久化数据结构很好的解决了并发地修改共享数据结构,但它往往不能很好地执行。
例如,持久化列表将所有新元素添加到列表的头部,并返回新元素的引用(该元素又指向列表的其余部分)。所有其他线程仍保留列表中前一个元素的引用,所以对于这些线程,列表看上去没有改变。它们看不到新添加的元素。
这种持久性列表是用链表实现的,但是链表在现代计算机上的性能并不好。列表中的每个元素都是一个单独的对象,这些对象可以分散在计算机内存的各个地方。当今的CPU在顺序访问数据方面要快得多,所以在当代计算机上,列表用数组实现会有更好的性能。数组是按顺序存储数据的,CPU缓存可以一次将更大的数组块加载到缓存中,之后让CPU直接访问缓存中的数据。而对于元素分散在内存中的链表来说,这是不可能的。
无状态工作机(Stateless Workers)
共享状态可以由系统中的其他线程修改。因此,工作机每次都必须重新读取状态,才能确保在最新的状态上工作。无论共享状态保存在内存中还是外部数据库中,都需要这么做。内部不保留状态(但每次都要重新读取)的工作机称为无状态工作机。
.
在每次需要时重新读取数据会变慢。如果状态存储在外部数据库中,会变得更慢。
任务次序是不确定的
并行工作机模型的另一个缺点是任务执行顺序不确定。无法保证哪些任务是先执行还是最后执行。任务A可以在任务B之前交给工作机,但任务B可能在任务A之前执行。
并行工作机模型天然的不确定性导致很难随时预测系统状态,也更难(如果可行)保证一项任务先于另一项任务。
流水线模型(Assembly Line)
每个工作机都在自己的线程中运行,并且与其他工作机不共享任何状态。有时这也称为无共享并发模型。
使用流水线并发模型的系统通常设计为使用非阻塞IO。非阻塞IO意味着当工作进程启动IO操作(例如从网络连接读取文件或数据)时,工作进程不会等待IO调用完成。IO操作很慢,因此等待IO操作完成是在浪费CPU时间。CPU这时可以做别的事情。IO操作完成后,IO操作的结果(例如读到的数据或数据写入的状态)将传递给另一个工作机。
反应/事件驱动系统(Reactive, Event Driven Systems)
使用流水线并发模型的系统有时也称为反应系统,或事件驱动系统。系统的工作机对系统中发生的事件做出反应,这些事件来自外部世界,或者由其他工作机发出。事件可以是传入的HTTP请求,或者某个文件完成内存加载等。
在编写本文时,已经有许多有趣的反应/事件驱动平台,以后会有更多。下面这些似乎更受欢迎:
Vert.x
Akka
Node.JS (JavaScript)
就我个人而言,我觉得Vert.x非常有趣(特别是对于像我这样的Java/JVM老油条来说 ——译者注:原文为especially for a Java / JVM dinosaur like me)。
参与者与通道(Actors vs. Channels)
流水线模型的优点
与并行工作机模型相比,流水线并发模型有几个优点。我将在下面的章节中介绍最大的优点。
不共享状态
工作机与其他工作机不共享任何状态,这意味着在实现它们时,所有在并发访问共享状态上会出现的问题,我们都不用再考虑了。这使得工作机更容易实现。你在实现一个工作机时,这个工作机就好像是唯一一个执行该任务的线程—本质上是一个单线程实现。
有状态的工作机
由于工作机知道其他线程不会修改他们的数据,工作机可以是有状态的。我的意思是,他们可以将需要操作的数据保存在内存中,只需将改动写回最终的外部存储系统。因此,有状态的工作机通常比无状态的工作机更快。
更好的硬件整合
单线程代码的优点是,它通常更符合底层硬件的工作方式。首先,当你认为代码会以单线程模式执行时,通常可以创建优化更好的数据结构和算法。
其次,单线程有状态工作机可以在内存中缓存数据,如上所述。当数据被缓存在内存中时,该数据也更有可能被缓存在执行线程的CPU缓存中。这使得访问缓存数据的速度更快了。
代码的编写方式天然的受益于底层硬件的工作方式,我称之为硬件一致性。一些开发者称之为机械同情(译者注:原文是mechanical sympathy,直译为机械同情)。我更喜欢硬件一致性这个词,因为计算机很少有机械部件,在这种情况下,“同情”一词隐喻为“更好地匹配”,而我相信“一致”一词传达得相当好。无论如何,这是吹毛求疵。你可以用你喜欢的任何术语。
可以进行任务排序
流水线模型的缺点
流水线并发模型的主要缺点是,一个任务通常分布在多个工作机上执行,从而分布在项目中的多个类上。因此,很难确切地看到某个任务有哪些代码在执行。
编写代码也可能会更困难。工作机的代码有时编写成回调处理。于是代码中出现层层嵌套的回调处理,一些开发者把这称为回调地狱。回调地狱指的是很难在所有回调中跟踪代码真正在做什么,也很难确保每个回调都能访问所需的数据。
而使用并行工作机并发模型,这个问题就简单了。你可以打开工作机代码并读取从头到尾的执行代码。当然,并行工作机代码也可以分布在许多不同的类上,但是通常从代码中更容易读到执行顺序。
函数式并行(Functional Parallelism)
最近(2015)讨论较多的是第三种并发模型:函数式并行。
函数式并行的基本思想是使用函数调用来实现程序。函数可以看作是相互发送消息的“代理”或“参与者”,就像在流水线并发模型(也称为反应式或事件驱动系统)中的一样。一个函数调用另一个函数时类似于发送一个消息。
传递给函数的所有参数都会被复制,因此任何在接收函数之外的实体都不能操作数据。这种复制对于避免共享数据上的竞态条件至关重要。这使得函数的执行类似于原子操作。每个函数调用的执行都可以独立于任何其他函数。
当函数调用可以独立执行时,每个函数调用就可以在单独的一个cpu上执行。这意味着,一个用函数式实现的算法可以在多个cpu上并行执行。
在Java7中有了Java.util.concurrent包,其中包含了ForkAndJoinPool,它可以帮助你实现类似于函数式并行的功能。在Java 8中有了并行流,它可以帮助你把大型集合的迭代并行化。要注意的是,有些开发人员对ForkAndJoinPool持批评态度(可以在我的ForkAndJoinPool教程中找到批评的链接)。
函数式并行的难点在于要弄清楚哪些函数调用要并行化。在CPU之间协调函数调用会带来开销。一个函数完成的工作单元需要达到一定的大小,才值得这样的开销。如果函数调用非常小,那么若把它们并行化实际上可能比单线程、单CPU执行的更慢。
根据我的理解(虽不完美),你可以使用一个反应式的、事件驱动的模型来实现一个算法,并将工作分解成类似于函数式并行所实现的那样。在我看来,使用事件驱动模型,你可以更准确地控制要并行化的内容和数量。
另外,只有当某个任务是程序当前执行的唯一任务时,将该任务拆分到多个cpu上所产生的协调开销才有意义。而如果系统同时执行多个其他任务(如web服务器、数据库服务器和许多其他系统),那么尝试并行一个任务是没有意义的。计算机中的其他CPU终将会忙于处理其他任务,因此没有理由试图用一个较慢的、函数式并行的任务来干扰它们。使用流水线(反应式)并发模型很可能更好,因为它具有更少的开销(以单线程模式顺序执行),并且更好地符合底层硬件的工作方式。
哪种并发模型最好?
那么,哪种并发模型更好呢?
通常情况下,答案取决于系统要做什么。如果你的任务本来就是并行的、独立的并且不需要共享状态,那么你可以使用并行工作机模型来实现你的系统。
然而,许多工作并不是自然而然并行和独立的。对于这些类型的系统,我相信流水线并发模型比并行工作机模型有更多的优点而不是缺点。
你甚至不必自己编写所有的流水线基础代码。像Vert.x这样的新平台已经为你实现了很多这样的功能。对于我来说,我将试着在Vert.x这样的平台上设计下一个项目。我觉得Java EE已经没有优势了。