工程指南

云端 Mac CI 用 Thread Sanitizer 定位 Swift 数据竞争

云端 Mac CI 用 Thread Sanitizer 定位 Swift 数据竞争

同一组 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 报告往往很长。先忽略后续连锁错误,只看第一组 ReadWrite 或两次 Write。分别记录线程、队列、源码行和对象创建位置,再回答三个问题:

  1. 两条路径是否访问同一个实例;
  2. 预期由谁拥有该状态;
  3. 同步边界是否覆盖完整的读改写过程。

例如 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 个在售节点中完成选择,实际可用状态以控制台实时返回为准。

选择配置并下单