目錄
一、寫在前面:為什么 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(...)
時,不妨想象事件循環在背后穿梭:它像一位老練的調度員,把每一個「等待」的空檔,填得滿滿當當。