对并发编程模型的思考和扯淡

1,692 阅读14分钟

1. 前言

最近一直在关注并发编程模型,启蒙主要来自于发现的一本书《七天七并发模型》,对于我这个iOS入坑的程序员,长期以来由于并没有去做其他方向的开发,最多也只是写写Ruby和Shell的command程序,所以对我来说大为震撼,因为在过去的世界里只知道线程和队列。加上Swift 5.5也加上了语言层面的并发特性,所以不可避免的,也将了解这些概念加入了我的学习计划中,而这篇文章,我将通过最近一段时间的资料阅读和理解,谈谈我对这些并发编程模型的认识。

另外需要说明的是,我不会用很多冗余的文字来解释比如为什么需要并发等科普云云,因为这不是本文的重点,我也会尽量不说废话,争取一针见血的表达我的想法。

以下将使用“并发模型”一词来指代“并发编程模型”。

2. 什么是并发模型

2.1 并发模型关注的是什么

首先,我们需要思考,什么是并发模型?按我的理解,其指的是对程序并发运行过程的高级抽象并进行建模,而程序的并发运行需要关注两个方面:问题的分解和并发单元的交互。所以并发模型一般都是围绕这两个方面进行设计来解决问题或展现自身的特点。

2.2 为什么要有不同的并发模型

在以前的个人知识系统里,提到并发一词,只会想到线程、队列和它们相关的安全设计,尤其是线程,其自身是一种接近系统底层并发机制的上层表达,而队列这种抽象设计,其实也是属于一种抽象设计了,那么当我们考虑为何需要队列的时候,其实也正好说明了为什么还会存在其他的并发模型,所以仔细想想,其实这种需要性,都是来自于计算机相关技术的快速发展带来的场景丰富或复杂化后带来的思想潮流。

2.3 并发模型和内存模型的相关性

那么并发模型和内存模型有什么关系呢?先说说内存模型,内存模型按大的方向来说,分为共享内存和分布式内存,具体起来可能比较多:比如UMA、NUMA、UMA + NUMA 、NORMA等。而并发模型在数据交互上不可避免的会和内存打交道,所以对于内存模型会有一定的针对性,有些并发模型只能工作在共享内存中,有些却通吃,原因是其高度的抽象设计将内存的细节给隐藏了。

3. 线程

线程是最基础的并发模型,基础到已经是操作系统底层执行流的概念,大家基本也都理解,但是也不能完全不讲,硬要讲的话,可以说下目前在应用中基本上并不会使用内核级别的线程,原因有二:

  1. 内核空间的容量限制
  2. 用户空间和内核空间的特权级切换时的性能消耗

所以现在的用户级线程一般都是多对多(多用户线程对应多内核线程)的设计,这样解决了上述问题。而线程之间的交互,使用的是共享内存的方式,所以也出现了很多相关的锁概念,而锁也增加了开发者的使用成本,需要十分小心的防止死锁发生,所以如果能减少锁的使用,甚至直接从设计上规避使用锁而采取更和谐的方式,这种想法也为后来的并发模型发展带来了一些方向。

4. 队列

队列是对于并发的一种抽象,其抽象出了任务的概念,而任务将有条不紊的放到队列中等待执行,开发者不再需要关注底层的线程概念,而将精力聚焦到实际的任务代码,并考虑任务的执行时序和同步异步上。而队列相对于线程的优势在于开发者不再需要关注线程数量在不同配置的硬件上该如何最优分配了。

这种早期并发模型使用的依然还是共享内存进行交互,但其在队列的概念上提出了一个新的同步机制:串行队列,在共享内存作为交互机制中,安全问题来自于对共享资源的访问,锁的本质也是达到局部的串行化。

5. Future/Promise

5.1 什么是Future/Promise模型

说到Future/Promise模型,很多人首先想到的是在某些语言下为了解决回调嵌套场景下的then-then风格的代码,实际上这是来自于Promise流水线这一应用场景的具体体现。Future和Promise这两个概念在某些情况下表达的意思是相同的,因为两者都可以理解为对未来结果的代理占位容器,如果要区分:Future的意义还是如上,而Promise是指设置结果的异步函数。在某些实现中,区别在于其状态不同(Future:uncompleted和completed,Promise:pending、fulfilled和rejected)。

Future/Promise模型的目的是将值和计算方式进行分离,从而使计算更加灵活,比如并行化,所以我们可以认为其核心是未来结果的代理占位容器Future,而每个并发单元是一个Promise。这种思想也带动了另一种编程风格:以串行的代码风格编写异步程序,而不是回调套回调的形式。这种编程风格的表现形式之一,也就是最前面所说的then-then代码(可以通过库的形式来支持),而在某些从语言层支持如async/await的编程语言中有更强的表现力。

而谈到Future/Promise模型的交互机制,个人认为其重点在于同步,并形成有序的传参过程。

5.2 数据流式编程

另外要说的,Future/Promise模型属于数据流式编程的一种体现,其强调了数据的流动,数据流式编程指的是将程序建模为一系列的连接,让代码在其所依赖的数据被准备好时才可运行,而数据流式编程也依托于函数式编程的引用透明性的特征,其在可能的并发场景中,不同的求值顺序(甚至说同时)不会带来副作用,这里可以用一张图来表示:

Untitled.png

如图表示两个计算目的都相同,在传统编程(a)中,共需要3个时间单位,而在数据流式编程(b)中,其可以将A和B和求值并行化,共只需要2个时间单位。

6. Actor

6.1 什么是Actor模型?

在Actor模型的概念中,一切皆Actor,听起来和面向对象的一切皆为对象的概念很像,我们先来看下Actor模型包含哪些内容:

  • 并发单元抽象为一个Actor对象,Actor对象之间相互独立可同时工作;
  • Actor内状态私有,外部只能通过消息机制来影响它;
  • Actor之间通过消息通信,每个Actor带有一个邮箱(消息队列),消息非阻塞的发往邮箱,接收方取出消息按序处理。

这些只是基本构造,它还拥有比较丰富的特点:

  • 消息发送异步性,不会造成发送方阻塞;
  • Actor内状态私有,这样不需要额外的锁来保证并发安全;
  • 发送方和接收方解耦,双方可以通过一些标识来映射;
  • Actor内之间相互独立,出错后不会相互影响,容错能力强;
  • 同时支持共享内存和分布式内存模型。

看完了这些,我们思考一下,Actor并发模型,是以什么为出发点来设计的?

我认为它主要是从面向对象为出发点考虑的,传统的类对象本身设计并没有考虑到并发安全问题,也没有考虑到在分布式系统中的表现,所以在发现这些问题后,Actor对传统类对象进行优化设计,并增加新的特性,所以Actor并发模型的重点也是其Actor实体,然后一切皆Actor。但是在交互机制上,由于其异步性,其实是缺乏直接的同步能力支持的,但是在某些实现中,会有对其的补充,比如下面要说的Swift。

前面提到了,Actor并发模型的交互机制是消息,所以不存在传统共享内存机制中的并发安全问题,因为状态都是私有的,但是其实际的实现中,邮箱本身还是要考虑并发安全的。

6.2 Swift中的Actor

Swift的并发特性支持也是促使我学习并发模型的原因之一,所以这里也来谈谈Swift中的Actor的部分设计。

Actor的核心之一是状态的隔离,Swift中也一样,Swift中的Actor之间也遵守这个概念,并使用消息机制来通信:

  • 隔离性

    Swift使用了一种新的结构类型(actor)来作为Actor模型在其中的实现,其拥有和class类似的特性,且也是引用类型。

    • Actor的可变状态只允许内部(self)更新,外部只允许访问不变状态;
    • Actor内部只存在一条执行流,虽然支持交叉执行,但是基本可以理解为串行执行收到的所有消息;
    • 跨Actor之间通过信箱收发消息。
  • 消息机制

    • 由于其作为语言特性(而不是库),编译器会将异步方法调用改写为消息的发送,对开发者是透明的,所以从语法中完全看不出来其消息收发的概念;(类似于Orleans.Grains)

      • Actor内支持同步方法,所以这也构成了事实上的局部原子性,当跨Actor调用时,会被自动转为异步方法以支持消息机制。
    • 消息接收方支持挂起调用其他异步方法的同时,让自身可以可重入的处理其他消息(单执行流的交叉处理),而不会单纯的阻塞,这解决了一部分死锁问题,也增加了并发处理消息的能力。

  • 可重入性

    简单来说,Actor可以理解为一种并发安全的对象,而这个对象内部同时只会处理一条消息,其他消息必须在邮箱中排队等待,而发送者也不必等待回复,在这种简单设计下,并不存在需要考虑是否可重入的问题。

    可重入绑定的作用域是整个Actor对象,而不是方法。

    而Swift的Actor在综合考虑下选择了支持可重入,如前面所说,增强了并发处理消息的能力,也解决了一部分死锁问题(不可重入时的任务链死锁问题),但是其也带来了一些代码处理上的安全问题,比如在一个挂起点前后的状态不一致问题,不过此问题可以通过人为的保证将状态的访问合并在一个同步过程中。

  • 未来发展方向

    从提案中看,未来的两个发展方向都是针对于可重入问题的,一个是可重入的标记,这一点明显可以从当前设计的不足可以看出,因为是否可重入在当前的设计是全局的,但是实际上对这方面产生要求的是每个挂起点自身,所以未来可能是通过一些比如@reentrant@reentrant(never)这样的标记来指定不同范围内的可重入性(这也类似于Orleans.Grains);另一个发展方向是任务链重入,任务链重入主要目的是想解决,在Actor内一个不可重入作用域下(比如前面提到的不可重入标记带来的特性)处理一个消息时,在处理周期内部又间接发送(直接调用self的异步方法可以被编译器静态检测到并被优化成一个同步调用)消息给自身导致的死锁问题,如果将其理解为一个任务,其后面收到的另一个消息可以理解为这个任务的内部子任务之一,这种情况应该被检测到并与不相关的重入问题明显区分,不过目前提到还没有合适的实现方式。

从上面来看,Swift中的Actor的设计主要是针对于Actor对象的并发安全性上的,而其他比如分布式支持和容错能力暂时还未考虑,目前比较完整的实现Actor特性的比如有Akka框架(Java 和 Scala)和Elixir。

7. CSP中Process和Channel

我对CSP模型不是很了解,因为其本身是一门语言用来描述并发系统间交互模式,理论比较丰富且没有时间和精力去仔细研究,所以这里只谈被广泛采用的Process和Channel构成的并发模型。

以下“CSP并发模型”将指代CSP中Process和Channel构成的并发模型。

CSP并发模型由Process和Channel构成,其中:

  • Process作为独立的并发执行单元;
  • Channel为Process提供通信功能,其是一个线程安全的队列,有读和写两种操作,通信双方通过持有Channel来进行交互。

其也拥有以下特点:

  • Process内状态私有,通过消息机制来通信,这样不需要额外的锁来保证并发安全;
  • 发送方和接收方解耦,而Channel独立,并不关心发送方和接收方是谁,也不关心分别有几个。

然后谈谈CSP并发模型的重点,重点是通信的管道Channel,而不是通信的实体,所以其设计的出发点也和Actor完全不同。Channel在交互上支持消息传递的同时也支持了执行的同步。

对CSP的想法并不多,在附加中会有其他的相关讨论。

8. 附加

这里增加一些附加讨论,用来对上面一些内容进行补充。

8.1 Actor和CSP的区别

说到Actor和CSP的区别,网上有过的对比可以说很多很多了,基本上都是从特性差异和一些实现细节进行对比的,这些我都觉得不是最核心的,我觉得最核心的区别,在于需要理解这两者的设计出发点完全不同:一个侧重于通信实体,一个侧重于通信通道,这种区别也会导致程序的外观出现明显区别,这里我举一个不非常贴切的对比例子:Proxy和EventBus,为什么要用这个作为例子,可以从我刚刚提到的出发点来看。

8.2 并发模型并不会彼此独立的存在

本文描述了几个并发模型,如果有跟我一样的理解,其实不难看出,这些设计都有各自不同的侧重点,这也导致在现实的实现中可能会出现一种并发特性里隐含了多个并发模型的设计,不过这都是促使写出表现力更强、安全性更高的并发代码的正向演化。

9. 结尾

本文只讨论了几个比较热门的并发模型,对于其他的暂时还没去看,当有合适的时机比如有了更多的想法时,再另起一篇来谈吧。最后要说的是,由于本文中有的想法比较主观,有错误也不可避免,实际上我更希望能有人指出问题,这样才能得到更深刻的理解,如果有能讨论的机会就更好了,感谢阅读!

原文地址:nakahira.notion.site/472e5190da7…

参考资料