前言
在接触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
"""
这段代码是一个使用协程的异步程序示例。让我们逐行解释它:
- 首先,导入 asyncio 模块,这是 Python 内置的用于异步编程的模块。
- 定义一个名为
crawl_page
的协程函数,它接受一个 URL 参数。在该函数内部,它首先打印正在爬取的 URL,然后根据 URL 中的数字部分计算出等待的秒数。接下来使用await asyncio.sleep(sleep_time)
来暂停协程的执行,模拟实际的爬取操作。最后,打印出爬取完成的消息。 - 定义一个名为
main
的协程函数,它接受一个 URL 列表作为参数。在该函数内部,通过遍历 URL 列表,对每个 URL 调用crawl_page
协程。由于crawl_page
是一个协程函数,所以需要使用await
关键字来等待该协程的执行结果。 - 使用
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
"""
有了前面的介绍,这段代码应该很容易能看懂,这里具体解释一下执行过程:
- asyncio.run(main()),程序进入 main() 函数,事件循环开启;
- task1 和 task2 任务被创建,并进入事件循环等待运行;运行到 print,输出 'before await';
- await task1 执行,用户选择从当前的主任务中切出,事件调度器开始调度 worker_1;
- worker_1 开始运行,运行 print 输出'worker_1 start',然后运行到 await asyncio.sleep(1), 从当前任务切出,事件调度器开始调度 worker_2;
- worker_2 开始运行,运行 print 输出 'worker_2 start',然后运行 await asyncio.sleep(2) 从当前任务切出;
- 以上所有事件的运行时间,都应该在 1ms 到 10ms 之间,甚至可能更短,事件调度器从这个时候开始暂停调度;
- 一秒钟后,worker_1 的 sleep 完成,事件调度器将控制权重新传给 task_1,输出 'worker_1 done',task_1 完成任务,从事件循环中退出;
- await task1 完成,事件调度器将控制器传给主任务,输出 'awaited worker_1',·然后在 await task2 处继续等待;
- 两秒钟后,worker_2 的 sleep 完成,事件调度器将控制权重新传给 task_2,输出 'worker_2 done',task_2 完成任务,从事件循环中退出;
- 主任务输出 'awaited worker_2',协程全任务结束,事件循环结束。
进阶
我们想给某些协程任务限定运行时间,一旦超时就取消,又该怎么做呢?再进一步,如果某些协程运行时出现错误,又该怎么处理呢?
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)
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,什么时候需要一并执行到底。