說到底沒有吐槽以前自己寫的 code 代表你沒進步-ThreadPoolManager 的原罪

「每次接手維護五、六年前的 Android 舊專案,十個有九個會在 util 資料夾深處看到一個全域單例 ThreadPoolManager。別笑,因為這八成也是我當年剛升 Senior 時寫出來的『傳家寶』——當時以為封裝了執行緒池就是架構的頂峰,直到線上崩潰日誌天天在半夜把我叫醒。」

程式設計師的成長往往伴隨著自我嫌棄。如果你回頭看自己三年前寫的程式碼,竟然覺得寫得無懈可擊,那很遺憾,這代表你這三年來根本沒什麼長進。

在 2016 到 2019 年那段 RxJava 尚未全面普及、Kotlin 協程還在娘胎裡的年代,Android 開發者面臨著極其野蠻的並行困境:UI 執行緒卡頓 5 秒就直接噴出 ANR(Application Not Responding)。

為了解決「耗時任務不能放主執行緒」的問題,工程師們各顯神通。而最常見、流傳最廣的祖傳方案,莫過於手寫一個全域單例 ThreadPoolManager。

但這個看似拯救了系統資源的「萬靈丹」,在專案規模膨脹後,直接變成了線上災難的源頭。


歷史切片:人人都在寫的 ThreadPoolManager 到底長怎樣?

翻開舊專案的骨董庫,標準的 ThreadPoolManager 通常長成這個模樣:

public class ThreadPoolManager {
    private static volatile ThreadPoolManager sInstance;
    private final ThreadPoolExecutor mExecutor;
    private final Handler mMainHandler = new Handler(Looper.getMainLooper());

    private ThreadPoolManager() {
        int corePoolSize = Runtime.getRuntime().availableProcessors() * 2 + 1;
        // (笑死,反正無界佇列永遠塞不滿,maximumPoolSize 設成 64 基本上就是寫給面試官看的安慰劑)
        int maximumPoolSize = 64;
        long keepAliveTime = 60L;

        // 致命原罪:無界佇列 LinkedBlockingQueue
        mExecutor = new ThreadPoolExecutor(
            corePoolSize,
            maximumPoolSize,
            keepAliveTime,
            TimeUnit.SECONDS,
            new LinkedBlockingQueue<Runnable>(),
            new ThreadPoolExecutor.CallerRunsPolicy()
        );
    }

    public static ThreadPoolManager getInstance() {
        if (sInstance == null) {
            synchronized (ThreadPoolManager.class) {
                if (sInstance == null) {
                    sInstance = new ThreadPoolManager();
                }
            }
        }
        return sInstance;
    }

    public void execute(Runnable runnable) {
        mExecutor.execute(runnable);
    }

    public Future<?> submit(Runnable runnable) {
        return mExecutor.submit(runnable);
    }

    public <T> Future<T> submit(Callable<T> callable) {
        return mExecutor.submit(callable);
    }

    public void postToMain(Runnable runnable) {
        mMainHandler.post(runnable);
    }
}

這個類別看起來四平八穩:雙重檢查鎖定(DCL)單例、依據 CPU 核心數動態計算核心執行緒、甚至還配了個回到主執行緒的 mMainHandler。(笑死,當年剛升 Senior 自以為很屌,還沾沾自喜寫進履歷;連 synchronized 該不該包在 if 裡面,我都能跟隔壁座位的同事吵半天。)

當年大家都是這樣寫的,甚至很多公司的面試官還會考你這段 DCL 單例與執行緒池參數。但只要專案一上線,各種光怪陸離的線上事故就會接踵而來。


原罪一:每 new 一條 Thread,你都在燃燒實體記憶體

在深入剖析架構前,我們先算一筆最殘酷的實體記憶體與 CPU 排程赤字。

很多高階語言開發者以為執行緒就像物件一樣便宜,new Thread() 呼叫一下就過去了。但在 Linux / Android 底層,Java 執行緒是透過 Bionic C 函式庫的 clone() 系統呼叫直接映射到 Linux 核心的輕量級行程(Light-weight Process, LWP):

[Linux Kernel task_struct (~2KB)]
        │
        ├── [虛擬記憶體 VMA 配置]
        └── [pthread 實體記憶體堆疊 (Thread Stack: 預設約 1024 KB)]

每 new 一條執行緒,作業系統就必須在核心空間配置 task_struct,並在虛擬記憶體中切出一段 Thread Stack(Android 預設約為 1024KB = 1MB)。

這筆實體記憶體赤字算下來有多慘烈:

  • 50 條閒置執行緒:光是發呆站著,就無條件佔據至少 50MB 的記憶體空間。在 2GB RAM 的低階裝置(扣除硬體保留區後可用不足 1GB)上,這 50MB 相當於直接吞掉一整套相機管線的緩衝區。
  • 執行緒暴增引發崩潰:當突發流量湧入,執行緒無節制增加時,很快就會觸發作業系統的記憶體限制,當場拋出致命的 java.lang.OutOfMemoryError: pthread_create (1040KB stack) failed: Try again,讓 App 秒退。

更可怕的是 Context Switch(上下文切換) 的算力反噬:

  1. 四核心 Cortex-A53 處理器一次只能真正平行運算 4 條執行緒。
  2. 當幾十條執行緒在背景爭奪 CPU 時間片時,CPU 必須以微秒級頻率不斷將暫存器狀態寫入記憶體、清空 CPU Pipeline、切換頁表(Page Table)。
  3. 頻繁的排程切換直接引發 L1/L2 快取抖動(Cache Thrashing)與 TLB Miss。結果 CPU 算力根本沒花在跑業務邏輯上,全耗在「排程切換」的無效開銷裡,導致前台 UI 執行緒直接卡死掉幀。

原罪二:Google 當年的 AsyncTask 到底有多搞笑?

很多人說:「既然手寫 Thread 這麼慘,那用官方的 AsyncTask 總行了吧?」

回顧 Android 歷史,官方的 AsyncTask 簡直是一部跨版本精神分裂史:

系統版本 AsyncTask 預設行為 隱含致命地雷
Android 1.5 單執行緒循序執行 全域排隊,前一個卡住後面全死。
Android 1.6 ~ 2.3 執行緒池平行執行 (Core=5, Queue=10, Max=128) 只要瞬間發出超過 138 個任務,第 139 個直接噴出 RejectedExecutionException 當場崩潰!
Android 3.0 ~ 10 認慫縮回單執行緒串列 (SERIAL_EXECUTOR) 再次回歸全 App 排同一個佇列的噩夢。
Android 11 (API 30) 官方宣告正式廢棄(Deprecated) 退出歷史舞台,全面推薦協程或 RxJava。

在 Android 3.0 之後的漫長歲月裡,AsyncTask.execute() 預設共用全域唯一的 SERIAL_EXECUTOR:

  • 全 App 共享單一佇列:模組 A 在背景做一個需要 30 秒的肥大資料庫整理;此時模組 B 在另一個畫面只想讀 2KB 的本地快取,也必須在後面乖乖罰站 30 秒!
  • 旋轉螢幕引發記憶體洩漏:AsyncTask 往往寫成 Activity 的匿名內部類別(Anonymous Inner Class),隱式持有外部 Activity 的強引用。使用者旋轉一下螢幕,舊 Activity 銷毀了卻無法被 GC 回收,直接引發幾十 MB 的記憶體洩漏。
  • cancel(true) 只是個心理安慰劑:呼叫 cancel(true) 底層執行緒根本不會立刻終止,只是把 isCancelled() 旗標設為 true,並嘗試呼叫中斷。如果開發者在 doInBackground 裡沒寫條件檢查,背景迴圈依然會吃到天荒地老,只是跑完不觸發 onPostExecute 回呼而已。

原罪三:全域單例 ThreadPoolManager 的深水區三大「真地雷」

既然官方的 AsyncTask 靠不住,開發者才轉向封裝自己的 ThreadPoolManager。但一個全域單例池,卻把整個專案拖入了更大的並行深水區。

後端工程師面試愛考的死鎖,在 Android 上其實很少見,因為只要主執行緒卡死 5 秒,系統就直接噴出 ANR 彈窗把 App 強制關掉了。在日常專案中,天天在 Crashlytics 上榜的,全是以下三大「真地雷」:

1. 搜尋框與高頻點擊時的「時序錯亂舊蓋新」

這是非同步介面最普遍的邏輯漏洞:

使用者輸入 "A"  ──(發送請求 A, 網路卡頓耗時 2.0s)──────────────────────────► [結果 A 姍姍來遲,強制覆蓋新畫面!]
使用者輸入 "AB" ─────(發送請求 AB, 命中快取耗時 0.2s) ──► [畫面顯示 AB] ───┘
  1. 使用者在搜尋框輸入「A」,發出請求 A(網路卡頓,耗時 2.0 秒);
  2. 緊接著輸入「AB」,發出請求 AB(命中本地快取,耗時 0.2 秒);
  3. 0.2 秒後,介面漂亮地渲染出「AB」的搜尋結果;
  4. 到了第 2.0 秒,請求 A 的舊結果慢吞吞地回傳了,直接把正確的「AB」畫面無情沖刷覆蓋掉!

加鎖(synchronized)有用嗎?完全沒用!鎖只管互斥,不管誰先誰後。使用者看著搜尋框寫著「AB」,下面卻列出「A」的搜尋清單,一臉問號。

2. 離開畫面後回呼 View 的 Detach 崩潰(IllegalStateException)

在 ThreadPoolManager 時代,最典型的回呼寫法是:

// 在 Fragment 或 Presenter 內
ThreadPoolManager.getInstance().execute(() -> {
    User user = mDatabase.loadUser(userId); // 背景耗時查詢
    ThreadPoolManager.getInstance().postToMain(() -> {
        // 使用者早就按返回退出了!
        requireActivity().showUserDetails(user); 
    });
});

當背景查詢在跑時,使用者等得不耐煩按了返回鍵。Fragment 已經被 Detach,甚至 Activity 都已經呼叫 onDestroy() 銷毀了。

背景工作跑完,興高采烈地用 postToMain() 衝進主執行緒,一呼叫 requireActivity() 或更新介面元件,立刻噴出:
java.lang.IllegalStateException: Fragment not attached to an Activity.
直接當場閃退給你看。

3. 執行緒飢餓死鎖(Thread Starvation Deadlock)與例外吞沒

這是在大中型專案中共用執行緒池最隱蔽的致命陷阱。

陷阱 A:submit() 悄悄把例外吞進黑洞

如果你用 executor.submit(Runnable) 取代 execute(Runnable),當任務內部拋出 NullPointerException 或 RuntimeException 時,控制台連一行錯誤 Log 都不會印出!
因為例外被包進了回傳的 FutureTask 裡;只要沒有人呼叫 future.get(),這個崩潰就會無聲無息地消失在虛空之中,功能莫名失效卻死活抓不到原因。

陷阱 B:父子任務共享同一個池引發飢餓死鎖

假設你的全域執行緒池核心數設為 4:

// 任務 A 佔據了全部 4 個核心執行緒
for (int i = 0; i < 4; i++) {
    ThreadPoolManager.getInstance().execute(() -> {
        // 內部又向同一個池子派發子任務 B,並阻塞等待其結果
        Future<?> subFuture = ThreadPoolManager.getInstance().submit(() -> doSubTask());
        subFuture.get(); // 阻塞等待子任務完成!
    });
}

4 個父任務把池子塞滿了,全部卡在 subFuture.get() 等待子任務回傳。但子任務全部卡在 LinkedBlockingQueue 裡,根本分不到任何一條執行緒來執行!
父等子釋放執行緒,子等父空出名額——全域執行緒池永久性死鎖,整個 App 的背景任務自此徹底癱瘓。


現代 Android 怎麼寫:Kotlin 協程與 Flow 的降維打擊

現在 Google 官方與業界早已全面封殺手寫全域 ThreadPoolManager,轉向 Kotlin Coroutines(協程)與 Flow。這不是盲目跟風,而是用精確的語言級編譯器狀態機,徹底終結上述歷史原罪:

flowchart TD
    subgraph Coroutine_Architecture ["現代 Android 並行架構"]
        direction TB
        VMS["viewModelScope (生命週期自動綁定)"]
        
        subgraph Flow_Pipeline ["搜尋與事件流處理"]
            direction LR
            INPUT["高頻輸入事件"] --> DEBOUNCE["debounce(300ms) 防抖動"]
            DEBOUNCE --> LATEST["collectLatest { ... }"]
        end
        
        subgraph Dispatchers_Split ["底層執行緒分流"]
            direction LR
            DEF["Dispatchers.Default\n(運算密集型: 精準綁定 CPU 核心數)"]
            IO["Dispatchers.IO\n(阻塞型 I/O: 彈性伸縮最大 64 條)"]
        end
        
        VMS --> Flow_Pipeline
        Flow_Pipeline --> Dispatchers_Split
    end

1. 算力與 I/O 物理級嚴格分流

現代架構不再將所有工作混雜在同一個全域池子裡,而是交給專門的任務排程器(Dispatcher):

  • Dispatchers.Default:專供矩陣計算、JSON 大解析等 CPU 密集運算。執行緒數量嚴格綁定 CPU 實體核心數(四核 QM215 就是 4 條),絕不多開,從根本消除上下文切換(Context Switch)的快取抖動。
  • Dispatchers.IO:專供網路請求與資料庫查詢等阻塞性任務。底層由彈性執行緒池支援(上限 64 條或 CPU 核心數取大值),並與 Dispatchers.Default 共用底層執行緒排程,實現 Work-Stealing(工作竊取演算法)。

2. 結構化並行(Structured Concurrency)滅絕 Detach 閃退

忘掉手動維護的 mMainHandler 吧!將任務繫結在 viewModelScope 或 lifecycleScope:

class UserProfileViewModel(
    private val userRepository: UserRepository
) : ViewModel() {

    fun loadUserData(userId: String) {
        // 自動綁定 ViewModel 生命週期
        viewModelScope.launch {
            // 在 IO 執行緒執行非阻塞掛起
            val user = withContext(Dispatchers.IO) {
                userRepository.getUserFromDb(userId)
            }
            // 自動回到主執行緒安全更新 UI,無須 Handler;頁面關閉時整個任務自動被 Cancel
            updateUi(user)
        }
    }
}

當使用者按下返回退出頁面時,ViewModel.onCleared() 自動被框架呼叫,viewModelScope 底層所有的 Job 會沿著階層關係自動向下廣播 Cancel 指令。
未完成的非同步工作立刻停止,主執行緒回呼自動取消,徹底杜絕 IllegalStateException 與記憶體洩漏!

3. collectLatest 一行程式碼終結「舊蓋新」

時序錯亂問題,在 Kotlin Flow 面前只需要一個運算子:

searchQueryFlow
    .debounce(300L) // 擋掉 300ms 內的高頻敲鍵
    .distinctUntilChanged()
    .collectLatest { query ->
        // 只要新的 query 進來,前一個還在跑的網路請求會立刻被底層 Cancel 掉!
        val results = searchApi.query(query)
        _searchResults.value = results
    }

collectLatest 的本質就是:「新浪一來,前浪直接拍死在沙灘上」。
當使用者快速輸入「A」然後輸入「AB」時,輸入「AB」的瞬間,還在跑的請求 A 會收到協程取消訊號立刻中斷退出,保證只有最新的「AB」結果能抵達介面。不再需要手動寫旗標,不再有加鎖競態。

4. 非阻塞掛起(Suspend):一條執行緒扛上萬個請求

執行緒是昂貴的(1MB Stack),但協程是極其廉價的(記憶體中僅為幾十 Bytes 的 Continuation 物件)。

協程的 suspend 特性利用編譯器生成的狀態機,在等待網路 I/O 回傳時,把執行緒讓出來給別人跑,自己則掛起沉睡;等資料回傳後,再向排程器要一條空閒執行緒恢復執行。

因此我們根本不需要為了高併發硬開幾百條 Thread,即便是 2GB RAM 的弱晶片,也能輕鬆排程數萬個並行協程,徹底消滅 pthread_create failed 的 OOM 噩夢。


架構權衡與收工大實話

當然,這世上沒有銀彈。轉向協程之後,依然有它的工程代價:

  1. 阻塞型 API 依然會咬死執行緒:如果在 withContext(Dispatchers.Default) 裡呼叫了 Thread.sleep() 或同步檔案讀寫,你依然會把 CPU 核心執行緒霸佔不放。協程的非阻塞需要底層庫(如 Retrofit, Room)原生支援 suspend。
  2. 協程取消是協作式的(Cooperative Cancellation):若在自訂的密集迴圈中沒有定期檢查 isActive 或呼叫 yield(),即便外部 Scope 取消了,你的 CPU 迴圈依然會頑固地在背景空轉。
  3. 學習曲線與除錯成本:相比於直來直往的 ThreadPoolManager,協程的非同步呼叫堆疊(StackTrace)被狀態機拆得四分五裂,追查併發 Bug 時對工程師的心智模型要求更高。

但無論如何,把所有任務無腦丟進全域單例 ThreadPoolManager 的時代,真的該徹底翻篇了。

感謝觀看,收工。

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *