Python中的协程
协程(co-routine,又称微线程、纤程)是一种多方协同的工作方式。协程不是进程或线程,其执行过程类似于 Python 函数调用,Python 的 asyncio 模块实现的异步IO编程框架中,协程是对使用 async 关键字定义的异步函数的调用。当前执行者在某个时刻主动让出(yield)控制流,并记住自身当前的状态,以便在控制流返回时能从上次让出的位置恢复(resume)执行。
一个进程包含多个线程,类似于一个人体组织有多种细胞在工作,同样,一个程序可以包含多个协程。多个线程相对独立,线程的切换受系统控制。
同样,多个协程也相对独立,但是其切换由程序自己控制。简而言之,协程的核心思想就在于执行者对控制流的 “主动让出” 和 “恢复”。相对于,线程此类的 “抢占式调度” 而言,协程是一种 “协作式调度” 方式,协程之间执行任务按照一定顺序交替执行。
协程async await
协程的运行原理
当程序运行时,操作系统会为每个程序分配一块同等大小的虚拟内存空间,并将程序的代码和所有静态数据加载到其中。然后,创建和初始化 Stack 存储,用于储存程序的局部变量,函数参数和返回地址;创建和初始化 Heap 内存;创建和初始化 I/O 相关的任务。当前期准备工作完成后,操作系统将 CPU 的控制权移交给新创建的进程,进程开始运行。
一个进程可以有一个或多个线程,同一进程中的多个线程将共享该进程中的全部系统资源,如:虚拟地址空间,文件描述符和信号处理等等。但同一进程中的多个线程有各自的调用栈和线程本地存储。
协程是一种比线程更加轻量级的存在,协程不是被操作系统内核所管理,而完全是由用户态程序所控制。协程与线程以及进程的关系如下图所示。可见,协程自身无法利用多核,需要配合进程来使用才可以在多核平台上发挥作用
协程之间的切换不需要涉及任何 System Call(系统调用)或任何阻塞调用。
协程只在一个线程中执行,切换由用户态控制,而线程的阻塞状态是由操作系统内核来完成的,因此协程相比线程节省线程创建和切换的开销。
协程中不存在同时写变量的冲突,因此,也就不需要用来守卫关键区块的同步性原语,比如:互斥锁、信号量等,并且不需要来自操作系统的支持。
协程应用场景
抢占式调度的缺点
协程使用注意事项
协程只有和异步IO结合起来才能发挥出最大的威力
假设协程运行在线程之上,并且协程调用了一个阻塞IO操作,这时候会发生什么?实际上操作系统并不知道协程的存在,它只知道线程,因此在协程调用阻塞IO操作的时候,操作系统会让线程进入阻塞状态,当前的协程和其它绑定在该线程之上的协程都会陷入阻塞而得不到调度。
因此,在协程中尽量不要调用阻塞IO的方法,比如打印,读取文件,Socket接口等,除非改为异步调用的方式,并且协程只有在IO密集型的任务中才会发挥作用。
一个线程内的多个协程是串行执行的,不能利用多核,所以,显然,协程不适合计算密集型的场景。协程适合I/O 阻塞型。
协程异步的例子
async 修饰词声明异步函数,于是,这里的 crawl_page 和 main 都变成了异步函数。而调用异步函数,我们便可得到一个协程对象(coroutine object)。
await 是同步调用,因此, crawl_page(url) 在当前的调用结束之前,是不会触发下一次调用的。于是,这个代码效果就和上面完全一样了,相当于我们用异步接口写了个同步代码。
协程使用task实现并行执行
协程中的异常处理
#协程异常处理
import time
import asyncio
async def worker_1():
await asyncio.sleep(1)
return 1
async def worker_2():
await asyncio.sleep(2)
return 2 / 0
async def worker_3():
await asyncio.sleep(3)
return 3
async def main():
task_1 = asyncio.create_task(worker_1())
task_2 = asyncio.create_task(worker_2())
task_3 = asyncio.create_task(worker_3())
await asyncio.sleep(2)
task_3.cancel()
res = await asyncio.gather(task_1, task_2, task_3, return_exceptions=True)
print(res)
# 开始计时
start = time.perf_counter()
# 执行函数
asyncio.run(main())
# 计算耗时
end = time.perf_counter()
print("took {} s".format(end - start))
执行结果
协程的回调
在 python 3.7 及以上的版本中,我们对 task 对象调用 add_done_callback() 函数,即可绑定特定回调函数
#协程回调future
import asyncio
import time
#异步函数 协程执行
async def crawl_page(url):
print('crawling {}'.format(url))
# 休眠时间
sleep_time = int(url.split("_")[-1])
await asyncio.sleep(sleep_time)
print('OK {}'.format(url))
async def main(urls):
# Python 3.7+
tasks = [asyncio.create_task(crawl_page(url)) for url in urls]
for task in tasks:
task.add_done_callback(lambda future:print('result: ',future.result()))
await asyncio.gather(*tasks)
#开始计时
start = time.perf_counter()
#python3.7+
asyncio.run(main(['url_1','url_2','url_3','url_4']))
end = time.perf_counter()
print("finish total cost {} s",end - start)
在代码里面,我们使用了 await
,后面跟了 get
方法。在执行这 10 个协程的时候,如果遇到了 await
,就会将当前协程挂起,转而去执行其他协程,直到其他协程也挂起或执行完毕,再执行下一个协程。
开始运行时,时间循环会运行第一个 task
。针对第一个 task
来说,当执行到第一个 await
跟着的 get
方法时,它被挂起,但这个 get
方法第一步的执行是非阻塞的,挂起之后立马被唤醒,所以立即又进入执行,创建了 ClientSession
对象,接着遇到了第二个 await
,调用了 session.get
请求方法,然后就被挂起了。由于请求需要耗时很久,所以一直没有被唤醒,好在第一个 task
被挂起了,那么接下来该怎么办呢?事件循环会寻找当前未被挂起的协程继续执行,于是就转而执行第三个 task
了,也是一样的流程操作,直到执行了第十个 task
的 session.get
方法之后,全部的 task
都被挂起了。所有 task
都已经处于挂起状态,那咋办?只好等待了。5 秒之后,几个请求几乎同时都有了响应,然后几个 task
也被唤醒接着执行,输出请求结果,最后总耗时 6 秒!
一次事件循环中,每个协程只会被执行一次,协程遇到await将会阻塞,这时事件循环机制会调用其它的协程去执行。