《并发哲学:从编程入道到开悟升天》3.3 从三大要素理顺基础并发编程
3.3 从三大要素理顺基础并发编程
你已经了解,Golang可以利用go关键词,配合其他组件实现并发编程。在进行这一节的阅读前,我们先回顾一下在上一节中的末尾,通过场景化联想工厂内人们协调工作的场景,总结出并发体系的基本要素:
- 任务分配机制
- 任务协调机制
- 任务等待与确认机制
在上一节中,我们以语法为重点,简要介绍了Golang相关的三大基本语法,分别是go关键字,channel管道和select机制,但本节,我们将以并发要素为视角进行介绍。在针对Golang的基础并发编程学习内,我们不能只停留在基础语法的了解,至少需要与具体场景结合,找到相关的知识与实践,分别贴合上述三类要素进一步了解,才能正式入门并发编程。本节你将了解:
- 任务分配——go关键字修饰
- 理解并发任务数量与关系
- 任务协调——管道与其他方法
- 任务等待与确认
- 基本需求分解与并发方案设计
任务分配机制——go关键字修饰
由于在上一节已经有过介绍,因而你一定不陌生。go关键字修饰的用法如下:
go 函数名( 参数列表 )
用法衍生出两个含义:
- 利用go进入并发状态的一定是一个子任务(可运行的函数)
- 所有子任务均可以利用go关键字以并发执行的方式存在
一句话,在Golang的实践中,一切可运行的函数,前面都可以加上go关键字修饰,从而使函数的执行进入并发执行状态。
例如,常规定义函数支持:
package main func main(){ go testFunc() } func testFunc(){ // do sth }
匿名函数支持:
package main func main(){ go func(){ // do sth }() }
引入的函数、系统包自带的函数也支持:
package main import "fmt" func main(){ go fmt.Println("hello,world!") //引用了其他包函数 }
而条件判断、流程控制、声明定义语句不支持。
在投掷者模型内,一定要确保并发的内容是一个可投掷实体,一个球、一支箭、一块石头。用手捞空气很明显是没有意义的,推大地虽然有行为,但是不是很能方便的喊出“走你!”之类的字眼,别人可能会认为这人是个傻子。
而在包工头模型内,包工头作为领导,需要确保分配的任务可执行,有明确边界,下属才会明白需要执行,并这样去做。诸如包工头发话:“明日太阳照常升起!”,“你的名字叫小王!”,"如果你肚子饿了!"干活的人一定是一头雾水,最多回复一个问号。而这些不明所以的命令,就好比编程语言内条件判断、流程控制和声明定义语句一样,只暴露出了有限的信息,无法作为完整的任务。
go关键字修饰也是如此,需要并发执行的内容必须抽象成子任务(函数),修饰才可被接受。
理解并发任务数量与关系
对于初次接触并发编程的新手,理解Golang并发状态下协程的任务数量至关重要。准确理解任务数量与任务层级关系,对于评估程序负载,设定并发限制,维持并发通信,衡量并发水准都具有重要意义。
我们定义如下任务的并发任务数量为1:
package main func main(){ go func(){ // do sth }() }
该案例中,程序开启了一个协程(Golang中的协程称为goroutine,为了更好的有助于理解并发哲学,本章节将协程与goroutine等同)。
而在循环中,每次循环若符合条件,则并发任务数量加1。以下面的代码为例,并发任务数量为10。
package main func main(){ for i:=0;i<10;i++{ go func(){ // do sth }() } }
此时借用投掷者模型可以更加精准的理解并发任务数量,当程序执行碰到go关键字的时候,可以将它理解为一个球:
package main func main(){ for i:=0;i<10;i++{ fmt.Println("走你!")//凡是go关键字修饰的语句,均替换为输出“走你!”,以尝试“抛出”该任务 /*go func(){ // 这一块变成了一个球 }()*/ } }
程序块执行后,有多少个“走你!”就代表着当前程序块执行后抛出了多少个并发任务。
当然,对于多层循环下该类方法也同样适用
package main func main(){ for i:=0;i<10;i++{ for j:=0;j<2;j++{ go func(){ // do sth }() } } }
你可以自己尝试将上述案例内的并发任务改写为输出“走你”,相信你能得到并发任务为20的答案。
接下来我们来理解一下并发任务之间的层级关系。利用包工头模型,我们进行假设,包工头找到了五个劳力,分别安排干五类活或同一个活的五个部分,以期望得到高效率。代码实现如下:
package main /*包工头*/ func main(){ //包工头开始分配活 for i:=0;i<5;i++{ go LaoLi() } select{} //包工头在这个案例里希望等待劳力干活有个结果,即使他自己不会确认具体情况 } /*劳力*/ func LaoLi(){ //do sth }
按照前文所述的程序代码块并发任务计算方法,我们可以很容易得出这位包工头喊出了五个“走你!”,即并发任务数量为5.
但是分发下去的任务,执行者就不可以再分割了么?很明显是可以的,此处的每一个劳力都找到了任务的可分割逻辑————毕竟不想当领导的劳工不是好程序员。于是,代码变成了下面这样,每位包工头直属劳力又找到了两个人,把任务分配给了他们:
package main /*包工头*/ func main(){ //包工头开始分配活,分配给的劳工我们称之为该包工头的直属劳力 for i:=0;i<5;i++{ go LaoLi() } //do sth select{} //包工头在这个案例里希望等待劳力干活有个结果,即使他懒得确认具体情况 } /*劳力也想当一个小包工头*/ func LaoLi(){ //劳力此时作为包工头开始分配活,分配给的劳工我们称之为该劳力(包工头)的直属劳力 for i:=0;i<2;i++{ go OtherPeople() } // do sth } func OtherPeople(){ // do sth }
或许这比双层循环理解并发任务稍微困难一些,不过相信你可以得出上述例子内,并发任务数量为10的答案。
现在请思考两个问题:
- 包工头对直属劳力工作的控制,是否会影响到劳力自己雇佣的旗下劳力的工作?
- 多级分配之后,最底层的劳动者可以影响到隔级包工头吗?
如果包工头加了无论已经隔了多少级的劳力的微信,那么一样双方都是可以通信的。当然也可以通过设计等方式,让“跨级沟通”不存在。这也就意味着,只要通信、资源共享方式合理得当,对于并发任务,可以进行同级控制(隔级屏蔽)、隔级控制等混合管控操作,从而更好的实现完成全部任务的目的。
任务协调——其他方法与管道(channel)
我们已经在3.2中简要了解过Golang中的管道机制。正如编程案例7中对管道基本作用的展示:无缓冲管道丢入内容必须即时得到处理,否则程序将阻塞。有缓冲管道管道若内部为空,取数据将发生阻塞;若内部盛满,则放数据将发生阻塞。而在编程案例8——龟兔赛跑中,我们发现管道可以在某些场景下用作通信手段,兔子通过往管道内塞了一个1,另外一侧探测的机器收到了信号,从而奠定了兔子的胜局。乌龟败者食尘,张嘴的机会都没有。
在探讨并发编程的任务协调时,管道只是一种工具,我们需要明白任务协调的目的或者说本质。正如包工头模型,项目能够得到良好推进的前提可以总结如下:
- 包工头不会突然跑路
- 包工头能够有效、合理的分配任务
- 做苦力的人可以充分协调资源,最大化利用资源
- 做苦力的人可以通讯,发挥各自特长
可以看到,在上面的条件中,协调占有举足轻重的地位。
我们可以这样说,任务协调的本质,包含两个含义:
- 资源协调
- 通信
那么目的就很明确了,在任何一个并发体系内,都需要有机制或者是工具,实现不同并发任务之间的资源协调与信息交流,这样才能实现任务协调的作用,更好的推进完整任务的实现。
问题来了,避开管道(channel)的实践,我们可以利用Golang实现并发任务之间的资源协调与信息通信吗?答案当然是可以的,只不过我们实现的可能远没有channel优雅————不过我们能够通过这种方式了解管道封装对于编程工作量减少的特征。让我们来看接下来的编程案例:
编程案例9--利用多数语言通用语法实现并发任务协调
package main import ( "fmt" "time" ) var ( Task1Done bool //信号量,使用者需要轮询来探测变化 Task2Done bool //信号量,使用者需要轮询来探测变化 PrintContent int //资源量 ) func main() { Task1Done = false Task2Done = true go Task1() //利用go关键字使任务执行进入并发状态 go Task2() for { //利用在main内无限循环确保程序不会退出,从而等待Task执行的结果 time.Sleep(10 * time.Second) } } func Task1() { i := 0 for { //利用无限循环的方式实现轮询 if Task2Done == true { PrintContent = i //通过更改资源量实现资源传递(此处是待打印的内容) Task1Done = true //在某些条件满足后,更改某些信号量,使另一个并发任务可以感应到 i++ Task2Done = false continue } else { continue } } } func Task2() { for { if Task1Done == true { fmt.Println(PrintContent) Task1Done = false Task2Done = true continue } else { continue } } }
编程案例9输出
0 1 2 3 4 (程序一直递增打印数字……)
在上面的案例中,我们通过拟定全局bool变量作为共享信号量,全局int变量作为共享资源量,实现了下列功能:
- 程序分配两个并发任务执行任务,分别为A,B
- A,B配合实现从0递增数列的打印
- A是负责递增待打印数字,只有确认已经产生的数字被打印,A才会进行递增
- B是负责打印数字,只有确认有新产生数字,B才会进行打印
案例内除了任务分配,在任务协调和等待均采用了几乎所有其他语言均存在的数据类型与结构,包括布尔类型、整形、全局变量概念、if作为条件判断、for(while)作为循环方式,可以说极为好理解,但确实稍显繁杂。因而下面我们用管道机制再来实现上述的需求,这样才能直观体现出差异。
编程案例10--利用管道机制改进编程案例9
package main import ( "fmt" "time" ) func main() { PrintContent:=make(chan int)//本案例内通过无缓冲管道最简优化案例9 go Task1(PrintContent) go Task2(PrintContent) for { time.Sleep(10 * time.Second) } } func Task1(PrintContent chan int) { i:=0 for { PrintContent<-i i++ } } func Task2(PrintContent chan int) { for { fmt.Println(<-PrintContent) } }
编程案例10输出
0 1 2 3 4 (程序一直递增打印数字……)
我们总结一下对比编程案例9和10之后,得出的结论:
- 管道(channel)可以承载数据的特性,使其可以替代资源量
- 管道阻塞特性,使其可以替代信号量,也避免了重复轮询
- 管道变动事件等效于通知A\B双方,从而替代了两个信号量
通过上面的介绍,到这里,你应该明白了:
- 为确保总任务推进有序、合理,并发任务之间需要进行资源协调与通信
- 即使没有管道机制,通过设定资源量、信号量、轮询方式,同样可以实现并发任务协调
- Golang中的管道通过可承载数据与阻塞两大特性,良好的对协调进行了优雅封装
- 管道并不是Golang并发任务协调的唯一方式,管道的作用也不只是进行并发任务协调
任务等待与确认
为什么需要任务等待与确认?
我们先回顾一下投掷者模型,人类因为掌握了投掷,掌握了魔法,实现了进化过程中极少有的远程攻击能力。但是投掷不是目的,远程投掷不是目的,往往是否击中目标才是人们所关心的。包工头模型内也是如此,只会分配任务的领导,不是好领导,分配任务后继续跟进,并提供适当反馈的领导,才是好领导。这么说来,并发体系内需要有任务等待与确认的核心目的为:
- 确保执行的完整可观察
- 对并发任务执行结果进行反馈,从而调整任务分配与协调方式
我们回顾3.2中的编程案例6————带有go关键子基于协程并发的Hello World,为何程序执行后绝大部分情况下没有任何输出,原因就是,对Golang或绝大部分的并发编程体系来说,任务等待与确认并不是默认的,是根据编程人员的需要加入的,你完全可以只顾着抛出球,而不用关心球飞向何方,落在何地。go关键字修饰只负责了任务分配,并不对任务执行结果负责。因而我们需要其他的机制来实现任务等待与确认。
在本节的编程案例9和10内,我们均使用for无限循环来进行对主程的阻塞,并通过调用sleep方法使循环不会这么“暴力”。总之我们通过这些方式实现了对并发任务的等待,使我们能够看到完整的并发任务执行结果。
在3.2节中,你应该了解了Golang提供select机制,对并发任务结果提供确认机制,我们再来回顾一下上一小节中提到的,Golang中select的用法涵义:
- 当需要检查执行结果时,且如果未有结果,程序不阻塞(使用default)
- 当需要等待并检查执行结果时(只使用case,不使用default)
- 只需要等待结果,或者单纯的为了使程序进入阻塞状态(select区块内不添加任何东西)
在3.2的编程案例8————龟兔赛跑里,我们的目的是当乌龟和兔子任意一方到达终点并成功触发信号,比赛结束。select配合case选择机制,有效的传达了乌龟跑步和兔子跑步两个并发任务谁先完成这一结果。
显然,select可以作为一种并发体系内的等待确认机制。那么我们进一步分析select的作用,是否还有其他的方案呢?
分析select的作用,你至少能理解下面两个:
- 提供阻塞,但不会自然提供轮询功能
- 可以通过通信取到数据,进行逻辑判定,决定是否继续阻塞
其实,基于这样的要求,管道也可以做到,体会下面的例子:
package main func main(){ c:=make(chan int) go testFunc() <-c } func testFunc(){ // do sth }
按照管道的基本特性,由于程序执行后管道c内不可能有东西供取出,因而从逻辑上分析,程序将永远在<-c处阻塞。
所以上面的程序,将永远阻塞么?
事实不是这样,Golang提供了运行时检查,你明白c内不可能有内容供取出并且testFunc一下子就执行完了————运行时检查也明白。这样的程序可以通过编译,但是无法成功永久阻塞,该程序实际运行结果如下:
fatal error: all goroutines are asleep - deadlock!(严重错误:所有协程处在休眠状态-发生死锁!)
如果testFunc内,即并发任务,也就是协程负责运行的内容内,有内容还需要运行,那么结果就不是这样,程序在所有协程(goroutine)结束前会一直阻塞在c<-处。这样的机制对于select也同样遵循。
Golang在警示世人,不要在不可能再推进的任务或者是已经圆满的任务上有任何额外消耗。
基本需求分解与并发方案设计
截止目前,你已经掌握了Golang基本语法,基础并发编程能力。对于实际工作中的并发编程,更加重要的是下面两个问题:
- 我有必要运用并发吗?究竟应该在代码什么地方运用并发?
- 我的并发需要控制任务数量吗?如果需要,标准和最佳实践是什么?
回答第一个问题,我们需要进行基本需求分解。遵循之前所介绍,要对需求和工作任务进行归类。
首先,目的为声明、定义、基本控制逻辑的,不能作为并发任务,但可以作为并发任务内的组成元素。其次,哪些工作进行分工可能效率更高?以包工头模型想象,在日常的工作安排中,根据经验可得,我们认为是内聚性质较强的任务分配给一个人去干效率更高。最后,应尝试比较并发前后效率,最简单的方法————计算一下程序采用并发方案或不同并发方案前后的执行时间,你就可以较快的得出第一个问题的答案。
而针对第二个问题,我们需要对并发任务和层级进行梳理。着重解决下列的问题:
- 程序运行环境里是否涉及稀缺资源?程序运行时访问的目标是否涉及稀缺资源?前者涉及程序是否能够正常运行,后者关系到程序并发量的提升是否可以切实促进效率的提升
- 需要评估的代码块内,并发任务数量如何,层级如何,相互之间是否有联系和制约?
- 并发任务是如何影响到稀缺资源的?
无论是第一个问题还是第二个问题,完整的建议都涉及代码和工程建模知识,我们将在3.6中对经典案例进行探讨并直接给出成熟解决方案,第四章中深入对背后基本观点进行探讨,感兴趣的读者可以着重留意。
本节,我们以并发体系必备要素的视角,重新审视Golang中的语法特性,并通过具体案例,清晰阐述了任务分配、任务协调和任务确认三大并发体系要素在Golang中的实现,为全书并发编程实践提供了指导。当然,对于并发控制、并发错误处理、Golang并发高级工具特性、工程化并发编程范式与可伸缩并发编程设计并未有探讨,即便如此,对于并发编程入门,上述知识已经足够。其他内容我们会在之后的内容中进一步介绍。
从这里开始,我们由衷希望希望你可以秉持着上述基本案例和思考,着手对自己先前的工程代码进行基于并发编程的改造工作,相信你能够收获良多。