同一组 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 应该在每次提交时运行吗?
不建议覆盖全部测试。每次提交可运行并发风险较高的小型测试集,完整套件安排为独立流水线,避免显著拖慢常规反馈。
测试通过是否代表代码没有数据竞争?
不是。Thread Sanitizer 只能报告本次执行实际触发的冲突访问,需要通过重复运行、增加并发交错和扩大关键路径覆盖来提高发现概率。
发现竞争后应优先使用 Actor 还是锁?
业务状态优先考虑 Actor 或明确的串行隔离;只有同步临界区很小、调用约束清楚且性能证据充分时,再考虑低层锁。
在 OpsVM 部署云端 Mac 工作流
从三档 Apple Silicon 配置与 6 个在售节点中完成选择,实际可用状态以控制台实时返回为准。