學習 / 實作

Java LLM 實踐(十):點了取消,讀取執行緒為什麼還在運行?

AI 應用 / 構建 · 工程案例

30次本地HTTP連接,區分會話取消、阻塞讀取退出與遠端工作,比較Future中斷和主動關閉連接。

進階 · 11 分鐘 · 更新於 2026-10-07 · 步驟 8 / 17

這篇之後,已經有了哪些結果?

保留本文發表時的結論與下一步構想;以下是已發布的後續內容,不代表所有侷限皆已解決。

繼續探索

繼續探索

  1. 工程案例

    Java 請求重試:三次超時,不等于一個總期限

    七種真實本地 HTTP 情況,比較狀態碼、Retry-After、嘗試上限和總等待預算。

    9 分鐘已發布

瀏覽統計暫不可用

本文目錄 9
跳至正文 ↓

固定版本程式碼 · 原始記錄與審計 · 實驗檔案

點下“停止生成”,頁面不再更新,不代表網路讀取也已經停止。

上一篇解決了舊回呼覆蓋新會話:只有當前請求才能提交預覽和歷史。本篇往下一層走,把記憶體中的 SSE 換成真實本地 HTTP 連接,檢查取消之後工作執行緒到底什麼時候退出。

先把三個“停止”分開

層次 希望停止什麼 本篇的驗證方式
會話 舊回答繼續寫入預覽或歷史 檢查 Session 最終狀態
客戶端 工作執行緒繼續阻塞在讀取上 讀取函數的 finally 單獨記錄退出時間
服務端 繼續計算或生成 記錄本地服務端完成了多少個工作步驟

這三個狀態沒有共同的完成按鈕。UI 可以立即撤銷請求的寫入資格,而讀取執行緒仍在運行;連接關閉了,服務端也可能繼續執行自己的任務。

Future.cancel(true) 的含義是嘗試取消並中斷執行緒;成功取消後的 Future 狀態,不是執行緒已經退出的證明。Java 8 Future 文件

實驗如何排除“只是改了一個標誌”

使用 Java 8 的經典阻塞 Socket,服務端綁定 loopback 隨機連接埠。回應為 text/event-stream,通過關閉連接結束 HTTP 回應;沒有 TLS、代理、HTTP/2 或 chunked 編碼。A6 的 SSE 解析器與 A7 的 Session 原樣重用。

五種條件各執行兩種策略,每組重複三次,共 30 次真實本地連接。六次正常完成作為控制組,其餘 24 次觸發取消或截止。

條件 服務端行為 觸發動作
正常完成 首段文字後發送 stop 和 DONE 不取消
手動取消/靜默 首段後一直等待測試清理 收到首段後取消
手動取消/持續發送 每輪等待約 80ms,共發送八段 收到首段後取消
截止/靜默 首段後靜默 讀取階段開始約 200ms 後取消
截止/持續發送 繼續八輪有界工作 讀取階段開始約 200ms 後取消

兩種策略都會先 session.cancel(),撤銷舊請求的歸屬。差別只有:僅執行 future.cancel(true),還是再執行 socket.close()。讀取閒置超時統一為 500ms。

持續發送條件中的 80ms 是請求的休眠時間,不保證真實發包間隔精確為 80ms。調度、緩衝和運行環境都會影響時間,因此表中使用實際記錄,不能用八乘八十替代測量。

Future 已取消,讀取仍然沒結束

下表測量的是“取消動作開始 → 讀取任務 finally 記錄時間”,單位毫秒。每格為三次運行的中位數和最小—最大範圍。

條件 僅取消 Future 同時關閉 Socket
手動取消/靜默 507.211(501.246—509.968) 0.137(0.119—0.389)
手動取消/持續發送 747.739(740.487—750.737) 0.176(0.173—0.181)
截止/靜默 310.534(309.455—310.868) 0.418(0.402—0.710)
截止/持續發送 532.356(526.331—543.952) 0.420(0.316—0.536)
兩種取消策略的實際讀取退出耗時;兩幅圖使用不同橫軸尺度,並顯示三次測量範圍。

兩幅圖的橫軸尺度不同,請比較數值,不要比較柱子的視覺長度。這是本機 Java 1.8.0_171 的固定條件測量,不是網路 SLA,也不是所有 Java HTTP 客戶端的統一行為。

僅中斷的靜默組最終收到 SocketTimeoutException;持續發送組反而一直讀到了完整回答,之後由 Session 拒絕提交,結果為 STALE_COMPLETE。關閉連接的十二次故障運行都以 SocketException 退出。

Socket.close() 會使正在該 Socket I/O 操作中阻塞的執行緒拋出異常,這是此處主動喚醒讀取的機制。Java 8 Socket 文件

為什麼要有第二個完成信號

不能把 future.get() 當作取消後的執行緒回收憑證。它可能立即拋出取消異常,而實際讀取還沒有返回。實驗把退出記錄放在工作函數內部:

try {
    String answer = reader.read(input, text -> session.preview(ticket, text));
    session.complete(ticket, answer);
} finally {
    exitedAt.set(System.nanoTime());
    workerExited.countDown();
}

取消端先撤銷寫入資格,再嘗試中斷和關閉連接:

session.cancel();
future.cancel(true);
socket.close();
// 用獨立退出信號做有界等待,不用 isDone() 證明執行緒已回收。
workerExited.await(5, TimeUnit.SECONDS);

上面是關鍵邏輯摘錄,完整 Demo 還處理資源關閉、服務端收尾和異常。等待與 I/O 不放進 Session 的同步區;否則負責取消的執行緒可能反而拿不到鎖。

24 次取消運行都沒有寫入歷史,六次正常運行各保留一組問題和回答。會話正確性相同,不代表資源回收及時性也相同。 只檢查聊天界面,很容易漏掉這個問題。

閒置超時與截止時間也不是一回事

500ms 的閒置超時約束一次阻塞讀取等待資料的時間。對方持續發送時,任務總時長仍可以繼續增加。200ms 截止由獨立調度器觸發,不依賴下一段資料到達。

不過本 Demo 的計時起點在連接建立並發出請求之後,因此它只覆蓋讀取階段,不包括 DNS、建連或寫請求。如果要升級為完整請求截止,需要把時間預算傳播到這些階段,並處理取消與連接創建之間的競態。本篇沒有驗證這一擴展。

為什麼不能據此聲稱“停止計費”

持續發送的本地服務端被刻意設計為:即使寫入失敗,也完成八個有界工作步驟。四組持續發送條件、兩種策略下,記錄都保留了八步工作。

這只證明“客戶端已關閉”不足以推出“遠端任務已取消”。它沒有測試 Agnes 的服務端實現,也沒有賬單資料。真實供應商是否支援任務取消、是否確認取消、如何計費,需要對應協議和實際記錄。

因此狀態可以分別保留 sessionInvalidated、readerExited 和未來的 remoteCancellationAcknowledged,不要用一個 cancelled=true 向所有層承諾完成。

自己重現與讀證據

進入程式碼倉庫的 demos/10-transport-cancellation,準備 Java 8、Maven 和 Python。run.py 中 Maven 使用本機安裝路徑,換機器時調整為自己的路徑;首次需要解析已聲明的 Maven 依賴,運行器使用離線構建。

python run.py --out evidence/my-run
python audit.py evidence/my-run

輸出目錄必須是新目錄,避免覆蓋已歸檔測量。results.json 保留 30 條記錄及 Session 事件,environment.json 記錄 JVM,manifest.json 固定實驗源程式碼與結果哈希。獨立審計檢查條件唯一性、歷史提交數量、退出時間順序和服務端工作步驟;它沒有把“小於一毫秒”寫成測試通過門檻。

建議先打開一條 cancel-dripping / interrupt-only 記錄:比較 futureCancelledAtAction、workerExitedAtAction、outcome 和空的 history。四個欄位放在一起,就能讀出“Future 取消了、執行緒繼續讀、協議完整了、會話仍拒絕提交”的全過程。

下一步該驗證什麼

本篇解決單個經典 Socket 的取消邊界。真實 SDK 的連接池重用、取消與完成同時發生、取消早於連接創建、多個並發請求的關閉歸屬,都應單獨建立測試。下一步優先接入實際使用的 HTTP 客戶端,再討論連接池和重試,而不是把這個教學 Socket 直接包裝成生產 SDK。

修復進展 · 2026-10-07

2026-10-07 修正:Demo08–10 增加 JSON 詞法校驗,三個範例各通過 53 項離線回歸;執行腳本改用 Maven 參數列表,支援含空格目錄。Demo10 稽核補上原始碼指紋檢查。原文固定版本與原始資料保持不變,修正版不是新增線上模型結果。

修正版程式碼與回歸指令。

寫給未來的自己合上文章前,留下一點自己的理解。

你的私人學習便箋,只存在目前瀏覽器,不會上傳或公開。清理瀏覽器資料會遺失,請匯出留存。三種語言共用這篇文章的便箋。

下一篇已發布內容:TypeScript 能保證模型輸出正確嗎?32組輸入與 Java 驗證器的邊界對照 →

目前學習位置

構建 / AI 應用開發

  1. ○ Java LLM 實踐(九):取消請求後,舊回答為什麼還會覆蓋新會話?
  2. ○ Java LLM 實踐(十):點了取消,讀取執行緒為什麼還在運行?
  3. ○ TypeScript 能保證模型輸出正確嗎?32組輸入與 Java 驗證器的邊界對照

繼續探索

繼續探索

  1. 實驗 / 027

    Java LLM 實踐(十):點了取消,讀取執行緒為什麼還在運行?

    30次本地HTTP連接,區分會話取消、阻塞讀取退出與遠端工作,比較Future中斷和主動關閉連接。

    已完成
  2. 專案 / 002

    Java LLM 實作集

    Java 會話、請求重試與持久化邊界,以及注意力、快取成本和量化誤差的可復現實驗。

    開發中
  3. 工程案例

    Java LLM 實踐(九):取消請求後,舊回答為什麼還會覆蓋新會話?

    18 種固定執行緒交錯,驗證流式預覽、完成和錯誤回呼的請求歸屬,以及取消和有限冪等的邊界。

    10 分鐘已發布
  4. 工程案例

    Java LLM 實踐(八):流式回答斷了,半句答案該不該進入歷史?

    22 個回環 HTTP 案例,驗證 UTF-8、SSE 分幀、取消與異常結束,把逐步預覽和完整歷史提交分開。

    11 分鐘已發布

接下來,走哪條路?

依據文章關聯與同主題已發布內容整理,不使用隨機推薦。

← 全部文章