Python协程之asyncio

asyncio 是 Python 中的异步IO库,用来编写并发协程,适用于IO阻塞且需要大量并发的场景,例如爬虫、文件读写。

asyncio 在 Python3.4 被引入,经过几个版本的迭代,特性、语法糖均有了不同程度的改进,这也使得不同版本的 Python 在 asyncio 的用法上各不相同,显得有些杂乱,以前使用的时候也是本着能用就行的原则,在写法上走了一些弯路,现在对 Python3.7+ 和 Python3.6 中 asyncio 的用法做一个梳理,以便以后能更好的使用。

1. 协程与asyncio

协程,又称微线程,它不被操作系统内核所管理,而完全是由程序控制,协程切换花销小,因而有更高的性能。

协程可以比作子程序,不同的是,执行过程中协程可以挂起当前状态,转而执行其他协程,在适当的时候返回来接着执行,协程间的切换不需要涉及任何系统调用或任何阻塞调用,完全由协程调度器进行调度。

Python 中以 asyncio 为依赖,使用 async/await 语法进行协程的创建和使用,如下 async 语法创建一个协程函数:

async def work():
    pass

在协程中除了普通函数的功能外最主要的作用就是:使用 await 语法等待另一个协程结束,这将挂起当前协程,直到另一个协程产生结果再继续执行:

async def work():
    await asyncio.sleep(1)
    print('continue')

asyncio.sleep() 是 asyncio 包内置的协程函数,这里模拟耗时的IO操作,上面这个协程执行到这一句会挂起当前协程而去执行其他协程,直到sleep结束,当有多个协程任务时,这种切换会让它们的IO操作并行处理。

注意,执行一个协程函数并不会真正的运行它,而是会返回一个协程对象,要使协程真正的运行,需要将它们加入到事件循环中运行,官方建议 asyncio 程序应当有一个主入口协程,用来管理所有其他的协程任务:

async def main():
    await work()

在 Python3.7+ 中,运行这个 asyncio 程序只需要一句:asyncio.run(main()) ,而在 Python3.6 中,需要手动获取事件循环并加入协程任务:

loop = asyncio.get_event_loop()
loop.run_until_complete(main())
loop.close()

事件循环就是一个循环队列,对其中的协程进行调度执行,当把一个协程加入循环,这个协程创建的其他协程都会自动加入到当前事件循环中。

其实协程对象也不是直接运行,而是被封装成一个个待执行的 Task ,大多数情况下 asyncio 会帮我们进行封装,我们也可以提前自行封装 Task 来获得对协程更多的控制权,注意,封装 Task 需要 当前线程有正在运行的事件循环,否则将引 RuntimeError,这也就是官方建议使用主入口协程的原因,如果在主入口协程之外创建任务就需要先手动获取事件循环然后使用底层方法 loop.create_task(),而在主入口协程之内是一定有正在运行的循环的。任务创建后便有了状态,可以查看运行情况,查看结果,取消任务等:

async def main():
    task = asyncio.create_task(work())
    print(task)
    await task
    print(task)

#----执行结果----#
<Task pending name='Task-2' coro=<work() running at d:\tmp\code\asy.py:5>>
<Task finished name='Task-2' coro=<work() done, defined at d:\tmp\code\asy.py:5> result=None>

asyncio.create_task() 是 Python3.7 加入的高层级API,在 Python3.6,需要使用低层级API asyncio.ensure_future() 来创建 Future,Future 也是一个管理协程运行状态的对象,与 Task 没有本质上的区别。

2. 并发协程

通常,一个含有一系列并发协程的程序写法如下(Python3.7+):

import asyncio
import time


async def work(num: int):
    '''
    一个工作协程,接收一个数字,将它 +1 后返回
    '''
    print(f'working {num} ...')
    await asyncio.sleep(1)    # 模拟耗时的IO操作
    print(f'{num} -> {num+1} done')
    return num + 1


async def main():
    '''
    主协程,创建一系列并发协程并运行它们
    '''
    # 任务队列
    tasks = [work(num) for num in range(0, 5)]
    # 并发执行队列中的协程并等待结果返回
    results = await asyncio.gather(*tasks)
    print(results)


if __name__ == "__main__":
    asyncio.run(main())

并发运行多个协程任务的关键就是 asyncio.gather(*tasks),它接受多个协程任务并将它们加入到事件循环,所有任务都运行完成后会返回结果列表,这里我们也没有手动封装 Task,因为 gather 函数会自动封装。

并发运行还有另一个方法 asyncio.wait(tasks),它们的区别是:

  • gather 比 wait 更加高层,gather 可以将任务分组,一般优先使用 gather:
tasks1 = [work(num) for num in range(0, 5)]
tasks2 = [work(num) for num in range(5, 10)]
group1 = asyncio.gather(*tasks1)
group2 = asyncio.gather(*tasks2)
results1, results2 = await asyncio.gather(group1, group2)
print(results1, results2)
  • 在某些定制化任务需求的时候,可以使用 wait:
# Python3.8 版本后,直接向 wait() 传入协程对象已弃用,必须手动创建 Task
tasks = [asyncio.create_task(work(num)) for num in range(0, 5)]
done, pending = await asyncio.wait(tasks)
for task in tasks:
    if task in done:
        print(task.result())
for p in pending:
    p.cancel()

3. Tips

  • await 语句后必须是一个 可等待对象 ,可等待对象主要有三种:Python协程,Task,Future。通常情况下没有必要在应用层级的代码中创建 Future 对象。
  • 在 asyncio 程序中使用同步代码虽然并不会报错,但是也失去了并发的意义,例如网络请求,如果使用仅支持同步的 requests,在发起一次请求后在收到响应结果之前不能发起其他请求,这样要并发访问多个网页时,即使使用了 asyncio,在发送一次请求后切换到其他协程还是会因为同步问题而阻塞,并不能有速度上的提升,这时候就需要其他支持异步操作的请求库如 aiohttp
  • 关于 asyncio 的更多更详细的操作见 官方文档
posted @ 2020-08-31 13:02  Assassin007  阅读(1881)  评论(0编辑  收藏  举报