并发的艺术-1:起源
并发的概念充斥在各种各样的程序设计书籍中,甚至有许多专门教授利用某一门编程语言进行并发编程的书籍。然而在我看来,并发确是高于语言的通用概念,本文就作为并发系列的第一篇,阐述我对于并发的理解,希望能和各位看官碰撞出思想的火花。
并发无处不在
人类是忙碌的,生活在这个世界中的我们每时每刻都在执行各种各样的任务,和各种各样的对象进行交互。假设此刻正值午休,你一边吃饭一边刷抖音,抖音里的视频非常可乐,时不时逗得你哈哈大笑。
即便是如此平常的生活场景,也已经蕴含了我们要讨论的两个概念,并发和并行:
并发(Concurrent)是任务拥有的一种属性
任务本身可以被建模成一系列操作和操作依赖的资源。普通任务必须从头开始,直到完成所有操作。支持并发的任务则将操作组织成一系列互不干扰,可以同时发生的执行流,方便由实体并行执行。并行(Parallel)是主体的一种主动能力
拥有并行能力的主体可以同时执行并发任务的多个执行流,并提供管理多个执行流共享资源的工具。
回到上文提到的那个午休场景,你就是执行任务的实体;”午休”是你执行的一个任务,这个任务包含了”吃午餐”和”看手机”两个执行流:由于”看手机”由你的眼睛主导,”吃午餐”由你的嘴巴主导,你可以同时并行”吃饭刷抖音”这两个流程,因此”午休”对你而言是支持并发的。特别的,当一则短视频把你逗乐时,你的嘴巴停止了咀嚼,转而发出”哈哈”的笑声,这个过程中嘴巴是”吃午餐”和”看视频”共用的资源。
不知道你注意到没有,并发能力其实是需要被刻意设计的:
假设你是一个有洁癖的人,在午饭前的加热饭菜阶段,因为不愿意两个菜互相串味儿,你不得不用同一个餐盒顺序加热这两个菜(为了节省空间,它们装在一个餐盒中)。如果你有并发的意识,在家里就把菜分别装入两个餐盒中,一旦公司提供第二台微波炉,你就可以同时加热它们。本质上,你通过标记可以并行的执行流,把原本的串行任务转化成了支持并发的任务。
我们为什么要把普通任务费心设计成支持并发呢?答案显而易见:提高效率。因为这个世界是天然并行的!每个个体相互独立,可以在同一个时间点做不同的事情,我在工作时你可以用餐,反之亦然。支持并发的任务能够合理的利用更多的执行实体,加速自身的执行。
并发的核心挑战
世界上没有免费的午餐,要想收获并发带来的效率提高,当然也需要解决相应的挑战:
图1建模了并发任务的核心元素:正常情况下,能够执行任务的实体数量总是远远小于执行流的数量(n>>m)。
因此,并发的核心挑战为如下两点:
下限-管理共享资源
并发的执行流间免不了存在共享资源,如何识别并管理这些共享资源,防止出现异常状态是并发任务设计的底线,它直接决定了任务的成败。
管理共享资源有两种最基础的模式:
- 加锁
这是最自然的想法,通过在访问前对共享资源加锁声明使用权。现实生活中你进入洗手间隔间时需要拉上门闩,这个门闩就是锁机制。锁机制一般都由执行并发的实体提供支持。 - 资源复制
通过让每个执行流拥有共享资源的拷贝,把共享资源退化成普通资源。例如上文中提到的餐盒。
上限-最大化并发任务的吞吐量
多个执行流之间除了共享资源,还需要处理一些复杂情况:
- 协作
一个执行流A的下一步操作可能依赖另一个执行流B的反馈。在等待执行流B的反馈时,执行流A可以选择持续等待(阻塞),也可以选择继续去做其它事情,直到执行流B返回成功的消息后继续(非阻塞)。
现实世界中也有对应的例子:你在餐馆门前等位时,取号之后需要等前台服务员叫号,在轮到你之前,只能在门外等候,这个时候去其它店铺逛逛可能是一个聪明的决定。 - 优先级
一个正在执行的执行流往往可以被其它优先级更高的执行流中断,例如你正在用手机和你妈妈拉家常,突然手机提示公司同事给你来电,你可能会对妈妈说”等我一会儿”,然后立马切到公司同事的电话上,处理公司的紧急情况。
上述情况都暗示了执行流在完成前应该能够支持暂停,并在后续某个时间点恢复,从而提高并发任务整体的执行效率。这种切换可以主动或被动的触发。
并发和事务处理的关系
事务处理的是另一个重要概念,主要是从健壮性的角度考虑把执行流封装成要么成功,要么失败的单元。
不得不说,在大多数文章中,往往会把并发和事务处理描述成一组互相绑定的概念。但是细究之后就可以发现:并发的起源并不关心事务处理,但是实现事务处理不能不考虑并发(并发是ACID原则中隔离性定义明示的场景)。事务处理是在效率的基础上叠加健壮性而产生的技术。
计算机体系中的并行和并发
图灵的论文暗示我们整个世界就是一台超级计算机。因此在计算机的不同层次模拟这个世界的并行特性也显得顺理成章。
底层-硬件架构
简单的说,CPU等硬件就是计算机执行任务的实体。
CPU
在很长的一段时间里,受惠于摩尔定律,通过不断增加CPU内封装的晶体管数量,即使是单核CPU,执行任务的速度也在不断显著提高。可是,无论有多快,任一时刻一个CPU只能执行一条指令,这是人类目前无法挣脱的物理牢笼。
这种情况下该如何实现并行?天下武功,唯快不破。工程师前辈们脑洞大开,既然CPU足够快(每秒可以执行G数量级的指令),何不将CPU的计算能力按照时间(纳秒级别)分片,在不同的执行流间循环分配呢?只要切换的速度足够快,从任务的角度无法感知,就像自己的多个执行流被同时进行一样。我们姑且把这种方式叫做”伪并发”。
进入21世纪,由于工艺和散热的限制,粗暴的增加单核CPU晶体管数量变得越来越困难。人们不得不转向,开始设计拥有多个物理核心的多核CPU(目前AMD线程撕裂者3995WX已经支持多达64个核心),多核CPU天然地支持并行执行指令,这也标志着”实并发”时代的到来。
相应的,内存是CPU并发执行时所管理的共享资源。因此,必须提供相应层级的工具同步对内存的访问,例如LOCK前缀等实现原子操作的指令。其它硬件
诸如硬盘控制器/网卡控制器之类的其它硬件也能执行各自的任务,它们相互独立。
依上所述,不同的硬件顺序执行各自的指令,并提供中断机制在硬件之间传递信息,整个计算机从底层便是事件驱动的。
中层-操作系统(C语言)
操作系统作为运行最高权限级别(level0)的大管家,是运行在最底层的事件驱动模型软件:
一方面,操作系统将系统硬件的各种功能封装成事件响应函数,运行在一个巨大的事件循环中。例如调度子系统是时钟中断的处理程序,调页处理程序是缺页的处理程序等。
一方面,操作系统为上层应用程序抽象出”线程”的概念,作为底层CPU上并发执行的最小指令序列。连带其它各种管理资源的能力封装成标准库(libc)中的系统调用,规范应用程序的使用。
在标准库中,为了在并发时管理好共享资源,还提供了构筑在底层原子机器指令之上的一系列同步手段,例如自旋锁,信号量,RCU等。
高层-高级语言
相比较上文提及的两个层次,高级语言层面的并发设计是程序员们更关心的话题:虽然编写应用程序无法修改计算机应用层之下的并行机制,但是不同的语言却各显神通,将不同的并发理念和数据结构内嵌进语言内部,提供了各式各样的并发范式:
指令式 vs 函数式
围绕着如何管理共享资源,高级语言可以分化成两大阵营:
指令式语言是系统语言(C语言)的天然继承者(典型如C++/Java等),将操作系统层广泛使用的各种锁机制开放给用户模式的应用程序,由程序员在自己决定如何妥善使用。
纯函数式语言为了去除共享资源带来的风险,规定不允许修改变量的值,计算逻辑被转换成一次次函数调用,而每一次函数执行所利用的内存资源只属于这次执行上下文(堆栈),因此天然避免了锁机制。线程 vs 协程 vs 事件驱动
围绕着多个执行流之间如何协作,可以分成三类:
线程模型将执行流标志成一个个线程并启动,后续交由操作系统负责调度,应用程序不负责主动协作。
协程模型通过在用户模式显式控制多个执行流间的切换,减少了系统调用的开销,提高了效率。
事件驱动模型是一种特别的编程范式,采用事件驱动模型的应用程序模仿操作系统,在最外层的循环中轮询事件,并执行对应的回调函数。这个模式特别适合重I/O操作的应用程序(例如Web服务)。Actor和CSP
这两个概念是在共享资源和协作两个维度之上抽象出的更高层次的通用并发模型。分别把关注点放在消息传递和消息通道上,各种语言实现Actor或者CSP模型时可以自由选择映射到的协作模式即管理共享资源的方法。
作为通用模型的好处在于可以在更高的层次上应用这些模型,例如Spark可以构建基于Actor模型的分布式计算集群。
总结
图2对本文提到的概念进行了总结:
末了,理解并发源于更好地理解这个世界。