第 1 章:异步编程与 I/O 等待
我们正在做一个“学生信息查询助手”。
现在要查询 3 个学生的信息。三次查询互不依赖,每次都要等 1 秒,模拟访问外部接口、数据库或 Redis 的耗时。
如果一个一个查,总共约需 3 秒。能否让三段等待重叠,把总耗时降到接近 1 秒?
我们会先查询一个学生,再连续查询三个学生,最后改成异步查询。运行每个版本,回答三个问题:
- 为什么同步查询多个 I/O 等待任务会变慢。
- 异步代码和同步代码在等待场景中有什么区别。
async def、await、asyncio.run()在最小异步程序里怎么用。
本章只比较等待方式,并跑通最小异步示例。并发任务的组织、异常、超时、并发数量限制、真实 HTTP 请求和 Web 接口开发留到后续章节。
本章学习目标
学完本章,你要能做到:
- 用
time.perf_counter()测出连续查询时等待时间怎样累加。 - 读懂
async def、await asyncio.sleep()和asyncio.run()的最小写法。 - 对比三个查询的同步与异步运行结果,说明等待重叠需要什么条件。
- 判断 I/O 等待与 CPU 计算场景中,异步能解决什么问题。
学生信息查询场景
我们的统一案例叫“异步学生信息聚合与任务处理系统”。
当前系统还只是 Python 脚本,没有 Web 服务或真实数据库。每次查询都人为等待 1 秒,代表一次外部查询的等待时间。
这 1 秒可以理解为:
- 等数据库返回结果。
- 等 Redis 返回结果。
- 等外部 HTTP 接口返回结果。
- 等文件系统或网络 I/O 返回结果。
注意,这 1 秒里程序大部分时间不是在计算,而是在等。
同步查询的流程大致是:
这个流程的问题很直接:每一次等待都会排队。
异步查询希望达到的效果是:
异步版本要让三个查询都开始,并在各自等待时给其他查询执行机会。下面用可运行的代码验证两张图中的耗时差异。
同步与异步查询对比
下面每一步的完整代码都直接显示在页面里。将代码按标注文件名保存到本地 _code_async/c01_why_async_programming/,然后在 _code_async 目录执行命令。
1. 单次同步查询
先查询一个学生。这里的 time.sleep(1) 会让当前线程暂停 1 秒,用来模拟一次同步查询的等待。
需求:查询一个学生的信息,等待 1 秒后返回结果。
功能分析:
- 准备一份内存学生数据。
- 写一个普通函数
query_student_sync(student_id)。 - 在函数里用
time.sleep(1)模拟等待。 - 根据学生 ID 返回学生信息。
文件名:01_sync_query.py
import time
student_data = {
1: {"id": 1, "name": "张三", "age": 18},
2: {"id": 2, "name": "李四", "age": 19},
3: {"id": 3, "name": "王五", "age": 20},
}
def query_student_sync(student_id):
print(f"开始查询学生:{student_id}")
time.sleep(1)
print(f"查询完成:{student_id}")
return student_data.get(student_id)
result = query_student_sync(1)
print(result)运行命令:
uv run python c01_why_async_programming/01_sync_query.py可能看到的输出:
开始查询学生:1
查询完成:1
{'id': 1, 'name': '张三', 'age': 18}运行逻辑:
- 程序先执行
query_student_sync(1)。 - 函数内部打印“开始查询学生:1”。
time.sleep(1)让执行脚本的当前线程暂停 1 秒。- 1 秒后,程序继续往下执行。
- 函数返回学生数据。
- 最后打印查询结果。
注意:数据来自内存字典,time.sleep(1) 只是在模拟“等待外部结果”,并未执行真实 I/O。真实项目中,数据库、Redis、网络请求或文件读写都可能产生 I/O 等待;程序主要在等结果,而不是进行 CPU 计算。看到程序慢时,先分清它是在计算还是在等待。
在这个脚本里,当前查询没结束,后面的查询代码就不会开始。time.sleep() 也适用于需要暂停的简单同步脚本;放进后面的异步查询函数,则会让其他查询无法趁这 1 秒推进。练习 3 会验证这一点。
2. 连续同步查询与耗时累加
现在把需求改成一次查询 3 个学生的信息。同步版本按顺序调用查询函数:前一个学生查完,才开始查下一个。
功能分析:
- 准备学生 ID 列表。
- 用
for循环一个一个查询。 - 每个学生查询等待 1 秒。
- 用
time.perf_counter()统计总耗时。
文件名:02_sync_query.py
import time
student_data = {
1: {"id": 1, "name": "张三", "age": 18},
2: {"id": 2, "name": "李四", "age": 19},
3: {"id": 3, "name": "王五", "age": 20},
}
def query_student_sync(student_id):
print(f"开始查询学生:{student_id}")
time.sleep(1)
print(f"查询完成:{student_id}")
return student_data.get(student_id)
def query_students_sync(student_ids):
results = []
for student_id in student_ids:
student = query_student_sync(student_id)
results.append(student)
return results
student_ids = [1, 2, 3]
start_time = time.perf_counter()
results = query_students_sync(student_ids)
end_time = time.perf_counter()
print(results)
print(f"同步查询耗时:{end_time - start_time:.2f} 秒")运行命令:
uv run python c01_why_async_programming/02_sync_query.py可能看到的输出:
开始查询学生:1
查询完成:1
开始查询学生:2
查询完成:2
开始查询学生:3
查询完成:3
[{'id': 1, 'name': '张三', 'age': 18}, {'id': 2, 'name': '李四', 'age': 19}, {'id': 3, 'name': '王五', 'age': 20}]
同步查询耗时:3.01 秒运行逻辑:
student_ids = [1, 2, 3]表示要查询 3 个学生。start_time = time.perf_counter()记录开始时间。query_students_sync(student_ids)开始同步查询多个学生。for循环先查学生 1,等待 1 秒。- 学生 1 查询完成后,才开始查学生 2。
- 学生 2 查询完成后,才开始查学生 3。
- 3 个学生全部查完后,记录结束时间。
- 最后打印总耗时。
为什么接近 3 秒?
因为每次查询都要等 1 秒,并且 3 次查询是顺序执行的。
学生 1 等 1 秒 + 学生 2 等 1 秒 + 学生 3 等 1 秒 ≈ 3 秒注意:这里的时间主要花在等待上,不是 Python 在计算学生字典。任务很少、等待很短或同步代码已经足够清楚时,按顺序查询仍是合适的写法;当大量慢查询都这样排队,总耗时就会继续累加。
3. 异步查询与等待重叠
同步版本已经测出约 3 秒。现在希望三个查询都开始等待,让三段等待尽量重叠。异步查询函数用 async def 定义;执行到 await asyncio.sleep(1) 时,它会暂停当前查询,让其他查询有机会推进。这里的 sleep() 只模拟异步等待,在实际异步程序中也可用于重试或轮询间隔。
还需要让多个查询一起开始:本章借 asyncio.gather() 完成这一步,并等它们全部返回。asyncio.run(main()) 从普通脚本启动异步主流程。先把这两个写法当作运行示例所需的工具;异步函数调用后的返回对象与 await 规则留到第 2 章,多个任务如何组织留到第 3 章。
功能分析:
- 使用
async def定义异步查询函数。 - 使用
await asyncio.sleep(1)模拟异步等待。 - 使用
asyncio.gather()同时等待多个异步查询结果。 - 使用
asyncio.run(main())运行异步入口函数。 - 使用
time.perf_counter()统计总耗时。
文件名:03_async_query.py
import asyncio
import time
student_data = {
1: {"id": 1, "name": "张三", "age": 18},
2: {"id": 2, "name": "李四", "age": 19},
3: {"id": 3, "name": "王五", "age": 20},
}
async def query_student_async(student_id):
print(f"开始查询学生:{student_id}")
await asyncio.sleep(1)
print(f"查询完成:{student_id}")
return student_data.get(student_id)
async def query_students_async(student_ids):
results = await asyncio.gather(
query_student_async(student_ids[0]),
query_student_async(student_ids[1]),
query_student_async(student_ids[2]),
)
return results
async def main():
student_ids = [1, 2, 3]
start_time = time.perf_counter()
results = await query_students_async(student_ids)
end_time = time.perf_counter()
print(results)
print(f"异步查询耗时:{end_time - start_time:.2f} 秒")
asyncio.run(main())运行命令:
uv run python c01_why_async_programming/03_async_query.py可能看到的输出:
开始查询学生:1
开始查询学生:2
开始查询学生:3
查询完成:1
查询完成:2
查询完成:3
[{'id': 1, 'name': '张三', 'age': 18}, {'id': 2, 'name': '李四', 'age': 19}, {'id': 3, 'name': '王五', 'age': 20}]
异步查询耗时:1.01 秒运行逻辑:
async def query_student_async(student_id)定义一个异步查询函数。asyncio.gather()让三个查询都有机会开始执行,并等待全部结果。- 每个查询执行到
await asyncio.sleep(1)时,暂停自己,让其他查询推进。 async def main()组织查询和计时;asyncio.run(main())从脚本入口启动它。- 查询全部完成后打印结果和总耗时。
为什么 3 个学生大约只要 1 秒?
因为 gather() 让三个查询都开始了;每个查询在异步等待时让出执行机会,三段等待便能够重叠。
学生 1 等 1 秒
学生 2 等 1 秒
学生 3 等 1 秒
三段等待重叠,所以总耗时接近 1 秒只写 async def 不会自动让多个查询同时推进。如果在循环里逐个 await query_student_async(student_id),仍是完成一个再等下一个,总耗时接近 3 秒;本例用 gather() 让等待重叠。第 3 章再专门讲多个任务的组织、结果收集和异常影响。
等待能重叠,还因为这三个查询互不依赖。如果查询学生 2 必须先拿到学生 1 的返回值,第二次查询就只能等第一次完成后再开始;即使用了异步,有依赖的两段等待也不能完全重叠。
这也是模拟实验:它说明了三段人为等待可以重叠,并不保证真实接口一定能从 3 秒降到 1 秒。真实耗时还取决于网络、服务端响应和并发限制。
常见误用是把 await asyncio.sleep(1) 改成 time.sleep(1),或忘记在异步调用前写 await。前者会阻塞运行查询的事件循环;后者涉及异步函数的调用规则,第 2 章会通过运行结果专门讲解。普通 .py 脚本也不能直接在顶层写 await,所以本例把异步流程放进 main(),由 asyncio.run(main()) 启动。
异步的适用边界与代码写法
适合异步的 I/O 等待场景
异步适合用在 I/O 等待比较多的场景:
- 一个接口要请求多个外部服务。
- 一个任务要等待多个远程接口返回。
- Web 服务里有大量网络 I/O。
- 后台任务经常访问数据库、Redis、HTTP 接口。
- 爬虫需要同时等待多个网页响应。
这些场景的共同点是:程序经常在等外部结果。
异步收益有限的场景
异步不是所有“慢问题”的通用答案。
下面这些场景不要一上来就用 async:
- 主要耗时是大量数学计算。
- 主要耗时是图像处理、视频编码、模型推理。
- 代码只有很少的 I/O 等待。
- 普通同步代码已经足够简单稳定。
如果慢是 CPU 算出来的,async 本身不会直接让 CPU 算得更快。
采用异步还需要正确处理资源释放、超时和异常。团队尚未掌握这些写法时,也要把学习和维护成本算进去。
最小异步程序的写法
本章阶段先记住几条:
- 异步函数用
async def定义。 - 异步等待用
await。 - 异步程序入口用
asyncio.run(main())。 - 异步代码中优先使用异步等待,例如
await asyncio.sleep()。 - 做耗时对比时,用
time.perf_counter()。
常见误用
先避免这些常见写法:
- 在
async def里写time.sleep()。 - 调用异步函数后忘记
await。 - 写了
async def main(),但没有用asyncio.run(main())启动。 - 看到 async 就以为代码一定更快。
- 本来只有一个简单查询,也强行改成异步。
异步是工具,不是项目变快的保证。
本章小结
本章我们用“查询多个学生信息”这个小需求,直观看到了同步和异步的差异。
同步代码会一个一个等,所以等待时间会累加。
当多个异步查询一起开始,并在等待时让出执行机会,它们的 I/O 等待就可以重叠。
本章最核心的是 4 个写法:
async def:定义异步函数。await asyncio.sleep(1):模拟异步等待,并让其他查询有机会推进。asyncio.gather():在本章示例中让三个查询一起开始,具体用法留到第 3 章。asyncio.run(main()):运行异步程序入口。
异步适合处理 I/O 等待,不是用来直接加速所有代码。
练习
练习 1:把同步版本里的学生 ID 改成 [1, 2, 3, 1, 2],自己运行一次,看看总耗时会不会继续累加。
练习 2:把异步版本里的等待时间改成 2 秒,再运行一次,观察总耗时是不是接近 2 秒,而不是 6 秒。
练习 3:尝试把异步函数里的 await asyncio.sleep(1) 故意改成 time.sleep(1),再观察输出顺序和总耗时。你会更容易理解“阻塞”是什么意思。
练习 4:判断下面三个任务中,哪些等待可以重叠、哪些步骤必须按顺序执行、哪个任务不会因为改成 async 就直接变快,并说明原因:① 分别从外部接口查询 3 个学生的基本信息,查询之间互不依赖;② 先查询学生的班级 ID,再用这个 ID 查询班级课程表;③ 对内存中的大量分数求平均值。