昨天講完 asyncio 跟 ThreadPoolExecutor 各自適合的場景,今天要誠實檢視這個系列素材專案裡真實存在的一段程式碼——它同時用了兩種模型,而且是用線程池去執行非同步協程。
def wrapper(coro):
return asyncio.run(coro) # 在新的執行緒裡,開一個全新的事件迴圈跑這個協程
async def download_all(segments):
with ThreadPoolExecutor(max_workers=10) as executor:
for index, segment in enumerate(segments):
executor.submit(wrapper, save_one(segment, index)) # 把協程丟進線程池執行
外層的 download_all 本身是一個 async def,已經身處 asyncio 的事件迴圈裡;但它沒有直接 await 每個片段的下載協程,而是把協程包進 wrapper,丟進 ThreadPoolExecutor——每個線程各自呼叫 asyncio.run(),等於在原本就存在的事件迴圈之外,又在每個線程裡各自開了一個全新的、獨立的事件迴圈去執行單一個協程。
一個合理的推測(可以對照第三部後段的重試邏輯驗證):save_one 這個協程內部呼叫的下載函式庫,可能混雜了同步(阻塞)跟非同步的呼叫方式,如果直接在外層的事件迴圈裡 await 大量協程,某個阻塞呼叫會卡住整個事件迴圈;用 ThreadPoolExecutor 讓每個協程在獨立線程、獨立事件迴圈裡執行,等於用「並行的線程」換掉「單一事件迴圈裡的並行協程」,繞開了阻塞呼叫拖累全局的問題。
這不是「不懂 asyncio」的錯誤示範,而是在某個限制下的務實妥協——用線程的隔離性,換掉正確處理阻塞呼叫所需要的額外工程(例如把阻塞呼叫包進 loop.run_in_executor())。它的代價是:每個下載工作要多負擔一個事件迴圈的建立/銷毀成本,並行上限也從「事件迴圈可以輕鬆處理的協程數量」降到「線程池的 max_workers 上限」(這裡是 10)。
誠實記錄這種妥協的存在,比假裝程式碼設計得很乾淨更有價值——下次要優化效能或修 bug 時,至少知道這裡是個混用了兩種模型的地方,不會誤以為它是照教科書寫法設計的。
你的專案裡有沒有類似「兩種技術方案混用」的地方?那是刻意的設計決策,還是逐步演變、沒人再回頭整理過的痕跡?你知道當初為什麼會變成這樣嗎?
ThreadPoolExecutor 跟 asyncio 不是標準做法,但可能是繞開阻塞呼叫拖累事件迴圈的務實妥協明天講下載進度的呈現:怎麼設計才不會變成一堆散落各處、彼此互相覆蓋的 print。