題幹與適用場景
在 Tokio 中用 select! 等待逾時、取消令牌和 I/O 時,如何判斷一個 future 是否 cancellation safe?如果操作已部分完成又被取消,你會如何恢復?
這道題適合 Rust、後端、基礎設施和非同步服務職位。Tokio 文件把取消描述為丟棄 future;select! 勝出的分支繼續,其他分支可能在任意 await 點被丟棄。關鍵是區分「停止等待」與「撤銷外部副作用」,並讓狀態機可安全重啟。
面試官考察點
- 是否理解 future 被 drop 不等於底層 I/O 或遠端副作用已回滾。
- 是否能辨識讀取緩衝、佇列、鎖和協議框的部分進度。
- 是否用所有權、狀態機和冪等鍵保證重啟不會遺失資料或重複提交。
- 是否知道哪些 Tokio 原語明確聲明 cancellation safety,哪些需要自行封裝。
- 是否區分 abort、逾時、優雅關閉和外部取消的語意。
- 是否用模型測試、故障注入和指標驗證取消路徑。
30 秒回答框架
「我先定義取消邊界:丟棄 future 只停止目前任務的輪詢,不保證遠端操作撤銷。若一個 future 在任意 await 後被丟棄,再次呼叫仍能正確繼續或重試,才可稱為 cancellation safe。讀取和傳送採用明確狀態機,已消費位元組、請求 ID、緩衝區和冪等鍵都由擁有者保存;不可逆副作用先落持久狀態或等待確認。逾時和取消測試要覆蓋每個 await 點。」
分步驟深入解答
第一步:標記可取消點
審查 async 函式中的每個 .await:它前面是否已經改變了本地或遠端狀態?如果 future 在這裡被 drop,哪些欄位仍可用於恢復?把網路讀取、寫入、鎖等待、佇列接收和 sleep 都視為可能的取消點,而不是只測試函式入口和出口。
第二步:區分取消與撤銷
select! 取消一個分支通常只是丟棄 future;已經傳送到遠端的請求可能繼續執行。逾時處理器應記錄請求 ID 和未知結果狀態,不能直接把它標成失敗後再次傳送非冪等操作。需要撤銷時,必須呼叫協議提供的取消介面並等待確認。
第三步:設計可恢復的狀態機
把操作分成準備、傳送、等待確認、已提交和補償狀態。狀態與緩衝區由明確的擁有者保存;重新進入時根據狀態決定繼續讀取、重發、查詢結果或執行補償。對串流協議記錄框邊界和已確認偏移,避免從半個訊息重新解釋位元組。
第四步:保證佇列與鎖的一致性
從 channel 或 stream 取出訊息後,在處理結果落盤前被取消會造成「已消費但未完成」。使用交易式確認、可重複讀取或持久化租約;不要在 select! 分支中先彈出訊息再把唯一副本放進臨時變數。鎖保護的共享狀態要在 drop 時釋放,並讓恢復邏輯知道鎖內操作是否已提交。
第五步:處理資源與任務終止
JoinHandle::abort 會停止任務,但不取代業務清理。為 socket、檔案、暫存檔和 semaphore permit 設計 RAII 守衛;需要後台完成的工作交給獨立任務並保存句柄。優雅關閉先停止接收新任務,再等待可完成操作,最後對剩餘任務發取消訊號並記錄原因。
第六步:驗證取消安全
在每個 await 前後注入取消,檢查重啟、重複、遺失和資源洩漏。用受控 fake I/O、模型狀態機和並行測試覆蓋逾時、channel 關閉、遠端斷線與任務 abort。指標包括未決請求、重複鍵命中、補償次數、取消延遲和 permit 洩漏;沒有這些指標就無法判斷「取消成功」。
取捨、邊界與資訊增益
取消安全的核心資訊是:非同步控制流被中斷時,業務狀態仍必須可解釋。短小、無外部副作用的 future 容易做到重啟安全;跨網路、資料庫和訊息佇列的操作需要冪等、確認和補償。把所有程式碼包進不可取消任務會隱藏資源問題,也會破壞關閉延遲,因此應只在明確的原子邊界使用。
高品質示範回答
「我把每個 .await 當作取消點,先問它之前是否產生了副作用以及丟棄後如何恢復。Tokio 的 select! 只會丟棄未勝出的 future,不會撤銷已發出的請求,所以逾時要保留請求 ID,並讓重試使用冪等鍵或先查詢結果。操作狀態機至少區分準備、傳送、等待確認、已提交和補償。
channel 消費、檔案寫入和資料庫提交要有交易式確認或可重複讀取,避免訊息已取出卻沒有完成。資源用 RAII 守衛釋放,後台必須完成的工作由獨立任務持有句柄;優雅關閉先停止接收,再取消和等待。
測試在每個 await 點注入取消,覆蓋重啟、重複、遺失和洩漏,監控未決請求、重複鍵、補償、取消延遲和資源 permit。這樣我能證明 future 的 cancellation safety,而不是只看到任務停止。」
常見錯誤
- 把 drop 當作遠端撤銷 → 請求可能已經執行 → 保存 ID、查詢結果或呼叫顯式取消。
- 消費訊息後才考慮取消 → 唯一副本可能遺失 → 使用確認、租約或可重複讀取。
- 逾時後盲目重發寫請求 → 產生重複副作用 → 使用冪等鍵或先查詢狀態。
- 只在入口測試取消 → 中間 await 才有競態 → 對每個取消點注入測試。
- 用 abort 取代清理 → 檔案、鎖和 permit 可能洩漏 → 使用守衛和關閉協議。
- 所有任務都不可取消 → 優雅關閉會無限等待 → 只保護真正的原子邊界。
追問及應對
如何判斷一個 Tokio 原語是否 cancellation safe?
查看其文件是否明確聲明,在 select! 中被取消後再次呼叫不會遺失資料。沒有聲明時按可能遺失部分進度處理,自行保存狀態並寫恢復測試。
逾時後如何知道資料庫寫入是否完成?
使用客戶端請求 ID 和唯一約束查詢結果,或讓服務端提供可查詢的操作狀態。不要僅憑客戶端逾時就再次執行非冪等寫入。
JoinHandle::abort 之後可以立即刪除資源嗎?
只有確認任務已停止且資源的 drop 已完成才安全。等待 join 結果或使用擁有資源的守衛,不能把發出 abort 當作同步完成。
什麼時候應該把工作移到獨立任務?
當工作必須完成、需要跨請求重試或不能在目前取消邊界回滾時,交給持久佇列或獨立任務,並用句柄、狀態儲存和冪等鍵觀察它;不要用獨立任務逃避所有取消語意。