并行与并发
简单介绍
我们先对这两个概念做一个简单的描述:
并行: 形容2个或多个地位相同的子任务同时运行的场景,通常运行于多个CPU或线程。
并发: 形容2个任务同时运行的场景,且这两个任务通常地位不同,最常见的例如:主进程抛出一个子线程在后台执行其他任务,自己继续执行主任务。
并行(parallel)与并发(concurrency)是逻辑概念,并行任务通常是指将大的任务拆分为多个地位相同的子任务,而并发则通常是指主任务抛出一个子任务,即便主任务在不同时间抛出了多个子任务,这些子任务之间也通常没有什么关联,我们只会把子任务和主任务本身称作并发(即同时发生)。
也就是说,并发是一种相对的概念。我们常说子任务是相对于主任务的并发
,所以我在上文中将并发简化描述为2个任务同时执行
。实际上,并发可以包含多个任务,也可以指在不同时间段启动的任务,只要它们在逻辑上存在交替或重叠的运行过程,就可以认为是并发。
协程(coroutine/goroutine)、多线程(multi thread)与多进程(multi process)则是具体的物理手段,并发的子任务可以用协程、线程、进程实现,并行的子任务也是用协程、线程、进程实现。
具体到具体的编程语言则是:在python语言中并行一般通过threading、multiprocessing两个标准库提供的多线程和多进程功能实现,并发一般通过async/await关键字实现。在go语言中则都是通过go关键字提供的goroutine实现的。
举两个例子:
并行: 餐厅人数众多,因此老板新增了多个取餐口(服务员),每个取餐口的服务员都是在并行的处理相同的任务,地位相同,这叫服务员并行
处理任务。
并发: 顾客到达取餐口后直接告诉服务员要什么餐,但是不在队列头继续等待食物制作完成,而是到餐厅里找了把椅子玩手机,等餐食完成后叫号直接去拿即可,这叫顾客并发
处理事务,一边玩手机一遍还能等待食物制作完成。
python协程
python中的多线程与多进程通过threading、multiprocessing两个标准库实现,这些比较简单不用多说。
python早期协程仅能通过yeild关键字/gevent库等定义,现在更通用的方式则是async与await实现对协程的管理。
async用于定义一个支持异步的函数,例如:
async foo():
...
result = await async_function()
...
await表示当foo函数执行到本行时,处理器直接去执行其他任务/函数,等到async_function()执行完毕后处理器再接续运行foo函数,获取async_function()的结果并执行await下一行的代码,与go语言中的go关键字效果相似(当然原理差异很大),不相似的点则在于go func()的结果无法直接获取只能通过channel等待获得,而await可以直接通过事件循环拿到函数返回值赋值给result,而造成这种差异的本质就是因为goroutine是真正的、会被分配到不同processor的并发,而python是在单核运行的前提下额外构建了事件循环来控制并发的所以结果可以直接获取。
await关键字后的函数必须是支持异步的函数,现在一般来说就是:await后的函数必须是async定义的支持异步的函数,否则运行时会报错:TypeError: object int can't be used in 'await' expression
。
async函数中可以不包含await关键字,但是这样的函数是有缺陷的,当他被直接执行时本质上只是生成一个coroutine(协程)且这个coroutine无法启动运行,也因此你永远无法获取其执行结果,因为他实际根本没有被执行过。
但是对于这样不包含await关键字的async定义的函数,你却可以使用await调用,此时await发现此函数没有异步相关的支持就会直接阻塞式的执行完毕,即await调用一个不包含await关键字的async函数时,此函数会像普通函数一样运行:
async foo():
time.sleep(10)
async run():
...
await foo()
...
# await关键字只能出现在async函数中
foo是一个伪异步函数,await foo()
时不会交出处理器控制权,而是直接sleep 10s然后继续执行接下来的代码。
在python中直接定义一个async函数是比较困难的,官方的很多示例直接用await asyncio.sleep(1)
来演示,但是实际编程中这个函数基本不会用到;一般来说我们都是通过await一些支持异步的标准库或第三方库来实现自身的async函数定义的,如果想要自行实现,可以查看官方文档或大模型,这里不再展开。
js/typescript中的async/await亦是相似的实现逻辑。
同步原语
从前述讲解可知并行和并发是逻辑概念,协程、子线程、子进程才是具体的物理实现,那么子任务之间的信息交互和对于共享资源的访问是如何控制的呢?
python语言中最常用的threading.Lock和threading.Semaphore两个同步原语分别提供了子任务之间对共享资源访问的控制和对子任务规模的控制。
当多个子任务(并行场景)或子任务与父任务(并发场景)需要访问相同的外部资源时,我们需要避免外部资源被同时修改产生数据异常,因此使用Lock可以加一个独占的锁,确保同一时刻只能有一个任务在访问资源。
而Semaphore则是一个有上限的计数器,多个子任务通过访问此计数器来决定自己是否应当开始自己的任务处理,每当有任务开始计数器+1,结束时计数器-1,每个任务开始时都检查计数器是否到达上限,到达上限则等待,未达上限则计数器+1开始运行。
golang中的常见的同步原语则是sync包中的对象,例如sync.Mutex,sync.WaitGroup等,这两者其实就相当于python中的Lock与Semaphore。
如果你对这些同步原语就熟悉就会发现,他们基本上只能控制子任务对共享资源的访问或控制子任务规模,很难直接控制子任务的具体执行逻辑,例如什么时候中断执行什么时候直接返回,在python中课本或文档会直接告诉你python的子线程之间或子进程与主线程之间时很难通信的,即主任务抛出子任务之后除了等待子任务自行完成或异常退出外没什么好办法,除非依赖外部的数据(如访问数据库等)。
那么有什么办法实现编程语言层面的子任务间的通信呢?golang提供了context包,由于子任务的通信需求基本只集中在并发场景(如上述示例中的顾客等待取餐消息或顾客发送终止制作的消息等),所以我一般认为context就是golang专门为并发设计的同步原语。
Go context库
WithCancel、WithTimeout、WithValue是最常用的三种并发同步原语,分别可以用于在子任务(golang中就是goroutine)之间传递 任务取消、任务超时、任务检查此值 的信息。
通过上述3种context,我们可以在主任务(main goroutine)中便捷的控制子任务的生命周期,分别实现 想让他取消就取消、给他设个超时时间、让他看到某个值后就做什么动作 的需求。