创建一个可变迭代器,并对每个后续值调用 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)")
}
这个版本表达了一个连续的消费会话。如果不需要单独的读取命令,就选择这种方式。如果单独读取是任务结构的一部分,则选择显式迭代器。无论哪种方式,都需要正确构造来源并明确结束条件。
诊断重复、缺失或未完成的读取
按以下顺序检查,可以区分来源生命周期问题与缓冲、完成问题。在添加更多行为前,先对固定示例应用这些步骤。
- 反复得到
4:检查来源构造。 确认每次操作是否都再次调用makeValues(),或访问了会创建新流的 getter。将工厂调用移出重复读取操作。 - 不同消费者收到的值出乎预期:检查所有权。 让此计算只使用一个消费者和一个迭代器。同一个流上的多个迭代器不会获得各自独立的值副本。
- 值缺失:检查缓冲容量。 示例在消费开始前生成了全部三个整数。缓冲区较小时,仍可读取的值会发生变化。
- 最后一次读取始终无法完成:检查完成状态。 对于基于 continuation 的流,仅有空缓冲区并不会结束生产。确保有限生产者调用
finish()。 - 读取操作重叠:串行消费。 等待一次读取完成后,再开始对迭代器进行下一次读取。
在此示例的条件下,缓冲区变化会产生可预期的结果。使用 .bufferingOldest(2) 时,最先的两个值会保留,新值 10 会被丢弃。使用 .bufferingNewest(2) 时,最早缓冲的值 4 会被丢弃,留下 7 和 10。这些结论针对上述生成顺序,根据 Apple 文档中的 bufferingOldest(_:) 和 bufferingNewest(_:) 策略推导而来。
这两种策略都不会让生产者等待消费。Apple 文档指出,yield(_:) 会立即返回,因此保留同一个迭代器无法弥补缓冲区过小的问题。对于这个包含三个元素的有限计算,容量为三就足够;其他生成模式则需要单独决定容量。
应按顺序访问迭代器。Apple 指出,AsyncStream.Iterator 不符合 Sendable,并且对 next() 发起相互竞争的并发调用属于程序员错误。取消操作也会影响 continuation:如果消费任务在等待值时被取消,Apple 的 next() 文档指出流会终止。文档并未说明创建另一个迭代器可以恢复该流。
对于这个本地计算,完整的消费约定很简单:创建一个来源,保留一个可变迭代器,按顺序等待读取,在缓冲区中保留全部三个值,并在遇到 nil 时停止。这些选择明确了推进过程,也使最终总和具有可检验的含义。
来源
- AsyncStream — Apple。
- AsyncIteratorProtocol — Apple。
- AsyncStream.Iterator — Apple。
- next() — Apple。
- yield(_:) — Apple。
- AsyncStream.Continuation.BufferingPolicy.bufferingOldest(_:) — Apple。
- AsyncStream.Continuation.BufferingPolicy.bufferingNewest(_:) — Apple。
- Foundation — Apple。
- 构建命令行工具 — Swift.org。
- AsyncStream.swift — Swift 项目。
- AsyncStream 和 AsyncThrowingStream,SE-0314 — Swift 项目。