目录
一、写在前面:为什么 IO 是瓶颈
二、同步模型:requests 的忧伤
三、线程池:用并发掩盖阻塞
四、aiohttp:让「等待」非阻塞
4.1 安装与版本约定
4.2 异步客户端:asyncio + aiohttp
4.3 错误处理与超时
4.4 背压与流量控制
五、异步服务端:用 aiohttp.web 构建 API
六、同步 vs 异步:心智模型对比
七、实战建议:何时该用 aiohttp
八、结语:让等待不再是浪费
一、写在前面:为什么 IO 是瓶颈
在 Python 世界里,CPU 很少成为瓶颈,真正拖慢程序的往往是「等待」。一次 HTTP 请求,服务器把数据发回来的过程中,我们的进程几乎什么都不做,只是傻傻地等在 recv 上。同步代码里,这种等待是阻塞的:一个线程卡在那里,别的请求也只能排队。
于是「异步」登场:在等待期间把 CPU 让出来给别人用,等数据到了再回来接着干。aiohttp 就是 asyncio 生态里最趁手的 HTTP 客户端/服务端框架之一。本文不罗列 API,而是带你从「同步」一步一步走向「异步」,用真实可运行的代码,体会两者在吞吐量、代码结构、心智模型上的差异。
二、同步模型:requests 的忧伤
假设我们要抓取 100 张图片,每张 2 MB,服务器延迟 200 ms。同步写法最直观:
# sync_downloader.py
import requests, time, osURLS = [...] # 100 条图片 URL
SAVE_DIR = "sync_imgs"
os.makedirs(SAVE_DIR, exist_ok=True)def download_one(url):resp = requests.get(url, timeout=30)fname = url.split("/")[-1]with open(os.path.join(SAVE_DIR, fname), "wb") as f:f.write(resp.content)return len(resp.content)def main():start = time.perf_counter()total = 0for url in URLS:total += download_one(url)elapsed = time.perf_counter() - startprint(f"sync 下载完成:{len(URLS)} 张,{total/1024/1024:.1f} MB,耗时 {elapsed:.2f}s")if __name__ == "__main__":main()
在我的 100 M 带宽机器上跑,耗时 22 秒。瓶颈显而易见:每次网络 IO 都阻塞在 requests.get
,一个线程只能串行干活。
三、线程池:用并发掩盖阻塞
同步代码并非无可救药,把阻塞 IO 丢进线程池,依旧能提速。concurrent.futures.ThreadPoolExecutor
就是 Python 标准库给的「急救包」:
# thread_pool_downloader.py
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests, time, osURLS = [...]
SAVE_DIR = "thread_imgs"
os.makedirs(SAVE_DIR, exist_ok=True)def download_one(url):resp = requests.get(url, timeout=30)fname = url.split("/")[-1]with open(os.path.join(SAVE_DIR, fname), "wb") as f:f.write(resp.content)return len(resp.content)def main():start = time.perf_counter()total = 0with ThreadPoolExecutor(max_workers=20) as pool:futures = [pool.submit(download_one, u) for u in URLS]for f in as_completed(futures):total += f.result()elapsed = time.perf_counter() - startprint(f"线程池下载完成:{len(URLS)} 张,{total/1024/1024:.1f} MB,耗时 {elapsed:.2f}s")if __name__ == "__main__":main()
20 条线程并行后,耗时骤降到 2.7 秒。但线程有代价:每条约 8 MB 栈内存,20 条就 160 MB,且受到 GIL 限制,在 CPU 密集任务里会互相踩踏。对网络 IO 而言,线程池属于「曲线救国」,真正原生的解决方案是「异步协程」。
四、aiohttp:让「等待」非阻塞
4.1 安装与版本约定
pip install aiohttp==3.9.1 # 文章编写时的稳定版
4.2 异步客户端:asyncio + aiohttp
把刚才的下载逻辑用 aiohttp 重写:
# async_downloader.py
import asyncio, aiohttp, time, osURLS = [...]
SAVE_DIR = "async_imgs"
os.makedirs(SAVE_DIR, exist_ok=True)async def download_one(session, url):async with session.get(url) as resp:content = await resp.read()fname = url.split("/")[-1]with open(os.path.join(SAVE_DIR, fname), "wb") as f:f.write(content)return len(content)async def main():start = time.perf_counter()conn = aiohttp.TCPConnector(limit=20) # 限制并发连接数timeout = aiohttp.ClientTimeout(total=30)async with aiohttp.ClientSession(connector=conn, timeout=timeout) as session:tasks = [download_one(session, u) for u in URLS]results = await asyncio.gather(*tasks)total = sum(results)elapsed = time.perf_counter() - startprint(f"async 下载完成:{len(URLS)} 张,{total/1024/1024:.1f} MB,耗时 {elapsed:.2f}s")if __name__ == "__main__":asyncio.run(main())
同一台机器,耗时 2.4 秒。表面上和线程池差不多,但内存占用仅 30 MB,且没有线程切换的上下文开销。
关键点在于 await resp.read()
:当数据尚未抵达,事件循环把控制权交出去,CPU 可以处理别的协程;数据到了,事件循环恢复这条协程,继续执行。整个过程是「单线程并发」。
4.3 错误处理与超时
网络请求总要面对超时、重试。aiohttp 把异常体系做得非常「async 友好」:
from aiohttp import ClientErrorasync def download_one(session, url):try:async with session.get(url) as resp:resp.raise_for_status()return await resp.read()except (ClientError, asyncio.TimeoutError) as e:print(f"下载失败: {url} -> {e}")return 0
4.4 背压与流量控制
并发不是越高越好。若不加限制,瞬间上千条 TCP 连接可能把目标服务器打挂。aiohttp 提供了 TCPConnector(limit=...)
和 asyncio.Semaphore
两种手段。下面演示自定义信号量:
sem = asyncio.Semaphore(20)async def download_one(session, url):async with sem: # 同一时刻最多 20 条协程进入...
五、异步服务端:用 aiohttp.web 构建 API
异步不仅用于客户端,服务端同样受益。下面写一个极简「图床」服务:接收 POST 上传图片,返回 URL。
# async_server.py
import asyncio, aiohttp, aiohttp.web as web, uuid, osUPLOAD_DIR = "uploads"
os.makedirs(UPLOAD_DIR, exist_ok=True)async def handle_upload(request):reader = await request.multipart()field = await reader.next()if field.name != "file":return web.Response(text="missing field 'file'", status=400)filename = f"{uuid.uuid4().hex}.jpg"with open(os.path.join(UPLOAD_DIR, filename), "wb") as f:while chunk := await field.read_chunk():f.write(chunk)url = f"http://{request.host}/static/{filename}"return web.json_response({"url": url})app = web.Application()
app.router.add_post("/upload", handle_upload)
app.router.add_static("/static", UPLOAD_DIR)if __name__ == "__main__":web.run_app(app, host="0.0.0.0", port=8000)
单进程单线程即可支撑数千并发上传。得益于 asyncio,磁盘 IO 不会阻塞事件循环;若换成同步框架(Flask + gunicorn 同步 worker),每个上传都要独占线程,高并发下线程池瞬间耗尽。
六、同步 vs 异步:心智模型对比
维度 | 同步 | 线程池 | 异步 |
---|---|---|---|
并发单位 | 线程 | 线程 | 协程 |
内存开销 | 低 | 中 | 极低 |
阻塞行为 | 阻塞 | 阻塞 | 非阻塞 |
代码风格 | 线性 | 线性 | async/await |
调试难度 | 低 | 中 | 中 |
同步代码像读小说,一行一行往下看;异步代码像翻扑克牌,事件循环决定哪张牌先被翻开。对初学者而言,最困惑的是「函数一半跑一半挂起」的感觉。解决方法是:
把每个
await
当成「可能切换点」,在它之前保证数据处于自洽状态。用
asyncio.create_task
而不是裸await
,避免顺序陷阱。日志里打印
asyncio.current_task().get_name()
追踪协程。
七、实战建议:何时该用 aiohttp
-
客户端高并发抓取:爬虫、压测、批量 API 调用,aiohttp + asyncio 是首选。
-
服务端 IO 密集:网关、代理、WebHook、长连接推送。
-
混合场景:若既有 CPU 密集又有 IO 密集,可用
asyncio.to_thread
把 CPU 任务丢进线程池,主协程继续处理网络。
不适用场景:
-
CPU 密集计算(如图像处理)应放到进程池或外部服务;
-
低延迟、小并发内部 RPC,同步 gRPC 可能更简单。
八、结语:让等待不再是浪费
从最早的串行下载,到线程池并发,再到 aiohttp 的协程狂欢,我们见证了「等待」如何被一点点榨干价值。掌握异步不是追逐时髦,而是回归本质:CPU 很贵,别让它在 IO 上睡觉。
下次当你写下 await session.get(...)
时,不妨想象事件循环在背后穿梭:它像一位老练的调度员,把每一个「等待」的空档,填得满满当当。