第52课:异步编程:asyncio基础与async/await
学习目标
- 理解协程(Coroutines)的基本概念,以及它与普通函数的区别。
- 掌握使用
async和await关键字定义和调用协程的语法。 - 学会使用
asyncio.run()启动并运行一个简单的协程程序。 - 能够区分同步(阻塞)和异步(非阻塞)代码,并编写基本的异步代码。
- 理解事件循环(Event Loop)在
asyncio中的基本作用。
核心概念
1. 为什么需要异步编程?
想象你在煮一壶咖啡。同步的做法是:你站在水壶旁,盯着水烧开(可能需要5分钟),什么都不做。水开了之后,你再去磨豆子、冲泡。 异步的做法是:你按下水壶的开关后,转身去磨豆子、准备杯子。水壶发出"呜呜"的提示音("我完成了!")时,你再转身来处理热水。这期间,你并没有一直空闲等待。
在编程中,I/O操作(如网络请求、读写文件、查询数据库)就像"等待水烧开"的过程。传统的同步代码会让程序在等待时"卡住"(阻塞),无法执行其他任务。而异步编程允许程序在等待I/O时,转而去执行其他任务,从而提高程序的效率,特别是在I/O密集型应用(如Web服务器、爬虫)中。
2. 什么是协程?
协程是 asyncio 中实现异步的核心。你可以把它理解为一个特殊的函数,它能够在执行到某个点(如等待I/O)时暂停,并将控制权交还给事件循环,让程序去处理其他协程。当之前的I/O操作完成时,它会从暂停的地方恢复执行。
- 定义:使用
async def关键字定义。 - 调用:调用一个协程函数不会立即执行它的代码,而是返回一个协程对象。
- 运行:必须通过
asyncio.run()或await来实际执行它。
3. async 和 await 关键字
async def:用于定义一个协程函数。await:用于在协程中暂停当前协程的执行,去等待另一个可等待对象(如另一个协程、Future)的完成。只有在协程函数内部才能使用await。
代码示例
示例1:从同步到异步
import asyncio
import time
# === 同步版本 ===
def make_coffee_sync():
print("开始烧水...")
time.sleep(2) # 模拟耗时2秒的阻塞I/O操作(如水在加热)
print("水烧开了。")
print("开始磨豆子...")
time.sleep(1) # 模拟耗时1秒的阻塞操作
print("咖啡豆磨好了。")
print("冲泡咖啡...")
time.sleep(1)
print("咖啡准备完成!")
# === 异步版本 ===
async def make_coffee_async():
print("开始烧水...")
await asyncio.sleep(2) # 使用asyncio.sleep模拟非阻塞的I/O等待
print("水烧开了。")
print("开始磨豆子...")
await asyncio.sleep(1)
print("咖啡豆磨好了。")
print("冲泡咖啡...")
await asyncio.sleep(1)
print("咖啡准备完成!")
if __name__ == "__main__":
# 运行同步版本
print("--- 运行同步程序 ---")
start = time.time()
make_coffee_sync()
end = time.time()
print(f"同步总耗时: {end - start:.2f}秒\n")
# 运行异步版本
print("--- 运行异步程序 ---")
start = time.time()
asyncio.run(make_coffee_async()) # 启动事件循环,运行协程
end = time.time()
print(f"异步总耗时: {end - start:.2f}秒")
运行结果分析:
同步版本的总耗时是各步骤之和(2+1+1=4秒)。虽然这个简单的异步示例看起来耗时相同,但关键在于 await asyncio.sleep() 在等待时不会阻塞整个程序。如果在 await 期间有其他协程需要运行,事件循环可以去执行它们。
示例2:并发运行多个协程
import asyncio
async def fetch_data(url, delay):
"""模拟一个耗时的数据请求"""
print(f"开始请求 {url}...")
await asyncio.sleep(delay) # 模拟网络延迟
print(f"完成请求 {url},耗时 {delay} 秒")
return f"{url} 的数据"
async def main():
# 创建协程对象(注意:此时还没有执行)
task1 = fetch_data("网站A", 2)
task2 = fetch_data("网站B", 1)
task3 = fetch_data("网站C", 3)
# 使用 asyncio.gather() 并发运行多个协程,并收集它们的结果
results = await asyncio.gather(task1, task2, task3)
print(f"\n所有结果: {results}")
if __name__ == "__main__":
asyncio.run(main())
输出示例:
开始请求 网站A...
开始请求 网站B...
开始请求 网站C...
完成请求 网站B,耗时 1 秒
完成请求 网站A,耗时 2 秒
完成请求 网站C,耗时 3 秒
所有结果: ['网站A 的数据', '网站B 的数据', '网站C 的数据']
关键点:
await asyncio.gather(task1, task2, task3)会并发(同时)启动这三个协程。- 事件循环在某个协程
await时,会去执行其他已就绪的协程。 - 整个程序的总耗时取决于最慢的协程(这里是3秒),而不是所有协程时间的总和(2+1+3=6秒)。这就是异步并发带来的效率提升。
实践练习
练习1:基础协程定义
编写一个协程函数 countdown(n),它从 n 倒数到1,每秒打印一次数字(使用 await asyncio.sleep(1)),最后打印"发射!"。
练习2:顺序与并发对比
编写两个协程:
task_a(): 模拟一个耗时2秒的任务,完成后返回'A完成'。task_b(): 模拟一个耗时1秒的任务,完成后返回'B完成'。 编写主协程main(),分别用以下两种方式运行它们:
- 顺序执行(先
await task_a(),再await task_b())。 - 并发执行(使用
asyncio.gather())。 观察并打印两种方式的总耗时和结果顺序。
练习3:模拟并发下载
编写一个协程函数 download(file_name, duration),模拟下载一个文件,duration 秒后打印下载完成。
创建一个包含3个不同文件名和时长(例如2秒,3秒,1秒)的协程列表,使用 asyncio.gather() 并发下载它们,并打印所有任务完成后的结束信息。
常见错误
-
忘记
await:async def main(): asyncio.sleep(1) # 错误!只是创建了一个协程对象,没有等待它执行。 await asyncio.sleep(1) # 正确结果:程序可能会立即结束,看起来什么都没做,或者抛出"coroutine ... was never awaited"的警告。
-
在普通函数中使用
await:def normal_func(): await asyncio.sleep(1) # 语法错误!await只能用在async def函数内部。 -
混淆协程函数和协程对象:
async def my_coroutine(): await asyncio.sleep(1) # 错误示例:直接在非async函数中调用 # my_coroutine() # 这会返回一个协程对象,但不会执行,且可能丢失。 # 正确做法:在async函数中await,或在顶层用asyncio.run() asyncio.run(my_coroutine()) -
阻塞事件循环:在协程中直接调用耗时的同步阻塞代码(如
time.sleep(), 大型计算循环),这会阻塞整个事件循环,失去异步的意义。应使用对应的异步版本(asyncio.sleep())或使用loop.run_in_executor()将阻塞操作放到线程/进程池中。
小结
- 协程是使用
async def定义的函数,调用后返回协程对象。 await关键字用于暂停当前协程,等待一个可等待对象完成,期间事件循环可以执行其他任务。asyncio.run()是启动异步程序的主要入口点,它会创建并管理一个事件循环。- 异步编程的核心优势在于处理I/O等待时能实现并发,提高程序整体效率。
- 简单的并发执行可以通过
asyncio.gather()来实现。 - 要避免在协程中使用阻塞的同步代码,否则会拖累整个程序的性能。
理解了这些 asyncio 的基础,你就迈入了异步编程的大门。下一课,我们将深入学习**任务(Tasks)**的概念,它是对协程的进一步封装,提供了更多的控制能力,如取消、状态查询等。