雲端 Mac 上的 Swift TaskLocal 脈絡傳遞與隔離測試
同一筆請求經過下載、解析與寫入磁碟三個非同步階段,日誌中的請求識別碼卻時有時無。把識別碼改放在全域變數後,平行測試又開始出現值互相混用的情況。在雲端 Mac 上排查這類問題,先別急著調整執行緒數量:真正需要驗證的是脈絡會傳遞到哪些工作,以及在哪個邊界結束。
本文使用一個可獨立執行的 Swift 套件驗證 TaskLocal,不連接業務服務,也不依賴模擬器。要檢查的是工作的語意,而非工作在哪條執行緒上執行;同一個工作即使切換執行緒,也不應因此遺失工作區域值。
先明訂傳遞契約
將請求識別碼定義為可選字串,預設值為 nil。沒有請求脈絡時保留預設值,比自動產生一個看似有效的識別碼,更容易發現漏傳的問題。
約定五條規則:作用範圍內能讀取綁定值;巢狀作用範圍可以暫時覆寫;離開後恢復外層值;一般子工作與工作群組中的子工作,會繼承建立當下的脈絡;分離工作預設不繼承。即使拋出錯誤,也必須恢復外層狀態。
TaskLocal 不是執行緒區域儲存,也不是全域變數的安全包裝。它傳遞的是工作脈絡,而不是某條執行緒上最後寫入的值。
傳遞的值也應盡量小巧且不可變。本文只傳遞字串,不傳遞可變字典、工作階段物件或資料庫連線。將參考型別物件放進去,並不會自動讓物件具備並行安全性。
建立可獨立執行的測試套件
在雲端 Mac 的一般工作目錄中執行以下命令。先記錄工具鏈版本,後續的失敗報告才能區分問題來自程式碼變更,還是編譯器變更。
mkdir TraceScopeLab
cd TraceScopeLab
swift --version
swift package init --type library --name TraceScopeLab
mkdir -p Tests/TraceScopeLabTests
在測試目錄中新增 ContextTests.swift,寫入以下完整程式碼。預設產生的測試檔案可以保留,兩者互不依賴。範例直接使用工具鏈提供的測試框架,沒有額外相依套件。
import XCTest
private enum TraceContext {
@TaskLocal static var requestID: String?
}
private enum ProbeError: Error {
case expected
}
final class ContextTests: XCTestCase {
func testNestedScopeRestoresAfterThrow() {
XCTAssertNil(TraceContext.requestID)
TraceContext.$requestID.withValue("outer") {
XCTAssertEqual(TraceContext.requestID, "outer")
XCTAssertThrowsError(
try TraceContext.$requestID.withValue("inner") {
XCTAssertEqual(TraceContext.requestID, "inner")
throw ProbeError.expected
}
)
XCTAssertEqual(TraceContext.requestID, "outer")
}
XCTAssertNil(TraceContext.requestID)
}
func testTaskBoundaries() async {
let observed = await TraceContext.$requestID
.withValue("request-a") {
let child = Task {
TraceContext.requestID
}
let detached = Task.detached {
TraceContext.requestID
}
let inherited = await child.value
let absent = await detached.value
let groupValue = await withTaskGroup(
of: String?.self,
returning: String?.self
) { group in
group.addTask {
TraceContext.requestID
}
var result: String?
for await value in group {
result = value
}
return result
}
return (inherited, absent, groupValue)
}
XCTAssertEqual(observed.0, "request-a")
XCTAssertNil(observed.1)
XCTAssertEqual(observed.2, "request-a")
XCTAssertNil(TraceContext.requestID)
}
}
這裡先以 await 取得結果,再傳給斷言,避免將非同步讀取放進同步斷言運算式。所有建立的工作都會等待完成,不讓測試結束後仍有工作留在背景執行。
分離工作必須明確選擇脈絡
Task {} 會繼承工作區域值,但它屬於非結構化工作,生命週期不會自動綁定到建立者的作用範圍。繼承脈絡與自動等待工作結束,是兩件不同的事。
因此,父層作用範圍結束後,先前建立的工作仍可能帶著原本的識別碼。這不代表還原失敗。請求內的工作應優先使用結構化並行;確實需要非結構化工作時,必須保留工作控制代碼,並明確等待或取消。
對獨立的背景作業而言,預設不帶請求脈絡反而更清楚。如果確實需要建立追蹤關聯,先擷取允許傳遞的欄位,再於分離工作內綁定:
let capturedID = TraceContext.requestID
let worker = Task.detached {
TraceContext.$requestID.withValue(capturedID) {
TraceContext.requestID
}
}
let observedID = await worker.value
這段程式碼應放在前述測試檔案的非同步測試方法中。擷取發生在建立端,綁定則發生在新工作中;不要等進入分離工作後,才嘗試讀取原本的脈絡。定時清理等無關作業應保留 nil,避免整批日誌被錯誤地歸到某一次使用者請求。
加入交錯執行的隔離驗證
只有單筆請求通過測試還不夠。新增一個非同步測試方法,同時建立兩個工作,分別綁定 request-a 與 request-b;兩者都在綁定範圍內呼叫 await Task.yield(),恢復執行後回傳目前的識別碼,最後逐一斷言結果。
不要要求兩個工作依固定順序完成。Task.yield() 只是提示可以讓出執行機會,並不保證交錯順序。驗證目標應是「無論誰先完成,各自的值都正確」,而不是「第一筆日誌一定來自 A」。
兩個非同步作用範圍都結束後,也要檢查預設值仍為 nil。不要使用共用陣列收集結果,也不要用全域計數器協調順序,否則測試本身就可能引入競爭條件。
讓失敗報告能定位問題
先執行指定測試,再啟用平行執行複查。第二次執行不能取代明確的隔離斷言,但有助於找出測試之間共用狀態造成的污染。
swift test --filter ContextTests
swift test --parallel --filter ContextTests
如果一般子工作讀到 nil,先核對工作是否確實建立於 withValue 內;如果分離工作讀到識別碼,則檢查是否有明確綁定或額外的包裝層。巢狀作用範圍結束後若未恢復原值,請確認讀取的確實是 TaskLocal,而非同名的全域屬性。
失敗報告只需保留工具鏈版本、測試案例名稱、預期值與實際值。不必將正式環境日誌中的真實請求識別碼複製到測試資料中。
最後,請將追蹤與授權分開:脈絡能協助串聯一次操作,卻不能證明呼叫者有權執行該操作。讓小型值沿著明確的工作邊界傳遞,並將權限作為明確輸入加以驗證,才能讓平行測試的結論長期可信。
常見問題
Task.detached 會繼承 TaskLocal 嗎?
不會。若需要傳遞請求識別碼,應先在建立端讀取明確的值,再於分離工作內用 withValue 建立新的作用範圍。
可以只靠 TaskLocal 判斷使用者權限嗎?
不可以。TaskLocal 適合提供追蹤脈絡,不能取代可信身分驗證。敏感操作仍須明確傳入並驗證授權資訊。
將下一次建置交給獨享雲端 Mac
MacFTP 提供搭載原生 macOS 的 Apple Silicon 獨享實體主機,可選擇日租、週租、月租或季租。先確認規格,再將現有工具鏈連接至節點;實際可用狀態請以控制台即時資訊為準。