《并发哲学:从编程入道到开悟升天》3.6 从经典案例谈可伸缩并发设计

从经典案例谈可伸缩并发设计

设计往往和哲学有更大的关系,因为设计意味着思路的自由,不像范式这样禁锢。范式就好比,如果你想,你应该这样做,而设计则在于,如果你这样做,你能够更好。为此作者本人还总结出了两句蹩脚的英文:

  • 范式: If you want, you should do.
  • 设计: If you do, you can be better!

本节主要对并发编程过程中的设计考量进行简略的整理,以力求在实际的并发编程过程中取得提优的效果。

从前几章走来,我们反复强调,并发作为一种起源古老,指导整个人类协调物、协调其他人类的重要思想,重要性不言而喻。将其应用到编程领域,目的也不是为了让各类并发要素充斥在我们的程序内,而是为了让我们能够利用有限的资源,做好协调的艺术,在可能受到并行环境加成的影响下,实现更大的价值实现。

由于Golang基于并发原语和基本原理的强力抽象,我们有能力可以减少许多在其他编程语言内对任务分配、协调等复杂而晦涩难懂的抽象理解成本,将更多的精力集中到我们的编程架构设计工作中,这也是选用Golang作为我们并发编程学习的首选语言的原因。

而在并发编程的学习中,我们已经了解了Golang的基本语法,Golang的基本并发要素,基于CSP在Golang内支持的其他衍生物,和基于并发要素与衍生物进一步基本组合形成的标准解决方案——范式,本节我们开始探讨设计考量。

我们简要分析了人类社会发展至今与并发思想相关的考量,总结关键考量因素包括下面两个方面:

  • 对物:基于现实资源和环境限制的评估,对人类行为、活动范围进行有意识规划与限制
  • 对人:基于人性和人自身基本属性,对任务分配效率、任务协调方式进行有意识调整与优化

结合投掷者与包工头的具体场景化假想,我们决定对下列设计考量进行展开介绍:

  • 任务规划、分配与协调:流水线、扇入扇出、请求复制抢占执行
  • 并发任务限制:资源考量与速率限制
  • 定位任务分配异常:错误处理、错误传递
  • 监测、发现损失并定损与止损:心跳、协程异常行为修复
  • 人为任务逻辑控制:超时与取消

为了避免直接的代码限制你的思路,我们特意在本节将设计思路和范例实现分离。在看到实际的代码之前,请你认真的体会这些设计要素和建议是如何影响到代码,是如何切实提升工程价值的。我们将从一个创业故事开始说起。

任务规划、分配与协调

你意识到,给别人打工是不可能发财的,因而你想要做老板。而领导者的关键就在于,如何合理的将任务拆解,并将拆解后的任务安排给正确的人执行。现在,正值苹果的丰收季节,你想到了一个好点子,用收来的苹果制作成好吃的苹果酱。

一般说来,无论是做苹果酱还好,还是做其他什么酱,在进行一项任务或者是工程时,我们都需要将任务进行拆解,并梳理好逻辑关系。经过仔细的梳理,你总能按照逻辑将一个巨大的任务层层拆解,并在每一条逻辑链路上做到按部就班。我们不考虑分支条件,在一个传统的任务通道上,结构应该如下:

任务A -> 任务B -> 任务C

我们可以参考3.5节中的流水线范式对上述流程进行规整化处理,但好像并没有什么特殊的价值,所有的任务按序进行,效率没有任何的改进空间。为了将并发思想的效用在上述步骤内最大化,你还需要考虑下面的问题:

  • 是否存在可拆分的任务,任务拆分后变为相似任务
  • 是否存在可抢占的任务,提升处理量后可以缩短响应时间

可拆分任务,就好比批量搬运苹果,一大堆苹果分解成许多小堆苹果,每小堆苹果都可以安排一个人来搬运,相比于原来一个人处理一大堆苹果,这样的处理方式无疑能够提高效率。在编程中,这样的思想又被称之为扇入扇出,将大任务拆分成小的相似任务批量安排流程进行处理,这被称为“扇出(fan out)”,再以某种规矩统一收集处理结果,这被称为“扇入(fan in)”

可抢占任务,则表示由于客观环境的限制,同一个任务可能由不同的人来处理,有些人处理的速度会更快。由于在这类任务我们的目的往往是不计成本的追求更快的响应结果,因而我们通过复制请求,批量分配协程进行执行,并在任意一个协程取得结果后,对其他协程进行取消操作。

在完整的对任务进行规划后,基本的代码组织也就确定了。一般说来,在完善而有效率的并发程序内,一定有封装良好的流水线,追求速率响应的请求复制部分,和追求处理效能的扇入扇出部分。我们将在这一小节的编程案例内介绍一个批量寻找素数的例子,感兴趣的读者可以自行翻阅。

虽然工人还没招募到,苹果也没到手,但是看着规划的宏伟蓝图,你不禁感叹自己简直天生就适合做资本家!

并发任务限制

高速公路上一辆满载苹果的运货车发生了侧翻,敏锐的你意识到商机来了,村口的一众村民跃跃欲试,听你调遣来准备鲸吞这天降的财源。

很快,理性压制住了你的欲望,前些日子另一个村子哄抢侧翻的汽油未加控制,最终在抢夺的人越来越多的情况下,摩擦点燃了油罐车,一时间死伤遍地,惨不忍睹。虽然苹果不会爆炸,但是你也明白某些任务的安排多多少少一切需要按照一定的秩序,同时执行的人不能太多,不然太过混乱,总归会有隐患。

在并发体系中,我们在下面几种情况时,需要考虑并发任务限制:

  • 并发任务占用稀缺资源:并发任务需要占用网络端口、数据库连接等稀缺资源,稀缺资源是指,在本机或者是与程序相关的远端客户机上,随着资源占用增加,资源不可用风险也随之提高的资源。
  • 安全性考量:并发任务占用的资源在程序运行周期内一般不是稀缺的,但是如果面对暴增的程序请求,并发任务的数量增长可能会对程序安全和可用性造成冲击,那么有必要对请求进行速率限制。

根据在第二章我们介绍的投掷者和包工头理解方式,并发任务就好比是丢出去的石头,分配干活的下手,对并发任务的控制,就是对丢出去石头、分配干活的下手数量的限制,因为存在如下的依附关系:

资源 -> 并发任务

那么,我们可以直接统计任务下发协程的数量吗?答案是否定的,Golang将所有协程都视为等同,没有父子关系,没有从属,一切都以用户标记为准,这也就意味着,Golang没有天然的机制供我们统计当前某个作用域内协程运行的数量。所以我们最好需要建立以下的映射关系:

资源 -> 并发任务 -> 标记

通过并发任务与标记数量的一一对应,我们在新的并发任务下发前,检查标记数量,便可以得知当前并发任务下发的数量。就好比丢出去的石头,在天上还有多少块石头飞着,可能很难统计,但是我们可以在丢出去一块石头后,就在纸上记一笔,来间接统计。

也就是说,如果我们要面对速率或者并发任务限制的场景,我们需要构建一个基于令牌桶的体系,控制令牌的产生来决定指定区间最大任务数量上限,通过发起协程前检查是否能取到令牌来实现最终控制。

请在本章附录内查看关于该小节并发速率限制的一个推荐实践,当然,你可以根据自己喜欢的方式构建各类令牌桶体系。

你命令村民一次只能去十个,直到有人搬着苹果回来,你再决定派遣新的村民,终于你用安全有序的方式,收集到了第一份原始生产资料,村民也看到了你的领导潜质,纷纷决定要跟着大老板混。事业蒸蒸日上,走入正轨。

定位任务分配异常

现在,作为包工头的你,安排了一堆工人分拣苹果到萝筐里,毕竟刚捡来的苹果良莠不齐,你可不希望在最终的苹果酱里混入了坏苹果。很不幸的是,不知道为什么,你总能在最后的这筐好苹果里面闻到一股尿味。

明明苹果拿来的时候只有苹果香味和坏苹果散发出来的臭味,再往坏了说,也是坏苹果发酵散发出来的醋味,绝对不会是尿味。这时候,经验丰富的你意识到,一定有人在分拣苹果的时候尿尿了!究竟是谁?他为什么要这样做?你百思不得其解,于是,你请来了调查人员,但很不幸的在于:

  • 你的工人都是临时工,他们并没有很明显的顺序和编号
  • 工人处理苹果的程序是按部就班的,但是发生异常是随机的

可想而知,你重金请来的调查人员也是无功而返,愤怒的你决定不给这个调查人员发一分钱。好在,工人个个都很淳朴,你告诉他们要怎么做,他们严格按照你所分配的任务方式,并按照先前约定的意外处理方式处理紧急情况。

问题是调查人员也犯难,他一次只能跟踪一个工人,而且由于人都是临时工,用完了就释放了,重新组织的时候也没有编号,最讽刺的一次,正在调查其中A工人的时候,听到了别处传来的尿尿声,最后也没捉到罪魁祸首,只有又一筐充满了尿味的苹果。

问题出在哪里?我们总结案例的特征如下:

  • 工人严格按部就班,针对意外情况,按照约定处理方式进行处理
  • 虽然无法看到工人分拣过程中发生的异常,但是还能看到最终的结果,即使这无济于事

所以,经验丰富的你应该发现了,我们需要在中间协商意外处理方式方面进行下功夫。

在Golang中,错误处理方式是十分严格的,所有在顺序过程中抛出异常的,都必须以下列的方式严格捕捉错误并处理,或者显式的声明放弃:

res,err:=FuncTest() //该函数将抛出一个error,如果程序正常,error一般为nil

if err!=nil{
    // do sth for error handle
}
res,_:=FuncTest() //如果我们不想处理error,在这种情况下,我们必须用_显式地声明放弃掉

但如果错误发生在协程中,产生的错误想要由协程(工人)抛到主程(包工头)进行处理,依赖函数返回错误来捕捉的方式是不可行的,因为通过函数返回值捕获错误是一个同步行为,而协程运行是一个异步过程,而且在golang中,设计者已经从语言层面禁止了来自由go关键字驱动的函数的返回值捕捉。因而我们应该要有其他的机制来完善这种场景下的错误处理。

在这种场景下,我们可以设计由其他的结构来统一承载协程产生的错误,并在其他地方进行单独和统一的处理。就好比,我们专门安排一位专员,如果有人想尿尿,汇报给在场专员,专员做下详细记录,即便专员无法阻止工人在你购买的苹果上尿尿,但是,在你从外面回来后,可以根据专员汇报的情况,对详细的异常进行查看和统一处理——这样至少比只有最后一筐充满尿味的苹果要强很多。

详细的Golang案例对比讲解请参见附录。解决异常很多情况下根本还是需要改善环境,比如说在工厂内能有一个厕所。不过,引入一个专员比修建一个厕所的成本要低很多,好歹工人和包工头心里都能有了个安心。

回到程序的视角,我们通过引入统一通道来承载协程内发生的错误,同时也需要明确在并发、分布式的场景下,错误传递需要明确哪些信息。总的来说,我们总结如下:

  • 发生了什么:这是对程序对人员最直观的表述,诸如上面的案例内,“我尿尿了”这样一个最清晰直白的描述,至少可以给人们提供一个对当前错误最直观的认识。
  • 何时何处:错误应始终包含一个完整的堆栈跟踪,从调用的启动方式开始,直到实例化错误。此外,错误应该包含有关它正在运行的上下文的信息。 例如,在分布式系统中,它应该有一些方法来识别发生错误的机器。当试图了解系统中发生的情况时,这些信息将具有无法估量的价值。另外,错误应该包含错误实例化的机器上的时间,一般以UTC表示。
  • 有效的信息说明:除了完善的跟踪记录,最好还能有一个清晰直白给第三方展示的信息,一般是一行字,用于用户交互。例如在上面的情景内,提示“我尿尿了”显然是不合适的,合适的说法应该是“有人尿了”,这样更加的书面和准确。
  • 如何获取更详细的错误信息:应持续、保持投入的在这方面钻研。如果对详细信息的传递存在瓶颈,应该提供一个索引或者是ID,来在日后必要之时能够查询到完善的信息。顺带一提,如果一个错误传递过程最终传递出来的错误不能提供错误消息一句话以外其他更有用的东西,还不如不展示。就好比你重金顾问的专员,到头来只是一个传话筒,我想还不如解雇了他。

一个十分完备的设计应当如此:当错误传播给用户时,我们记录错误,同时向用户显示一条友好的消息,指出发生了意外事件。如果我们的系统中支持自动错误报告,那是最好不过的事情。如果不支持,应当建议用户提交一个错误报告。请注意,任何微小的错误都会包含有用的信息,即使我们无法保证面面俱到。

在了解到是一个生产线上的工人会因为坏苹果而过敏尿尿,你及时的发现了这个问题,并解决了拥有这个问题的工人。处理好并发状态下的错误传递,有助于我们更好的定位任务分配——也就是并发发生后在协程内出现的异常

监测、发现损失并定损与止损

由于充满了尿味的苹果难以售卖,虽然及时通过发现问题纠偏,但最终你的第一次创业失败了。好在,远在切尔诺贝利的远房亲戚找到了一个清洗核废料的生意,风险越大,收益越大,当地有数不胜数不怕死的劳工等待你派遣,能不能翻身就看这一回了。

清理核废料的过程中,你发现常常发现有人有去无回。这种有去无回大大增加了任务安排中的不确定性。当然,常见的作法就是利用心跳体系建立监测机制。

心跳机制对于很多并发场景下,并不是必须的,在Golang异常强大的调度协程的底层体系支撑下,提倡用户在需要利用大量廉价并发能力的场景下,利用海量协程压制化作业,即使某些协程存在遭遇异常的可能,也不影响整体任务的推进。但是在某一种特殊的场景下,最好是补齐相关的监测机制,那就是当协程运行时间足够长、数量稀少并且对于程序设计功能有举足轻重的地位,我们必须保证开指定数量的协程,并且协程还可能遭遇异常而退出。

像极了在切尔诺贝利派遣劳工的你,对吧。

我们将在附录的代码完整对比案例内讲述带有心跳机制的协程。简而言之,我们需要在函数运行过程中,按某种心跳机制向专门负责心跳的通道内送入内容,也就是触发信号。而心跳机制一般是按照一定时间间隔产生的信号。在Golang中,利用time.Tick(internal)函数,可以返回一个每隔internal时间产生一个信号的通道。

而在完整的监测-定损-止损流程中,我们不止需要依托心跳等机制发现协程的异常,还最好要引入再启动机制做到自动化。这里我们首先需要明确可以用作这种模式的协程特点:

  • 标准化:协程的重新启动应携带完整必须参数,这些参数应支持再获取以确保协程的正常启动。如果由于设计上的缺陷导致启动必备条件不满足,这将是十分灾难的,你将在可预期的范围内收获一定程度的性能损耗并无法达到预期生产结果。
  • 任务可中断性:由于协程如果发生异常退出,绝大部分情况下,这种异常是不可恢复的,该协程所拥有的任务大概率需要重头再来。这种情况下,协程的启动应该确保从该协程负责的关键点的起点而不是中途开始。并且要确保从头开始后对完整流程的影响并不是

我们将在附录内的编程案例精讲以心跳信号为监测机制,适时启动指定任务的止损方案。

终于,源源不断的新劳工背上了标准化的工装和工具箱,奔向核废料区,即便有人发生了失联,你也能及时的发现并派遣新的劳工,翻身之路终于稳步展开蓝图。

人为任务逻辑控制

有些时候,并不是所有无响应都是因为“意外”影响的。你发现有一个劳工总是霸占着劳动用具,也正常着发送着心跳信号——这也就意味着他还活着。但是奇怪的是,这个人迟迟没有将他的任务完成,于是苹果创业的阴影又在你的心里挥之不去——该不会这人在核废料上拉尿吧:

  • 这虽然不怎么影响结果,但是着实还在占用着资源

于是你意识到,需要有人为的规定,定义超时,并对这种超时的行为做出响应。

那么,为什么在编程中我们需要考虑超时呢?原因有下面几点:

  • 系统饱和:这代表着,如果试图拉起协程处理新的请求,但由于资源受限等原因,拉起协程产生阻塞,这时候我们希望抛出超时异常。换言之,我们准备派人干活,但是派遣过去的人发现无法干活,我们安排其不要勉强,选择放弃吧。
  • 数据过期:这代表着,已经在处理过程中的协程,由于在处理过程中费时较久,导致运行时拥有的数据已经不适合再行进接下来的过程,我们也希望抛出超时异常。换言之,我们派出去的人已经干活干了一半,但是经过评估,这个人即使干完,也无法按照既定标准进行产出,我们也会规劝其放弃。
  • 防止死锁:往往是无限期资源相互试图抢占发生死锁,这时,如果我们能够规定超时机制,这样涉及死锁的任意一方就会被强制要求放弃资源抢占请求,也就自然解除了死锁。

请注意,引入超时机制并不意味着你不用再考虑如何解决上述三个问题,也就是系统容量、数据生命周期和死锁问题,超时仅仅是上述三个问题在实际遭遇时,通过引入超时机制,获得更好的“处理异常体验”。你应该尝试花费必要的精力,在容量规划、数据生命周期治理和避免死锁上下功夫。

而对于更广泛的情况,你还需要考虑以下工人任务被取消的可能:

  • 你不想让他干了,就是不想让他干了而已
  • 工人自己招徕的手下,应该在这个工人被通知取消任务的时候,也停止手中的任务
  • 本身工人的任务是可抢占任务,已经有人取得结果的情况下,应该自觉停止手中任务

放到具体的并发编程中,也就是我们需要在以下场景都考虑取消:

  • 超时:来自于事前商定,暗含了隐式的取消含义
  • 用户干预:为了获得良好的用户体验,有时需要允许用户取消他们已经开始的操作
  • 父节点取消:如果作为子节点的任何父节点停止,我们应当执行取消
  • 重复任务且为可抢占模式:已经有协程取得结果的情况下,其他协程应当取消

而在具体取消操作的实践上,你还需要考虑下面的问题:

  • 取消是否及时:由于我们一般利用done通道来捕获取消信号,这也就意味着,在没有响应done通道信号的地方,都无法响应上层发来的取消操作。如果在两个done通道检查点之前有拖留时间过长的风险,这也就意味着对应的并发任务存在无法及时响应取消信号的风险。
  • 是否需要回滚:一般说来,如果已经下发了取消信号,这也就意味着很多任务都必须暂停,并且放弃先前的成果。然而此时可能已经发生了许多事情,需要健全评估这些操作是否需要回滚的标准。
  • 消息是否重复:就好比利用三次握手结果评判TCP链接是否已经可靠建立一样,对超时和取消的消息控制流,一样存在各种不稳定性,对取消信号开始响应的时间点的把握也不是这么的简单,很有可能在信号上游发出,中游刚刚往下游发送了请求但还没收到回复,此时响应了上游的取消,而这个时候下游发生的情况就没有被考虑在内,具有一定的风险。

做到及时的取消,一个简单的方法就是将你的协程工作分解成更小的部分。 你应该瞄准所有不可抢占的原子操作,见缝插针加入对done通道的监听,以便在更短的时间内完成取消操作。当然这不是必须的,你需要综合评估超时的判定和取消可容纳的最长时间。

而对于需要判定回滚的操作,具体的操作实现上,如何处理这个问题很难给出一般性的建议,因为算法的性质决定了你如何处理这种情况; 如果在较小的范围内保留对任何共享状态的修改,无论是否需要确保这些修改回滚,通常都可以很好地处理。如果可能的话,在内存中建立临时存储,然后尽可能快地修改状态。简而言之,不好走回头路的东西,设计为最快的弄完,确保中间出事(被通知需要回滚)的概率最小。

在解决可能面对的消息重复问题,有几种方法可以避免这样的情况发生。一种是在执行下发任务的协程已经报告结果后,下发任务的主程或者协程不再发送取消信号。这需要各个阶段之间的双向通信,你可以参考在心跳中的具体实践;一种是改进你的算法,使得下游在接受消息时产生的响应是幂等的,这意味着可以简单地在下游进程中允许重复消息,并选择是否接受你收到的第一条或最后一条消息。

其实,在编写并发代码时,超时和取消会频繁出现。作为人为引入的进一步提升程序和程序交互逻辑自洽性的做法,超时和取消是相当重要的设计考量。就好比带薪拉屎的风潮席卷各大公司,如何阻挡懒惰员工,提升客户体验是新一代老板的追求一样,在设计具有并发特点的程序时,一定要考虑超时和取消。像软件工程中的许多其他技术问题一样,如果在项目初期忽略超时和取消,然后尝试将它们放在项目后期加入,有点像试图在蛋糕烘烤后再将蛋添加到蛋糕中。

通过完善的超时约定和取消机制加入,你的手下已经成为了你的铁军,财富的阳光大道正在向你招手!

小结

本节我们通过介绍并发编程中的可伸缩并发设计,做到在具体业务场景下更加全面的系统化设计,为构建实际可用基于并发思想提升效能的系统提供了良好指导。

和并发范式不同,并发设计内讨论的内容,还没有一成不变的代码来直接套用,我们往往需要根据具体的场景,以思想为指导构建合适的应用体系。即便已经有了很多优秀的代码实践,我们依旧尝试将探讨用例和编程实践分开,一个良好的学习方式是,在你充分思考了这些设计要点对系统的价值影响后,根据实际的业务需求,再参考附录内的编程实践。对于暂时无需用到的内容,只需要记住思想就好,这足以应付面向并发设计的技术交流。

本小节,我们将模拟你创业的心路历程贯穿在设计思想的考量中,你可以充分体会到从第二章一路走来的,并发思想和社会实践相互影响的影子,我们还将在第四章内专门以实际代码实践,探讨生活中的平凡思考。

全部评论

相关推荐

头像
不愿透露姓名的神秘牛友
03-13 10:56
点赞 评论 收藏
转发
点赞 收藏 评论
分享
牛客网
牛客企业服务