协程基础:用 async/await 改写你的并发逻辑
说实话,我当年刚开始接触协程的时候,也觉得很玄乎。什么 async、await,看着像魔法一样。直到我在一个高并发的网关项目里,用线程池扛到 5000 QPS 就开始频繁上下文切换,CPU 飙升到 90%,服务直接卡死……
后来我把那套逻辑改成了协程,同样的机器,QPS 干到了 3 万,CPU 才 60%。你说差距大不大?
什么是协程?跟线程到底差在哪
你可以把协程想成一个「轻量级线程」。但它不是操作系统管理的,而是由我们的程序自己在用户态调度的。什么意思呢?线程切换要进内核态,保存一堆寄存器、栈信息,开销很大;协程切换只改几个指针,几乎零成本。
| 对比维度 | 线程 | 协程 |
|---|---|---|
| 调度者 | 操作系统内核 | 用户态程序(事件循环) |
| 切换成本 | 微秒级(涉及上下文保存) | 纳秒级(函数调用级别) |
| 内存占用 | 默认每个线程栈 1~8 MB | 每个协程栈几十 KB 起步 |
| 并发数量 | 通常几千个就扛不住了 | 轻松几万、甚至几十万 |
| 数据竞争 | 共享内存,需要加锁 | 单线程模型内天然无竞争 |
简单粗暴地记:协程是用户态线程,协程切换就是一次函数调用。我刚开始用 Python 的 asyncio 时,一个进程里跑了 5 万个协程任务,内存才 200 多 MB。换成线程?光栈空间就爆炸了。
async / await 语法——让异步代码看起来像同步
你想想看,如果不用 async/await,我们写异步回调是什么画面?
def fetch_data(callback):
# 发起 IO,假设用回调通知
pass
fetch_data(lambda result: handle(result))
# 再来一层回调嵌套?噩梦就开始了
这种回调地狱,我维护过一个老项目,三层回调嵌套,改一个逻辑要在五个不同函数间跳转,调试的时候想骂人。async/await 就是来解决这个问题的。
import asyncio
async def fetch_data():
# 假装在做网络 IO,等待的时候不阻塞线程
await asyncio.sleep(1)
return "数据拿到了"
async def main():
result = await fetch_data()
print(result) # 跟同步代码一模一样
asyncio.run(main())
核心要点就两个:
async def定义一个协程函数。调用它不会立刻执行,而是返回一个协程对象。await挂起当前协程,把控制权交还给事件循环。等到被等待的协程完成,再回来继续执行。
事件循环模型——协程的调度器
协程自己不会跑,它需要一个「引擎」来驱动,这个引擎就是事件循环(Event Loop)。
在 Python 里,asyncio.run() 会帮你创建一个事件循环,然后把入口协程放进去运行。事件循环内部维护一个任务队列,不断从队列里取出可执行的任务,遇到 await 就挂起,等 IO 完成后再把任务放回就绪队列。
来看个实际例子:
import asyncio
import time
async def worker(n):
print(f"任务 {n} 开始")
await asyncio.sleep(1) # 等待但不阻塞
print(f"任务 {n} 结束")
async def main():
tasks = [worker(i) for i in range(3)]
# 并发执行三个协程
await asyncio.gather(*tasks)
start = time.time()
asyncio.run(main())
print(f"总耗时: {time.time() - start:.2f} 秒")
输出类似:
任务 0 开始
任务 1 开始
任务 2 开始
任务 0 结束
任务 1 结束
任务 2 结束
总耗时: 1.01 秒
看到了吗?三个任务几乎是同时开始的,总耗时只有 1 秒,而不是 3 秒。因为 asyncio.sleep(1) 是协程版的 sleep,它告诉事件循环:“我要歇 1 秒,你先去跑别的任务”。
协程与线程的协作关系
很多人误解:「用了协程就不需要线程了」。不对。Python 的协程默认跑在一个线程里,这个线程负责事件循环。如果遇到阻塞式操作(比如 time.sleep()、普通的 requests.get()),整个事件循环都会被卡住。
import asyncio
import time
async def bad_task():
print("坏任务开始")
time.sleep(2) # 这是同步阻塞!事件循环歇菜了
print("坏任务结束")
async def good_task():
print("好任务开始")
await asyncio.sleep(2) # 协程式等待
print("好任务结束")
async def main():
await asyncio.gather(bad_task(), good_task())
asyncio.run(main())
# 坏任务先开始,然后整个程序卡死2秒,好任务才能开始
# 总耗时 4 秒+
所以我的建议是:协程和线程不是互斥的,而是互补的。如果非要用同步库,就用 concurrent.futures.ThreadPoolExecutor 把阻塞操作扔到线程池里跑,再 await 那个线程池的 future。
import asyncio
import time
import requests
from concurrent.futures import ThreadPoolExecutor
executor = ThreadPoolExecutor(max_workers=10)
async def fetch_url(url):
loop = asyncio.get_event_loop()
# 把同步 requests 交给线程池,不会阻塞事件循环
result = await loop.run_in_executor(executor, requests.get, url)
return result.text
这样既享受了协程的轻量,又不丢同步生态的便利。
time.sleep() 或带 GIL 释放的复杂计算。我曾经把一个加密验证的 CPU 密集操作扔进协程里,结果事件循环被锁死,所有请求超时。排查时还以为是网络问题,折腾了两天才发现罪魁祸首。
知识体系图:协程模型全景
下面这张图把协程的执行流程、组件关系理清楚了。建议你保存下来,写代码时对照着看。
什么时候用协程,什么时候用线程?
我的经验法则:
- IO 密集型 + 大量并发连接(比如 Web 服务、网关、消息队列消费者)→ 优先用协程。单机能扛几万连接,线程根本做不到。
- CPU 密集型任务(图像处理、加密、科学计算)→ 不要用协程,用多进程。
- 混合型(即有一部分 CPU 计算,又有大量 IO)→ 可以把 CPU 部分丢给进程池,IO 部分走协程。Python 的 asyncio 和 concurrent.futures 配合得很好。
- 需要强实时性(微秒级响应)→ 线程可能更合适,因为事件循环内部如果有一个协程执行时间过长(比如不小心写了个大循环),会阻塞其他所有协程。
嗯,到这你应该对协程的核心概念有个清晰的画面了。下一部分我们会深入事件循环的实现细节,包括如何自定义 loop、如何与多线程混合使用——但那些是后面的章节了。先把基础打牢,写几个简单例子跑一跑,比看十遍理论都管用。