一個 CompletableFuture 完成的那一瞬間,接在它後面的那段 lambda,到底是誰把它跑掉的?

大部分人心裡有個預設答案:跑在執行緒池裡。畢竟整條鏈子都是非同步的,callback 當然也是非同步跑的。

這個答案有一半的時間是對的。另一半的時間它跑在 main

先把問題縮到最小

不要一開始就搬 Spring 或 WebFlux 出來。寫一支只有 System.out.println 的探針,把每一段的 Thread.currentThread().getName() 印出來就好。

這裡有個容易踩的陷阱:探針的訊息不要寫死。如果你在 log 裡寫「第二次會跑在 pool」,那句話印出來的是你的假設,不是程式的行為。一律印實際取到的值。

1
2
3
static void log(String tag) {
System.out.println(tag + " -> " + Thread.currentThread().getName());
}

第一組實驗只問一件事:掛 callback 的時候,上游完成了沒有。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 情境 1:上游還在 sleep,callback 是掛在一個還沒完成的 future 上
CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> {
log(" supplyAsync body");
Thread.sleep(200); // 這裡實際要包 try-catch,省略
return "v1";
});
f1.thenApply(v -> { log(" thenApply callback"); return v; }).join();

// 情境 2:先 sleep 300ms 確定它完成了,才掛 callback
CompletableFuture<String> f2 = CompletableFuture.supplyAsync(() -> "v2");
Thread.sleep(300);
System.out.println(" 掛 callback 前,f2.isDone() = " + f2.isDone());
f2.thenApply(v -> { log(" thenApply callback"); return v; }).join();

OpenJDK 25 上跑出來:

1
2
3
4
5
6
7
8
9
10
A. main -> main

--- 情境 1:掛 callback 時 future 還沒完成 ---
supplyAsync body -> ForkJoinPool.commonPool-worker-1
thenApply callback -> ForkJoinPool.commonPool-worker-1

--- 情境 2:掛 callback 時 future 已經完成 ---
supplyAsync body -> ForkJoinPool.commonPool-worker-1
掛 callback 前,f2.isDone() = true
thenApply callback -> main

同一行 thenApply,兩種歸屬。

為什麼會這樣

thenApply 想成在門口貼一張便條紙。

你走到門口,如果屋裡的人還在忙,你就把便條貼上去然後走人。等他忙完,開門看到便條,這件事就順手由他做掉。所以 callback 跑在完成上游的那條執行緒上。

但如果你走到門口時他已經忙完了,門開著,沒人接便條。這時你不會把紙貼在空門上等下一個人,你就自己動手做了。callback 於是跑在你這條執行緒,也就是呼叫 thenApply 的那一條。

isDone() 為 true 的那個瞬間決定了一切。而「上游完成」與「你掛上 callback」這兩件事,本來就是兩條執行緒在賽跑。

這就是為什麼它會變成 heisenbug。你在本機測,資料少、上游一下就完成,callback 全部跑在 main。上了線流量進來,上游變慢,callback 全部搬到 pool 裡去。程式碼一個字都沒改。

executor 只綁「有 Async 的那幾段」

知道歸屬是競態決定的之後,下一個問題自然浮出來:那我指定 executor 有沒有用。

第二支探針把一條四段的鏈子,每段各印一次:

1
2
3
4
5
6
CompletableFuture
.supplyAsync(() -> { log(" 1) supplyAsync(pool)"); return "x"; }, pool)
.thenApply(v -> { log(" 2) thenApply(沒指定)"); return v; })
.thenApplyAsync(v -> { log(" 3) thenApplyAsync(沒指定 executor)"); return v; })
.thenApplyAsync(v -> { log(" 4) thenApplyAsync(pool)"); return v; }, pool)
.join();

pool 是我自己建的 newFixedThreadPool(2),執行緒名字都以 biz-pool- 開頭。實際輸出:

1
2
3
4
1) supplyAsync(pool) -> thread=biz-pool-25
2) thenApply(沒指定) -> thread=main
3) thenApplyAsync(沒指定 executor) -> thread=ForkJoinPool.commonPool-worker-1
4) thenApplyAsync(pool) -> thread=biz-pool-34

第 3 段是這篇最值得記住的一行。

我在第 1 段明明白白指定了 pool,第 3 段也明明白白寫了 Async。結果它跑在 ForkJoinPool.commonPool。executor 不會沿著鏈子傳下去,每一個 *Async 方法都是獨立決定的:你給它 executor 它就用,你沒給它就用 commonPool。

commonPool 的預設大小是「可用處理器數減一」。我這台機器 8 核,ForkJoinPool.getCommonPoolParallelism() 印出來是 7。這個池是整個 JVM 共用的,parallelStream() 也在裡面跑。所以一段做 IO 的 thenApplyAsync 忘了帶 executor,它會去卡住整個 JVM 共用的那七條執行緒。

traceId 會在第二段就斷掉

上面那支探針其實還藏了一半沒給你看。我在 main 塞了一個 ThreadLocal,模擬 MDC 裡的 traceId:

1
2
static final ThreadLocal<String> TRACE = new ThreadLocal<>();
TRACE.set("REQ-8891");

把 trace 值一起印出來,同一次執行:

1
2
3
4
5
A. main 起點 -> thread=main trace=REQ-8891
1) supplyAsync(pool) -> thread=biz-pool-25 trace=null
2) thenApply(沒指定) -> thread=main trace=REQ-8891
3) thenApplyAsync(沒指定 executor) -> thread=commonPool-worker-1 trace=null
4) thenApplyAsync(pool) -> thread=biz-pool-34 trace=null

ThreadLocal 綁在執行緒上,執行緒一換值就沒了,這件事本身不意外。真正難搞的是第 2 段:它有值。

如果 traceId 斷掉是穩定斷的,你第一次跑就會發現,加個 TaskDecorator 或用 MDC.getCopyOfContextMap() 手動搬過去就解決了。但這裡是同一行 code 有時候有值、有時候沒有,取決於上游完成得多快。日誌看起來就是:大部分請求 trace 串得好好的,尖峰時段少數請求中間斷一截。

我之前追一個 log 對不起來的問題繞了很久,就是被這個規律騙了。traceId 有斷有不斷,第一直覺是去懷疑寫入端有沒有漏設,而不是去懷疑「這行 code 換執行緒了」。

異常不會走中間那幾站

歸屬拆完,換異常。這條路徑同樣違反直覺。

第三支探針讓上游直接丟 IllegalStateException,後面接兩個 thenApply,每個都會印一行「我有跑到」:

1
2
3
4
5
6
7
8
9
static CompletableFuture<String> boom() {
return CompletableFuture.supplyAsync(() -> {
throw new IllegalStateException("原始爆點");
});
}

boom().thenApply(v -> { System.out.println(" thenApply 有跑到"); return v + "-a"; })
.thenApply(v -> { System.out.println(" 第二個 thenApply 有跑到"); return v + "-b"; })
.join();

輸出裡沒有任何一行「有跑到」:

1
2
3
--- 1. 中間的 thenApply 會不會被執行 ---
join 丟出:java.util.concurrent.CompletionException
/ cause=java.lang.IllegalStateException / msg=原始爆點

上游一爆,後面所有 thenApply 直接跳過,異常沿著鏈子往下滑到第一個願意接的人手上。這跟 try 區塊裡丟例外之後下面的程式碼不會執行是同一個道理,只是這裡的「下面」散在好幾個 lambda 裡,看起來比較不像同一段程式。

接下來這條最容易寫錯:

1
2
3
4
5
6
7
8
9
--- 2. join vs get 包裝的例外型別 ---
join() -> java.util.concurrent.CompletionException
get() -> java.util.concurrent.ExecutionException

--- 3. exceptionally / handle / whenComplete 各收到什麼 ---
exceptionally 收到 java.util.concurrent.CompletionException cause=java.lang.IllegalStateException
handle 收到 value=null throwable=java.util.concurrent.CompletionException
whenComplete 收到 value=null throwable=java.util.concurrent.CompletionException
whenComplete 之後 join 仍丟:java.util.concurrent.CompletionException

exceptionally 拿到的是包過一層的 CompletionException,不是你丟的那個。所以這種寫法會永遠走不進去:

1
2
3
4
5
6
.exceptionally(t -> {
if (t instanceof IllegalStateException) { // 永遠 false
return "降級";
}
throw new RuntimeException(t);
})

要判斷原始型別得先剝一層 t.getCause()。而且不能無腦剝。上游如果是同步就完成的路徑,有時候不會包,getCause() 會給你 null。安全的寫法是 Throwable real = (t instanceof CompletionException && t.getCause() != null) ? t.getCause() : t;

三個收尾方法的分工也不同。exceptionallyhandle 會把異常吃掉,鏈子恢復成正常值繼續往下走;whenComplete 只是讓你偷看一眼,看完異常照樣往下丟。想記 log 又不想改變鏈子行為,用 whenComplete;想給降級值,用另外兩個。

沒人 join 的異常,是真的沒有聲音

最後一格實驗。建一條會爆的鏈子,然後什麼都不做:

1
2
boom().thenApply(v -> v + "-never");
Thread.sleep(1000);

等一秒,stderr 一片乾淨,沒有 stack trace,沒有 warning。

這跟裸 Thread 不一樣。裸執行緒裡沒接的例外會走到預設的 handler 印出來,至少你在日誌裡看得到屍體。CompletableFuture 把異常存進自己的欄位,等有人來拿;沒人來拿,它就一直躺在那裡直到被 GC 掉。

ExecutorService.submit() 也有這個特性(Futureget() 就看不到異常),這是同一種設計取捨的兩個版本。所以任何一條鏈子的尾巴都應該掛一個東西,不接值也要接異常,至少 whenComplete 記一筆 log。

拆完之後真正的用法

把上面五格倒過來看,CompletableFuture 的使用規則其實很短。

只要那段 callback 裡有任何 IO、任何鎖、任何耗時運算,就用 thenApplyAsync 並且自己帶 executor。不是為了效能,是為了讓歸屬變成確定的——確定之後,效能和 traceId 才有得談。

鏈子的每一段都要當作「可能換執行緒」來寫。任何靠 ThreadLocal 傳遞的東西(MDC、Spring 的 SecurityContextHolder、交易狀態)都不會自動跟著走,要嘛在進鏈子前把值撈成區域變數,要嘛用會複製 context 的 decorator。

然後,尾巴一定要有人接。

這個「誰先跑到誰就負責」的模式不只出現在 Java。JavaScript 的 Promise 用微任務佇列把它繞掉了,代價是所有 callback 都晚一拍執行;Kotlin 的 coroutine 用 dispatcher 把歸屬變成顯式參數。三種語言,三種取捨。但它們在解同一道題:一個非同步結果完成的時候,接下來的工作該記在誰的帳上。

我這三支探針是在 OpenJDK 25 上跑的,行為從 Java 8 引進這個類別以來沒變過,但我沒有在每個版本上都實測。有一件事我到現在還沒完全弄清楚:在真實服務的高併發下,第 2 段那種「有時候跑在呼叫端」的比例到底跟上游延遲呈什麼關係,我只知道它會變,還沒量出曲線。

想自己驗的話,把上面的 log() 貼進任何一支 main,改幾個 sleep 的毫秒數,你會看到歸屬跟著換。這比讀十篇文章有用。

延伸閱讀:PriorityBlockingQueue 永遠不會擋你,四個實測出來的地雷 — 那篇講的是無界佇列怎麼讓 maximumPoolSize 變成裝飾品,跟這篇的 commonPool 是同一類問題的兩個入口。