雲端 Mac 上的 Swift TaskLocal 脈絡傳遞與隔離測試

雲端 Mac 上的 Swift TaskLocal 脈絡傳遞與隔離測試

雲端 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 獨享實體主機,可選擇日租、週租、月租或季租。先確認規格,再將現有工具鏈連接至節點;實際可用狀態請以控制台即時資訊為準。

立即租用雲端 Mac

從閱讀回到專案

先確認規格,再安排交付

比較機型與租期,或回到部落格繼續閱讀技術文章。