实际上,异步I/O是一种单线程的编程模式,换句话说,尽管在单个进程中使用了一个线程,但异步I/O给人一种并发的效果。协同调用不是并行。 异步IO是一种并发编程风格。它与线程、多进程更紧密地结合在一起,但与这两者有很大的不同,是并发技术中的一个独立子集。异步意味着什么?意味着不要将宝贵的CPU时间浪费在一个被I/O等待的任务,事件循环通过不断轮询任务队列,以确保立即调度并运行一个处于非I/O等待的任务。通过上述机制,异步代码有助于并发执行。
需要注意的是异步模式的代码属于I/O密集型程序,我在谈论Python多线程爬虫时已经深入谈到过该问题。
事件循环,实质上在C底层是一个while循环,它管理和分配不同任务的调度。 注册并处理多个运行中的任务对象之间的控制权限分配。
协程是一种用关键字async def定义的函数,是Python生成器函数的专用版本,换句话说,协程本质还是一个生成器,我们也可以称一个协程对象是一个任务,当Python执行到await时,当处于I/O等待时会将控制权归还给事件循环。 只有当携程函数封装到task容器后,并由事件循环执行任务调度,携程函数才算真正执行。
futures是一种用于托管多个协程对象的并返回其执行结果的(已执行或未执行)的对象。 甚至是某个携程对象出现的异常状态。
备注:协程和futures都统称可等待对象。
异步I/O的思想
异步IO的核心部件是协程函数,它就是一个大型的I/O任务分解成多个的子任务,这些子任务就是用async def 定义的协程函数。每个协程函数从单次CPU时间的分配来说,实际上也是只执行一次,只要当前协程获得事件循环的优先调度也就获得了CPU时间的分配,由于CPU的切换速度在单个事件循环中是非常快速的,从宏观上说给人一种多个协程函数同时执行的效果。协程函数内部实质上包含yield执行点,也就是在Python执行到任意一个携程函数内部的await关键字所在代码语句时,并且处于I/O挂起,携程函数的上下文切换就会发生,从I/O等待的携程函数会将控制权归还给事件循环,并由事件循环分配给其他就绪的协程函数,反之则不会发生上下文切换。
async,await其实等价于yield关键字定义一个Python生成器的语法糖,async、await比yield非常友好,它们让程序员像定义同步版本的函数一样编写函数。备注:asyncio中的上下文切换表示事件循环生成从一个协程到下一个协程的控制流。
让我们看一个非常基本的例子:
import asyncio
import time
import random
start=time.time()
def take_time():
return "%1.2f秒" % (time.time()-start)
async def task_A():
print("运行task_A")
await asyncio.sleep(random.uniform(1.0,8.0)/10)
print(f"task_A结束!!耗时{take_time()}")
async def task_B():
print("运行task_B")
await asyncio.sleep(random.uniform(1.0,8.0)/10)
print(f"task_B结束!!耗时{take_time()}")
async def task_C():
print("运行task_C")
await asyncio.sleep(random.uniform(1.0,8.0)/10)
print(f"task_C结束!!耗时{take_time()}")
async def task_exect():
tasks=[task_A(),task_B(),task_C()]
await asyncio.gather(*tasks)
if __name__ == "__main__":
asyncio.run(task_exect())
我们查看一下执行效果
首先,我们声明3个的协程函数,它们使用asyncio.sleep函数执行非阻塞模式运行。然后我们创建一个携程执行入口点函数,从中我们使用gather函数包装行前面定义的协程函数以备并发执行。最后,我们使用asyncio.run调用入口点协程函数,asyncio.run()内部负责创建事件循环和调用入口点协程函数。
如果在Python3.7之前,你需要手动显示调用当前时间循环
loop = asyncio.get_event_loop()
loop.run_until_complete(....)
loop.close()
而在Python3.7之后,有另外一种形式
async def task_exect():
tasks=[task_A(),task_B(),task_C()]
await asyncio.gather(*tasks)
if __name__ == "__main__":
asyncio.run(task_exect())
通过对协程使用await,我们说协程函数执行到await关键字所在代码语句,并且当他处于I/O等待状态下,会将控制劝返回给事件循环, Python在task_A协程函数底层执行yield执行点后发生task_A的上下文切换到task_B的上下文,因此事件循环将上下文切换调度任务列表的下一个任务task_B。 类似地,携程task_B执行到await的代码行,它允许事件循环在yield执行点将控制权传递回task_A或者task_C,可见task_A、task_B和task_C之间在各自I/O状态等待时间是不可预知的,也就是说三个携程的各自await语句的后半部分的代码执行顺序是随机。
重要的是要理解,在标准库中编写asyncio是一个独立的模块,其模块内部提供的任何函数接口都是非I/O阻塞的,asyncio模块以外的其余模块提供的函数都是I/O阻塞的。但我们可以使用
async/await的使用限定
如何使用async/await有一套严格的规则,如下定义的例子:
async def foo():
await bar()
async def foo2(x):
yield bar(x)
async def foo3(x):
return await g(x)规则1:使用async def引入的函数是协程。它可以使用await、return或yield。
规则2:使用await和return创建一个协程函数。在调用协程函数后,必须等待它获得结果。
规则3:在async def定义的协程函数作用域内允许使用yield的, 这将创建一个异步生成器,并且对该异步生成器进行迭代。 但笔者不建议这么做,而应该简化协程函数的语法,仅使用await和return。
规则4:不能在async def定义的协程函数作用域内使用yield from,这将触发一个语法错误SyntaxError
备注:使用await后跟另外一个函数的调用,例如await g(x),意味着g(x)必须是一个可等待对象(awaitable),可等待对象就是一个协程函数。
关于return和return await的迷思
当我们从一个协程调用另一个协程时,总是伴随await。协程函数总是返回一个可等待对象(Awaitable),即使是简单return语句也是如此。考虑以下实例
第一个协程函数在return 101时,并没有显示指定await,但在main协程调用foo(),foo协程返回的是一个隐式封装的可等待对象,而不是函数的执行结果101,也就是说,对于foo来说,return 101 其实返回的是一个额外封装的可等待对象
#!/usr/bin/env python3
import asyncio
async def foo():
return 101
async def main():
return foo()
if __name__ == "__main__":
asyncio.run(main())
从上面的运行结果,对于main函数来说,return foo()是非法的,因为调用协程foo()返回的明摆着是一个可等待对象,因此必须显式使用await关键字 即return await foo(),从上面的示例,我们得到一个协程函数对于简单return语句的行为如下表述:
规则5:在协程函数内部 return 任意的Python内置类型、例如整数、字符串、列表、或任意object的派生类型,Python解释器默许你这么做。这是合法的,Python会隐式封装成一个可等待对象返回。
另外,笔者认为return await 这种写法对于Python异步来说是不优雅的,因为从语法层面来说,await和return await对于协程函数之间的上下文切换行为是一致的,也就是说我们刻意使用return await 显得有些画蛇添足。我们再查看如下示例代码
#!/usr/bin/env python3
import asyncio,random,time
async def foo1():
return await asyncio.sleep(random.randint(1,10)/100.0),\
print(f"foo1耗时{time.time()-start}")
async def foo2():
return await asyncio.sleep(random.randint(1,10)/100.0),\
print(f"foo2耗时{time.time()-start}")
async def foo3():
return await asyncio.sleep(random.randint(1,10)/100.0),\
print(f"foo3耗时{time.time()-start}")
async def foo4():
return await asyncio.sleep(random.randint(1,10)/100.0),\
print(f"foo4耗时{time.time()-start}")
async def main():
await asyncio.gather(foo1(),foo2(),foo3(),foo4())
if __name__ == "__main__":
start=time.time()
asyncio.run(main())
运行效果,我们说使用从foo1到foo4这些协程函数使用retun await仍然得到异步运行的效果,倘若你将上面示例代码的return关键字去掉,仍然得到同样异步运行效果。只不过没有return语句的协程函数在执行await语句后,都会遵循Python的一般函数的行为,在Python函数结前隐含调用return None
备注:如果你深入了解javascript异步的话,javascript的异步模型对于await和return await,是两种不同的行为,有兴趣的同学可以自行找这类文章阅读。一旦转到Python异步可能会给你带来一定的困扰,因此请注意与js异步那套理论区别对待。
笔者的建议是如果协程函数需要显式返回某个值的话,请将return语句写在await语句之后,两者不要混写。
同步模式的for循环内部滥用await问题
for循环内部滥用await语句,这种错误会给异步编程带来恶梦,下面再看一个稍微复杂的例子,请好好思考一下面的例子
#!/usr/bin/env python3
import asyncio,random,time
async def foo():
await asyncio.sleep(random.randint(1,8)/10.0)
end=time.time()
print(f"foo()耗时{end-start}秒")
return 101
async def foo2():
await asyncio.sleep(random.randint(1,8)/10.0)
end=time.time()
print(f"foo2()耗时{end-start}秒")
return 102
async def foo3():
await asyncio.sleep(random.randint(1,8)/10.0)
end=time.time()
print(f"foo3()耗时{end-start}秒")
return 103
async def main(L):
R=[foo(),foo2(),foo3()]
for r in R:
L.append(await r)
if __name__ == "__main__":
L=[]
start=time.time()
asyncio.run(main(L))
print(L)
end=time.time()
print(f"__main__耗时{end-start}秒")
执行效果,你们发现到问题了吗?从多次测试可以看到协程函数foo()、foo2、foo3()的执行顺序按照函数定义的执行顺序执行,而且他们的执行时间开销依次递增。这等同于同步编程模式,丧失了异步编程的优势。
上面的代码示例丧失异步的特性的原因究竟在哪里呢?请将注意力集中在,协程main内部的同步for迭代的代码,如下代码所示。
async def main(L):
R=[foo(),foo2(),foo3()]
for r in R:
L.append(await r)
这里其实是依次显式依次调用了三次await,即等价的代码
await foo()
await foo2()
await foo3()
这里的await就是等待协程的实际返回结果,而非返回一个协程的可等待对象。上面的代码,foo2()要等待foo()执行完毕并return后再执行,foo3()要等foo2()执行完毕并return后才最后执行。也就是每个协程的时间开销会依次线性递增的。这种滥用await关键字的糟糕行为会给异步编程带来严重的性能问题。
那么更为优雅的方式,我们是对用于多个协程函数的定义的异步上下文,最优雅的方式是使用asyncio模块的gather函数,托管多个协程函数的任务队列。
#!/usr/bin/env python3
import asyncio,random,time
async def foo():
await asyncio.sleep(random.randint(1,8)/10.0)
end=time.time()
print(f"foo()耗时{end-start}秒")
return 101
async def foo2():
await asyncio.sleep(random.randint(1,8)/10.0)
end=time.time()
print(f"foo2()耗时{end-start}秒")
return 102
async def foo3():
await asyncio.sleep(random.randint(1,8)/10.0)
end=time.time()
print(f"foo3()耗时{end-start}秒")
return 103
async def main():
R=[foo(),foo2(),foo3()]
await asyncio.gather(*R)
if __name__ == "__main__":
start=time.time()
asyncio.run(main())
end=time.time()
print(f"__main__耗时{end-start}秒")
执行代码所示,我们说在协程入口点通过调用await asyncio.gather()函数,会得到每个协程函数的最佳的时间开销
小结
本文谈论了Python异步的编程的基本思想,以及笔者以往遇到的一些其他甚少谈到的问题。若有其他想法的话,笔者会随时更新
读后有收获可以微信或支付宝请笔者喝杯咖啡