4.并发模型

● 并发模型和分布式系统的相似性

● 共享状态(Shared State)与分离状态( Separate State)

● 并行工作者模型

● 并行工作者模型的优势

● 并行工作者模型的劣势

    ○ 共享状态会变得复杂

    ○ 无状态工作者

    ○ 工作的顺序是不确定的

● 流水线模型

    ○ 反应器、事件驱动系统

    ○Actors 与 Channels

● 流水线模型的优势

    ○ 无共享状态

    ○ 有状态的工作者

    ○ 更好地与硬件整合

    ○ 顺序的工作任务成为可能

● 流水线模式的缺点

● 函数式并行(Functional Parallelism)

● 哪种并发模型最好?


可以使用不同的并发模型来实现并发系统。 并发模型指定系统中的线程如何协作完成给定的任务。 不同的并发模型以不同的方式拆分任务,并且线程可以以不同的方式进行通信和协作。 本并发模型教程将更深入地介绍撰写本文时(2015年至2019年)使用的最受欢迎的并发模型。

并发模型和分布式系统的相似性

本文中描述的并发模型与分布式系统中使用的许多体系结构类似。 在并发系统中,不同的线程彼此通信。 在分布式系统中,不同的进程相互通信(有可能在不同的计算机上)。 线程和进程本质上非常相似。 这就是为什么许多并发模型,通常看起来与许多分布式系统体系结构相似的原因。

当然,分布式系统还面临着额外的挑战,即网络可能会失败,或者远程计算机或进程已关闭等。但是,如果CPU发生故障,网卡发生故障,磁盘发生故障,则在大型服务器上运行的并发系统也有可能会遇到类似的问题。 只是失败的可能性会相对更低,但是理论上仍然有可能会发生。

由于并发模型与分布式系统体系结构相似,因此它们经常可以相互借鉴。 例如,用于在工作者程序(线程)之间分配工作任务的模型,通常类似于分布式系统中的负载均衡模型。 错误处理技术(例如日志记录,故障转移,多任务的幂等性)也是如此。

共享状态(Shared State)与分离状态( Separate State)

并发模型的一个重要方面是,组件和线程是设计为,在线程之间共享状态,还是永远不会在线程之间共享状态的分离状态。

共享状态(Shared State)意味着系统中的不同线程将在它们之间共享某些状态。 状态是指一些数据,通常是一个或多个对象或类似对象。 当线程共享状态时,可能会出现争用条件死锁等问题。 当然,这取决于线程如何使用和访问共享对象。


共享状态(Shared State)

分离状态(Separate State)意味着系统中的不同线程在它们之间不共享任何状态。 万一不同的线程需要通信,它们可以通过在它们之间交换不可变对象,或通过在它们之间发送对象(或数据)的副本来进行通信。 因此,当没有两个线程写入同一对象(数据/状态)时,可以避免大多数常见的并发问题。


分离状态(Separate State)

使用分离状态并发设计,通常可以使代码的某些部分更易于实现和推理,因为你知道只有一个线程将写入给定的对象。 你不必担心并发访问该对象的问题。 但是,使用分离状态并发模式,你可能需要更全面地考虑应用程序的设计。 我觉得这是值得的。 我个人更喜欢分离状态并发设计。

并行工作者模型

第一种并发模型我称其为并行工作者模型。 传入的作业会分配给不同的工作者。 下图展示了并行工作者并发模型:

并行工作者并发模型

在并行工作者并发模型中,委托者(delegator)将传入的工作任务分配给不同的工作者。 每个工作者完成分配给他的整个工作任务。 这些工作者并行工作,在不同的线程中运行,并可能在不同的CPU上运行。

如果在汽车制造厂实施并行工作者模型,则每辆汽车将会由一个工人生产出来。 这个工人将获得这辆汽车的完整设计规格,并从头到尾制的把整辆汽车制造出来。

并行工作者并发模型是Java应用程序中最常见的并发模型(尽管这种情况正在发生变化)。 java.util.concurrent Java包中的许多并发实用程序都是根据此模型设计的。 你还可以在JavaEE应用程序服务器的设计中看到此模型的踪迹。

并行工作者并发模型可以设计为使用共享状态或分离状态,这意味着工作者可以访问某些共享状态(共享对象或数据),或者不共享状态。

并行工作者模型的优势

并行工作者并发模型的优点是易于理解。 要提高应用程序的并行化级别,你只需添加更多工作者即可。

例如,如果你正在实施Web爬虫,则可以使用不同数量的工作者来爬取一定数量的页面,并查看哪个数量的总爬取时间最短(这就意味着最高的性能)。 由于爬取Web页面是一项IO密集型工作任务,你可能最终在计算机中的每个CPU(或CPU内核)上同时执行多几个线程。 每个CPU一个线程太少了,因为在等待数据下载时,很多时间CPU处于空闲状态。

并行工作者模型的劣势

但是,并行工作者并发模型,具有一些隐藏在其看似简单的表面之下的缺点。 我将在以下各节中解释最明显的一些缺点。

共享状态会变得复杂

如果共享工作者需要访问内存或共享数据库中的某些共享数据,则正确的管理并发访问将变得很复杂。 下图展示了这如何使并行工作者并发模型变得复杂的:

并行工作者模式访问共享状态将变得复杂

有些共享状态类似于队列之类的通信机制, 但另外一些共享状态则包括业务数据、数据缓存、数据库的连接池等。

一旦共享状态潜入并行工作者并发模型中,情况就会开始变得复杂起来。 线程需要确保一个线程的更改,对其他线程以可见的方式访问共享数据(将其推送到主内存中,而不仅仅是停留在执行该线程的CPU的CPU缓存中)。 线程需要避免争用条件死锁和许多其他共享状态并发问题。

此外,当线程因为访问共享数据结构而彼此等待时,一部分的并行化将会丢失。 许多并发数据结构将发生阻塞,这意味着只能有一个或一组有限的线程可以在任何给定时间访问它们。 这可能导致对这些共享数据结构的争用。 高争用本质上将导致访问共享数据结构的代码会在一定层度上以串行化的方式执行(并行化被消除)。

现代非阻塞并发算法可以减少争用并提高性能,但是非阻塞算法很难实现。

持久化数据结构是另一种选择。 持久化数据结构在修改后始终保留其自身的先前版本。 因此,如果多个线程指向相同的持久化数据结构,并且一个线程对其进行了修改,则修改线程将获得对新数据的引用。 所有其他线程保留对旧数据的引用,该旧数据仍保持不变,因此保持一致。 Scala标准API包含一些持久化数据结构。

虽然持久化数据结构是对共享数据结构进行并发修改的理想解决方案,但持久化数据结构往往无法很好地被施行。

例如,一个持久化列表会将所有新元素添加到列表的开头,并返回对新添加元素的引用(该引用随后指向列表的其余部分)。 所有其他线程仍保留对列表中先前第一个元素的引用,并且对这些线程而言,列表保持不变。 他们看不到有新元素添加进来了。

这样的持久化列表通常使用链表来实现。 不幸的是,链表在现代硬件上的表现不佳。 链表中的每个元素都是一个单独的对象,这些对象可以分布在计算机内存中。 现代CPU顺序访问数据的速度要快得多,因此在现代硬件上,从阵列顶部实现的列表中可以获得更高的性能。 数组是顺序存储数据的, 因此CPU高速缓存可以一次将更大的阵列块加载到高速缓存中,并让CPU在加载后直接访问CPU高速缓存中的数据。 对于链表,将元素分散在整个RAM上,是不可能做到数组这样的效果的。

无状态工作者

共享状态可以由系统中的其他线程修改。 因此,工作者必须在每次需要时重新读取状态,以确保他们正在使用的副本是最新的, 无论共享状态是保留在内存中还是外部数据库中,都需要这样做。 不在内部保持状态的工作者(每次需要时都会重新读取状态)称为无状态的。

每次需要时重新读取数据都会变慢, 特别是状态存储在外部数据库中时。

工作的顺序是不确定的

并行工作者模型的另一个缺点是,工作任务的执行顺序是不确定的, 无法保证哪些工作任务先执行,哪些工作任务后执行。 工作任务A可以在工作任务B之前提供给工作者,但工作任务B可以在工作任务A之前执行。

并行工作者模型的不确定性,使得在任何给定时间点都难以推断系统状态。 这也使得很难保证一项任务先于另一项任务完成(如果不是不可能的话)。 但是,这并不总是会引起问题, 这取决于系统的需求。

流水线模型

第二种并发模型,我称其为流水线并发模型。 我选择这个名称只是为了与之前的“并行工作者”的比喻相吻合。 其他开发人员根据平台/社区使用其他名称(例如,反应系统或事件驱动系统)。 这是说明组装线并发模型的图:


流水线模型

工作者的组织就像工厂中装配线的工人一样。 每个工作者仅完成全部工作任务的一部分。 完成该部分后,工作者会将工作任务转发给下一个工作者。

使用流水线并发模型的系统通常设计为使用非阻塞IO, 非阻塞IO意味着当工作进程开始IO操作时(例如从网络连接读取文件或数据),工作进程不会等待IO调用完成。 IO操作速度很慢,因此等待IO操作完成会浪费CPU时间。 同时,CPU可能正在做其他事情。 IO操作完成后,IO操作的结果(例如,读取的数据或写入的数据的状态)将传递给另一个工作者。

使用非阻塞IO,IO操作将确定工作线程之间的边界。 工作者会执行他自己的工作任务,直到必须进行IO操作为止, 然后工作者放弃对工作任务的控制, 等IO操作完成后,流水线中的下一个工作者将继续进行该工作任务,直到必须开始下一个IO操作为止。

非阻塞IO的执行示意图

实际上,这些工作任务可能不会沿着一条流水线执行。 由于大多数系统可以执行一项以上的工作任务,因此工作会在工作者之间流转,这取决于下一步需要执行的是工作任务的哪一部分。 实际上,可能同时运行多个不同的虚拟流水线, 下图展示了可能是真实的流水线系统的工作流程:

真实的流水线系统的工作流程示意图

甚至可以将工作任务转发给多个工作者进行并发处理, 例如,你可以将工作任务转发给工作执行器和工作日志器。 下图说明了三条流水线如何通过将其工作任务转发给同一工作者来完成的情况(中间流水线中的最后一个工作者):

三条流水线将其工作任务转发给同一工作者来完成的示意图

真实情况下流水线甚至比这还要复杂。

反应器、事件驱动系统

使用流水线并发模型的系统有时也称为反应器系统或事件驱动系统。 系统的工作者会对系统中发生的事件做出反应,这些事件可能是从外界接收到的,也可能是其他工作者发出的。 具体的事件,有可能是传入的HTTP请求,也有可能是某个文件已完成加载到内存等。

在撰写本文时,有许多有趣的反应器/事件驱动平台可用,将来还会有更多。 下面几个似乎比较受欢迎:

    ● Vert.x

    ● Akka

    ● Node.JS (JavaScript)

我个人认为Vert.x非常有趣(特别是对于像我这样的Java / JVM恐龙(Java / JVM dinosaur))。

参与者模型(Actors)与通道模型(Channels)

Actors与Channels是流水线(或反应器/事件驱动)模型的两个类似示例。

在参与者模型(Actors)中,每个工作者被称为参与者。 参与者之间可以彼此直接的发送消息。 消息是异步发送和处理的。 如前所述,可以使用Actors来实现一个或多个工作任务处理流水线。 下图展示了参与者模型:

参与者模型(Actors)

通道模型(Channels)中,工作者不直接相互通信。 相反,他们在不同的通道上发布消息(事件)。 然后,其他工作者可以在这些通道上监听消息,而发件人不知道谁在监听。 下图展示了通道模型:

通道模型(Channels)

在撰写本文时,通道模型对我来说似乎更灵活。 工作者不需要知道哪个工作者将会接手处理流水线中的下一步工作任务, 它只需要知道将工作任务(或消息)转发到哪个通道就可以了。 通道上的监听器可以订阅也可以取消订阅,而不会影响工作者对通道的写入。 这便得工作者之间的关联更松散一些。

流水线模型的优势

与并行工作者模型相比,流水线并发模型具有许多优点。 在以下各节中,我将介绍最大的几个优点。

无共享状态

工作者与其他工作者不共享任何状态的事实,意味着无需考虑并发访问共享状态可能引起的所有并发问题,就可以实现他们。 这使得实施工作者变得容易得多。 你只需要将工作者实现为好像是执行该工作的唯一线程就可以了-本质上是单线程实现。

有状态的工作者

由于工作者知道,没有其他线程会修改其数据,因此工作者可以是有状态的。 有状态的意思是,他们可以将需要操作的数据保留在内存中,而只把更改写回最终的外部存储系统即可。 因此,有状态工作者通常可以比无状态工作者更快。

更好地与硬件整合

单线程代码的优势在于,它通常与底层硬件的工作方式更好地吻合。 首先,当你可以假定代码以单线程模式执行时,通常可以创建更多优化的数据结构和算法。

其次,如上所述,单线程有状态工作者可以将数据缓存在内存中。 当将数据缓存在内存中时,也更有可能将该数据也缓存在执行线程的CPU的CPU缓存中。 这样可以更快地访问缓存的数据。

当使用自然受益于底层硬件工作原理的方式来编写代码时,我将其称之为硬件一致性(hardware conformity)。 一些开发人员称之为机械共振(mechanical sympathy)。 我更喜欢“硬件一致性(hardware conformity)”一词,因为计算机几乎没有机械零件,并且在这种情况下,“共振(sympathy)”一词被用作“更好地匹配”的隐喻,我相信“符合”一词可以很好地传达原本的意思。 无论如何,这是挑剔的。 你使用你喜欢的任何术语即可。

顺序的工作任务成为可能

可以根据流水线并发模型以保证工作任务以顺序的方式实现并发系统。 顺序的执行工作任务,能够更容易的在任何给定时间点推断系统状态。 此外,你可以将所有传入的工作任务写入日志。 然后,在系统的任何部分出现故障的情况下,可以使用此日志从头开始重建系统状态。 工作以特定顺序写入日志,并且该顺序成为保证的工作任务的顺序。 下图展示了这样的设计:

顺序的工作任务

确保顺序的执行工作任务不一定是很容易的,但是通常是可能的。 如果可以的话,它可以极大地简化备份、还原数据、复制数据等任务,因为所有这些都可以通过日志文件来完成。

流水线模式的缺点

流水线并发模型的主要缺点是,工作任务通常分散在多个工作者中执行,因此会分散在项目中的多个类中。 因此,很难确切地知道给定工作任务正在执行哪一段代码。

编写代码也可能会更困难。 辅助代码有时被编写为回调处理程序。 具有许多嵌套回调处理程序的代码可能会导致某些开发人员称之为回调地狱(callback hell)的情况发生。 回调地狱只是意味着很难跟踪所有回调中代码的实际作用,以及确保每个回调都可以访问所需的数据。

使用并行工作者并发模型,这往往会更容易。 你可以打开工作者代码,并从头到尾阅读执行的代码。 当然,并行工作者代码也可以分布在许多不同的类上,但是执行顺序通常更容易从代码中读取。

函数式并行(Functional Parallelism)

函数式并行(Functional Parallelism)是第三种并发模型,最近(2015年)被广泛讨论。

函数式并行的基本思想是,使用函数调用的方式实现你的应用程序。 函数可以看作是相互发送消息的“代理”或“参与者”,就像在流水线并发模型(AKA反应或事件驱动的系统)中一样。 当一个函数调用另一个函数时,这就类似于发送消息。

传递给函数的所有参数都将被复制,因此除接收函数之外的任何实体都无法操纵数据。 此复制操作,对于避免共享数据出现争用的情况至关重要。 这使得函数执行的,类似于原子操作。 每个函数调用都可以独立于任何其他函数调用执行。

当每个函数调用可以独立执行意味着,每个函数调用都可以在单独的CPU上执行。 这就是说,函数实现的算法可以在多个CPU上并行执行。

使用Java 7,我们获得了包含ForkAndJoinPooljava.util.concurrent包,该包可以帮助你实现类似于函数式并行的功能。 使用Java 8,我们获得了并行,可以帮助你并行化迭代大型集合。 请记住,有些开发人员对ForkAndJoinPool持批评态度(你可以在我的ForkAndJoinPool教程中找到批评的链接)。

关于函数式并行的困难部分是,知道要调用哪个函数进行并行化。 跨CPU协调函数调用会带来开销。 一个函数来完成的工作单元,需要具有一定的内存空间,来负担此开销。 如果函数调用非常小的情况下,如果尝试并行执行它们,实际上可能比单线程、单CPU执行还要慢。

根据我的理解(一点也不完美),你可以使用反应器/事件驱动的模型来实现算法,并实现类似于函数式并行的工作任务分解。 使用事件驱动模型,你可以更好地控制要并行化的对象和数量(在我看来)。

此外,只有在该任务当前是程序唯一执行的任务时,将任务分配给多个CPU,产生的开销才有意义。 但是,如果系统正在同时执行多个其他任务(例如,Web服务器、数据库服务器和许多其他系统都在执行),则尝试并行处理单个任务是没有意义的。 无论如何,计算机中的其他CPU都将忙于其他任务,因此没有理由尝试以较慢的,函数式并行的任务来打扰它们。 使用流水线(反应器)并发模型可能会更好,因为它具有较少的开销(以单线程模式顺序执行),并且与底层硬件的工作方式能够更好地兼容。

哪种并发模型最好?

那么,哪种并发模型更好呢?

通常,答案是:这取决于系统应该执行的操作。 如果你的工作自然就是并行的、独立的、并且不需要共享状态的,则可以使用并行工作者模型来实现你的系统。

但是,许多工作并非自然而然地是并行的、独立的。 对于这类系统来说,我相信流水线并发模型的优点要大于缺点,相较于并行工作者模型来说,流水线并发模型有更多的优点。

你甚至不必自己编写所有流水线基础结构的代码, 像Vert.x这样的现代化平台,已经为你实现了很多功能。 我个人将在下一个项目中,探索在Vert.x等平台上运行的设计。 我觉得Java EE不再具有优势。


译自:Concurrency Models

Jakob Jenkov

Last update: 2021-03-08

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,651评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,468评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,931评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,218评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,234评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,198评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,084评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,926评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,341评论 1 311
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,563评论 2 333
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,731评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,430评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,036评论 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,676评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,829评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,743评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,629评论 2 354

推荐阅读更多精彩内容

  • 本系列译自jakob jenkov的Java并发多线程教程(本章节部分内容参考http://ifeve.c...
    Steven_cao阅读 982评论 1 10
  • 并发系统可以采用多种并发模型来实现。并发模型指定了系统中的线程如何通过协作来完成分配给它们的作业。不同的并发模型采...
    只是肿态度阅读 134评论 0 1
  • Java Concurrency 并发模型 并发系统可以使用不同的并发模型来实现。 并发模型指定系统中的线程如何协...
    熬夜的猫头鹰阅读 257评论 0 1
  • 并行工作者模型可以这样理解:将完整的一个事情(比如生产一台汽车),交由一个工作者来完成,每个工作者都会拿到所有的生...
    Mr_ran阅读 306评论 0 0
  • 1、并发模型一般有两类 阻塞方式 – 通过加锁来实现资源并发 非阻塞方式 - 系统原语实现 I、死锁 VS 活锁 ...
    轩居晨风阅读 246评论 0 0