1. 什么是 I/O 等待? #
1.1 什么是 I/O 等待? #
程序经常需要等待某些操作完成,例如:
- 等网络请求返回
- 等磁盘读写完成
- 等数据库查询结果
这类操作叫 I/O 操作。在等待期间,CPU 大部分时间在"干等",没有做有用的事。
1.2 同步 vs 异步 #
同步:代码一行行执行,遇到 I/O 就停在那里等,等完再继续。后面的代码必须等前面的 I/O 完成。
异步:遇到 I/O 时,不傻等,而是先去做别的事,等 I/O 完成后再回来处理。这样多个 I/O 可以"重叠"进行,提高效率。
通俗比喻:同步像一个人排队等奶茶,等的时候一直站着;异步像排队时顺便回消息、刷手机,奶茶好了再过去取。
1.3 为什么需要 asyncio? #
- 多线程:可以并发,但线程切换有开销,共享数据要加锁,容易出错。
- asyncio:在单线程里用协程实现并发,没有线程切换,一般不需要锁,代码更简洁。
适用场景:I/O 密集型(网络、文件、数据库)。不适用:CPU 密集型(大量计算),应改用多进程。
2. 什么是 asyncio? #
asyncio 是 Python 3.4+ 引入的标准库,用于编写异步并发代码,核心是:
- 协程:用
async def定义的函数,用await挂起等待 - 事件循环:负责调度协程,当一个协程等待时,运行其他协程
3. 协程:async def 和 await #
3.1 定义协程 #
用 async def 定义的函数叫协程函数。调用它不会立即执行,而是返回一个协程对象。
# 导入 asyncio
import asyncio
# 用 async def 定义协程函数
async def say_hello():
# 打印第一行
print("Hello")
# 等待 1 秒(模拟 I/O),不阻塞其他协程
await asyncio.sleep(1)
# 打印第二行
print("World")
# 协程函数必须由事件循环运行,不能直接调用
# asyncio.run() 会创建事件循环并运行传入的协程
asyncio.run(say_hello())说明:await asyncio.sleep(1) 表示"挂起 1 秒",在这 1 秒内事件循环可以运行其他协程。asyncio.sleep 是异步的,不会阻塞。
3.2 在协程里调用另一个协程 #
协程内部用 await 调用其他协程,等待其完成后再继续。
# 导入 asyncio
import asyncio
# 定义协程:等待 2 秒后返回
async def slow_task():
await asyncio.sleep(2)
return "完成"
# 定义主协程
async def main():
# 先打印
print("开始")
# 等待 slow_task 完成,拿到返回值
result = await slow_task()
# 打印返回值
print("结果:", result)
# 运行
asyncio.run(main())4. 并发:create_task 和 gather #
4.1 用 create_task 并发运行 #
asyncio.create_task() 把协程包装成任务,放入事件循环立即开始执行,不阻塞当前协程。
# 导入 asyncio
import asyncio
# 模拟一个耗时 1 秒的任务
async def task(name):
print(f"{name} 开始")
await asyncio.sleep(1)
print(f"{name} 结束")
# 主协程
async def main():
# 创建两个任务,立即开始执行(不等待)
t1 = asyncio.create_task(task("任务A"))
t2 = asyncio.create_task(task("任务B"))
# 等待两个任务都完成
await t1
await t2
print("全部完成")
# 运行
asyncio.run(main())输出顺序:先打印 "任务A 开始" 和 "任务B 开始",约 1 秒后打印 "任务A 结束" 和 "任务B 结束",两个任务并发执行。
4.2 用 gather 批量并发 #
asyncio.gather() 可以同时运行多个协程,并等待全部完成,返回结果列表。
# 导入 asyncio
import asyncio
# 模拟耗时任务,返回结果
async def fetch(id):
await asyncio.sleep(1)
return f"结果-{id}"
# 主协程
async def main():
# 并发运行 3 个 fetch,返回结果列表
results = await asyncio.gather(
fetch(1),
fetch(2),
fetch(3),
)
# 打印所有结果
print(results)
# 运行
asyncio.run(main())说明:gather 会并发执行 3 个 fetch,总耗时约 1 秒(而非 3 秒),因为它们是同时进行的。
5. 事件循环 #
事件循环是 asyncio 的"调度器",负责:
- 运行协程
- 当协程
await时,切换到其他可运行的协程 - 当 I/O 完成时,唤醒等待的协程
通常不需要手动操作事件循环,asyncio.run() 会自动创建并运行。
6. 模拟多个网络请求 #
下面用 asyncio.sleep 模拟多个"网络请求",演示并发效果。
# 导入 asyncio
import asyncio
import time
# 模拟一次网络请求,耗时 1 秒
async def fetch_url(url_id):
print(f"请求 {url_id} 开始")
# 模拟网络延迟
await asyncio.sleep(1)
print(f"请求 {url_id} 完成")
return f"url_{url_id}"
# 主协程
async def main():
# 记录开始时间
start = time.perf_counter()
# 并发发起 5 个"请求"
results = await asyncio.gather(
fetch_url(1),
fetch_url(2),
fetch_url(3),
fetch_url(4),
fetch_url(5),
)
# 计算总耗时
elapsed = time.perf_counter() - start
print(f"全部完成,耗时 {elapsed:.1f} 秒")
print("结果:", results)
# 运行
asyncio.run(main())说明:5 个请求并发执行,总耗时约 1 秒;若串行执行,则需要 5 秒。
7. 注意事项 #
7.1 不要用同步阻塞代码 #
在协程里不要调用会阻塞的同步函数(如 time.sleep、requests.get),否则会阻塞整个事件循环,其他协程无法运行。
# 错误示例:不要这样做!
import asyncio
import time
async def bad():
time.sleep(1) # 错误!会阻塞事件循环
# 正确做法:用 asyncio.sleep
async def good():
await asyncio.sleep(1) # 正确,不会阻塞7.2 必须用异步库 #
网络请求要用 aiohttp 等异步库,不能用 requests(同步)。文件读写要用 aiofiles 等,不能用普通的 open。否则会阻塞事件循环。
7.3 协程必须由事件循环运行 #
协程不能直接调用,必须通过 asyncio.run() 或 await 来执行。在顶层入口用 asyncio.run(main())。
8. asyncio 与多线程对比 #
| 特性 | asyncio | 多线程 |
|---|---|---|
| 并发模型 | 单线程 + 事件循环 | 多线程 |
| 切换开销 | 极小 | 较大 |
| 数据共享 | 一般无需锁 | 需锁 |
| 适用场景 | I/O 密集型 | I/O 密集型 |
| 编程难度 | 需理解 async/await | 需处理线程安全 |
9. 总结 #
| 内容 | 要点 |
|---|---|
| 定义协程 | async def |
| 等待协程 | await |
| 运行入口 | asyncio.run(main()) |
| 并发执行 | create_task() 或 gather() |
| 模拟等待 | await asyncio.sleep(秒数) |
记忆口诀:协程用 async def,等待用 await,入口用 asyncio.run,并发用 gather。