云端 Mac 上的 Swift TaskLocal 上下文传播与隔离测试

云端 Mac 上的 Swift TaskLocal 上下文传播与隔离测试

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

立即租用云端 Mac

从阅读回到项目

先核对配置,再安排交付

比较机型与租期,或返回博客继续查阅技术记录。