创建一个可变迭代器,并对每个后续值调用 await iterator.next()。对于简单的 Mac 命令行计算,将流和迭代器都置于重复读取操作之外,并将 nil 视为完成。

本指南使用合成整数 4、7 和 10,将每个值平方,并检查总和是否为 165。计算只使用内存中创建的值。其目的是通过一个小程序清楚展示迭代器的生命周期、缓冲和完成状态。

值重复时,究竟是什么重新开始了?

Swift 将异步序列与其迭代器分开。序列通过 makeAsyncIterator() 提供迭代器,迭代器则通过调用 next() 向前推进。每次调用都会返回一个元素;迭代结束时则返回 nil。Apple 在 AsyncIteratorProtocol 中说明了这一约定。

当应用反复收到第一个值时,需要检查两种不同的操作:创建另一个迭代器,以及创建另一个流。两者不能混为一谈。在当前标准库实现中,AsyncStream 迭代器持有流的共享上下文,而 makeAsyncIterator() 会将现有上下文传给迭代器。因此,在同一个流上创建另一个迭代器,并不会从头开始独立重放。参见官方 AsyncStream 实现。

相比之下,调用一个创建新流并依次生成 4、7 和 10 的工厂,会创建一个包含这些值的另一个来源。如果每次读取都调用该工厂并只消费一个元素,那么每次读取都可能收到 4。在计算属性的 getter 中创建流也会产生同样的效果。对于此任务,只构造一次来源,并在整个计算过程中保留同一个迭代器。

决定将代码移到哪里时,请利用这一区别:将流的创建移出重复操作,以保留来源;将迭代器的创建也移出该操作,以保持清晰一致的消费路径。不要把反复创建迭代器当作重启机制或保持进度的方法。

在 Mac 上运行完整的本地计算

使用安装了 Swift 6 或更新工具链的 Mac。在 Terminal 中检查已安装的编译器,然后在新目录中创建一个小型可执行程序包。Swift.org 的构建命令行工具指南介绍了版本检查、程序包创建和 swift run 工作流程。

swift --version
mkdir IteratorDemo
cd IteratorDemo
swift package init --name IteratorDemo --type executable

在 Sources 下找到生成的可执行程序源文件。将其内容替换为下面的完整程序。将源文件命名为 IteratorDemo.swift;如果模板创建了 main.swift,请重命名并删除其原有内容。只保留一个可执行程序入口点。Swift.org 说明,同一个可执行程序中不能同时使用 main.swift 入口点和 @main 入口点。

此示例不需要导入任何模块:AsyncStream 属于 Swift,而 Foundation 是一个独立框架。此计算不需要 Foundation API。Apple 的 AsyncStream 文档和 Foundation 概览介绍了这些界限。

@main
struct IteratorDemo {
    static func makeValues() -> AsyncStream<Int> {
        AsyncStream<Int>(bufferingPolicy: .bufferingOldest(3)) {
            continuation in
            for value in [4, 7, 10] {
                let result = continuation.yield(value)
                guard case .enqueued = result else {
                    preconditionFailure("A sample value was not enqueued")
                }
            }
            continuation.finish()
        }
    }

    static func main() async {
        let stream = makeValues()
        var iterator = stream.makeAsyncIterator()
        var received: [Int] = []
        var squareTotal = 0

        for readNumber in 1...3 {
            guard let value = await iterator.next() else {
                preconditionFailure("Stream ended before read \(readNumber)")
            }

            received.append(value)
            squareTotal += value * value
            print("Read \(readNumber): \(value)")
        }

        let end = await iterator.next()
        let stillEnded = await iterator.next()

        precondition(received == [4, 7, 10])
        precondition(squareTotal == 165)
        precondition(end == nil)
        precondition(stillEnded == nil)

        print("Square total: \(squareTotal)")
        print("Finished: \(end == nil)")
    }
}

在程序包目录中运行可执行程序:

swift run IteratorDemo

除构建信息外,预期的应用程序输出如下:

Read 1: 4
Read 2: 7
Read 3: 10
Square total: 165
Finished: true

这里的算术仅用于说明:4 × 4 + 7 × 7 + 10 × 10 = 165。这些断言会检查具体值、顺序、计算结果以及终止行为。研究环境中没有 Swift 编译器,因此这里仅检查了程序,并未实际运行。

为什么这个迭代器能正确持续推进?

程序调用了一次 makeValues(),也调用了一次 makeAsyncIterator()。三次读取都使用同一个可变变量。Apple 将迭代器的 next() 方法声明为异步变异方法,这也解释了为何使用 var iterator 和 await iterator.next()。

生产者在构造流时就生成了三个值,早于消费开始的时间。因此,缓冲容量是此示例约定的一部分:.bufferingOldest(3) 足以容纳全部三个整数。检查每次 yield 的结果,可以让意外的入队失败显现出来,避免计算在值缺失的情况下继续进行。Apple 文档指出,当没有读取操作等待时,yield(_:) 会尝试缓冲一个值。

在这些值生成后调用 finish(),会关闭生产,同时允许消费已缓冲的值。Swift 提案明确指出,缓冲的元素会先传给消费者,然后才到达终止状态 nil。因此,尽管生产者已经结束,前三次读取仍会成功。参见 AsyncStream 和 AsyncThrowingStream,SE-0314。

第四次读取会发现流已结束且缓冲区已耗尽,并返回 nil。第五次读取确认了终止约定:迭代器返回 nil 后,后续读取也必须返回 nil。不要将 nil 理解为稍后重试或重建迭代器的指令;AsyncIteratorProtocol 将其定义为迭代结束。

选择显式读取还是连续循环

当外围操作需要在单次读取之间进行控制时,就像这个按顺序编号的计算一样,请使用显式的 next() 调用。将迭代器保存在负责整个消费会话的作用域内。每次调用时都自行创建迭代器的辅助函数会掩盖迭代器的归属;同时重建流的辅助函数则会改变来源本身。

如果任务只是处理每个元素直到结束,请使用一个 for await 循环。Apple 的 AsyncStream 概览展示了这种受支持的消费形式。对于示例程序,下面这段代码可替换整个现有的 main() 方法;保留现有的 makeValues() 方法及其外围类型:

static func main() async {
    let stream = makeValues()
    var received: [Int] = []
    var squareTotal = 0

    for await value in stream {
        received.append(value)
        squareTotal += value * value
    }

    precondition(received == [4, 7, 10])
    precondition(squareTotal == 165)
    print("Square total: \(squareTotal)")
}

这个版本表达了一个连续的消费会话。如果不需要单独的读取命令,就选择这种方式。如果单独读取是任务结构的一部分,则选择显式迭代器。无论哪种方式,都需要正确构造来源并明确结束条件。

诊断重复、缺失或未完成的读取

按以下顺序检查,可以区分来源生命周期问题与缓冲、完成问题。在添加更多行为前,先对固定示例应用这些步骤。

  1. 反复得到 4:检查来源构造。 确认每次操作是否都再次调用 makeValues(),或访问了会创建新流的 getter。将工厂调用移出重复读取操作。
  2. 不同消费者收到的值出乎预期:检查所有权。 让此计算只使用一个消费者和一个迭代器。同一个流上的多个迭代器不会获得各自独立的值副本。
  3. 值缺失:检查缓冲容量。 示例在消费开始前生成了全部三个整数。缓冲区较小时,仍可读取的值会发生变化。
  4. 最后一次读取始终无法完成:检查完成状态。 对于基于 continuation 的流,仅有空缓冲区并不会结束生产。确保有限生产者调用 finish()。
  5. 读取操作重叠:串行消费。 等待一次读取完成后,再开始对迭代器进行下一次读取。

在此示例的条件下,缓冲区变化会产生可预期的结果。使用 .bufferingOldest(2) 时,最先的两个值会保留,新值 10 会被丢弃。使用 .bufferingNewest(2) 时,最早缓冲的值 4 会被丢弃,留下 7 和 10。这些结论针对上述生成顺序,根据 Apple 文档中的 bufferingOldest(_:) 和 bufferingNewest(_:) 策略推导而来。

这两种策略都不会让生产者等待消费。Apple 文档指出,yield(_:) 会立即返回,因此保留同一个迭代器无法弥补缓冲区过小的问题。对于这个包含三个元素的有限计算,容量为三就足够;其他生成模式则需要单独决定容量。

应按顺序访问迭代器。Apple 指出,AsyncStream.Iterator 不符合 Sendable,并且对 next() 发起相互竞争的并发调用属于程序员错误。取消操作也会影响 continuation:如果消费任务在等待值时被取消,Apple 的 next() 文档指出流会终止。文档并未说明创建另一个迭代器可以恢复该流。

对于这个本地计算,完整的消费约定很简单:创建一个来源,保留一个可变迭代器,按顺序等待读取,在缓冲区中保留全部三个值,并在遇到 nil 时停止。这些选择明确了推进过程,也使最终总和具有可检验的含义。

来源