《用Go理解并发编程》1-2 定义与理解并发
本小节我们将重点讨论并发权威定义,以及如何理解。你将会看到针对并发、并行、分布式等易混淆概念的介绍与理解。
并发定义
狭义
并发一词是计算机术语,根据百度百科定义,狭义的并发是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行,但任一个时刻点上只有一个程序在处理机上运行。
如何理解这个狭义的定义?笔者认为这对某些初学者来说已经比较绕了。前文还说几个程序都在同一个处理机上处于已启动运行到运行完毕之间,怎么后文又变成了任意时刻点上只有一个程序在处理机上运行呢?别急,我们先来看广义的并发定义。
广义
广义上,并发指逻辑控制流在时间上重叠。
我们现在来解释其中的奥妙。准确的说,在程序控制流范畴内,“暂停”和“运行完毕”是不一样的。程序如果运行完毕,则代表全部流程已经结束,但暂停则表示程序还可以重启以进行后续的流程。暂停的程序可以不占用运行时资源,因而,保证一个处理机上单位时间内只有一个程序在运行,与多个程序都处于已启动(开始)到运行完毕(结束/停止)之间,并不冲突。
识别并发过程
在计算机编程领域,笔者认为,理解并发过程,重点在于理解下列内容:
- 并发的定义
- 组织并发的数据流特征
- 并发思想在工程作用后的预期
在并发定义小节内,我们已经介绍了并发广义和狭义的定义。然而日常过程中我们更多接触并发,是在具体的工程中,因而为了更好的理解并发过程,我们需要先识别出程序中的并发过程。接下来我们进行简单介绍。
并发计算数据流
当并发思想作用于具体的工程,我们一般称之为并发计算。并发计算,简单来说,就是将一个计算任务,分区成几个小的部分,期望其被同时计算以完成任务。它跟并行计算(Parallel computing)与分布式计算(Distributed computing),有重叠之处,在概念上不同,但常会让人混淆。
本质上说,并发计算是并发思想在解决串行作业系统与并行化作业需求这一矛盾过程中客观实现的各类解决方案的运行时体现,如无特别说明,后文的并发均指代并发计算。
从并发计算的定义中可以看出,并发计算的关键在于,在程序的逻辑流程中,是否存在一种设计,使得某一块计算任务被区分成了几个小的部分,并要求其同时被计算(即便实际可能无法同时发生)。那么在程序中我们只要发现了符合这种定义的地方,该程序我们便认为是运用了并发计算技术。
并发任务
了解了并发计算数据流的特点,不难发现,凡是符合运用并发计算技术的程序,都能找到被区分成几个小的部分的计算任务。为了便于说明,笔者后文将用“并发任务”一词指代分割后的小部分计算任务。
让我们继续来关注Golang中的并发任务是如何识别的。阅读过前言的朋友能够看到,在Golang中,实现并发计算,我们使用go关键字修饰函数,使对应的函数块在运行时进入并发状态,我们称一次这样的运行为启动了一个并发任务,或者说启动了一个goroutine(涉及到后文的协程概念,此处不展开):
func A(){ // do sth } func main{ go A()//通过这种方式使函数A进入并发状态 }
而在Java中,我们声明继承Thread类的类,或者是书写实现Runnable接口的类,来实现并发。其中,我们通过在声明时使用run方法包裹需要并发运行的程序区块,并在确认需要进入并发状态时使用start方法调用,具体如下:
public class ThreadCreateDemo1 { public static void main(String[] args) { MyThread thread = new MyThread(); thread.start(); //由于Java思想是一切皆对象,因而这里可以理解为使MyThread进入了并发状态 } } class MyThread extends Thread { //此处通过继承Thread类方式实现多线程,进而实现并发计算 @Override public void run() { super.run(); // do sth } }
public class ThreadCreateDemo2 { public static void main(String[] args) { Runnable runnable = new MyRunnable(); Thread thread = new Thread(runnable); thread.start();// 同理,这里可以理解为使MyThread进入了并发状态 } } class MyRunnable implements Runnable { //此处通过实现Runnable接口的方式实现多线程,进而实现并发计算 public void run() { // do sth } }
在JavaScript内,通过书写Promise(ES6开始原生支持,由于现在探讨JavaScript技术,起步ES6,因而将Promise作为典型案例),使得指定函数块进入异步状态,全局来看,对应的函数块也可以看作使用了并发计算,如下是一个JavaScript使用Promise的案例:
let another_parameter = xxx1; //和Promise初始化过程相关的其他变量 let js_example=new Promise(function (resolve,reject) { //先声明Promise,其中resolve与reject是Promise运行结果 // do sth // another_parameter = xxx2 //可以在Promise初始化时修改外部变量 if (/* 异步操作成功的判定条件 */) { resolve(value); } else { reject(error); } } ) js_example.then(function (res) { //Promise被使用,对应的do sth将进入并发状态。如果需要保持对并发过程的跟踪,需要在随后紧跟.then,表示并发过程被执行完毕后接下来应该要发生的内容。 // handle res // another_parameter = xxx2 //也可以在Promise到处理res阶段修改外部变量 },function (err) { // handle err // another_parameter = xxx2 //也可以在Promise到处理err阶段修改外部变量 } )
在ES7,JavaScript引入了async/await关键字,如下的JavaScript程序段内的async与上述Promise作用等同:
async function jsexample(parameter){ try{ // do sth }catch(err){ // handle err } } let another_parameter = xxx1; jsexample(another_parameter); // 如果需要上一个例子内.then的作用,则需要在此处为await jsexample(another_parameter)
作为语法糖,这项语法改进希望用更加直观、封装边界更加清楚的方式告诉程序员:对应函数块使用了并发计算,将在运行时被异步调用而进入并发状态。
除了上述介绍的JavaScript并发实现方式,JavaScript开发同学可以进一步关注web worker多线程实现JavaScript并发编程的内容。
而在Python内,我们也能找到关键字start_new_thread,作为标准python使用多线程进行并发编程的标志,一个示例如下:
#!/usr/bin/python3 import _thread import time # 为线程定义一个函数 def print_time( threadName, delay): count = 0 while count < 5: time.sleep(delay) count += 1 print ("%s: %s" % ( threadName, time.ctime(time.time()) )) # 创建两个线程 try: _thread.start_new_thread( print_time, ("Thread-1", 2, ) ) _thread.start_new_thread( print_time, ("Thread-2", 4, ) ) except: print ("Error: 无法启动线程") while 1: pass
Python还有其他利用多进程、协程实现并发的方法,这里不进一步阐述。
从上面的例子内,我们可以看到,一般高级语言会通过特定的关键字,作为区分是否使用并发计算技术的界限,通过计算由这些关键字标识语句的执行次数,我们可以方便的识别并衡量程序中的并发任务数量。这些语言承载并发技术的载体/思路,不尽相同,早期程序届的主流做法是利用多进程/多线程实现并发作业,随着Golang等面向并发风格优化了的语言的兴起,协程调度作为一种更好的实现高性能并发的设计被越来越多人追捧,我们将在本专栏第六章简单介绍这三者的区别。
除了典型的由标准关键字词声明的并发标志,分析程序时我们还需要注意隐式的并发调用。
隐式并发任务
高级语言往往具有明确的关键字以声明函数是否使用了并发计算,但我们需要注意,部分场景下,程序可能存在隐式的并发调用,这可能来源于客观能够实现并发的其他关键字,也可能来源于已经包裹了并发计算能力的第三方函数,这在程序建模中尤其需要注意。
例如python中的map函数,如果是使用的multiprocessing或者是multiprocessing.dummy中的map函数,那么此时的map将基于多进程/多线程实现并发计算,map函数内部的结果产出将不再按序(并发计算后带来的副作用)。
如下是使用dummy模块实现多线程并发的map使用案例:
import urllib2 from multiprocessing.dummy import Pool as ThreadPool urls = [ 'http://www.python.org', 'http://www.python.org/about/', 'http://www.onlamp.com/pub/a/python/2003/04/17/metaclasses.html', 'http://www.python.org/doc/', 'http://www.python.org/download/', 'http://www.python.org/getit/', 'http://www.python.org/community/', 'https://wiki.python.org/moin/', 'http://planet.python.org/', 'https://wiki.python.org/moin/LocalUserGroups', 'http://www.python.org/psf/', 'http://docs.python.org/devguide/', 'http://www.python.org/community/awards/' # etc.. ] # 建立线程池 pool = ThreadPool(4) results = pool.map(urllib2.urlopen, urls) #此处的map将使用并发计算的方案 #下面两句也是固定写法,表示关闭线程池并等待任务结束 pool.close() pool.join()
又例如bash shell中的&,在linux脚本撰写中,我们学习到&表示将任务转入后台执行,其实这种说法隐含了并发的含义。想一想并发思想起源内人与人之间协作的场景吧,转入后台执行,相当于将该任务“交给他人执行”,也是一种并发思想的体现,尽管&的本意只是转入后台执行。事实上,在使用&关键词修饰后,将依托unix(linux本身也是由unix拓展而来)本身的系统进程管理,依托多进程方式实现并发计算。
echo "hello world!" & #通过“转入后台”的方式,实现自然的并发
由于隐式并发任务往往逻辑是黑箱且自洽,基本不会暴露接口与黑箱外部相互影响和受控,因而我们不单独探讨隐式并发任务的并发任务计数,只需要关注输入输出即可。在一定需要统计隐式并发任务的并发任务数量时(例如统计客观上产生了多少进程),可以阅读对应函数的文档,或者是通过阅读源代码的方式,将隐式并发任务转化为显式并发任务计数。
理解并发过程
并发计算与并行计算、分布式计算
我们在前文简单介绍了如何识别高级语言程序内是否使用了并发计算技术,也举出了一些常见语言的并发计算示例,为了更好的理解并发思想在工程作用后的预期,我们需要简单介绍一下并发计算与并行计算、分布式计算的区别。
根据并发计算定义,只要任务被拆分成至少两个子任务,并且有期望其同时计算的需求表露出来,这就可以被称为运用了并发计算。
并行计算将更强调客观上的同时,因而我们在探讨并发概念时,会强调单个处理机,而在探讨并行时,一定会提到多处理机。
分布式计算,则是在并行计算的基础上,强调依托网络联系各分立计算部分,实现信息共享与协同计算。
可以看到,三者的区别在,并发计算关注“分拆与协同的艺术”,强调任务拆分、任务协调(调度);并行计算强调客观同时,强调计算实际的并行性;分布式计算强调计算机网络在并行计算中的作用。
也就是说,当程序运用了并发计算后,在程序流程上,并发任务将逻辑并行,但是客观上并发任务不一定并行运行,这将取决于调度系统是否支持并行运行,以及客观环境资源是否满足能让并发任务同时运行的条件。
合适的理解方式
尽管网上有很多用于理解并发过程的案例,但是在本专栏内,笔者隆重推荐投掷者模型和包工头模型两大理解案例用于理解各类并发计算流程,在这里简单介绍一下。
投掷者模型
将每个并发任务想象为抛出去的球,发起并发任务的程序就好比投掷者。几个含义:1.并发任务开始运行后,默认与发起该并发任务的主程序无关。联系通过观察和通讯实现。2.提倡并发任务具有整体性、黑箱性,边界清楚。3.球的飞行依赖自然规律(惯性、重力等),抛出球的多少、先后完全依赖于投掷者。提倡对并发系统的程序分析,不考虑底层调度逻辑。
包工头模型
将整个程序比喻为公司,主程序就好比老板,当老板认为人手不够时,需要安排任务给下手干。并发任务的运行就好比下手干活。几个含义:1.并发计算关注任务的分配、协调、结果收集,就好比老板跟踪员工的工作,同时追求总体结果最优。2.并发任务实际是否并行,也取决于自然环境。例如两名员工日常工作都需要占用一个稀缺资源,即使两名员工被分配的任务不同,也会因为这个稀缺资源,出现局部串行。3.并发任务本身内部可以再开启新的并发任务,称为并发计算的嵌套。这一点很容易理解,就好比是下属又将活根据其自己的理解分配给下属的下属。如当前并发任务是封装良好的黑箱,并发嵌套不影响对上一层级的黑箱完整性。
良好的理解方式,可以在程序代码分析不甚明了的情况下,做出对技术方案产出的正确预测。
理性看待运用并发技术后的产出预期
如果能够正确理解并发过程,那么我们就可以准确的理解程序使用并发技术之后的预期。我们总希望一种技术是有益的,体现在这门技术用于我们的程序后,要么在整体任务完成效率上有所提升,要么在任务成本消耗上有所降低。
简单说来,并发技术作用于程序后,即便使用的方法完全正确,也并不是所有情况下对程序都是有益的。另一方面,也需要认识到并发计算方法的优化,对程序效率的提升是有限度的。如何说明?我们简单结合上文中提及的理解方式,联系生活实际,先来定性。
想象在企业内部,为何用人单位总是需要动态调整人员规模?答案想必是明确的,一方面,人数过少,面对更加复杂艰巨的任务时,每位员工将超额负载;人数过多,光是协调这些人员的日常,就会耗费过多精力,这也是很多所谓大公司病的渊源——即便从方法分析上说公司的日常管理没有问题,但就是效率不及小公司,其实就是结构臃肿+人太多了。(包工头模型)
又想象你在投球,根据并发只关注于任务的分配与协调,你只能决定单位时间内抛出球的多少与先后,这就意味着,无论你单位时间内投出多少球,用什么顺序投出,最终投完所有球的最小时间,一定大于单个球在空中飞行的时间(因为球离手后,投掷者就没法控制了)。从这个案例中可以想象出,某些情况下无论并发计算方法如何优化,对于整个程序的效率提升,也是有限度的。(投掷者模型)
并发计算不是银弹,通过正确理解并发概念,我们能够更加理性的看待程序运用并发技术后的产出预期。我们还将在之后结合具体的案例分析并发技术运用在计算后的效率改进。