第二次參與 ShenYu:記錄一次 TCP Server 併發問題的方案取捨,以及幾個 AI 給出不同答案之後,我是怎麼繼續判斷的。
上一篇記錄了我第一次在 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 放入結果之前,也通過同一次檢查。
這裏的創建還不是普通的 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。
這就是這裏的 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。失敗的佔位符清掉之後,後續請求才有機會重新嘗試。
這部分讓我意識到,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,而不是等它們給出一個標準答案。對還在學習的我來説,能借助這些討論,把一個原本只想到“加鎖”的問題繼續想清楚,就是這次參與開源很實在的收穫。