1. 什么是协程
在 Python 3.4
时候引进了协程的概念,它使用一种单线程单进程的的方式实现并发。
协程,英文名是 Coroutine
, 又称为微线程,是一种用户态的轻量级线程。协程不像线程和进程那样,需要进行系统内核上的上下文切换,协程的上下文切换是由程序员决定的。
在 Python
中协程就是一个可以暂停执行的函数,听起来和生成器的概念一样。
从 Python3.4
开始协程被加入到标准库,当时的协程是通过 @asyncio.coroutine
和 yeild from
实现的,看起来和生成器的实现方式没什么区别。后来为了更好的区分开协程和生成器,到 Python3.5
的时候引入 async/await
语法糖临时定格了下来,直到 Python3.6
的时候才被更多的人认可,后来到了 Python3.7
官方把 async
和 await
作为保留字,同时协程的调用也变得简单了许多。
2. 协程实现
2.1 原生实现
在同一个线程中,如果发生以下事情:
- A 函数执行时被中断,传递一些数据给 B 函数;
- B 函数拿到这些数据后开始执行,执行一段时间后,发送一些数据到 A 函数;
就这样交替执行……这种执行调用模式,被称为协程。
可以看到,协程是在同一线程中函数间的切换,而不是线程间的切换,因此执行效率更优,Python
的异步操作正是基于高效的协程机制。
下面通过一个例子,加深对协程的理解。
def A():
a_list = ['1', '2', '3']
for to_b in a_list:
from_b = yield to_b
print('receive %s from B' % (from_b,))
print('do some complex process for A during 200ms ')
def B(a):
from_a = a.send(None)
print('response %s from A ' % (from_a,))
print('B is analysising data from A')
b_list = ['x', 'y', 'z']
try:
for to_a in b_list:
from_a = a.send(to_a)
print('response %s from A ' % (from_a,))
print('B is analysising data from A')
except StopIteration:
print('---from a done---')
finally:
a.close()
if __name__ == "__main__":
a = A()
B(a)
分析执行过程:
-
a.send(None)
激活 A 函数,并执行到yield to_b
,把变量to_b
传递给 B 函数,A 函数中断; -
from_a
就是 上步 A 函数返回的to_b
值,然后执行分析这个值; -
当执行到
a.send(to_a)
时, B 函数将加工后的to_a
值发送给 A 函数; -
from_b
变量接收来自 B 函数的发送,然后使用此值做分析 200 ms 后,又将to_b
传递给 B 函数,A 函数中断; -
重复 2、 3、4
-
直到
from_a
获取不到响应值,函数触发StopIteration
异常,程序执行结束。
执行结果:
response 1 from A
B is analysising data from A
receive x from B
do some complex process for A during 200ms
response 2 from A
B is analysising data from A
receive y from B
do some complex process for A during 200ms
response 3 from A
B is analysising data from A
receive z from B
do some complex process for A during 200ms
---from A done---
通过上述看到,协程是在同一个线程中,不同函数间交替的、协作的执行完成任务。
2.2 使用库函数
使用协程也就意味着你需要一直写异步方法。在 Python
中我们使用 asyncio
模块来实现一个协程。如果我们把 Python
中普通函数称之为同步函数(方法),那么用协程定义的函数我们称之为异步函数(方法)。
同步函数定义:
def regular_double(x):
return 2 * x
异步函数定义:
async def async_double(x):
return 2 * x
对于同步函数我们知道是这样调用的:
regular_double(2)
异步函数如何调用呢?带着这个问题我们看下面的一个简单例子。
import asyncio
async def foo():
print("这是一个协程")
if __name__ == '__main__':
loop = asyncio.get_event_loop() # 定义一个事件循环
try:
print("开始运行协程")
coro = foo()
print("进入事件循环")
loop.run_until_complete(coro) # 运行协程
finally:
print("关闭事件循环")
loop.close() # 运行完关闭协程
输出结果:
开始运行协程
进入事件循环
这是一个协程
关闭事件循环
首先,需要导入需要的包 asyncio
。然后,创建一个事件循环,因为协程是基于事件循环的。 之后,通过 run_until_complete
方法传入一个异步函数,来运行这个协程。 最后在结束的时候调用 close
方法关闭协程。 综上就是调用一个协程的写法。除此之外协程还有其他的不同之处。
3. 协程之间的链式调用
我们可以通过使用 await
关键字,在一个协程中调用一个协程。 一个协程可以启动另一个协程,从而可以使任务根据工作内容,封装到不同的协程中。我们可以在协程中使用 await
关键字,链式地调度协程,来形成一个协程任务流。像下面的例子一样:
import asyncio
async def main():
print("主协程")
print("等待result1协程运行")
res1 = await result1()
print("等待result2协程运行")
res2 = await result2(res1)
return (res1, res2)
async def result1():
print("这是result1协程")
return "result1"
async def result2(arg):
print("这是result2协程")
return f"result2接收了一个参数,{arg}"
if __name__ == '__main__':
loop = asyncio.get_event_loop()
try:
result = loop.run_until_complete(main())
print(f"获取返回值:{result}")
finally:
print("关闭事件循环")
loop.close()
输出结果:
主协程
等待result1协程运行
这是result1协程
等待result2协程运行
这是result2协程
获取返回值:('result1', 'result2接收了一个参数,result1')
关闭事件循环
在上面,我们知道调用协程需要通过创建一个事件循环然后再去运行。 这里我们需要了解的是如果在协程里想调用一个协程我们需要使用 await
关键字,就拿上面的例子来说在 main
函数里调用协程 result1
和 result2
。 那么问题来了:await
干了什么呢?
4. await 的作用
关键字 await
,它要用于这个 async
的函数内部,await
用于需要等待的地方(比如网络 IO),出现 await
的时候,asyncio
去调度,在不同的 task
里面切换。
await
的作用就是等待当前的协程运行结束之后再继续进行下面代码。因为我们执行 result1
的时间很短,所以在表面上看 result1
和 result2
是一起执行的。这就是 await
的作用。
等待一个协程的执行完毕,如果有返回结果,那么就会接收到协程的返回结果,通过使用 return
可以返回协程的一个结果,这个和同步函数的 return
使用方法一样。
5. 并发执行任务
一系列的协程可以通过 await
链式调用,但是有的时候我们需要在一个协程里等待多个协程,比如我们在一个协程里等待 1000 个异步网络请求,对于访问次序没有要求的时候,就可以使用关键字 await
来解决了。await
可以暂停一个协程,直到后台操作完成。
import asyncio
async def num(n):
print(f"当前的数字是:{n}")
await asyncio.sleep(n)
print(f"等待时间:{n}")
async def main():
tasks = [num(i) for i in range(5)] #协程列表
#await asyncio.gather(*tasks) #有序并发
await asyncio.wait(tasks) #并发运行协程列表的协程
if __name__ == '__main__':
loop = asyncio.get_event_loop()
try:
loop.run_until_complete(main())
finally:
loop.close()
输出结果:
当前的数字是:1
当前的数字是:2
当前的数字是:3
当前的数字是:0
当前的数字是:4
等待时间:0
等待时间:1
等待时间:2
等待时间:3
等待时间:4
如果运行的话会发现首先会打印 5 次数字,但是并不是顺序执行的,这也说明 asyncio.wait
并发执行的时候是乱序的。如果想保证顺序只要使用 gather
把 task
写成解包的形式就行了,也就是上面的注释部分的代码(实际测试并不能保证顺序执行)。
6. 在协程中使用普通的函数
在协程中可以通过一些方法去调用普通的函数。可以使用的关键字有 call_soon
等。可以通过字面意思理解调用立即返回。 下面来看一下具体的使用例子:
import asyncio
import functools
def callback(args, *, kwargs="defalut"):
print(f"普通函数做为回调函数,获取参数:{args},{kwargs}")
async def main(loop):
print("注册callback")
loop.call_soon(callback, 1)
wrapped = functools.partial(callback, kwargs="not defalut")
loop.call_soon(wrapped, 2)
await asyncio.sleep(0.2)
if __name__ == '__main__':
loop = asyncio.get_event_loop()
try:
loop.run_until_complete(main(loop))
finally:
loop.close()
输出结果:
注册callback
普通函数做为回调函数,获取参数:1,defalut
普通函数做为回调函数,获取参数:2,not defalut
7. 协程优点
多线程编程是比较困难的, 因为调度程序任何时候都能中断线程, 必须记住保留锁, 去保护程序中重要部分, 防止多线程在执行的过程中断。
而协程默认会做好全方位保护, 以防止中断。我们必须显示产出才能让程序的余下部分运行。对协程来说, 无需保留锁, 而在多个线程之间同步操作, 协程自身就会同步, 因为在任意时刻, 只有一个协程运行。
总结下大概下面几点:
- 无需系统内核的上下文切换,减小开销;
- 无需原子操作锁定及同步的开销,不用担心资源共享的问题;
- 单线程即可实现高并发,单核
CPU
即便支持上万的协程都不是问题,所以很适合用于高并发处理,尤其是在应用在网络爬虫中。
8. 协程缺点
- 无法使用
CPU
的多核
协程的本质是个单线程,它不能同时用上单个 CPU
的多个核,协程需要和进程配合才能运行在多 CPU
上。当然我们日常所编写的绝大部分应用都没有这个必要,就比如网络爬虫来说,限制爬虫的速度还有其他的因素,比如网站并发量、网速等问题都会是爬虫速度限制的因素。除非做一些密集型应用,这个时候才可能会用到多进程和协程。
- 处处都要使用非阻塞代码
写协程就意味着你要一值写一些非阻塞的代码,使用各种异步版本的库, 不过这些缺点并不能影响到使用协程的优势。
协程的并发是异步 IO 方式实现的,其实在 Python 3.4 的时候使用 Yeild 的方式实现的,只是后来加入了语法糖 async/awati,协程遇到 IO 操作就切换,协程之所以能处理大并发,就是由于挤掉了 IO 操作,使得 CPU 一直运行。 事件循环和异步是两个概念,事件循环是执行我们的异步代码并决定如何在异步函数之间切换的对象。如果某个协程在等待某些资源,我们需要暂停它的执行,在事件循环中注册这个事件,以便当事件发生的时候,能再次唤醒该协程的执行。 运行异步函数我们首先需要创建一个协程,然后创建 Future 或 Task 对象,将它们添加到事件循环中,也就是说异步函数是需要靠事件循环运行的。协程本质上是单线程的,那这样的异步方式,是通过异步函数之间切换的实现的,因为没有阻塞,所以速度很多,看起来像是并发。实际上并不是并发。
原文链接:
https://docs.python.org/zh-cn/3.7/library/index.html
https://gitbook.cn/books/5cda6064e8757e4d2f2fd292/index.html
https://gitbook.cn/gitchat/activity/5e3c11c1f55e4f503f2611f0