协程不是线程:深入理解 Python async/await 运行机制
"我以为 async 就是开了个新线程......" ------ 几乎每一位第一次接触异步编程的同事
如果你也有过这样的困惑,这篇文章就是为你写的。
一、为什么要有异步编程?
在聊 async/await 之前,我们先回到一个最朴素的问题:同步代码哪里不够用了?
想象你是一家餐厅的服务员。同步模式下,你服务完1号桌(等他点菜、上菜、结账),才能去服务2号桌。如果1号桌的客人在纠结菜单,你就只能站在那里干等。
这就是同步 I/O 的本质:CPU 在等待网络、磁盘响应的过程中,什么都不做,白白浪费时间。
异步模式下,你把菜单留给1号桌,转身去招呼2号桌,等1号桌准备好了再回来。一个人,服务多张桌,没有多雇人(没有多开线程),效率却大幅提升。
这就是 Python 异步编程的核心动机。
二、async/await 的运行机制
2.1 从一个最简单的例子开始
python
import asyncio
async def say_hello():
print("Hello")
await asyncio.sleep(1) # 模拟 I/O 等待
print("World")
asyncio.run(say_hello())
运行结果:
Hello
(等待约1秒)
World
看起来和同步代码没什么区别?别急,我们加点料:
python
import asyncio
import time
async def task(name, delay):
print(f"[{name}] 开始")
await asyncio.sleep(delay)
print(f"[{name}] 完成,耗时 {delay}s")
async def main():
start = time.time()
# 并发执行三个任务
await asyncio.gather(
task("A", 2),
task("B", 1),
task("C", 3),
)
print(f"总耗时:{time.time() - start:.2f}s")
asyncio.run(main())
输出:
[A] 开始
[B] 开始
[C] 开始
[B] 完成,耗时 1s
[A] 完成,耗时 2s
[C] 完成,耗时 3s
总耗时:3.01s
三个任务总共需要 2+1+3=6 秒,但实际只用了 3 秒。没有多线程,没有多进程,只有一个线程在跑。 这就是异步的魔法。
2.2 async 函数到底返回什么?
这是理解异步的第一个关键点。
python
async def greet():
return "你好"
result = greet()
print(result) # <coroutine object greet at 0x...>
print(type(result)) # <class 'coroutine'>
调用 async 函数,不会执行函数体,而是返回一个协程对象(coroutine object)。
协程对象就像一份"执行计划书",它描述了"要做什么",但还没有真正开始做。你需要把它交给 Event Loop 去调度执行。
2.3 await 做了什么?
await 是一个暂停点。当执行到 await 时,当前协程会:
- 暂停自身执行,把控制权交还给 Event Loop
- Event Loop 去执行其他就绪的协程
- 等待的操作完成后,Event Loop 恢复这个协程,从暂停处继续执行
python
async def demo():
print("步骤1:开始")
await asyncio.sleep(0) # 暂停,让出控制权
print("步骤3:恢复执行") # Event Loop 调度回来后继续
async def other():
print("步骤2:趁机执行")
async def main():
await asyncio.gather(demo(), other())
asyncio.run(main())
输出:
步骤1:开始
步骤2:趁机执行
步骤3:恢复执行
这就是协程的"协作式调度"------主动让出,而非被动抢占。
三、协程对象、Task、Event Loop 三者的关系
这是很多人最容易混淆的地方,我用一个类比把它讲清楚。
类比:剧本、演员、导演
| 概念 | 类比 | 职责 |
|---|---|---|
| 协程对象(coroutine) | 剧本 | 描述"要做什么",是惰性的,不会自己执行 |
| Task | 演员 | 包裹剧本,负责实际执行,有状态(pending/running/done) |
| Event Loop | 导演 | 统筹调度所有演员,决定谁上场、谁等待 |
3.1 协程对象:惰性的执行计划
python
async def fetch_data():
await asyncio.sleep(1)
return {"data": 42}
# 只是创建了协程对象,什么都没发生
coro = fetch_data()
print(coro) # <coroutine object fetch_data at 0x...>
协程对象本身不会运行,它需要被"驱动"。
3.2 Task:被调度的执行单元
Task 是对协程的封装,创建 Task 后,Event Loop 会在合适的时机调度它执行。
python
async def main():
# 方式1:asyncio.create_task(推荐)
task1 = asyncio.create_task(fetch_data())
# 方式2:asyncio.ensure_future(较旧的写法)
task2 = asyncio.ensure_future(fetch_data())
# Task 创建后立即被调度,不需要 await 就已经"启动"了
print(task1) # <Task pending name='Task-1' coro=<fetch_data()>>
# await 等待 Task 完成并获取结果
result = await task1
print(result) # {'data': 42}
关键区别:
python
async def main():
# 顺序执行:先等 A 完成,再执行 B
await asyncio.sleep(2) # 等2秒
await asyncio.sleep(2) # 再等2秒
# 总耗时:4秒
# 并发执行:A 和 B 同时跑
task_a = asyncio.create_task(asyncio.sleep(2))
task_b = asyncio.create_task(asyncio.sleep(2))
await task_a
await task_b
# 总耗时:2秒
3.3 Event Loop:一切的核心调度器
Event Loop 是异步程序的心脏,它做的事情本质上是一个无限循环:
while True:
1. 检查哪些 Task 已经就绪(I/O 完成、定时器到期等)
2. 执行就绪的 Task,直到它遇到下一个 await
3. 处理 I/O 事件(通过 select/epoll/kqueue)
4. 重复
用代码感受一下 Event Loop 的存在:
python
import asyncio
async def main():
loop = asyncio.get_event_loop()
print(f"当前 Event Loop:{loop}")
print(f"是否在运行:{loop.is_running()}")
asyncio.run(main())
asyncio.run() 做了三件事:创建 Event Loop → 运行传入的协程直到完成 → 关闭 Event Loop。
3.4 三者协作的完整流程
你的代码
│
├─ 调用 async 函数 ──→ 生成 协程对象(coroutine)
│
├─ asyncio.create_task() ──→ 包装成 Task,注册到 Event Loop
│
└─ asyncio.run() / await
│
▼
Event Loop
│
├─ 调度 Task A(运行到 await,暂停)
├─ 调度 Task B(运行到 await,暂停)
├─ I/O 完成,Task A 就绪,恢复执行
└─ Task A 完成,返回结果
四、如何向只写同步代码的同事解释"协程不是线程"
这是我在团队里真实遇到过的场景。下面是我用过的最有效的解释方式。
4.1 先破除误解
同事的直觉:"async 函数会在后台开一个新线程跑。"
验证一下:
python
import asyncio
import threading
async def check_thread():
print(f"协程运行在线程:{threading.current_thread().name}")
await asyncio.sleep(0.1)
print(f"恢复后还是线程:{threading.current_thread().name}")
async def main():
print(f"主线程:{threading.current_thread().name}")
await asyncio.gather(
check_thread(),
check_thread(),
check_thread(),
)
asyncio.run(main())
输出:
主线程:MainThread
协程运行在线程:MainThread
协程运行在线程:MainThread
协程运行在线程:MainThread
恢复后还是线程:MainThread
恢复后还是线程:MainThread
恢复后还是线程:MainThread
三个协程,全部在同一个线程里运行。 这是铁证。
4.2 用"单线程的并发"来描述
我会这样跟同事说:
"线程是操作系统级别的并发,由 OS 调度,可以真正并行(多核)。协程是用户态的并发,由 Event Loop 调度,同一时刻只有一个协程在执行,但它们可以在等待 I/O 时互相切换,从而实现并发效果。"
4.3 展示最关键的差异:阻塞行为
python
import asyncio
import time
# ❌ 错误示范:在协程里用同步阻塞调用
async def bad_task(name):
print(f"[{name}] 开始")
time.sleep(2) # 这会阻塞整个 Event Loop!
print(f"[{name}] 完成")
# ✅ 正确做法:用异步版本
async def good_task(name):
print(f"[{name}] 开始")
await asyncio.sleep(2) # 只暂停当前协程,不阻塞其他
print(f"[{name}] 完成")
async def main_bad():
start = time.time()
await asyncio.gather(bad_task("A"), bad_task("B"))
print(f"bad 总耗时:{time.time() - start:.2f}s") # 4秒
async def main_good():
start = time.time()
await asyncio.gather(good_task("A"), good_task("B"))
print(f"good 总耗时:{time.time() - start:.2f}s") # 2秒
asyncio.run(main_bad())
asyncio.run(main_good())
这个对比是最直观的教学工具。time.sleep() 会冻结整个 Event Loop,因为它阻塞了线程;asyncio.sleep() 只是告诉 Event Loop "我先歇一会儿,你去忙别的"。
4.4 一张对比表,贴在工位上
| 维度 | 线程(Thread) | 协程(Coroutine) |
|---|---|---|
| 调度者 | 操作系统 | Event Loop(用户态) |
| 切换时机 | 任意时刻(抢占式) | 遇到 await(协作式) |
| 内存开销 | 约 1-8 MB/个 | 约 几 KB/个 |
| 并发数量 | 数百到数千 | 轻松数万 |
| 适合场景 | CPU 密集型 | I/O 密集型 |
| 共享状态风险 | 需要锁(race condition) | 单线程,相对安全 |
| 调试难度 | 较高(竞态条件) | 中等(回调地狱已消失) |
4.5 一个实战案例:并发抓取网页
python
import asyncio
import aiohttp
import time
# 模拟 URL 列表
URLS = [f"https://httpbin.org/delay/1" for _ in range(5)]
async def fetch(session, url, idx):
async with session.get(url) as response:
data = await response.json()
print(f"请求 {idx} 完成,状态码:{response.status}")
return data
async def main():
start = time.time()
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url, i) for i, url in enumerate(URLS)]
results = await asyncio.gather(*tasks)
print(f"5个请求总耗时:{time.time() - start:.2f}s")
# 每个请求需要1秒,但并发执行,总耗时约1秒
asyncio.run(main())
如果用同步 requests 逐个请求,需要约 5 秒。异步并发只需约 1 秒。同一个线程,5倍的效率提升。
五、常见陷阱与最佳实践
陷阱1:忘记 await
python
async def main():
# ❌ 忘记 await,协程对象被创建但从未执行
fetch_data() # RuntimeWarning: coroutine 'fetch_data' was never awaited
# ✅ 正确
await fetch_data()
陷阱2:在协程中调用阻塞函数
python
import asyncio
from concurrent.futures import ThreadPoolExecutor
# 对于无法避免的同步阻塞操作(如某些第三方库)
# 用 run_in_executor 放到线程池里跑
async def safe_blocking_call():
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, time.sleep, 2)
return result
陷阱3:混淆 gather 和顺序 await
python
async def main():
# 顺序执行,总耗时 = 各任务之和
await task_a()
await task_b()
# 并发执行,总耗时 = 最长任务的耗时
await asyncio.gather(task_a(), task_b())
六、总结
async/await 的本质是单线程内的协作式并发,三个核心概念的关系一句话概括:
协程对象 是执行计划,Task 是被调度的执行单元,Event Loop 是统筹一切的调度器。
协程不是线程,它不会神奇地并行执行 CPU 计算,但在 I/O 密集型场景(网络请求、文件读写、数据库查询)下,它能用极低的资源开销实现极高的并发吞吐。
理解了这一点,你就掌握了 Python 异步编程的灵魂。
互动讨论
- 你在项目中有没有遇到过"把同步库用在异步代码里导致性能反而下降"的情况?是怎么排查和解决的?
- 你认为 Python 的 GIL 和异步编程之间是什么关系?欢迎在评论区聊聊你的理解。
参考资料
- Python 官方 asyncio 文档
- PEP 492 -- Coroutines with async and await syntax
- 书籍推荐:《流畅的Python(第2版)》第19-21章,David Beazley 的《Python Cookbook》
- 库推荐:
aiohttp(异步 HTTP)、aiomysql(异步 MySQL)、aiofiles(异步文件 I/O)