Python协程:异步编程的利器

124 阅读8分钟

前言

在接触FastAPI框架时,知道它是一个异步的框架。那什么是异步呢?

在传统的同步编程模型中,程序按照顺序逐行执行,当遇到阻塞操作时,程序会等待操作完成后再继续执行下一行代码。但是在某些场景下,这种同步的方式可能效率低下,因为计算机的大部分时间都在等待外部资源的响应,而不是真正进行计算。

Python引入了协程(Coroutine)的概念,提供了一种轻量级的并发编程方式,使得在IO密集型任务中可以充分利用计算机资源,提高程序的性能和吞吐量。本文将介绍Python协程的概念、工作原理以及如何在实战中应用它们。

什么是协程?

它是一种特殊的函数,可以在函数的执行过程中暂停,并在需要时恢复执行,从而允许程序在多个任务之间切换执行,而无需依赖操作系统的线程调度机制。

Python协程的实现方式

在Python中,协程的实现可以使用多种方式,其中最常用的方式是使用asyncio库和async/await关键字。下面是一个简单的示例,展示了如何使用async/await定义和调用协程:

实例演示

看下面这段模拟爬虫案例

import time
​
def crawl_page(url):
    print('crawling {}'.format(url))
    sleep_time = int(url.split('_')[-1])
    time.sleep(sleep_time)
    print('OK {}'.format(url))
​
def main(urls):
    for url in urls:
        crawl_page(url)
​
main(['url_1', 'url_2', 'url_3', 'url_4'])
​
"""
crawling url_1
OK url_1
crawling url_2
OK url_2
crawling url_3
OK url_3
crawling url_4
OK url_4
"""

这段代码模拟网络通信,分别从4个url中爬取数据,假设得到响应时间分别需要1秒、2秒、3秒、4秒,这样爬取完4个url需要10秒。显然效率低下,遇到阻塞只能等待。如何优化呢?完全可以并发。

看优化后的代码

import asyncio
​
​
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):
        for url in urls:
            await crawl_page(url)
​
asyncio.run(main(['url_1', 'url_2', 'url_3', 'url_4']))
​
"""
crawling url_1
OK url_1
crawling url_2
OK url_2
crawling url_3
OK url_3
crawling url_4
OK url_4
"""

这段代码是一个使用协程的异步程序示例。让我们逐行解释它:

  1. 首先,导入 asyncio 模块,这是 Python 内置的用于异步编程的模块。
  2. 定义一个名为 crawl_page 的协程函数,它接受一个 URL 参数。在该函数内部,它首先打印正在爬取的 URL,然后根据 URL 中的数字部分计算出等待的秒数。接下来使用 await asyncio.sleep(sleep_time) 来暂停协程的执行,模拟实际的爬取操作。最后,打印出爬取完成的消息。
  3. 定义一个名为 main 的协程函数,它接受一个 URL 列表作为参数。在该函数内部,通过遍历 URL 列表,对每个 URL 调用 crawl_page 协程。由于 crawl_page 是一个协程函数,所以需要使用 await 关键字来等待该协程的执行结果。
  4. 使用 asyncio.run(main(['url_1', 'url_2', 'url_3', 'url_4'])) 来运行 main 协程函数。asyncio.run() 是一个方便的函数,用于运行异步程序的顶级入口点。

运行这段代码,发现怎么还是10秒呢?因为await 是同步调用,因此, crawl_page(url) 在当前的调用结束之前,是不会触发下一次调用的。于是,这个代码效果就和上面完全一样了,相当于我们用异步接口写了个同步代码。

那该如何解决呢?这就涉及到协程中的一个重要概念,任务(Task)。

看下面再次优化后的代码

import asyncio
​
​
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):
        tasks = [asyncio.create_task(crawl_page(url)) for url in urls]
        for task in tasks: await task
​
asyncio.run(main(['url_1', 'url_2', 'url_3', 'url_4']))
​
"""
crawling url_1
crawling url_2
crawling url_3
crawling url_4
OK url_1
OK url_2
OK url_3
OK url_4
"""

可以看到数据结果已经变了,运行时长大大缩减。这段代码,我们通过asyncio.create_task 来创建任务。任务创建后很快就会被调度执行,这样,我们的代码也不会阻塞在任务这里。所以,我们要等所有任务都结束才行,用for task in tasks: await task 即可。

我们还可以换一种做法,执行task

import asyncio
​
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):
    tasks = [asyncio.create_task(crawl_page(url)) for url in urls]
    await asyncio.gather(*tasks)
​
asyncio.run(main(['url_1', 'url_2', 'url_3', 'url_4']))
  • 我们使用asyncio.gather()方法,将所有的任务作为参数传递给它。asyncio.gather()将同时运行这些协程任务,并等待它们全部完成。
  • 最后,我们使用await关键字等待asyncio.gather()的结果,这样main函数只有在所有任务都完成后才会继续执行后续的代码。

通过使用asyncio.create_task()来创建任务,我们可以将多个协程对象封装为任务,并实现并发执行。而asyncio.gather()方法则用于等待所有并发任务的完成。

深入理解

先看下面这个例子

import asyncio
​
async def worker_1():
    print('worker_1 start')
    await asyncio.sleep(1)
    print('worker_1 done')
​
async def worker_2():
    print('worker_2 start')
    await asyncio.sleep(2)
    print('worker_2 done')
​
async def main():
    task1 = asyncio.create_task(worker_1())
    task2 = asyncio.create_task(worker_2())
    print('before await')
    await task1
    print('awaited worker_1')
    await task2
    print('awaited worker_2')
​
asyncio.run(main())
​
​
"""
before await
worker_1 start
worker_2 start
worker_1 done
awaited worker_1
worker_2 done
awaited worker_2
"""

有了前面的介绍,这段代码应该很容易能看懂,这里具体解释一下执行过程:

  1. asyncio.run(main()),程序进入 main() 函数,事件循环开启;
  2. task1 和 task2 任务被创建,并进入事件循环等待运行;运行到 print,输出 'before await';
  3. await task1 执行,用户选择从当前的主任务中切出,事件调度器开始调度 worker_1;
  4. worker_1 开始运行,运行 print 输出'worker_1 start',然后运行到 await asyncio.sleep(1), 从当前任务切出,事件调度器开始调度 worker_2;
  5. worker_2 开始运行,运行 print 输出 'worker_2 start',然后运行 await asyncio.sleep(2) 从当前任务切出;
  6. 以上所有事件的运行时间,都应该在 1ms 到 10ms 之间,甚至可能更短,事件调度器从这个时候开始暂停调度;
  7. 一秒钟后,worker_1 的 sleep 完成,事件调度器将控制权重新传给 task_1,输出 'worker_1 done',task_1 完成任务,从事件循环中退出;
  8. await task1 完成,事件调度器将控制器传给主任务,输出 'awaited worker_1',·然后在 await task2 处继续等待;
  9. 两秒钟后,worker_2 的 sleep 完成,事件调度器将控制权重新传给 task_2,输出 'worker_2 done',task_2 完成任务,从事件循环中退出;
  10. 主任务输出 'awaited worker_2',协程全任务结束,事件循环结束。

进阶

我们想给某些协程任务限定运行时间,一旦超时就取消,又该怎么做呢?再进一步,如果某些协程运行时出现错误,又该怎么处理呢?

import asyncio
​
async def worker_1():
    await asyncio.sleep(1)
    return 1async def worker_2():
    await asyncio.sleep(2)
    return 2 / 0async def worker_3():
    await asyncio.sleep(3)
    return 3async 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)
​
asyncio.run(main())
​
"""
[1, ZeroDivisionError('division by zero'), CancelledError()]
"""

这里的执行过程如下:

  • worker_1() 执行完成,返回结果 1
  • worker_2() 在执行过程中发生了除零错误,返回 ZeroDivisionError('division by zero') 异常。
  • 在经过 2 秒等待后,调用 task_3.cancel() 取消了 worker_3() 的执行。
  • worker_3() 被取消,返回 CancelledError()
  • 在使用 asyncio.gather() 并发执行所有任务时,通过 return_exceptions=True 参数指定,在遇到异常时将异常对象作为结果返回。
  • 最后,打印执行结果。

注意:return_exceptions=True这行代码。如果不设置这个参数,错误就会完整地 throw 到我们这个执行层,从而需要 try except 来捕捉,这也就意味着其他还没被执行的任务会被全部取消掉。为了避免这个局面,我们将 return_exceptions 设置为 True 即可。

实战

案例一: 并发文件下载示例(使用aiohttp库):

import asyncio
import aiohttp
​
async def download_file(url, filename):
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as response:
            with open(filename, 'wb') as f:
                while True:
                    chunk = await response.content.read(1024)
                    if not chunk:
                        break
                    f.write(chunk)
​
async def main():
    urls = [
        'https://example.com/file1.txt',
        'https://example.com/file2.txt',
        'https://example.com/file3.txt'
    ]
    tasks = []
    for i, url in enumerate(urls):
        filename = f'file{i+1}.txt'
        tasks.append(asyncio.create_task(download_file(url, filename)))
    await asyncio.gather(*tasks)
​
asyncio.run(main())

在这个示例中,我们使用了aiohttp库来异步地从多个URL下载文件。通过创建多个下载任务,并使用asyncio.gather()方法并发执行这些任务,我们可以实现并发的文件下载,提高下载效率。

最后

Python协程是一种强大的异步编程工具,它提供了一种轻量级的并发编程方式,可以在IO密集型任务中提高程序的性能和吞吐量。通过使用async/await关键字和asyncio库,我们可以轻松定义和调用协程,并实现并发处理、异步IO操作等任务。

有位大佬这样总结,简单明了:
1、协程和多线程的区别,主要在于两点,一是协程为单线程;二是协程由用户决定,在哪些地方交出控制权,切换到下一个任务。
2、协程的写法更加简洁清晰,把 async / await 语法和 create_task 结合来用,对于中小级别的并发需求已经毫无压力。
3、写协程程序的时候,你的脑海中要有清晰的事件循环概念,知道程序在什么时候需要暂停、等待 I/O,什么时候需要一并执行到底。