要在同级任务失败时保留成功的 Swift 计算结果,请在每个子任务内部捕获计算错误,并返回一个结果值。这个 Mac 命令行示例会收集所有结果、保留输入顺序,并保留成功的值供后续使用。

此任务是一个小型的合成批量计算:对一组非负整数求平方,并报告哪些输入计算成功。负数输入和整数溢出会分别导致失败。所有计算数据都保留在内存中;程序会在终端中打印结果。

为什么一个错误会中断结果收集?

抛出错误的子任务不会立即取消同级任务。关键的边界在任务组主体:当错误从该主体中逸出时,Swift 会取消其余子任务。Apple 在 withThrowingTaskGroup(of:returning:isolation:body:) 的参考文档中说明了这一区别。

因此,带有外层错误捕获的抛错迭代循环可能会在首次观察到错误时停止收集。较早的值可能已经存储,但循环不会在捕获错误后恢复。若错误从任务组主体中逸出,其余子任务会收到取消信号。

对于相互独立的计算,应明确失败处理策略。下面的示例使用一个不会抛错的任务组,其子任务会返回成功、失败或取消结果。计算本身仍会抛错;其包装代码会在错误到达任务组之前将其转换为一个值。

这符合 Swift 文档所说明的边界:不会抛错的任务组中的子任务无法传播抛出的错误,因此必须在内部处理错误,或将结果表示为返回值。请参阅 Apple 的 TaskGroup 文档。

定义每个输入必须返回的结果

批量处理的约定是每个输入位置对应一个结果。成功的计算会返回精确的整数平方。被拒绝的计算会返回失败说明。若计算因检测到取消而跳过,则会返回取消结果。

情况 结果 批量处理行为
平方可由 Int64 表示的非负输入 包含平方值的成功结果 保留该值
负数输入 失败:negativeInput 继续收集其他结果
平方超出 Int64 范围 失败:overflow 继续收集其他结果
计算开始前检测到取消 已取消 保留已完成的结果,并跳过待处理工作

拒绝负数输入是一条用于说明的应用规则;从数学上说,负数也可以求平方。将这条规则与溢出分开,可以让示例展示两种不同的失败条件。

进行乘法时,使用 multipliedReportingOverflow(by:) 并检查其溢出标志。Apple 说明,只有当该标志为 false 时,返回的部分值才是完整乘积;否则它会被截断。示例会丢弃这个截断值并抛出错误。请参阅 Int64.multipliedReportingOverflow(by:)。

每个结果还会携带其输入索引。Swift 不保证子任务按完成顺序排列,因此仅追加已完成的值无法保留原始顺序。语言指南在 Concurrency 中介绍了这种调度行为。

在 Mac 上运行完整示例

使用安装了 Swift 6 或更高版本工具链的 Mac。在终端中运行 swift --version,按照 Swift.org 的 入门指南所推荐的方式检查已安装的编译器。如果 Swift 尚不可用,请先按照该指南中的安装链接进行安装。

将以下完整程序保存到工作文件夹中的 Calculations.swift。程序导入 Foundation,以便根据当前处理器数量选择初始并发限制。

import Foundation

enum CalculationError: Error {
    case negativeInput
    case overflow
}

enum Outcome: Sendable {
    case success(Int64)
    case failure(String)
    case cancelled
}

struct Row: Sendable {
    let index: Int
    let outcome: Outcome
}

func square(_ input: Int64) throws -> Int64 {
    guard input >= 0 else {
        throw CalculationError.negativeInput
    }
    let product = input.multipliedReportingOverflow(by: input)
    guard !product.overflow else {
        throw CalculationError.overflow
    }
    return product.partialValue
}

func evaluate(index: Int, input: Int64) -> Row {
    do {
        try Task.checkCancellation()
        let value = try square(input)
        return Row(index: index, outcome: .success(value))
    } catch is CancellationError {
        return Row(index: index, outcome: .cancelled)
    } catch {
        return Row(
            index: index,
            outcome: .failure(String(describing: error))
        )
    }
}

func collect(
    _ inputs: [Int64],
    maxConcurrent: Int
) async -> [Row] {
    guard !inputs.isEmpty else { return [] }
    let width = min(inputs.count, max(1, maxConcurrent))

    return await withTaskGroup(of: Row.self) { group in
        var rows: [Row] = []
        rows.reserveCapacity(inputs.count)

        for index in 0..<width {
            let input = inputs[index]
            group.addTask {
                evaluate(index: index, input: input)
            }
        }
        var nextIndex = width

        while let row = await group.next() {
            rows.append(row)

            if Task.isCancelled {
                while nextIndex < inputs.count {
                    rows.append(Row(
                        index: nextIndex,
                        outcome: .cancelled
                    ))
                    nextIndex += 1
                }
            } else if nextIndex < inputs.count {
                let index = nextIndex
                let input = inputs[index]
                nextIndex += 1
                group.addTask {
                    evaluate(index: index, input: input)
                }
            }
        }

        return rows.sorted { $0.index < $1.index }
    }
}

@main
struct CalculationDemo {
    static func main() async {
        let inputs: [Int64] = [3, -2, 4, Int64.max, 0]
        let width = min(
            4,
            max(1, ProcessInfo.processInfo.activeProcessorCount)
        )
        let rows = await collect(inputs, maxConcurrent: width)
        var successes: [Int64] = []

        for row in rows {
            let input = inputs[row.index]
            switch row.outcome {
            case .success(let value):
                successes.append(value)
                print("[\(row.index)] \(input): success \(value)")
            case .failure(let message):
                print("[\(row.index)] \(input): failure \(message)")
            case .cancelled:
                print("[\(row.index)] \(input): cancelled")
            }
        }
        print("Successful values: \(successes)")
    }
}

在该文件夹中编译并运行程序:

swiftc -swift-version 6 -parse-as-library Calculations.swift -o Calculations
./Calculations

-parse-as-library 选项允许这个单一源文件使用显式的 @main 入口点。可执行文件会进行计算,不会读取或写入应用程序数据。研究环境中没有 Swift,因此程序经过了检查,但未在该环境中编译或执行。

对于提供的输入,在没有发生取消的情况下,预期输出为:

[0] 3: success 9
[1] -2: failure negativeInput
[2] 4: success 16
[3] 9223372036854775807: failure overflow
[4] 0: success 0
Successful values: [9, 16, 0]

打印顺序是在收集完成后按输入索引排序得到的。执行顺序可能不同,但不会影响输出格式。成功值数组是在之后构建的,因此完整的结果报告仍可供检查。

收集器如何保留每个成功结果?

父任务拥有结果数组。每个子任务捕获一个索引和一个不可变输入,计算其结果,然后返回一行数据。子任务不会直接向父任务的数组追加内容。这遵循了语言指南中在任务组主体内累积子任务结果的做法。请参阅 Concurrency。

收集器最初只提交有限数量的子任务。每完成一行,就会空出一个位置用于处理另一个输入。因此,计算失败和成功一样都会推进批量处理:两者都会返回一条普通数据行,收集过程也会继续。

Foundation 的 activeProcessorCount 会报告这台电脑上可用的活动处理核心数量。示例将该数量限制为最多四个,这只是用于说明的调度选择。这个上限并非经过测量得出的最优值,也不保证四项计算会同时运行。请参阅 Apple 的 activeProcessorCount 文档。

并发限制会限制尚未完成的子任务数量,但结果数组仍会随着输入数量增长。这适用于小型桌面批量处理。对于这些简单的平方计算,该示例用于展示结果处理方式;它并未声称比顺序循环性能更好。

空输入保护会返回空数组。并发限制为零或负数时会被设为一。重复的输入值仍会分别保留,因为身份由位置决定,所以两个相同的输入仍会产生两行数据。

父任务取消时会发生什么?

Swift 的取消机制是协作式的。取消父任务会将取消状态传播给其结构化子任务,但子任务必须观察到该信号并作出响应。Apple 的 TaskGroup 文档解释了取消传播以及需要协作处理这一要求。

这里,evaluate 会在计算前调用 Task.checkCancellation()。Apple 文档说明,检测到取消时,此方法会抛出 CancellationError;包装代码会单独捕获该错误并返回一条已取消的记录。请参阅 checkCancellation()。

收集器一旦检测到取消,就会将尚未提交的输入标记为已取消。已经提交的子任务仍会被收集完毕。其成功结果会保留在报告中,而检测到取消的子任务会返回已取消记录。

取消可能在子任务检查之后才到达。该子任务仍可能成功完成,而此约定允许这种情况。如果计算变成长循环,请在循环内适当的位置添加取消检查。根据 TaskGroup 的文档,任务组仍会等待所有已提交的子任务完成后才返回。

何时应改用会抛错的任务组?

如果现有子任务已经会抛错,而且你希望保留这种接口,会抛错的任务组同样支持收集各项结果。反复调用 nextResult(),并对每个返回的成功或失败结果进行分支处理,直到可选结果为空。与 next() 不同,此方法返回一个 Result 值,而不是将子任务的错误抛给调用者。Apple 在 nextResult() 中说明了其按完成顺序处理的行为。

采用这种设计时,应在成功值以及需要标明来源的错误中包含输入身份信息。一个没有其他信息的算术错误无法说明多个输入中的哪一个导致了该错误。还应明确选择取消策略,而不是把取消当作普通计算失败处理。

如果主要需求是获得完整的逐项输入报告,请使用返回结果值的方案。如果期望错误从任务组中传播出去,或通过 nextResult() 调用现有的抛错操作更方便,请使用会抛错的任务组。

对于这个本地计算任务,核心约定很简单:保留每个成功值,记录每个失败输入,并持续收集。让每个结果都携带输入索引,即使计算完成顺序难以预测,最终报告仍然有用。

来源