进阶篇:并发编程相关概念理解

并发编程是编程语言学习中非常重要的知识,但是这块知识中一些概念确实相当难理解,一些概念区分起来很难,比如并发和并行,同步和异步,阻塞和非阻塞,但是这些概念却很重要。因此在此把它总结下来。

一、并发与并行

并发:在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行,但任一个时刻点上只有一个程序在处理机上运行。简言之,是指系统具有处理多个任务的能力。

并行:当系统有一个以上CPU时,则线程的操作有可能非并发。当一个CPU执行一个线程时,另一个CPU可以执行另一个线程,两个线程互不抢占CPU资源,可以同时进行,这种方式我们称之为并行(Parallel)。简言之,是指系统具有同时处理多个任务的能力。

来看个例子

import threading
import time


def music():
    print('begin to listen music {}'.format(time.ctime()))
    time.sleep(3)
    print('stop to listen music {}'.format(time.ctime()))


def game():
    print('begin to play game {}'.format(time.ctime()))
    time.sleep(5)
    print('stop to play game {}'.format(time.ctime()))


if __name__ == '__main__':
    music()
    game()
    print('ending.....')

music的时间为3秒,game的时间为5秒,如果按照我们正常的执行,直接执行函数,那么将按顺序顺序执行,整个过程8秒。

import threading #线程
import time


def music():
    print('begin to listen music {}'.format(time.ctime()))
    time.sleep(3)
    print('stop to listen music {}'.format(time.ctime()))


def game():
    print('begin to play game {}'.format(time.ctime()))
    time.sleep(5)
    print('stop to play game {}'.format(time.ctime()))


if __name__ == '__main__':
    t1 = threading.Thread(target=music) # 创建一个线程对象t1 子线程
    t2 = threading.Thread(target=game) # 创建一个线程对象t2 子线程

    t1.start()
    t2.start()

    # t1.join() # 等待子线程执行完 t1不执行完,谁也不准往下走
    t2.join()

    print('ending.......') # 主线程
    print(time.ctime())

在这个例子中,我们开了两个线程,将music和game两个函数分别通过线程执行,运行结果显示两个线程同时开始,由于听音乐时间3秒,玩游戏时间5秒,所以整个过程完成时间为5秒。我们发现,通过开启多个线程,原本8秒的时间缩短为5秒,原本顺序执行现在是不是看起来好像是并行执行的?看起来好像是这样,听音乐的同时在玩游戏,整个过程的时间随最长的任务时间变化。但真的是这样吗?那么下面我来提出一个GIL锁的概念。

GIL(全局解释器锁):无论你启多少个线程,你有多少个cpu, Python在执行的时候会淡定的在同一时刻只允许一个线程运行。

再来看一个例子

import time
from threading import Thread


def add():
    sum = 0
    i = 1
    while i<=1000000:
        sum += i
        i += 1
    print('sum:',sum)


def mul():
    sum2 = 1
    i = 1
    while i <= 100000:
        sum2 = sum2 * i
        i += 1
    print('sum2:',sum2)


start = time.time()

add()
mul()

print('cost time %s'%(time.time()-start))

import time
from threading import Thread


def add():
    sum = 0
    i = 1
    while i <= 1000000:
        sum += i
        i += 1
    print('sum:',sum)


def mul():
    sum2 = 1
    i = 1
    while i <= 100000:
        sum2 = sum2 * i
        i += 1
    print('sum2:',sum2)


start = time.time()
t1 = Thread(target=add)
t2 = Thread(target=mul)

l = []
l.append(t1)
l.append(t2)

for t in l:
   t.start()

for t in l:
    t.join()

print('cost time %s'%(time.time()-start))

哎吆,这是怎么回事,串行执行比多线程还快?不符合常理呀。是不是颠覆了你的人生观,这个就和GIL锁有关,同一时刻,系统只允许一个线程执行,那么,就是说,本质上我们之前理解的多线程的并行是不存在的,那么之前的例子为什么时间确实缩短了呢?这里有涉及到一个任务的类型,计算机任务分为以下两种类型:

  • IO密集型:会有CPU空闲的时间,sleep等同于IO操作, socket通信也是IO。
  • 计算密集型

之前那个例子恰好是IO密集型的例子,后面这个由于涉及到了加法和乘法,属于计算密集型操作,那么,就产生了一个结论,多线程对于IO密集型任务有作用,而计算密集型任务不推荐使用多线程。我们还可以得到一个结论:由于GIL锁,多线程不可能真正实现并行,所谓的并行也只是宏观上并行微观上并发,本质上是由于遇到io操作不断的cpu切换所造成并行的现象。由于cpu切换速度极快,所以看起来就像是在同时执行。问题在于没有利用多核的优势。

这就造成了多线程不能同时执行,并且增加了切换的开销,串行的效率可能更高。

二、同步与异步

所谓同步就是一个任务的完成需要依赖另外一个任务时,只有等待被依赖的任务完成后,依赖的任务才能算完成,这是一种可靠的任务序列`。要么成功都成功,失败都失败,两个任务的状态可以保持一致。

所谓异步是不需要等待被依赖的任务完成,只是通知被依赖的任务要完成什么工作,依赖的任务也立即执行,只要自己完成了整个任务就算完成了。至于被依赖的任务最终是否真正完成,依赖它的任务无法确定,所以它是不可靠的任务序列。

消息通知

异步的概念和同步相对。当一个同步调用发出后,调用者要一直等待返回消息(结果)通知后,才能进行后续的执行;当一个异步过程调用发出后,调用者不能立刻得到返回消息(结果),调用完成后,被调用方主动通知调用方调用结果。

场景比喻

示例1:银行办理业务

举个例子,比如我去银行办理业务,可能会有两种方式:

  1. 选择排队等候;
  2. 另种选择取一个小纸条上面有我的号码,等到排到我这一号时由柜台的人通知我轮到我去办理业务了;

第一种:前者(排队等候)就是同步等待消息通知,也就是我要一直在等待银行办理业务情况;

第二种:后者(等待别人通知)就是异步等待消息通知。在异步消息处理中,等待消息通知者(在这个例子中就是等待办理业务的人)往往注册一个回调机制,在所等待的事件被触发时由触发机制(在这里是柜台的人)通过某种机制(在这里是写在小纸条上的号码,喊号)找到等待该事件的人。

示例2:买奶茶

  • 同步买奶茶:小明点单交钱,然后等着拿奶茶;
  • 异步买奶茶:小明点单交钱,店员给小明一个小票,等小明奶茶做好了,再来取。

异步买奶茶: 小明要想知道奶茶是否做好了,有两种方式:

  • 小明主动去问店员,一会就去问一下:“奶茶做好了吗?”…直到奶茶做好。这叫轮训。
  • 等奶茶做好了,店员喊一声:“小明,奶茶好了!”,然后小明去取奶茶。这叫回调。

二者区别

通过以上事例,我们将同步与异步的区别总结如下:

同步与异步的重点在消息通知的方式上,也就是调用结果通知的方式。要想获得结果,一般有两种方式:调用方主动轮询异步调用的结果;被调用方主动通知调用方调用结果。

  • 同步:当一个同步调用发出去后,调用者要一直等待调用结果的通知后,才能进行后续的执行。
  • 异步:当一个异步调用发出去后,调用者不能立即得到调用结果的返回,调用完成后,被调用方主动通知调用方调用结果

二者优缺点

  • 同步的执行效率会比较低,耗费时间,但有利于我们对流程进行控制,避免很多不可掌控的意外情况;
  • 异步的执行效率高,节省时间,但是会占用更多的资源,也不利于我们对进程进行控制。

使用场景

异步使用场景

  • 不涉及共享资源,或对共享资源只读,即非互斥操作
  • 没有时序上的严格关系
  • 不需要原子操作,或可以通过其他方式控制原子性
  • 常用于IO操作等耗时操作,因为比较影响客户体验和使用性能
  • 不影响主线程逻辑

同步使用场景

  • 不使用异步的时候

三、阻塞与非阻塞

概念描述

阻塞和非阻塞这两个概念与程序(线程)等待消息通知(无所谓同步或者异步)时的状态有关。也就是说阻塞与非阻塞主要是程序(线程)等待消息通知时的状态角度来说的。

阻塞与非阻塞的重点在于进/线程等待消息时候的行为,也就是在等待消息的时候,当前进/线程是挂起状态,还是非挂起状态。

  • 阻塞调用在发出去后,在消息返回之前,当前进/线程会被挂起,直到有消息返回,当前进/线程才会被激活。
  • 非阻塞调用在发出去后,不会阻塞当前进/线程,而是立即返回,当前进程/线程会执行其他操作。

有人也许会把阻塞调用和同步调用等同起来,实际上它们是不同的。

  1. 对于同步调用来说,很多时候当前线程可能还是激活的,只是从逻辑上当前函数没有返回而已,此时,这个线程可能也会处理其他的消息。还有一点,在这里先扩展下:

(a) 如果这个线程在等待当前函数返回时,仍在执行其他消息处理,那这种情况就叫做同步非阻塞;

(b) 如果这个线程在等待当前函数返回时,没有执行其他消息处理,而是处于挂起等待状态,那这种情况就叫做同步阻塞;

所以同步的实现方式会有两种:同步阻塞、同步非阻塞;同理,异步也会有两种实现:异步阻塞、异步非阻塞;

  1. 对于阻塞调用来说,则当前线程就会被挂起等待当前函数返回;

非阻塞和阻塞的概念相对应,指在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回。虽然表面上看非阻塞的方式可以明显的提高CPU的利用率,但是也带了另外一种后果就是系统的线程切换增加。增加的CPU执行时间能不能补偿系统的切换成本需要好好评估。

场景比喻

继续上面的那个例子,不论是排队还是使用号码等待通知,如果在这个等待的过程中,等待者除了等待消息通知之外不能做其它的事情,那么该机制就是阻塞的,表现在程序中,也就是该程序一直阻塞在该函数调用处不能继续往下执行。

相反,有的人喜欢在银行办理这些业务的时候一边打打电话发发短信一边等待,这样的状态就是非阻塞的,因为他(等待者)没有阻塞在这个消息通知上,而是一边做自己的事情一边等待。

但是需要注意了,同步非阻塞形式实际上是效率低下的,想象一下你一边打着电话一边还需要抬头看到底队伍排到你了没有。如果把打电话和观察排队的位置看成是程序的两个操作的话,这个程序需要在这两种不同的行为之间来回的切换,效率可想而知是低下的;而异步非阻塞形式却没有这样的问题,因为打电话是你(等待者)的事情,而通知你则是柜台(消息触发机制)的事情,程序没有在两种不同的操作中来回切换。

再回到买奶茶的例子中:

  • 阻塞买奶茶:小明点单交钱,干等着拿奶茶,什么事都不做;
  • 非阻塞买奶茶:小明点单交钱,等着拿奶茶,等的过程中,时不时刷刷微博、朋友圈。

通过上面的分析,我们可以得知:同步与异步,重点在于消息通知的方式;阻塞与非阻塞,重点在于等消息时候的行为

所以,就有了下面4种组合方式:

  • 同步阻塞:小明在柜台干等着拿奶茶;
  • 同步非阻塞:小明在柜台边刷微博边等着拿奶茶;
  • 异步阻塞:小明拿着小票啥都不干,一直等着店员通知他拿奶茶;
  • 异步非阻塞:小明拿着小票,刷着微博,等着店员通知他拿奶茶。

四、场景梳理

对上面所讲的概念再次进行一个场景梳理,上面已经明确说明,同步/异步关注的是消息通知的机制,而阻塞/非阻塞关注的是程序(线程)等待消息通知时的状态。以小明下载文件打个比方,从这两个关注点来再次说明这两组概念,希望能够更好的促进大家的理解。

  1. 同步阻塞:小明一直盯着下载进度条,到 100% 的时候就完成。

同步体现在:等待下载完成通知;

阻塞体现在:等待下载完成通知过程中,不能做其他任务处理;

  1. 同步非阻塞:小明提交下载任务后就去干别的,每过一段时间就去瞄一眼进度条,看到 100% 就完成。

同步体现在:等待下载完成通知;

非阻塞体现在:等待下载完成通知过程中,去干别的任务了,只是时不时会瞄一眼进度条;【小明必须要在两个任务间切换,关注下载进度】

  1. 异步阻塞:小明换了个有下载完成通知功能的软件,下载完成就“叮”一声。不过小明仍然一直等待“叮”的声音(看起来很傻,不是吗)。

异步体现在:下载完成“叮”一声通知;

阻塞体现在:等待下载完成“叮”一声通知过程中,不能做其他任务处理;

  1. 异步非阻塞:仍然是那个会“叮”一声的下载软件,小明提交下载任务后就去干别的,听到“叮”的一声就知道完成了。

异步体现在:下载完成“叮”一声通知;

非阻塞体现在:等待下载完成“叮”一声通知过程中,去干别的任务了,只需要接收“叮”声通知即可;【软件处理下载任务,小明处理其他任务,不需关注进度,只需接收软件“叮”声通知,即可】

也就是说,**同步/异步是“下载完成消息”通知的方式(机制),而阻塞/非阻塞则是在等待“下载完成消息”通知过程中的状态(能不能干其他任务)**,在不同的场景下,同步/异步、阻塞/非阻塞的四种组合都有应用。

所以,综上所述,同步和异步仅仅是关注的消息如何通知的机制,而阻塞与非阻塞关注的是等待消息通知时的状态。也就是说,同步的情况下,是由处理消息者自己去等待消息是否被触发,而异步的情况下是由触发机制来通知处理消息者,所以在异步机制中,处理消息者和触发机制之间就需要一个连接的桥梁

在银行的例子中,这个桥梁就是小纸条上面的号码。

在小明的例子中,这个桥梁就是软件“叮”的声音。

五、简单小结

从另一个角度来讲,同步和异步是针对执行过程来讲的,比如买奶茶、银行处理业务、商家卖商品、网络请求等,同步和异步的区别在于这些执行过程中消息通知的机制。如果这些执行过程不主动通知,必须由调用者主动查询执行过程是否完成,则是同步执行;如果这些执行过程采取主动通知的方式,即执行过程完成之后主动通知调用者,则是异步执行。因此,同步和异步可以通过修改执行过程消息通知的方式进行改变,在生活中如果我们是商家,可以通过改变自身消息通知方式来改变自己是同步还是异步执行过程。

而阻塞和非阻塞是针对调用者来说的,比如对于买奶茶、银行处理业务、商家卖商品、网络请求等执行过程的调用者来说,阻塞和非阻塞的区别在于调用者等待执行过程完成时的状态。如果调用者在等待执行过程完成时,调用者的状态被挂起,即不能做其他事情,就是阻塞,直到执行过程完成则结束阻塞;如果调用者在等待执行过程完成时,调用者的状态没有被挂起,而是去做其他事情。这时候同步的情况下如果调用者采用的是非阻塞的方式,即等待执行过程完成时去做其他事情,由于执行过程不会主动通知调用者,调用者必须主动查看执行过程是否完成,则需要在做其他事情和查询执行过程是否完成之间不断切换,此时对于进程/线程来说系统切换开销较大,对于人来说频繁切换状态也非常麻烦,所以同步的情况一般会采用阻塞的方式,即常听到的同步阻塞。而异步的情况下由于执行过程结束时会主动通知调用者,此时调用者去做其他事情就不需要考虑查询执行过程是否完成这件事情,即采用非阻塞的方式,省去了系统切换的开销,此时对于进程/线程来说充分利用了其处理性能,对于人来说充分利用了空闲时间,做了很多事情。所以异步的情况会采用非阻塞的方式,即常听见的异步非阻塞。因此,阻塞和非阻塞可以通过修改调用者在等待执行过程完成时的状态来改变,在生活中,我们在办理一些业务、买一些东西时,如果在这个等待这个执行过程中,我们什么也不做,直到在这个执行过程完成之后,这个过程就是阻塞。如果我们等待时去做了其他事情,比如玩手机、看电视等,则是非阻塞状态,即当前执行过程不会阻塞调用者做其他事情。但是在这种情况,我们一般会根据执行过程的消息通知机制来判断我们是采用阻塞还是非阻塞的方式,比如我们在等外卖的时候,由于外卖送到的时候会电话通知,我们经常会选择这个时候去做其他事情,这就是异步非阻塞;我们在餐厅买饭、小吃摊买手抓饼的时候,这种情况下一般需要我们主动去查看打饭或手抓饼是否完成,我们一般不去做其他事情,即选择阻塞的方式,因为注意力需要经常切换,长时间走神的话打饭或者手抓饼完成已经有一段时间了。即阻塞和非阻塞是由调用者选择的。


文章作者: 张亚飞
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 张亚飞 !
评论
 上一篇
pandas和SQL操作语句对照 pandas和SQL操作语句对照
SQL的神奇之处在于它容易学习,而它容易学习的原因是代码语法非常直观。另一方面,Pandas不是那么直观,特别是如果像我一样首先从SQL开始。就我个人而言,我发现真正有用的是思考如何在SQL中操作数据,然后在Pandas中复制它。所以如果你
下一篇 
轻松玩转pandas 轻松玩转pandas
pandas系列教程,带你轻松玩转pandas。教程文档和源码已上传至git仓库
  目录