事情是这样的。上个月 leader 扔给我一个“小活”:把公司舆情监控的爬虫改一版,现在每天要抓 8 万条数据,旧的同步脚本跑到凌晨都跑不完,服务器还动不动 OOM。我当时心想,不就加几层并发吗,ThreadPoolExecutor 一把梭就完了。结果上线第二天就被报警电话叫醒了——线程池开到 200 的那台机器,CPU 没怎么动,内存先爆了,swap 用满,数据库连接池也打光了。团队里一个做 Go 的同事路过我工位,说了句:“你这么多 IO 等待,为什么不用 asyncio?”这句话值 3 天加班费。
如果你写过 Python 后端,大概率也遇到过这种场景:程序大部分时间根本不在计算,而是在等——等 HTTP 响应、等数据库返回、等 Redis 的 GET。这时候线程也好进程也好,都是用大炮打蚊子,上下文切换的开销比实际干活还大。而 asyncio 的思路恰好相反:用一个线程,靠事件循环去调度所有等待任务,谁先就绪就切过去执行谁,不浪费一点时间在无意义的阻塞上。
下面我把这次重构中的核心逻辑和真实踩过的坑全部还原,代码可以直接跑。
事件循环怎么“变魔术”?
说到底,asyncio 的三个核心角色就是事件循环(Event Loop)、协程(Coroutine)和 Future/Task。事件循环像个永不停歇的调度员,在一个线程里盯着所有已注册的任务。当某个协程执行到 await 时,相当于主动举手:“我需要等一会儿,你先管别人。”事件循环立刻把它挂起,去看下一个就绪的任务。整个过程中线程没有阻塞,所以 100 个任务和 10000 个任务,只要 IO 密度没打满带宽,性能几乎是线性增长。
我们用一段最简代码感受一下:
import asyncio
import time
async def fetch_user(user_id: int) -> dict:
"""
模拟从远程 API 获取用户数据。
假设每次请求需要 1 秒网络延迟。
"""
await asyncio.sleep(1) # 模拟 IO 等待,不阻塞事件循环
return {"id": user_id, "name": f"User_{user_id}"}
async def main():
start = time.time()
# 同时发起 10 个请求
tasks = [fetch_user(i) for i in range(10)]
results = await asyncio.gather(*tasks)
elapsed = time.time() - start
print(f"完成 {len(results)} 个请求,总耗时 {elapsed:.2f} 秒")
# 如果同步执行需要 10 秒,这里只需要约 1 秒
asyncio.run(main())
关键点全在 await asyncio.sleep(1) 这一行。如果写成 time.sleep(1),整个线程就卡死了,事件循环也得跟着一起睡;而 asyncio.sleep 会立刻把控制权交还给事件循环,所以其他协程得以在同一个 1 秒内“同时”等待。这就是 asyncio 并发的基础:让等待的时间重叠起来。
实际项目中没人会只并发 10 个请求,但直接拉到 10000 又容易把对方服务器打挂。真正落地的写法必须加并发控制,这就要用到 asyncio.Semaphore。
生产级写法:信号量限流 + 异常隔离 + 超时兜底
下面这段代码来自我重构后的舆情采集模块,三个关键改进:
- Semaphore 做并发上限,防止耗光文件描述符或触发对方限流。
- 每个任务独立 try/except,一个 URL 挂了不影响其他采集。
- asyncio.timeout 兜底,避免某个慢响应拖死整个批次。
import asyncio
import aiohttp
from typing import List, Dict, Any
# 最大同时请求数,根据对方服务 QPS 和本机资源设定
MAX_CONCURRENT = 50
async def fetch_url(
session: aiohttp.ClientSession,
sem: asyncio.Semaphore,
url: str
) -> Dict[str, Any]:
"""
带限流和超时保护的单条采集逻辑。
"""
async with sem: # 超过 MAX_CONCURRENT 的协程会在这里阻塞等待
try:
# 设置单次请求超时 30 秒
async with asyncio.timeout(30):
async with session.get(url) as resp:
text = await resp.text()
return {"url": url, "status": resp.status, "length": len(text)}
except asyncio.TimeoutError:
return {"url": url, "error": "timeout", "length": 0}
except Exception as e:
return {"url": url, "error": str(e), "length": 0}
async def batch_fetch(urls: List[str]) -> List[Dict[str, Any]]:
"""
并发抓取所有 URL,返回结果列表。
"""
sem = asyncio.Semaphore(MAX_CONCURRENT)
# 复用同一个 session(内含连接池),极大减少 TCP 握手开销
async with aiohttp.ClientSession() as session:
tasks = [fetch_url(session, sem, url) for url in urls]
results = await asyncio.gather(*tasks)
return results
# 使用示例
if __name__ == "__main__":
test_urls = [f"https://httpbin.org/delay/{i % 5}" for i in range(200)]
data = asyncio.run(batch_fetch(test_urls))
success = [d for d in data if "error" not in d]
print(f"成功: {len(success)} / {len(data)}")
你可能会问,为什么用 aiohttp 而不用 requests?因为 requests 是同步阻塞库,一旦在协程里调用,整个事件循环就会被卡住,你的 10000 个并发瞬间退化成逐个排队。这是新手最容易踩的坑,也是我当初把线程池换成 asyncio 后,性能反而“负优化”的根源——我在协程里偷偷藏了一句 requests.get()。
这些坑,我不希望你再用 3 小时去排查
1. 协程里夹带同步阻塞调用
任何 time.sleep()、requests.get()、同步的 socket 读写、甚至大的 json.dumps 都可能成为事件循环的堵点。排查方法:在 debug 模式下把 asyncio 的慢回调检测打开:
import asyncio
asyncio.get_event_loop().slow_callback_duration = 0.1 # 超过 100ms 就告警
一旦日志刷屏,就是你藏了阻塞炸弹的信号。
2. asyncio.gather 的异常默认会传播
如果 200 个任务中有一个抛异常,gather() 在默认行为下会立刻抛出,剩下的 199 个会被取消。线上就闹过这样一个乌龙:一两个 URL 超时,整个批次都丢了。解决方案就像上面代码一样,每个任务内部用 try/except 兜底,让 gather 拿到的是已处理的返回值,或者传 return_exceptions=True。
3. 连接池耗尽
aiohttp 的 ClientSession 在默认情况下总连接数只有 100。你把 Semaphore 设成 200,结果发现并发峰值根本超不过 100。设置连接池上限要与业务匹配:ClientSession(connector=aiohttp.TCPConnector(limit=200))。
4. 测试环境爽,生产炸——文件描述符上限
每个 TCP 连接占用一个 fd,Linux 默认的 1024 很容易被撑爆。压测前务必调高:ulimit -n 10000。同时记得用 async with 确保连接及时释放,别用裸的 session.get() 不关。
总结
异步编程不是银弹,但在 IO 密集场景下,它的性价比几乎碾压所有传统并发方案。学会让等待的时间重叠,比学会用多核更重要。
#Python #asyncio #性能优化 #爬虫实战 #后端开发