90% 的开发者都在错误理解 async/await:协程本质与高并发实战指南
很多人在第一次写 async def + await 的时候,心里都暗暗期待:这下代码应该变快了吧?
结果写完一测,单个接口的响应时间和以前同步写法几乎一模一样,甚至还慢了零点几毫秒。于是很多人开始怀疑:“协程是不是被吹过头了?”
我曾经也这么想过。直到把"协"字的底层含义、实际运行模型、以及 Web 后端真实场景反复拆解后,才真正明白:
协程(Coroutine)的核心从来不是让单个请求更快,而是让一台服务器用极少的线程,优雅地扛住大量并发 I/O 请求。
一、"协"字的真正灵魂:协作式,而非自动化
"协"字本义是众人同力而和,强调主动配合、相互礼让、协调调度。
放到编程里,它对应的是 Cooperative Multitasking(协作式多任务):
- 多线程 = 操作系统是暴君,随时抢占 CPU
- 协程 = 代码是绅士,遇到 I/O 就主动让出控制权,等好了再精确恢复
更精准的中文翻译是:
- 协作式例程
- 可挂起函数
- 协调式例程
它本质上是一种开发者主动控制的挂起与恢复机制,而不是"运行时自动帮你加速"的魔法。
二、最接地气的比喻:单人厨房多任务
把线程想象成一个厨师:
- 同步阻塞:把水烧上后,厨师就傻站在那里干等 3 分钟
- 协程 await:水烧上后(
await),厨师主动放下手头的事,去洗菜、切肉、腌制其他菜。等水开了,系统通知他,他再回来继续
整个过程还是一个厨师(单线程),却能同时推进多道菜。这就是协程在单线程下实现高并发的底层逻辑。
三、最容易被误解的点:单个请求下,await 几乎没用
是的,你的感觉完全正确。
如果你写的只是一个从头干到尾的单任务脚本(爬一个页面、查一次数据库、处理一次请求),那么:
- 用
await和不用协程的同步代码,执行时间几乎没有差别 - 单个请求里连续写多个
await,本质还是串行等待,总耗时 ≈ 所有 I/O 时间之和
协程真正的威力,只有在「高并发、多 I/O 同时进行」时才会爆发。
四、如何让多个 await 真正并发?(最实用代码)
很多人会犯的经典错误:
# ❌ 串行执行,总时间 = t1 + t2 + t3
r1 = await query_sql(sql1)
r2 = await query_sql(sql2)
r3 = await query_sql(sql3)
正确做法 —— 使用 asyncio.gather 并发执行:
import asyncio
async def main():
# 并发执行多个 I/O 操作
results = await asyncio.gather(
query_sql(sql1),
query_sql(sql2),
query_sql(sql3),
fetch_url(url1),
fetch_url(url2)
)
return results
asyncio.run(main())
或者使用 create_task() 更灵活地控制每个任务。
核心结论:
- 顺序
await= 串行 asyncio.gather()= 并行(几乎同时发出请求)
五、协程服务器是如何"扛住高并发"的?
1. 单线程事件循环 + 非阻塞 I/O
协程服务器(如 Python 的 asyncio、Node.js)的核心架构是:
┌─────────────────────────────────┐
│ 单个线程(Event Loop) │
│ ┌─────────────────────────┐ │
│ │ while True: │ │
│ │ 检查哪些 I/O 就绪了 │ │ ← epoll/kqueue/iocp
│ │ 恢复对应的协程 │ │
│ │ 执行到下个 await │ │
│ │ 挂起,继续检查... │ │
│ └─────────────────────────┘ │
└─────────────────────────────────┘
关键机制:
- 请求进来时,创建一个协程对象(轻量级,几 KB 内存)
- 遇到 I/O(数据库查询、HTTP 请求),协程主动
await挂起,不占用 CPU - Event Loop 立刻切换到其他就绪的协程,不会空转等待
- I/O 完成后(由操作系统 epoll 通知),协程被精确唤醒,继续执行
核心优势:10000 个并发请求 = 10000 个协程对象,但只需要 1 个线程来调度它们。
六、三种服务器模型的本质对比
1. 多进程模型(PHP-FPM / Apache prefork)
请求1 → 进程1 (8MB 内存)
请求2 → 进程2 (8MB 内存)
请求3 → 进程3 (8MB 内存)
...
请求1000 → 进程1000 (8GB 内存!) 💥
特点:
- 每个请求独占一个进程
- 进程间完全隔离(安全,但笨重)
- 并发能力受限于内存:100 个 PHP-FPM 进程就要 800MB+ 内存
- 上下文切换开销大:1000 个进程频繁切换会拖垮 CPU
适用场景:
- 传统 LAMP 架构
- 请求简单、并发量低(< 100)
- 需要进程隔离(共享主机)
2. 多线程模型(Java Tomcat / Python Flask+多线程)
请求1 → 线程1 (1MB 栈)
请求2 → 线程2 (1MB 栈)
...
请求10000 → 线程10000 (10GB 栈!) 💥
特点:
- 每个请求一个线程
- 比进程轻量(1MB vs 8MB),但仍然很重
- C10K 问题:1 万个线程会导致:
- 频繁的上下文切换(每次切换 1-10 微秒)
- 大量内存被栈空间占用
- 线程调度器负担过重
改进方案:
- 线程池(固定数量线程,复用)
- 但仍受限于线程数量上限
3. 协程模型(asyncio / Node.js / Go goroutine)
单线程 Event Loop
├─ 协程1 (几 KB) ← await 挂起
├─ 协程2 (几 KB) ← 正在执行
├─ 协程3 (几 KB) ← await 挂起
├─ ...
└─ 协程100000 (几百 MB) ✅
特点:
- 1 个线程管理成千上万个协程
- 协程切换在用户态完成(无需陷入内核),速度极快(纳秒级)
- 遇到 I/O 主动让出 CPU,零浪费
内存对比(10000 并发):
- PHP 进程:~80GB
- Java 线程:~10GB
- asyncio 协程:~100MB
七、一个真实场景:同时查询 3 个数据库
多进程/多线程服务器(阻塞 I/O)
# 请求处理函数(在独立进程/线程中)
def handle_request():
r1 = query_db1() # 阻塞 50ms → 线程/进程干等
r2 = query_db2() # 阻塞 50ms → 线程/进程干等
r3 = query_db3() # 阻塞 50ms → 线程/进程干等
return combine(r1, r2, r3)
# 总耗时: 150ms
问题:
- 线程在等待 I/O 时占着茅坑不拉屎
- 100 并发 = 100 个线程同时阻塞 = CPU 大量空转
协程服务器(非阻塞 I/O)
async def handle_request():
# 三个查询同时发出!
r1, r2, r3 = await asyncio.gather(
query_db1(), # 发起后立刻返回
query_db2(), # 发起后立刻返回
query_db3() # 发起后立刻返回
)
return combine(r1, r2, r3)
# 总耗时: max(50, 50, 50) = 50ms ✅
优势:
- 三个数据库查询并行发起
- 协程挂起时,Event Loop 去处理其他请求
- 单线程同时推进 100 个请求的数据库查询
八、协程 vs 多线程真实对比
| 维度 | 协程(asyncio) | 多线程(同步) | 谁更强 |
|---|---|---|---|
| 单个请求耗时 | 几乎一样(可能略慢) | 几乎一样 | 平手 |
| 高并发吞吐量 | 极高 | 较低 | 协程完胜 |
| 内存占用 | 极低 | 很高 | 协程 |
| 适用场景 | I/O 密集(Web、爬虫) | CPU 密集 | - |
九、为什么多线程服务器也能高并发?
你可能会问:Tomcat 也能扛住几千并发啊?
答案是线程池 + 异步 I/O 的混合方案:
- 固定线程池(如 200 个线程)
- 非阻塞 I/O(NIO / Netty)
- 每个线程内部用事件循环复用
本质上已经接近协程模型了,只是用线程来模拟协程的效果。
十、三种模型的适用场景总结
| 模型 | 并发能力 | 内存占用 | 适用场景 |
|---|---|---|---|
| 多进程 | < 100 | 极高 | 传统 Web(PHP),需要进程隔离 |
| 多线程 | < 1000 | 高 | 企业应用(Java),CPU 密集 |
| 协程 | 10000+ | 极低 | I/O 密集(Web API、爬虫、聊天) |
核心区别:
- 进程/线程:操作系统调度,抢占式,重量级
- 协程:用户态调度,协作式,轻量级
十一、实战:在 Web 开发中如何用协程提升单请求处理效率
很多人误以为协程只对"服务器整体吞吐量"有帮助,对"单个请求的响应时间"没用。这是错的。
只要你的单个请求内部有多个可以并行的 I/O 操作,协程就能显著降低响应时间。
场景 1:用户详情页 —— 并行查询多个数据源
❌ 错误写法(串行)
from fastapi import FastAPI
import httpx
app = FastAPI()
@app.get("/user/{user_id}")
async def get_user_detail(user_id: int):
# 串行执行,总耗时 = 各项之和
user = await db.fetch_user(user_id) # 30ms
orders = await db.fetch_orders(user_id) # 50ms
reviews = await db.fetch_reviews(user_id) # 40ms
credits = await redis.get_credits(user_id) # 10ms
async with httpx.AsyncClient() as client:
recommendation = await client.get(
f"http://rec-service/api/recommend/{user_id}"
) # 80ms
return {
"user": user,
"orders": orders,
"reviews": reviews,
"credits": credits,
"recommendation": recommendation.json()
}
# 总耗时: 30+50+40+10+80 = 210ms
✅ 正确写法(并行)
import asyncio
from fastapi import FastAPI
import httpx
app = FastAPI()
@app.get("/user/{user_id}")
async def get_user_detail(user_id: int):
async with httpx.AsyncClient() as client:
# 所有 I/O 操作并行发起
user, orders, reviews, credits, recommendation = await asyncio.gather(
db.fetch_user(user_id),
db.fetch_orders(user_id),
db.fetch_reviews(user_id),
redis.get_credits(user_id),
client.get(f"http://rec-service/api/recommend/{user_id}")
)
return {
"user": user,
"orders": orders,
"reviews": reviews,
"credits": credits,
"recommendation": recommendation.json()
}
# 总耗时: max(30, 50, 40, 10, 80) = 80ms ✅
# 性能提升: 210ms → 80ms (快了 2.6 倍!)
关键点:
- 5 个 I/O 操作同时发出,而不是排队等待
- 总耗时取决于最慢的那个操作(80ms),而不是所有操作之和
场景 2:批量数据处理 —— 限制并发数
有时你需要调用 100 个外部 API,但不能一次性全发出去(会打爆对方服务器或触发限流)。
✅ 使用 Semaphore 控制并发数
import asyncio
import httpx
async def fetch_with_limit(sem, client, url):
"""带并发限制的请求"""
async with sem: # 获取信号量
response = await client.get(url)
return response.json()
async def batch_fetch_user_data(user_ids):
sem = asyncio.Semaphore(10) # 最多同时 10 个并发
async with httpx.AsyncClient() as client:
tasks = [
fetch_with_limit(
sem,
client,
f"http://api.example.com/user/{uid}"
)
for uid in user_ids
]
results = await asyncio.gather(*tasks)
return results
# 调用
user_ids = range(1, 101) # 100 个用户
results = await batch_fetch_user_data(user_ids)
效果:
- 100 个请求不是串行执行(那样太慢)
- 也不是一次性发出 100 个(会被限流)
- 而是始终保持 10 个并发,滚动执行
场景 3:数据聚合 —— 部分失败不影响整体
有时某些数据源可能超时或失败,但你不想让整个请求挂掉。
✅ 使用 gather 的 return_exceptions 参数
import asyncio
async def get_dashboard_data(user_id):
results = await asyncio.gather(
db.fetch_user(user_id),
db.fetch_orders(user_id),
external_api.get_weather(), # 可能超时
return_exceptions=True # 🔑 关键参数
)
user, orders, weather = results
# 处理可能的异常
if isinstance(weather, Exception):
weather = {"error": "天气服务不可用", "temp": None}
return {
"user": user,
"orders": orders,
"weather": weather
}
效果:
- 即使天气 API 超时,用户和订单数据仍然正常返回
- 提升了系统的容错性和可用性
场景 4:缓存穿透保护 —— 并发请求合并
多个用户同时请求同一个数据,避免重复查询数据库。
✅ 使用 asyncio.Lock 实现请求合并
import asyncio
from functools import wraps
# 全局字典,存储正在进行的请求
_pending_requests = {}
_lock = asyncio.Lock()
def merge_requests(key_func):
"""装饰器:相同 key 的并发请求只执行一次"""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
key = key_func(*args, **kwargs)
async with _lock:
if key in _pending_requests:
# 如果已有相同请求在执行,直接等待结果
return await _pending_requests[key]
# 创建新的任务
task = asyncio.create_task(func(*args, **kwargs))
_pending_requests[key] = task
try:
result = await task
return result
finally:
async with _lock:
_pending_requests.pop(key, None)
return wrapper
return decorator
# 使用示例
@merge_requests(lambda user_id: f"user:{user_id}")
async def get_user_expensive(user_id):
"""耗时的数据库查询"""
print(f"实际查询数据库: {user_id}")
await asyncio.sleep(1) # 模拟慢查询
return {"id": user_id, "name": f"User{user_id}"}
# 测试
async def test():
# 100 个并发请求同一个用户
tasks = [get_user_expensive(123) for _ in range(100)]
results = await asyncio.gather(*tasks)
# 控制台只会打印一次 "实际查询数据库: 123"
场景 5:依赖链优化 —— 部分并行
有时请求之间有依赖关系,但可以分阶段并行。
async def process_order(order_id):
# 第一步:必须先获取订单信息
order = await db.fetch_order(order_id)
# 第二步:基于订单信息,并行执行后续操作
user, product, inventory = await asyncio.gather(
db.fetch_user(order.user_id),
db.fetch_product(order.product_id),
warehouse.check_inventory(order.product_id)
)
# 第三步:基于库存结果,决定是否并行执行
if inventory.available:
payment, shipment = await asyncio.gather(
payment_service.charge(order, user),
logistics.arrange_delivery(order)
)
else:
# 库存不足,只退款
payment = await payment_service.refund(order)
shipment = None
return {
"order": order,
"user": user,
"payment": payment,
"shipment": shipment
}
优化要点:
- 有依赖的必须串行(如必须先获取订单才能查用户)
- 无依赖的尽量并行(如用户、商品、库存查询可以同时进行)
十二、实战总结:协程并发的最佳实践
1. 识别可并行的 I/O 操作
问自己:这些操作之间有依赖关系吗?
- ✅ 无依赖 → 用
gather并行 - ❌ 有依赖 → 必须串行
2. 选择合适的并发工具
| 需求 | 工具 | 示例 |
|---|---|---|
| 简单并行 | asyncio.gather() | 同时查多个数据库 |
| 需要控制并发数 | asyncio.Semaphore() | 限制同时只有 10 个 HTTP 请求 |
| 需要超时控制 | asyncio.wait_for() | 3 秒内必须返回 |
| 谁先完成就用谁 | asyncio.wait(return_when=FIRST_COMPLETED) | 多个数据源竞速 |
| 后台任务(不等待结果) | asyncio.create_task() | 异步记录日志、发送通知 |
3. 常见错误
❌ 错误 1:忘记 await
# 错误:task 只是一个协程对象,没有真正执行
task = fetch_data()
# 正确
result = await fetch_data()
❌ 错误 2:在循环中串行 await
# 错误:串行执行
results = []
for user_id in user_ids:
result = await fetch_user(user_id)
results.append(result)
# 正确:并行执行
tasks = [fetch_user(uid) for uid in user_ids]
results = await asyncio.gather(*tasks)
❌ 错误 3:混用同步和异步代码
# 错误:time.sleep 会阻塞整个 Event Loop
async def bad():
await fetch_data()
time.sleep(1) # 💥 阻塞!
# 正确
async def good():
await fetch_data()
await asyncio.sleep(1) # ✅ 非阻塞
十三、写给所有异步开发者的忠告
-
简单脚本、单任务流程,优先用同步代码,更清晰、更易调试
-
当你的单个请求内部有多个可并行的 I/O 操作(查多个数据库、调多个 API),果断用
gather并发,能提升 2-5 倍响应速度 -
当你的服务需要同时处理大量并发请求(高并发 Web、批量爬虫),协程服务器能用更少的资源扛住更多流量
-
永远记住:
await不是加速器,而是资源释放器- 顺序
await是串行,gather才是并行 - 协程解决的是"一台服务器同时伺候几千个请求还不崩"的问题
理解了这些,你对异步的认知才会真正上一个台阶。

983

被折叠的 条评论
为什么被折叠?



