如果任务强引用所有者,延迟的 Swift 计算就会让其所有者保持存活。使用弱捕获可让所有者在等待期间消失;如果计算应在没有所有者的情况下完成,则捕获独立的值快照。

对于开发小型桌面计算预览的 Mac 开发者来说,实际要做的是有意选择对象的生命周期。本示例短暂等待后,将合成值 [4, 8, 12] 相加,并格式化总和。所有操作都在本地内存中完成。

计算应具有哪种生命周期?

Swift 捕获列表允许你指定闭包保留哪些内容。强捕获会持有所引用的实例;弱引用则允许该实例被释放,之后变为 nil。这些是有明确文档说明的语言行为,与计算等待多久无关。自动引用计数

先选择生命周期约定,再选择捕获语法。下表将这些语言规则应用于延迟的桌面预览:

期望行为 捕获策略 调用方释放其所有者后会发生什么?
使用同一个所有者完成计算 [self] 任务会在执行期间保留所有者。
仅当所有者仍可用时继续 [weak self] 等待后,任务会检查所有者是否仍然存在。
使用计划任务时选定的值完成计算 [samples = self.samples] 任务会保留捕获的值,而不会保留此所有者。

快照一行有个重要前提:本示例捕获的是一个整数数组。捕获列表表达式会在创建闭包时求值。如果捕获的是类实例,则会保留对该实例的引用;这不会为其实例可变内容创建独立副本。表达式

此外,还有一个独立的值选择问题。下面的强捕获和弱捕获版本会在延迟后读取所有者的值。快照版本使用的是计划任务时的值。如果预览输入可能在等待期间发生变化,就要决定结果应代表哪一时刻的输入。

在 Mac 上运行完整的本地对比

使用 macOS 13 或更高版本,以及已安装 Swift 6 或更高版本工具链。此处使用的基于时长的 Task.sleep(for:) 重载有 macOS 13 起的文档说明。它会挂起任务而不阻塞底层线程;如果等待结束前发生取消,则抛出 CancellationError。sleep(for:tolerance:clock:)

将以下完整程序保存为 CaptureDemo.swift。计算器、任务闭包和演示都运行在 @MainActor 上,因此对状态的访问会串行执行。Swift 对类级别的主线程隔离以及此处使用的显式闭包注解都有文档说明。并发

import Foundation

enum CapturePolicy: String {
    case strong, weak, snapshot
}

@MainActor
func formatTotal(_ samples: [Int]) -> String {
    let total = samples.reduce(0, +)
    let formatter = NumberFormatter()
    formatter.numberStyle = .decimal
    formatter.locale = Locale(identifier: "en_US_POSIX")
    formatter.usesGroupingSeparator = false
    formatter.maximumFractionDigits = 0

    guard let text = formatter.string(from: NSNumber(value: total)) else {
        return "Formatting unavailable"
    }
    return "Total: \(text)"
}

@MainActor
final class Calculator {
    var samples = [4, 8, 12]

    func start(_ policy: CapturePolicy) -> Task<String, Error> {
        switch policy {
        case .strong:
            return Task { @MainActor [self] in
                try await Task.sleep(for: .milliseconds(300))
                try Task.checkCancellation()
                return formatTotal(self.samples)
            }

        case .weak:
            return Task { @MainActor [weak self] in
                try await Task.sleep(for: .milliseconds(300))
                try Task.checkCancellation()
                guard let self else {
                    return "Owner unavailable"
                }
                return formatTotal(self.samples)
            }

        case .snapshot:
            return Task { @MainActor [samples = self.samples] in
                try await Task.sleep(for: .milliseconds(300))
                try Task.checkCancellation()
                return formatTotal(samples)
            }
        }
    }
}

@main
struct CaptureDemo {
    @MainActor
    static func demonstrate(
        _ policy: CapturePolicy,
        cancel: Bool = false
    ) async {
        var owner: Calculator? = Calculator()
        weak var probe = owner
        let task = owner!.start(policy)
        owner = nil

        let label = cancel ? "\(policy.rawValue), cancelled" : policy.rawValue
        print("\(label) retains owner: \(probe != nil)")

        if cancel {
            task.cancel()
        }

        do {
            let result = try await task.value
            print(result)
        } catch is CancellationError {
            print("Cancelled")
        } catch {
            print("Calculation failed: \(error)")
        }
    }

    @MainActor
    static func main() async {
        await demonstrate(.strong)
        await demonstrate(.weak)
        await demonstrate(.snapshot)
        await demonstrate(.snapshot, cancel: true)
    }
}

算术部分使用标准库的 reduce(0, +) 将整数合并为一个总和。Foundation 的 NumberFormatter 会将总和转换为显示文本;它的 string(from:) 方法返回可选字符串,因此程序会明确报告格式化失败。示例中的固定数据相加结果为 24。reduce(::)、NumberFormatter、string(from:)

在终端中切换到文件所在文件夹,然后运行以下命令。目标平台为 Mac 的处理器架构指定 macOS 13。-parse-as-library 选项允许文件使用显式的 @main 入口点。Swift 编译器接受源文件和命令行选项;swiftc -help 会列出工具链中已安装的选项。Swift 命令行手册

swift --version
swiftc -parse-as-library -swift-version 6 -target "$(uname -m)-apple-macos13.0" CaptureDemo.swift -o CaptureDemo
./CaptureDemo

下面展示预期的应用程序输出。该输出依据捕获和取消规则推导得出;本稿的研究环境中没有可用的 Swift 编译器,因此这里只检查了程序,未实际运行。

强捕获保留所有者: true
总计: 24
弱捕获保留所有者: false
所有者不可用
快照保留所有者: false
总计: 24
快照已取消时保留所有者: false
已取消

弱 probe 会观察所有权,但不会添加另一个强引用。每段演示都会在等待任务前清除自己的强引用。由于演示和任务闭包使用同一个 actor,新创建的闭包无法与这段同步设置代码交错执行。因此,这种对比不依赖于猜测任务何时启动。并发

为什么弱引用检查的位置很重要?

在弱捕获分支中,guard let self 出现在延迟之后。因此,任务可以在不强持有所有者的情况下等待。如果所有者已经消失,显式的 Owner unavailable 结果就会结束这次计算。

如果将解包移到延迟之前,生命周期就会改变。下面是替换弱捕获分支闭包主体的示意片段,并非独立程序:

// Inside the existing [weak self] task closure:
guard let self else {
    return "Owner unavailable"
}
try await Task.sleep(for: .milliseconds(300))
try Task.checkCancellation()
return formatTotal(self.samples)

在这里,可选绑定成功后会创建一个强局部引用,因为挂起后仍需再次使用它。因此,一旦绑定成功,所有者就会在等待期间保持可用。这符合 Swift 的强引用规则:弱捕获决定闭包最初如何访问所有者,而解包后的局部变量决定后续代码如何持有它。自动引用计数

如果约定是“等待时不保留所有者,之后仅在所有者仍存在时计算”,就应将解包放在等待之后,并紧接着执行这项简短计算。解包后如果还要经过额外的挂起点,就需要重新考虑生命周期。

unowned 捕获要求每次访问时被引用的实例都仍然有效。对于本示例这种有意允许所有者在延迟期间释放的情况,它无法提供所需的可选缺失检查。自动引用计数

释放任务句柄会停止计算吗?

即使丢弃任务句柄,任务仍会继续运行。保留句柄后,你可以等待结果,也可以请求取消。Apple 还说明,任务完成后会立即释放其操作闭包以及该闭包持有的引用。因此,有限时长的强捕获会让所有者在任务执行期间保持存活,但不一定会造成持久的引用循环。Task

取消与所有权是两个不同的约定。最后一段演示通过句柄请求取消。会抛错的休眠操作以及之后的 Task.checkCancellation() 提供了操作可以停止的时机,调用方则会报告结果。Swift 的取消采用协作式机制:发出取消请求并不会强行中断任意计算代码。Task

如果取消应阻止产生结果,就不要将会抛错的休眠替换为 try?,然后无条件继续执行。忽略该错误会让程序继续进行计算,除非另有取消检查将其停止。同样,将 nil 赋给所有者变量也不是取消请求。

对于桌面预览,这些区别提供了三种有用的控制方式:所有者是否继续被分配、计算使用哪些输入值,以及操作是否应停止。请分别明确选择,不要指望弱捕获能同时实现这三点。

桌面预览需要做哪些修改?

如果待处理的预览只有在所有者仍可用时才有价值,就使用弱捕获版本。如果选定的合成输入应独立生成结果,就使用快照版本。如果完成操作确实需要所有者,就使用强捕获版本。

对象生命周期也不同于界面可见性。窗口关闭本身并不能证明指向其计算器的所有强引用都已移除。如果预览关闭时应用程序应停止待处理的工作,就将该应用事件关联到所保留任务句柄的 cancel() 方法;计算本身仍需配合取消机制。Task

请让本示例的工作量保持较小。主线程隔离让状态访问更简单,等待操作会挂起,但同步求和仍在该 actor 上执行。Swift 并发指南说明,主线程 actor 也负责处理界面工作。若计算量大得多,就需要另行设计执行方式,并向工作线程传递合适的值。并发

对于这项本地计算,关键测试很简单:在延迟操作运行前移除调用方的引用,然后观察结果。强捕获会保留所有者;弱捕获允许产生明确的不可用结果;整数数组快照则只保留完成计算所需的输入。

来源