同一条请求经过下载、解析和落盘三个异步阶段,日志里的请求标识却时有时无。把标识改成全局变量后,并行测试又开始串值。在云端 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 独享物理机,按天、周、月、季选择租期。先确认配置,再将现有工具链接入节点;实际可用状态以控制台实时返回为准。