同一組 XCTest 在本機連續通過,放進平行化流水線後卻偶爾在字典寫入、陣列索引或狀態斷言處崩潰,這通常不是「機器不穩定」,而是共享的可變狀態同時遭到兩條執行路徑存取。資料競爭取決於具體的排程順序,單次重新執行很容易讓問題現場消失。更有效的做法,是在雲端 Mac 上建立獨立的 Thread Sanitizer 測試任務,放大競爭、保留結果套件,再從第一組衝突存取反推狀態的所有權。
先將偵測任務與常規測試分開
Thread Sanitizer 會記錄記憶體存取並維護執行緒關係,因此執行時間與記憶體用量都會增加。不要直接在整條流水線上啟用它。先建立 ConcurrencySanitizer Scheme 或 Test Plan,只納入可能跨執行緒修改狀態的測試,例如快取、下載佇列、資料庫封裝、回呼橋接與平行解析。
建議將執行層級分成三類:
| 層級 | 測試範圍 | 執行時機 |
|---|---|---|
| 快速檢查 | 純函式與一般單元測試 | 每次提交 |
| 競爭檢查 | 高風險並行測試 | 每次合併請求 |
| 擴充檢查 | 完整單元與整合測試 | 獨立週期任務 |
Scheme 必須共享至儲存庫,否則命令列環境無法找到它。提交前可使用 xcodebuild -list -workspace App.xcworkspace 核對名稱。測試目標也應關閉平行測試執行所帶來的隱性隨機性,之後在測試案例內主動製造並行;如此一來,壓力會來自可讀的測試程式碼,而不是無法控制的測試排程器。
使用命令列固定一次可重現的執行
先在雲端 Mac 上確認已安裝的模擬器名稱,再將範例中的裝置替換成實際值。為每個任務配置獨立的 DerivedData,避免兩個執行器改寫同一份索引與建置中間產物。
set -euo pipefail
RUN_ID="${CI_RUN_ID:-local}"
RESULT_DIR="$PWD/Artifacts/tsan"
DERIVED_DATA="$PWD/.derived-data/tsan-$RUN_ID"
mkdir -p "$RESULT_DIR"
xcodebuild test \
-workspace App.xcworkspace \
-scheme ConcurrencySanitizer \
-configuration Debug \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-derivedDataPath "$DERIVED_DATA" \
-enableThreadSanitizer YES \
-resultBundlePath "$RESULT_DIR/result.xcresult"
不要與 Address Sanitizer 同時啟用。兩套插樁疊加後,資源成本與日誌雜訊都會增加,也更難判斷失敗來源。Release 設定通常還包含最佳化與不同的斷言策略,第一輪定位應使用 Debug;確認修正後,再依專案需求補充其他設定的驗證。
一次顯示綠燈的結果,只代表這次排程沒有觸發競爭,並不表示共享狀態已經安全。
主動提高任務交錯的機率
最有價值的壓力測試不是盲目重複執行整個 App,而是針對同一個共享物件,同時進行讀取、寫入、取消與重設。下方測試會讓多個任務競爭更新計數器,適合用來驗證偵測流程是否確實生效。
final class UnsafeCounter: @unchecked Sendable {
private(set) var value = 0
func increment() {
value += 1
}
}
func testConcurrentIncrement() async {
let counter = UnsafeCounter()
await withTaskGroup(of: Void.self) { group in
for _ in 0..<100 {
group.addTask {
counter.increment()
}
}
}
XCTAssertEqual(counter.value, 100)
}
@unchecked Sendable 並不是修正,它只是將責任交給開發者。若實際程式碼依賴它,就應將每個使用位置納入審查。為了提高命中率,可以讓高風險測試重複執行 20 至 50 次,但應設定流水線的總時限。不要加入隨機且長時間的休眠;短暫的 Task.yield() 更適合增加交錯,同時讓任務維持可控。
修正狀態所有權
對於業務狀態,應優先讓單一 Actor 成為唯一寫入者:
actor Counter {
private var value = 0
func increment() {
value += 1
}
func currentValue() -> Int {
value
}
}
呼叫端必須透過 await 存取,所有權邊界也因此進入型別系統。若現有介面必須保持同步,可以使用一條明確的序列佇列,或範圍極小的鎖,但不能讓部分路徑加鎖、其他路徑卻直接讀取。鎖保護的是不變條件,而不只是某一行指派。
從第一組衝突存取開始閱讀報告
Thread Sanitizer 報告通常很長。先忽略後續的連鎖錯誤,只查看第一組 Read 與 Write,或兩次 Write。分別記錄執行緒、佇列、原始碼行號與物件建立位置,再回答三個問題:
- 兩條路徑是否存取同一個實例;
- 預期應由誰擁有該狀態;
- 同步邊界是否涵蓋完整的讀取、修改與寫回流程。
例如,cache[key] = value 看似只有一行,內部卻可能包含查找、擴充容量與寫入。只在呼叫前後分別加上標記,無法形成互斥。同樣地,「先判斷陣列不是空的,再取得第一個元素」必須位於同一個隔離域中,否則在判斷與讀取之間,其他任務仍可能將陣列清空。
請保留 .xcresult,不要只截取終端機最後幾十行。可以先匯出結構化摘要:
xcrun xcresulttool get test-results summary \
--path Artifacts/tsan/result.xcresult \
--format json > Artifacts/tsan/summary.json
命令是否可用會隨本機 Xcode 版本而變化,指令碼應先執行 xcrun xcresulttool help 驗證子命令。結果套件、原始碼提交編號、Scheme 名稱與模擬器執行階段版本都必須一併封存,否則之後很難重建問題現場。
將修正驗收變成穩定的門禁
修正後先執行原始壓力測試,確認 Sanitizer 不再回報競爭;接著執行未插樁的常規測試套件,避免隔離調整引入死結、順序變化或逾時。進行程式碼審查時,還應檢查:
- 是否已移除缺乏依據的
@unchecked Sendable; - 可變集合是否只有一個寫入入口;
- 將回呼轉為
async時,是否可能重複恢復 continuation; Task.detached是否繞過了原有 Actor;- 測試替身與全域單例是否在測試案例之間共享狀態;
- 失敗任務是否完整上傳
.xcresult。
Thread Sanitizer 適合找出執行期間實際發生的競爭,Swift 嚴格並行檢查則負責在編譯期間約束隔離,兩者無法互相取代。將編譯警告、並行壓力測試與 Sanitizer 結果安排在不同階段,失敗原因會更清楚。最終目標不是讓報告消失,而是讓每一份可變狀態都有可解釋、可審查的唯一所有者。
常見問題
Thread Sanitizer 應該在每次提交時執行嗎?
每次提交可執行一組並行風險較高的小型測試,完整的量測測試則放入獨立工作,避免拖慢一般回饋速度。
測試通過是否代表程式沒有資料競爭?
不是。這只代表該次執行沒有觀察到衝突存取。需要增加重複次數、並行交錯與關鍵路徑覆蓋來提高發現機率。
修正競爭時應選 Actor 還是鎖?
相關的可變業務狀態優先交由 Actor 管理。只有臨界區很短、所有權清楚且有實際效能需求時,才考慮使用低階鎖。
在 OpsVM 部署雲端 Mac 工作流程
從三種 Apple Silicon 配置與 6 個在售節點中選擇,實際可用狀態以控制台即時回傳結果為準。