跨时区交接云端 Mac 上的异步代码时,最难复现的一类故障是:界面已经显示超时,测试却迟迟不结束。原因通常不是计时器失效,而是底层操作没有响应取消。把工作和计时器放进任务组,只能建立竞速关系,不能强制中断任意代码。
下面用 Swift 的结构化并发建立可执行基线,再把结果、取消与真正退出分开验收。示例要求支持 Duration 和 Task.sleep(for:) 的工具链,命令行运行环境为 macOS 13 或更新版本;团队交接时应记录实际使用的编译器版本。
先定义超时承诺,而不是先写计时器
将契约写成三条:工作先完成则返回结果;计时分支先被观察到则抛出超时错误;父任务取消时传播取消,不把它转换成业务超时。两个分支接近同时完成时,不承诺固定胜者。
还要区分相对等待与严格截止时间。下面的计时从计时分支执行等待时开始,不代表调用入口之后的绝对响应上限;调度延迟也不能靠一个很短的等待值消除。
超时触发、取消请求发出、底层操作退出,是三个不同事件。只有最后一个发生,任务组才具备结束的条件。
如果业务要求到点后立刻返回,就不能同时声称所有工作仍受这个任务组约束。需要重新设计底层取消接口,或把不可中断工作放进独立进程并管理其生命周期。
实现一个能注入等待逻辑的竞速器
把以下内容保存为 Timeout.swift。等待闭包既能连接真实时钟,也能在测试里换成可控闸门;工作结果必须满足跨任务传递的要求。
import Foundation
enum TimeoutFailure: Error {
case expired
}
func raceTimeout<Value: Sendable>(
wait: @escaping @Sendable () async throws -> Void,
operation: @escaping @Sendable () async throws -> Value
) async throws -> Value {
try Task.checkCancellation()
return try await withThrowingTaskGroup(of: Value.self) { group in
defer { group.cancelAll() }
group.addTask {
try Task.checkCancellation()
return try await operation()
}
group.addTask {
try Task.checkCancellation()
try await wait()
try Task.checkCancellation()
throw TimeoutFailure.expired
}
guard let value = try await group.next() else {
throw CancellationError()
}
try Task.checkCancellation()
return value
}
}
先用编译命令检查类型与并发约束,再接入工程测试:
xcrun swiftc -swift-version 6 -strict-concurrency=complete \
-parse-as-library -emit-module \
-module-name Timeout Timeout.swift \
-emit-module-path /tmp/Timeout.swiftmodule
真实计时的调用片段如下,调用方须处于异步上下文:
let result = try await raceTimeout(
wait: { try await Task.sleep(for: .seconds(2)) },
operation: {
try await Task.sleep(for: .milliseconds(20))
return "ready"
}
)
defer 保证成功和抛错路径都会请求取消剩余分支。计时器取消后会抛错;不要用 try? 吞掉该错误,否则它可能继续执行并被误报成超时。
用闸门固定顺序,避免靠睡眠碰运气
核心单元测试不要写成“等待一点,再猜哪个分支先运行”。建立两个测试闸门,分别控制工作完成和计时完成;另设启动信号,确认两个分支都已进入等待后再释放目标闸门。
闸门应记录取消状态,并通过取消处理器释放等待者。若内部用 continuation,必须保证只恢复一次;取消先到、等待者后注册的顺序也要覆盖。否则测试夹具自身就会制造卡死。
至少保留以下四组断言:
- 工作胜出:返回指定值,并观察到计时等待退出。
- 计时胜出:得到
TimeoutFailure.expired,工作收到取消后退出。 - 工作失败:原始业务错误保留,不能被包装成超时。
- 父任务取消:在两个分支均已启动后取消父任务,检查取消传播及全部退出信号。
每组不仅检查调用结果,还应等待两个分支的结束标记。结果正确但子任务未退出,仍不能算验收通过。
专门证明不响应取消会阻塞退出
增加一个故意忽略取消、但可以由测试释放的工作闸门。先让计时分支获胜,再确认工作已收到取消请求,而外层调用仍未完成;最后释放工作闸门,确认外层才抛出超时。
这个用例证明的是结构化并发的等待语义,不是鼓励忽略取消。测试必须有释放兜底,不能用无限循环模拟卡死,也不能让断言失败后遗留永不恢复的 continuation。
生产代码应沿调用链检查:长循环是否定期执行 Task.checkCancellation();回调适配是否调用底层取消句柄;阻塞调用能否返回。仅在操作入口检查一次,无法覆盖后续长时间工作。
把退出证据一起交接给下一班
在云端 Mac 上运行时,将提交标识、编译器版本、用例名、胜出分支、取消观察和两个退出标记写入测试附件。耗时使用单调时钟计算,不要用不同成员桌面的墙上时间相减。
真实时钟测试只验证生产等待闭包接线正确,给调度保留余量,不要求精确到某一毫秒。测试进程还需独立的执行上限,因为一个不结束的任务组无法依靠自己的计时子任务自救。
最后检查错误处理没有把 CancellationError 全部改写成超时,等待闭包没有吞错,失败路径没有漏掉退出证据。交接完成的标准不是“计时器响过”,而是结果可解释、取消可观察、剩余工作已退出。
常见问题
任务组中的计时分支抛错后,超时函数会立即返回吗?
不一定。离开任务组作用域前仍要等待全部子任务结束;cancelAll 只发送取消请求,不会强制终止不响应取消的操作。
测试超时逻辑必须真的等待几秒吗?
核心契约测试不需要。可以注入可控的等待闭包,用测试闸门决定分支完成顺序;真实时钟只保留少量集成测试,并设置独立的测试执行上限。
为下一项任务选择云端 Mac
按团队主要所在地选择节点,使用整机独享的物理 Mac mini。浏览器远程桌面需要现代浏览器与稳定网络;配置、计费周期和实际可用状态请在下单前核对。