iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Software Development

下載器不是寫完就能跑:一個 HLS 分段式串流工具的架構與測試修練系列 第 17 篇

Day 17:ThreadPoolExecutor 包 asyncio coroutine——一個混用兩種併發模型的權宜設計

  • 分享至 

  • xImage
  •  

前言:兩種併發模型可以混著用嗎?

昨天講完 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。


上一篇
Day 16:為什麼要非同步——I/O bound 工作的線程池 vs asyncio
系列文
下載器不是寫完就能跑:一個 HLS 分段式串流工具的架構與測試修練 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言