Python协程原理介绍及基本使用
1.协程的简单介绍
1.1 什么是协程?
协程,又称微线程。协程是python个中另外一种实现多任务的方式,只不过比线程更小占用更小执行单元(理解为需要的资源)。 为啥说它是一个执行单元,因为它自带CPU上下文。这样只要在合适的时机, 我们可以把一个协程切换到另一个协程。 只要这个过程中保存或恢复 CPU上下文那么程序还是可以运行的。
通俗的理解:在一个线程中的某个函数,可以在任何地方保存当前函数的一些临时变量等信息,然后切换到另外一个函数中执行,注意不是通过调用函数的方式做到的,并且切换的次数以及什么时候再切换到原来的函数都由开发者自己确定。(就是自己控制啥时候切换)
再来另一篇文章的解释。可以认为是比线程更小的执行单元,因为他自带CPU上下文。这样只要在合适的时机, 我们可以把一个协程切换到另一个协程。 只要这个过程中保存或恢复CPU上下文那么程序还是可以运行的。
目前的协程框架一般都是设计成 1:N 模式,即一个线程作为一个容器里面放置多个协程。协程的切换,是由协程自身去主动让出cpu。当一个协程发现自己执行不下去了(比如异步等待网络的数据回来,但是当前还没有数据到), 这个时候就可以由这个协程通知调度器去执行到调度器代码,调度器根据事先设计好的调度算法找到当前最需要CPU的协程。 切换这个协程的CPU上下文,把CPU的运行权交个这个协程。
协程的工作原理更详细的说明
1.协程由于由程序主动控制切换,没有线程切换的开销,所以执行效率极高。对于IO密集型任务非常适用,如果是cpu密集型,推荐多进程+协程的方式。
2.线程的切换会保存到CPU的栈里,协程拥有自己的寄存器上下文和栈,
3.协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈
4.协程能保留上一次调用时的状态(即所有局部状态的一个特定组合),每次过程重入时,就相当于进入上一次调用的状态
5. 协程最主要的作用是在单线程的条件下实现并发的效果,但实际上还是串行的(像yield一样)
协程的作用:是在执行函数A时,可以随时中断,去执行函数B,然后中断继续执行函数A(可以自由切换)。但这一过程并不是函数调用(没有调用语句),这一整个过程看似像多线程,然而协程只有一个线程执行.
说明
协程的主要特色是:
协程间是协同调度的,这使得并发量数万以上的时候,协程的性能是远远高于线程。
注意这里也是“并发”,不是“并行”。
目的:
想要在单线程下实现并发异步
并发指的是多个任务看起来是同时运行的
并发=切换+保存状态
1.2 为什么协程更快?
这张图说明了什么?首先,一条线程是进程中一个单一的顺序控制流,一个进程可以并发多个线程执行不同任务。协程由单一线程内部发出控制信号进行调度,而非受到操作系统管理,因此协程没有切换开销和同步锁机制,具有极高的执行效率。
在实现多任务时, 线程切换从系统层面远不止保存和恢复 CPU上下文这么简单。 操作系统为了程序运行的高效性每个线程都有自己缓存Cache等等数据,操作系统还会帮你做这些数据的恢复操作。 所以线程的切换非常耗性能。但是协程的切换只是单纯的操作CPU的上下文,所以一秒钟切换个上百万次系统都抗的住。
1.3 进程、线程和协程的区别和关系
- 一个进程至少有一个线程,进程里面可以有多个线程。一个线程里面可以有多个协程
- 进程是资源分配的单位
- 线程是操作系统调度的单位
- 进程切换需要的资源最大,效率很低
- 线程切换需要的资源一般,效率一般(当然了在不考虑GIL的情况下)
- 协程切换任务资源很小,效率高
- 多进程、多线程根据cpu核数不一样可能是并行的,但是协程是在一个线程中 所以是并发
详细的参考本篇文章: https://www.cnblogs.com/lizexiong/p/17189908.html
1.4 协程的优缺点
优点:
不仅是处理高并发(单线程下处理高并发),还特别节省资源(协程的本质是一个单线程,当然节省空间)。
协程的切换开销更小,属于程序级别的切换,无需线程上下文切换的开销,操作系统完全感知不到,因而更加轻量级
方便切换控制流,简化编程模型
单线程内就可以实现并发的效果,最大限度地利用cpu
高并发+高扩展性+低成本:一个CPU支持上万的协程都不是问题。所以很适合用于高并发处理
缺点:
缺点是无法利用多核资源,本质是单核的,它不能同时将单个CPU的多个核用上,协程需要和进程配合才能运行在多CPU上。
协程的本质是单线程下,无法利用多核,可以是一个程序开启多个进程,每个进程内开启多个线程,每个线程内开启协程
协程指的是单个线程,多个任务一旦有一个阻塞没有切,整个线程都阻塞在原地,该线程内的其余的任务都不能执行了(可以解决)
一旦引入协程,就须要检测单线程下全部的IO行为, 实现遇到IO就切换,少一个都不行,觉得一旦一个任务阻塞了,整个线程就阻塞了, 其余的任务即使是能够计算,可是也没法运行了
1.5 协程的实现
首先说一句gevent 是对greenlet进行的封装,而greenlet 又是对yield进行封装。(具体的示例咱们见第二章)
gevent :gevent只用起一个线程,当请求发出去后 gevent就不管,永远就只有一个线程工作,谁先回来谁先处理。
为什么协程能够遇到 I/O 自动切换 ?greenlet是C语言写的一个模块,遇到 I/O 手动切换,协程有一个gevent模块,封装了greenle模块,遇到 I/O自动切换。
1.6 协程的进化史
Python 对协程的支持经历了多个版本:
- Python2.x 对协程的支持比较有限,通过 yield 关键字支持的生成器实现了一部分协程的功能但不完全。
- 第三方库 gevent 对协程有更好的支持。
- Python3.4 中提供了 asyncio 模块。
- Python3.5 中引入了 async/await 关键字。
- Python3.6 中 asyncio 模块更加完善和稳定。
- Python3.7 中内置了 async/await 关键字。
2.课堂实现
上面说过了,协程的核心思想就在于执行者对控制流的 “主动让出” 和 “恢复”。相对于,线程此类的 “抢占式调度” 而言,协程是一种 “协作式调度” 方式,协程之间执行任务按照一定顺序交替执行。
也说了,协程是并发不是并行,并且刚才的缺点有一个是“进行阻塞(Blocking)操作(如IO时)会阻塞掉整个程序(因为是串行的)”,这个问题可以解决,见上图的讲解案例,解决大概思路,遇到阻塞就切换协程,这里也是课堂上简约的一个解决方案。
假设协程a调用os的接口,然后把硬盘上的file读取,这个过程是阻塞的,因为是串行的,怎么才能不阻塞?
那么如果我文件开始读取的时候,我把这个任务丢到操作系统的队列里面,然后我就去干其他事情,等会协程在切换回来,这时候可能file就读取到了。如果没读取完后,那么在放到队列里面,等会在切换回来看看读取完成,这样是否就可以不阻塞了。
现在用一个案例来演示。
2.1 yield关键字(Python2.x开始)
看楼上的例子,我问你这算不算做是协程呢?你说,我他妈哪知道呀,你前面说了一堆废话,但是并没告诉我协程的标准形态呀,我腚眼一想,觉得你说也对,那好,我们先给协程一个标准定义,即符合什么条件就能称之为协程:
- 必须在只有一个单线程里实现并发
- 修改共享数据不需加锁
- 用户程序里自己保存多个控制流的上下文栈
- 一个协程遇到IO操作自动切换到其它协程
基于上面这4点定义,我们刚才用yield实现的程并不能算是合格的线程,因为它有一点功能没实现,哪一点呢?那就是阻塞切换。
下面先来一个过渡产品:Greenlet
2.2 Greenlet
greenlet是一个用C实现的协程模块,相比与python自带的yield,它可以使你在任意函数之间随意切换,而不需把这个函数先声明为generator
2.3 Gevent(协程异步非阻塞)
greenlet 已经实现了协程,但是这个还的人工切换,太麻烦了。python还有一个比greenlet更强大的并且能够自动切换任务的模块gevent,其实也是对Greenlet的封装,叫做Gevent。
Gevent 是一个第三方库,可以轻松通过gevent实现并发同步或异步编程,在gevent中用到的主要模式是Greenlet, 它是以C扩展模块形式接入Python的轻量级协程。 Greenlet全部运行在主程序操作系统进程的内部,但它们被协作式地调度。
其原理是当一个 greenlet 遇到IO(指的是input output 输入输出,比如网络、文件操作等)操作时,比如访问网络,就自动切换到其他的greenlet,等到IO操作完成,再在适当的时候切换回来继续执行。
由于IO操作非常耗时,经常使程序处于等待状态,有了gevent为我们自动切换协程,就保证总有greenlet在运行,而不是等待IO。
这里就简单演示一下,如果需要深究Gevent,那可以要下功夫了。
上图中直接在sleep查看切换的效果,这里在单线程并发的下载多个页面看看效果。就是gevent的spawn用法。
现在再看一个更牛逼的例子,就是通过gevent实现单线程下的多socket并发
通常socket是一对一的,一个socket只能跟一个socket客户端,后面就把单个socket改成了多线程的socket,每一个客户端连接过来,都会给客户端分配一个新的线程给客户端连接。一个线程给一个客户端服务。那么这是低效的。
比如客户端跟socket连接上了,但是不跟socket server传输数据,socket server又不好主动断掉,会话只能保持着,还得没事检查一下这个连接是不是活跃的,大部分都是空跑,所以开销很大,效率很低。
怎么实现单线程下实现多socket并发,只维护一个线程,过来一个连接创建一个实例,只有活跃的连接才会通信,不活跃的就切换走,但是这样有个问题,如果一个socket客户端连接一直在传输,是活跃的,那么其它的就阻塞了,这个还是有阻塞,这个怎么办。
这个可以让socket server和client不直接通信,就比如client的消息放到一个小盒子里,server去轮询,看看有谁在跟我说话,然后我在快速的去回复。这样就解决了这个阻塞的问题,怎么实现的呢?见如下代码:(这个代码最好不好在windows下测试,各种麻烦报错)
Server
Client
因为是手动输入,看不出啥并发的效果,手的速度太慢了,有兴趣的同学可以把客户端写成一个死循环,然后多开几个客户端测试。这里还是贴一下代码,因为下一课的开头他也演示了,这里演示一下吧。
以下是并发100的请求代码
gevent的优势不仅仅是在代码中调用方便,厉害的是它拥有的monkey机制。(至于这个monkey机制,这里一篇文章讲解不清楚,单独另一篇文章讲解吧,或者去看学习视频)假设你不愿意修改原来已经写好的python代码,但是又想充分利用gevent机制,那么你就可以用monkey来做到这一点。
你所要做的就是在文件开头打一个patch,那么它就会自动替换你原来的thread、socket、time、multiprocessing等代码,全部变成gevent框架。这一切都是由gevent自动完成的。注意这个patch是在所有module都import了之后再打,否则没有效果。
甚至在编写的Web App代码的时候,不需要引入gevent的包,也不需要改任何代码,仅仅在部署的时候,用一个支持gevent的WSGI服务器,就可以获得数倍的性能提升。
下面还有在python的发展过程中,一些加入的新的方式,以下贴出来给大家做个参考,想了解的同学可以看看。
2.4 asyncio装饰器(Python 3.4开始)
""" * @Author: lizexiong * @software: PyCharm * @Description: """ # asyncio(在python3.4之后的版本) # 遇到IO等耗时操作会自动切换 import asyncio import time @asyncio.coroutine def func1(): print(1) yield from asyncio.sleep(3) # 遇到耗时后会自动切换到其他函数中执行 print(2) @asyncio.coroutine def func2(): print(3) yield from asyncio.sleep(2) print(4) @asyncio.coroutine def func3(): print(5) yield from asyncio.sleep(2) print(6) tasks = [ asyncio.ensure_future(func1()), asyncio.ensure_future(func2()), asyncio.ensure_future(func3()) ] # 协程函数使用 func1()这种方式是执行不了的 start = time.time() loop = asyncio.get_event_loop() loop.run_until_complete(asyncio.wait(tasks)) # loop.run_until_complete(func1()) 执行一个函数 end = time.time() print(end - start) # 只会等待3秒
运行结果:
2.5 async、await关键字(Python 3.5开始)
""" * @Author: lizexiong * @software: PyCharm * @Description: """ import asyncio import time async def func1(): print(1) await asyncio.sleep(3) # 遇到耗时后会自动切换到其他函数中执行 print(2) async def func2(): print(3) await asyncio.sleep(2) print(4) async def func3(): print(5) await asyncio.sleep(2) print(6) tasks = [ asyncio.ensure_future(func1()), asyncio.ensure_future(func2()), asyncio.ensure_future(func3()) ] # 协程函数使用 func1()这种方式是执行不了的 start = time.time() loop = asyncio.get_event_loop() loop.run_until_complete(asyncio.wait(tasks)) # loop.run_until_complete(func1()) 执行一个函数 end = time.time() print(end - start) # 只会等待3秒
运行结果:
3.协程的运行原理
课堂实现中用非常通俗的话解释了协程为什么有这些优势,这里,我们用更加官方的语言和流程来解释一下。
当程序运行时,操作系统会为每个程序分配一块同等大小的虚拟内存空间,并将程序的代码和所有静态数据加载到其中。然后,创建和初始化 Stack 存储,用于储存程序的局部变量,函数参数和返回地址;创建和初始化 Heap 内存;创建和初始化 I/O 相关的任务。当前期准备工作完成后,操作系统将 CPU 的控制权移交给新创建的进程,进程开始运行。
一个进程可以有一个或多个线程,同一进程中的多个线程将共享该进程中的全部系统资源,如:虚拟地址空间,文件描述符和信号处理等等。但同一进程中的多个线程有各自的调用栈和线程本地存储。
协程是一种比线程更加轻量级的存在,协程不是被操作系统内核所管理,而完全是由用户态程序所控制。协程与线程以及进程的关系如下图所示。可见,协程自身无法利用多核,需要配合进程来使用才可以在多核平台上发挥作用。
- 协程之间的切换不需要涉及任何 System Call(系统调用)或任何阻塞调用。
- 协程只在一个线程中执行,切换由用户态控制,而线程的阻塞状态是由操作系统内核来完成的,因此协程相比线程节省线程创建和切换的开销。
- 协程中不存在同时写变量的冲突,因此,也就不需要用来守卫关键区块的同步性原语,比如:互斥锁、信号量等,并且不需要来自操作系统的支持。
4.协程应用场景
4.1 抢占式调度的缺点
在 I/O 密集型场景中,抢占式调度的解决方案是 “异步 + 回调” 机制。
其存在的问题是,在某些场景中会使得整个程序的可读性非常差。以图片下载为例,图片服务中台提供了异步接口,发起者请求之后立即返回,图片服务此时给了发起者一个唯一标识 ID,等图片服务完成下载后把结果放到一个消息队列,此时需要发起者不断消费这个 MQ 才能拿到下载是否完成的结果。
可见,整体的逻辑被拆分为了好几个部分,各个子部分都会存在状态的迁移,日后必然是 BUG 的高发地。
4.2 用户态协同调度的优势
而随着网络技术的发展和高并发要求,协程所能够提供的用户态协同调度机制的优势,在网络操作、文件操作、数据库操作、消息队列操作等重 I/O 操作场景中逐渐被挖掘。
协程将 I/O 的处理权从内核态的操作系统交还给用户态的程序自身。用户态程序在执行 I/O 时,主动的通过 yield(让出)CPU 的执行权给其他协程,多个协程之间处于平等、对称、合作的关系。
5.协程使用注意事项
协程只有和异步IO结合起来才能发挥出最大的威力
假设协程运行在线程之上,并且协程调用了一个阻塞IO操作,这时候会发生什么?实际上操作系统并不知道协程的存在,它只知道线程,因此在协程调用阻塞IO操作的时候,操作系统会让线程进入阻塞状态,当前的协程和其它绑定在该线程之上的协程都会陷入阻塞而得不到调度。
因此,在协程中尽量不要调用阻塞IO的方法,比如打印,读取文件,Socket接口等,除非改为异步调用的方式,并且协程只有在IO密集型的任务中才会发挥作用。
6.补充章节:论事件驱动与异步IO
这个章节比较晦涩难懂,因为是课堂上接下来的一课,这里还是作为补充章节吧。等哪天能理解透了,单独在写一篇关于事件驱动与异步IO的文章。
首先什么叫事件?鼠标点击一下就是一个事件,带着这个理论下面的教程可能会清晰一点。
通常,我们写服务器处理模型的程序时,有以下几种模型:
(1)每收到一个请求,创建一个新的进程,来处理该请求;
(2)每收到一个请求,创建一个新的线程,来处理该请求;
(3)每收到一个请求,放入一个事件列表,让主进程通过非阻塞I/O方式来处理请求
上面的几种方式,各有千秋,
第(1)中方法,由于创建新的进程的开销比较大,所以,会导致服务器性能比较差,但实现比较简单。
第(2)种方式,由于要涉及到线程的同步,有可能会面临死锁等问题。
第(3)种方式,在写应用程序代码时,逻辑比前面两种都复杂。
综合考虑各方面因素,一般普遍认为第(3)种方式是大多数网络服务器采用的方式
看图说话讲事件驱动模型
在UI编程中,常常要对鼠标点击进行相应,首先如何获得鼠标点击呢?
方式一:创建一个线程,该线程一直循环检测是否有鼠标点击,那么这个方式有以下几个缺点:
1. CPU资源浪费,可能鼠标点击的频率非常小,但是扫描线程还是会一直循环检测,这会造成很多的CPU资源浪费;如果扫描鼠标点击的接口是阻塞的呢?
2. 如果是堵塞的,又会出现下面这样的问题,如果我们不但要扫描鼠标点击,还要扫描键盘是否按下,由于扫描鼠标时被堵塞了,那么可能永远不会去扫描键盘;
3. 如果一个循环需要扫描的设备非常多,这又会引来响应时间的问题;
所以,该方式是非常不好的。
方式二:就是事件驱动模型
目前大部分的UI编程都是事件驱动模型,如很多UI平台都会提供onClick()事件
这个事件就代表鼠标按下事件。事件驱动模型大体思路如下:
1. 有一个事件(消息)队列;
2. 鼠标按下时,往这个队列中增加一个点击事件(消息);
3. 有个循环,不断从队列取出事件,根据不同的事件,调用不同的函数,如onClick()、onKeyDown()等;
4. 事件(消息)一般都各自保存各自的处理函数指针,这样,每个消息都有独立的处理函数;
事件驱动编程是一种编程范式,这里程序的执行流由外部事件来决定。它的特点是包含一个事件循环,当外部事件发生时使用回调机制来触发相应的处理。另外两种常见的编程范式是(单线程)同步以及多线程编程。
让我们用例子来比较和对比一下单线程、多线程以及事件驱动编程模型。下图展示了随着时间的推移,这三种模式下程序所做的工作。这个程序有3个任务需要完成,每个任务都在等待I/O操作时阻塞自身。阻塞在I/O操作上所花费的时间已经用灰色框标示出来了。
以上的图片就是总结一句话,异步io的效率是最高的,但是有个缺点,就是只能在单线程里面实现多任务的异步。
详细的理论见以下:
在单线程同步模型中,任务按照顺序执行。如果某个任务因为I/O而阻塞,其他所有的任务都必须等待,直到它完成之后它们才能依次执行。这种明确的执行顺序和串行化处理的行为是很容易推断得出的。如果任务之间并没有互相依赖的关系,但仍然需要互相等待的话这就使得程序不必要的降低了运行速度。
在多线程版本中,这3个任务分别在独立的线程中执行。这些线程由操作系统来管理,在多处理器系统上可以并行处理,或者在单处理器系统上交错执行。这使得当某个线程阻塞在某个资源的同时其他线程得以继续执行。与完成类似功能的同步程序相比,这种方式更有效率,但程序员必须写代码来保护共享资源,防止其被多个线程同时访问。多线程程序更加难以推断,因为这类程序不得不通过线程同步机制如锁、可重入函数、线程局部存储或者其他机制来处理线程安全问题,如果实现不当就会导致出现微妙且令人痛不欲生的bug。
在事件驱动版本的程序中,3个任务交错执行,但仍然在一个单独的线程控制中。当处理I/O或者其他昂贵的操作时,注册一个回调到事件循环中,然后当I/O操作完成时继续执行。回调描述了该如何处理某个事件。事件循环轮询所有的事件,当事件到来时将它们分配给等待处理事件的回调函数。这种方式让程序尽可能的得以执行而不需要用到额外的线程。事件驱动型程序比多线程程序更容易推断出行为,因为程序员不需要关心线程安全问题。
当我们面对如下的环境时,事件驱动模型通常是一个好的选择:
1.程序中有许多任务,而且…
2.任务之间高度独立(因此它们不需要互相通信,或者等待彼此)而且…
3.在等待事件到来时,某些任务会阻塞。
当应用程序需要在任务间共享可变的数据时,这也是一个不错的选择,因为这里不需要采用同步处理。
网络应用程序通常都有上述这些特点,这使得它们能够很好的契合事件驱动编程模型。
Nginx就是一个典型的采用异步的模型。(epoll),并且nginx多进程单线程
此处要提出一个问题,就是,上面的事件驱动模型中,只要一遇到IO就注册一个事件,然后主程序就可以继续干其它的事情了,只到io处理完毕后,继续恢复之前中断的任务,这本质上是怎么实现的呢?哈哈,下面我们就来一起揭开这神秘的面纱。。。。(下一课,讲解select模型源码实现)
7.参考
https://blog.csdn.net/c_lanxiaofang/article/details/126394229
https://zhuanlan.zhihu.com/p/447684575
http://www.noobyard.com/article/p-gygpezts-p.html
https://blog.csdn.net/FRIGIDWINTER/article/details/124369567
https://blog.csdn.net/fuhanghang/article/details/127848517
https://blog.csdn.net/WuDan_1112/article/details/125569106
https://www.cnblogs.com/lizexiong/articles/17019955.html