简

第二次參與 ShenYu:記錄一次 TCP Server 併發問題的方案取捨,以及幾個 AI 給出不同答案之後,我是怎麼繼續判斷的。

從一把鎖的爭論到 Single-Flight:Apache ShenYu TCP 併發創建修復
24 分鐘
4865 字

上一篇記錄了我第一次在 ShenYu 中完整處理一個 Issue。那次,我更多是在學習怎麼從問題入口出發,沿着調用鏈往下讀。第二次遇到的問題,表面上反而更簡單:檢查緩存,沒有就創建一個 TCP Server,再放進緩存。

單線程下,這段邏輯很好理解。但如果兩個同步事件同時處理同一個 selector,它們就可能都看到“還沒有”,然後各自啓動一個 Server。這正是 Issue #6735 指出的問題。

我最初以為,把這幾個操作保護起來就行。真正開始比較方案之後,才發現麻煩的不是“會不會加鎖”,而是應該保護多大的範圍,以及一個 selector 的慢操作會不會把其他 selector 也堵住。

這次我也讓幾個 AI 分別分析了問題,得到的答案卻不太一樣。最後的修復通過 PR #7012 合併。下面想記下的不只是 single-flight 怎麼實現,也包括我在幾個看起來都有道理的方案之間,是怎麼繼續做判斷的。

看起來只是三個操作,為什麼會重複創建?h2

原來的處理路徑可以簡化成:

// 原有逻辑的简化示意
if (!cache.containsKey(name)) {
BootstrapServer server = createBootstrapServer(...);
cache.put(name, server);
}

檢查、創建、發佈,是三個獨立動作。即使用的是 ConcurrentHashMap,也不會自動把它們變成一整個原子操作。線程 A 檢查完緩存之後,線程 B 完全可能在 A 放入結果之前,也通過同一次檢查。

圖 1 · 同一個 selector,兩個線程都通過了緩存檢查
圖 1 · 同一個 selector,兩個線程都通過了緩存檢查 按從上到下的順序,線程 A 和 B 先後檢查緩存,都看到不存在,然後各自進入 TCP Server 創建流程,產生重複啓動與資源管理風險。 Thread A Thread B 檢查緩存:不存在inCache(name) → false 檢查緩存:仍不存在inCache(name) → false 進入 Server 創建createBootstrapServer(...) 也進入 Server 創建createBootstrapServer(...) 重複啓動與資源管理風險端口綁定衝突 / 新建資源失去緩存跟蹤
線程交錯示意,從上往下讀。小屏可在圖內左右滑動查看。

這裏的創建還不是普通的 new:createBootstrapServer() 會調用啓動方法,涉及 Event Loop、端口綁定和 TCP Server 啓動。因此,重複創建不只是多了一個 Java 對象,還可能遇到端口綁定失敗,或者讓已經創建的服務和資源失去緩存跟蹤。

看到這裏,我意識到,需要保護的是同一個 selector 的這一輪創建,而不只是某一次 Map 操作。但不同 selector 本來沒有這個衝突,沒必要一起排隊。

幾個方案都有道理,為什麼最後沒有選全局鎖?h2

我當時把問題交給了幾個 AI 分別分析。它們給出的方向差別挺大:DeepSeek 的回答偏向全局鎖,GPT 的回答偏向公平讀寫鎖和更嚴格的原子邊界,Gemini 則更傾向按 selector 保存創建狀態,不使用全局生命週期鎖。

單獨看,每種方案都能解釋得通。全局鎖最直接,把檢查、創建、發佈包在同一個臨界區裏,思路很清楚。讀寫鎖看起來更細,可以區分讀寫訪問;公平模式也試圖減少線程長期搶不到鎖的情況。

但我繼續往下想時,關注點變了:這把鎖會讓誰等誰?

TCP Server 的創建和關閉都可能比較慢。如果在整個 Factory 上使用一把全局鎖,讓啓動、關閉這些操作也進入臨界區,selector-a 的慢操作就可能擋住無關的 selector-b。把 synchronized 換成公平讀寫鎖,也不會自動改變這個互斥範圍。

討論過的方向我當時主要考慮的地方
全局鎖容易理解,但不想讓無關 selector 的啓動和關閉一起排隊
全局公平讀寫鎖能細分讀寫訪問,但仍要判斷哪些操作持鎖、持多久
按 selector 的 single-flight同一輪創建只交給一個線程,其他 selector 不爭同一把全局鎖

所以後來我給自己定的方向是:只限制真正發生衝突的同一個 selector,讓其他 selector 儘可能獨立地處理。

這不是説“鎖越少越好”。對我來説,更重要的是先弄清楚要保護什麼,再決定互斥範圍,而不是看到併發問題就先把鎖加上。

Single-flight:給正在創建的 selector 留一個位置h2

最終的實現是在 TcpBootstrapFactory 中增加一個創建狀態表:

ConcurrentMap<String, CompletableFuture<BootstrapServer>> creations

這裏的 CompletableFuture 讓我覺得很有意思。它不只是“異步編程工具”,也可以用來表示:這個 selector 已經有人在創建了,後來的請求可以等這一次結果。

準備創建時,先嚐試登記自己的 Future:

CompletableFuture<BootstrapServer> creation =
new CompletableFuture<>();
CompletableFuture<BootstrapServer> existingCreation =
creations.putIfAbsent(selectorName, creation);

putIfAbsent() 是原子的。同一個 selector 同時收到兩個請求時,只有一個能成功放入佔位符,成為這一輪的創建者;另一個拿到已有 Future,走 awaitCreation(existingCreation),不再自己啓動 Server。

圖 2 · 同一輪創建,一個線程執行,另一個等待
圖 2 · 同一輪創建,一個線程執行,另一個等待 緩存未命中時,線程用 creations.putIfAbsent 登記佔位符。登記成功者啓動併發布服務,然後完成 Future;其他相同 selector 的調用等待這次 Future,不重複創建。示意省略二次緩存檢查與發佈衝突分支。 同一個 selector 的創建請求緩存未命中 原子登記創建佔位符creations.putIfAbsent(name, future) 登記成功 已有佔位 成為創建者啓動 TCP Server 成為等待者awaitCreation(existingCreation) 發佈啓動成功的 Servercache.putIfAbsent(name, server) 完成同一次 Futurecreation.complete(server) 移除本輪創建佔位符finally: remove(name, creation) 等待結束,不重複創建當前調用返回 false
展示成功主路徑;虛線表示 Future 完成後等待者得以繼續。小屏可在圖內左右滑動查看。

這就是這裏的 single-flight:對同一個 key,同一輪只由一個線程執行創建,其他相同請求等待這一次執行。不同 selector 使用不同的佔位符,因此不需要爭奪一把 Factory 級別的生命週期鎖。

最終實現還有兩處緩存檢查:進入創建流程前先檢查已有緩存,成功登記佔位符後再檢查一次。真正的 Server 啓動放在 Map 的原子操作之外,沒有把可能比較慢的啓動邏輯塞進 computeIfAbsent() 的回調裏。

有了佔位符,為什麼發佈時還用 putIfAbsent?h3

方案到這裏還沒結束。創建完成、準備放入緩存時,也要處理“緩存裏已經出現了另一個實例”的情況。原有的公開 Factory 方法仍然保留,不能只因為新入口有 single-flight,就假定其他路徑一定不會寫入緩存。

所以發佈時不是直接 cache.put(),而是:

BootstrapServer existingServer =
cache.putIfAbsent(selectorName, bootstrapServer);
if (existingServer != null) {
bootstrapServer.shutdown();
}

如果已有實例,就關閉這次新建但沒有成功發佈的實例,而不是覆蓋已有值。然後把已有實例完成到 Future 中,讓等待者結束等待。

這裏我開始意識到,一個主方案講得通,不代表周圍的狀態變化就都不用再檢查。佔位符負責協調這一輪創建,緩存的原子發佈負責守住最後一道邊界,兩者解決的不是同一個問題。

創建失敗了,等待中的線程怎麼辦?h2

再往下推演,就會碰到失敗路徑。比如創建者在綁定端口時失敗了,另一邊卻還有線程在等它的 Future。

如果只讓創建線程拋出異常,卻沒有完成這個 Future,等待者就沒有辦法知道這次創建已經結束了。因此,失敗也要作為這一輪創建的結果傳出去:

creation.completeExceptionally(ex);
throw ex;

等待方通過 join() 得到失敗,awaitCreation() 再從 CompletionException 中取出原始原因。對於這裏處理的運行時異常,會重新拋出原來的異常。

不管成功還是失敗,最後都會移除這次創建的佔位符:

creations.remove(selectorName, creation);

這裏使用 remove(key, value),只移除當前這一輪的 Future。失敗的佔位符清掉之後,後續請求才有機會重新嘗試。

圖 3 · 啓動失敗後,也要讓這一輪創建結束
圖 3 · 啓動失敗後,也要讓這一輪創建結束 TCP Server 啓動失敗後嘗試釋放已經創建的 LoopResources,保留原始啓動異常;工廠將 Future 完成為失敗,等待線程得到異常,finally 移除當前佔位符,讓後續請求能夠重試。 TCP Server 啓動失敗例如端口綁定失敗 嘗試釋放已創建的 LoopResources清理異常通過 addSuppressed 保留 將創建 Future 標記為失敗creation.completeExceptionally(ex) 等待者收到創建異常awaitCreation 解包原始原因 清除當前創建佔位符finally: remove(name, creation) 後續請求可以重新嘗試不會被失敗佔位符一直擋住
小屏可在圖內左右滑動查看。

這部分讓我意識到,Future 佔位符不是登記完就能不管了。成功要通知等待者,失敗也要通知,最後還要清掉本輪狀態。少了一步,原本用來協調線程的機制,反而可能把後來的請求一直擋住。

併發創建之外,還要照顧資源的開始和結束h2

在這次修復裏,我也繼續檢查了 TcpBootstrapServer 的生命週期。Server 啓動失敗時,創建過程可能已經走了一半;關閉時,也可能被多個清理路徑重複調用。

啓動失敗,要釋放已經創建的資源h3

啓動過程會先創建 LoopResources,再調用 bindNow() 綁定端口。如果後一步失敗,前面創建的資源不會因為方法拋出異常就自動釋放。

因此,這次在啓動失敗時補了清理邏輯。如果清理本身也拋出異常,就把它作為 suppressed exception 掛到原始啓動異常上,再繼續拋出原始異常。

這樣,排查時能看到最初為什麼啓動失敗,也不會丟掉隨後清理失敗的信息。

shutdown() 可以重複調用,但關閉流程不重複執行h3

這次還給 shutdown() 加了實例級別的 synchronized 和 disposed 標記。同一個服務實例的關閉不會併發執行;關閉流程結束時,在 finally 中標記為已處理,後續調用直接返回。

這裏不是給整個 Factory 加一把全局鎖,只是在同一個 Server 實例上協調關閉。

另外,server.disposeNow() 失敗後,仍然會嘗試釋放 LoopResources;如果兩處都失敗,後面的異常通過 addSuppressed() 保留下來。即使關閉拋出異常,disposed 也會置為 true,後續調用不會自動重試。

一個 selector 關閉得慢,不要擋住另一個h3

刪除操作通過 removeAndShutdown(selectorName) 先從緩存中原子移除實例,再執行關閉。不存在時就直接返回,關閉操作不放在 Factory 的全局生命週期鎖裏。

這也是我比較在意的一點:修復同一個 selector 的重複創建,不應該順帶把其他 selector 的刪除堵住。PR 的測試專門構造了一個服務關閉被卡住、另一個服務仍然可以刪除的場景。

測試時,我開始更多地關注失敗和線程交錯h2

相比上一篇,這次測試更偏向併發與生命週期邊界,而不是隻看某個方法有沒有被調用。

場景驗證內容
同一 selector 併發創建16 個線程同時請求,只有一次調用真正創建並緩存 Server
創建失敗不留下成功緩存,後續可以重新嘗試
等待中的調用接收到原始創建失敗
不同 selector 刪除一個 shutdown 卡住,不阻塞另一個 selector 的刪除
bind 失敗嘗試釋放已創建的 LoopResources
重複 shutdown關閉流程不重複執行
多處釋放失敗通過 suppressed exception 保留異常信息

我覺得這次測試帶來的變化是:不能只順着“創建成功、正常關閉”這條路看,還要主動想一想,兩個線程插在一起時會怎樣,操作只完成一半時又會留下什麼。

把這些場景拆開之後,我對自己的實現才更有把握,也更容易發現只看正常路徑時漏掉的地方。

第二次參與開源,我對 AI 協作的理解也變了一點h2

這次最值得我記下的,其實不只是 single-flight 這個方案,還有幾個 AI 給出不同答案之後,我是怎麼繼續往下想的。

一開始,每種回答都能講出一套理由,我很難只看解釋就判斷誰更適合。後來我發現,與其繼續問“哪一個更安全”,不如把問題具體化:如果 selector-a 啓動很慢,selector-b 會不會等?如果創建失敗,正在等 Future 的線程會怎樣?如果資源已經分配了一半,這時候誰負責清理?

這些問題更接近代碼實際會遇到的情況,也讓我不再只盯着某個方案的名字。

最後,我沒有直接照搬某一個模型的完整代碼。按 selector 保存佔位狀態的主方向更接近 Gemini 當時的建議,而 GPT 的分析也讓我繼續注意原子發佈、異常傳播和資源釋放這些細節。我需要做的,是判斷它們能不能放進同一套實現裏,以及哪些改動確實屬於這個 Issue。

這個過程比“AI 幫我寫了一段代碼”更有價值。模型之間意見不一致,有時會讓我更困惑,但也會逼着我把原本模糊的要求説清楚:到底哪些操作需要互斥,哪些等待是不必要的,失敗之後資源又歸誰管。

上一篇讓我開始找到閲讀大型倉庫的方法。這次則讓我意識到,有了 AI,也不能省掉自己理解代碼和做取捨的過程。它可以幫我找問題、提方案、補充我沒想到的場景,但最後提交的是我的 PR,我還是需要知道里面每一處修改為什麼存在。

現在我更願意把 AI 當作幾個可以一起討論的 reviewer,而不是等它們給出一個標準答案。對還在學習的我來説,能借助這些討論,把一個原本只想到“加鎖”的問題繼續想清楚,就是這次參與開源很實在的收穫。


相關鏈接h2