若要独立取消一个定时器等待者,请为每个使用者提供各自的 AsyncStream 和任务句柄。共享 Foundation 定时器,然后将其值分发到不同的流中。这样,取消使用者 A 可以结束 A 的等待,同时让使用者 B 继续。

本教程将构建一个小型 Mac 桌面应用,其中包含两个合成数字显示和一个 Cancel A 按钮。两个显示都从同一个定时器接收值。具体任务是停止 A 的异步消费,同时不停止 B,也不使共享定时器失效。

为什么取消一个等待者会影响其他使用者?

AsyncSequence 描述异步迭代;取消行为取决于具体的序列实现。对于 AsyncStream,当前标准库源码说明,取消一个暂停在 next() 中的任务会终止该流。该调用可能立即返回 nil,也可能在后续调用中返回。对同一流创建新的迭代器,并不会建立独立订阅。Swift 标准库源码:AsyncStream.swift。

因此,所有权很重要。如果两个任务消费同一个流,那么把取消视为只针对一个迭代器的私有操作,并不符合此任务所需的约定。应改为创建两个具有独立 continuation 的流,并明确地将每个生成的值发送给两者。Apple 将 AsyncStream 说明为一种把回调桥接到异步迭代的方式,使用 yield(_:) 提供元素,使用 finish() 结束流。AsyncStream。

任务取消也是协作式的:调用 cancel() 会标记任务并运行其取消处理程序,但不会中断任意代码。使用者应在应用收到的值之前检查取消状态。取消也会传播到子任务,因此取消共同的父任务可能产生比取消预期使用者句柄更广泛的影响。Task.cancel()。

此应用采用以下所有权规则:

操作 预期效果
取消 A 的任务 结束 A 的消费并移除 A 的订阅
结束 A 的 continuation 正常结束 A 的流
取消 B 的任务 独立结束 B 的消费
停止定时器中心 使定时器失效并结束所有剩余流

定时器使用者应接收什么值?

示例使用一个刻意简化的值约定。每次处理定时器回调时,整数从 1 递增到 1,000,然后回绕到 1。每个显示会显示该整数及其平方。例如,示例值 7 会生成 n=7, square=49。这个上限能让计算在反复循环期间保持轻量。

每个订阅只缓冲最新的待处理值。对于只需显示最新合成状态的显示器,这种方式很合适:速度较慢的显示器可能会跳过中间值。如果必须处理每个生成的事件,这种方式就不适用。Apple 介绍了 AsyncStream 的有界缓冲以及默认的无界缓冲。AsyncStream 缓冲。

计数器统计的是已处理的回调,而不是经过的秒数。Foundation 定时器依赖运行循环,可能会延迟触发,也不会重放所有错过的触发。下面的两秒间隔便于观察取消行为,但并不构成精确时钟。Timer。

如何构建独立订阅?

在 Xcode 中使用 macOS SwiftUI 应用目标、Swift 6 工具链,并将最低部署目标设为 macOS 14 或更高版本。添加以下三个文件,替换模板应用入口和视图。这些文件共同组成一个完整示例;不需要外部软件包、网络访问或存储数据。代码已经过检查,但未能在本研究环境中编译。

创建共享定时器中心

定时器中心负责管理定时器和订阅 continuation 字典。每次调用 subscribe() 都会构建一个新流。其终止回调只移除与之匹配的标识符。Apple 说明,取消活动流迭代会调用 onTermination,并以 nil 结束迭代。AsyncStream.Continuation.onTermination。

将以下代码放入 TickHub.swift:

import Foundation

@MainActor
final class TickHub {
    private var timer: Timer?
    private var number = 0
    private var continuations:
        [UUID: AsyncStream<Int>.Continuation] = [:]

    func subscribe() -> (id: UUID, stream: AsyncStream<Int>) {
        let id = UUID()
        let pair = AsyncStream<Int>.makeStream(
            bufferingPolicy: .bufferingNewest(1)
        )

        pair.continuation.onTermination = { [weak self] _ in
            Task { @MainActor [weak self] in
                self?.unsubscribe(id)
            }
        }
        continuations[id] = pair.continuation
        return (id, pair.stream)
    }

    func start() {
        guard timer == nil else { return }
        let newTimer = Timer(
            timeInterval: 2,
            repeats: true
        ) { [weak self] _ in
            Task { @MainActor [weak self] in
                self?.emit()
            }
        }
        newTimer.tolerance = 0.2
        timer = newTimer
        RunLoop.main.add(newTimer, forMode: .common)
    }

    private func emit() {
        guard timer != nil else { return }
        number = number == 1_000 ? 1 : number + 1
        for continuation in continuations.values {
            _ = continuation.yield(number)
        }
    }

    func unsubscribe(_ id: UUID) {
        let continuation = continuations.removeValue(forKey: id)
        continuation?.finish()
    }

    func stop() {
        timer?.invalidate()
        timer = nil
        let remaining = Array(continuations.values)
        continuations.removeAll()
        for continuation in remaining {
            continuation.finish()
        }
    }
}

定时器初始化器会创建一个未安排的定时器,因此定时器中心会将它显式添加到主运行循环中。回调会进入一个主 actor 任务,然后再访问定时器中心状态。timer != nil 检查可防止已排队的回调在关闭后发出值。Timer.init(timeInterval:repeats:block:) 和 RunLoop.add(_:forMode:)。

定时器中心的所有状态都隔离在 MainActor 上,其执行器等同于主分发队列。终止回调会在该处安排清理,而不会从回调的执行上下文直接访问字典。MainActor。

为每个使用者提供单独的任务句柄

将以下代码放入 TimerDemoModel.swift。两次调用 subscribe() 至关重要:它们创建了独立的流生命周期。defer 代码块也会在使用者退出时取消订阅,既涵盖显式退出循环,也涵盖取消任务的情况。

import Combine

@MainActor
final class TimerDemoModel: ObservableObject {
    @Published private(set) var aText = "Waiting"
    @Published private(set) var bText = "Waiting"
    @Published private(set) var aCancelled = false
    @Published private(set) var stopped = false

    private let hub = TickHub()
    private var aTask: Task<Void, Never>?
    private var bTask: Task<Void, Never>?
    private var started = false

    func start() {
        guard !started else { return }
        started = true
        let a = hub.subscribe()
        let b = hub.subscribe()
        let source = hub

        aTask = Task { @MainActor [weak self] in
            defer { source.unsubscribe(a.id) }
            for await number in a.stream {
                guard !Task.isCancelled else { break }
                self?.aText = "n=\(number), square=\(number * number)"
            }
        }

        bTask = Task { @MainActor [weak self] in
            defer { source.unsubscribe(b.id) }
            for await number in b.stream {
                guard !Task.isCancelled else { break }
                self?.bText = "n=\(number), square=\(number * number)"
            }
        }

        hub.start()
    }

    func cancelA() {
        guard !aCancelled, !stopped else { return }
        aCancelled = true
        aTask?.cancel()
    }

    func stopAll() {
        guard !stopped else { return }
        stopped = true
        aTask?.cancel()
        bTask?.cancel()
        hub.stop()
        aTask = nil
        bTask = nil
    }
}

请注意,cancelA() 只执行一次取消操作。它不会调用 hub.stop(),也不会触及 B 的任务。循环会在接收到数字之后、更新显示之前检查 Task.isCancelled,遵循 Swift 文档所述的协作式取消模型。《Swift 编程语言》——并发。

两条清理路径都可能尝试移除 A:终止回调和任务的 defer。这是有意为之。unsubscribe(_:) 会先移除条目,然后结束返回的 continuation。之后再次移除时不会找到任何条目,因此不会影响其他订阅。

添加桌面控件

将以下代码放入 TimerWaiterDemoApp.swift。目标中只保留一个 @main 应用声明。

import SwiftUI

@main
@MainActor
struct TimerWaiterDemoApp: App {
    var body: some Scene {
        WindowGroup {
            TimerDemoView()
        }
    }
}

@MainActor
struct TimerDemoView: View {
    @StateObject private var model = TimerDemoModel()

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Independent timer consumers").font(.headline)
            Text("A: \(model.aText)")
            Text(model.aCancelled ? "A cancelled" : "A active")
            Text("B: \(model.bText)")
            HStack {
                Button("Cancel A") { model.cancelA() }
                    .disabled(model.aCancelled || model.stopped)
                Button("Stop all") { model.stopAll() }
                    .disabled(model.stopped)
            }
        }
        .padding(24)
        .frame(minWidth: 420)
        .onAppear { model.start() }
        .onDisappear { model.stopAll() }
    }
}

@StateObject 会在视图容器的生命周期内为模型提供稳定存储,已发布的更改会更新依赖它的文本视图。视图消失时执行的操作提供了视图级清理;其准确时机取决于视图。显式的 Stop all 按钮也提供了直接关闭操作。StateObject 和 onDisappear(perform:)。

如何验证取消只影响指定使用者?

运行应用并按以下验收步骤操作。下面列出的数字只是你可以观察的示例,并非已记录的测试结果。

  1. 等待两个显示开始变化。它们通常应显示相同的最新数字及其平方。
  2. 在两次定时器触发之间点击 Cancel A。A 的状态会变为 A cancelled,其显示值保持不变。
  3. 再等待至少两次触发。B 应继续变化,而 A 保持不变。
  4. 点击 Stop all。B 也应停止变化。
  5. 重新启动应用,并在 A 收到第一个值之前立即取消 A。A 应保持 Waiting,而 B 会开始接收数字。

这套步骤同时检查了消费开始之后以及第一个有效值到来之前的取消行为。在此设计中,主 actor 上的取消操作和显示更新会按顺序执行。按钮操作之前已完成的更新仍会显示;取消检查则会阻止 A 后续恢复迭代时应用另一个值。

如果行为不符,先检查所有权边界,再更改定时器间隔:

现象 首先检查的内容
取消 A 时 B 也停止 检查是否共享了流,或清理操作是否调用了 hub.stop()
两个显示收到的值各不相同 确认每个使用者都获得单独的 subscribe() 结果
没有值出现 确认定时器已启动,并已添加到应用正在运行的主运行循环中
值偶尔跳过 检查最新值缓冲策略是否符合显示需求
视图消失后工作仍在继续 确认关闭操作已触及两个任务句柄和定时器中心

单次等待者或重启时要做哪些更改?

对于单次等待者,应将其订阅设为私有,在处理第一个符合条件的值后退出循环。保留 defer 取消订阅操作:即使循环通过 break 结束,它也能明确体现预期的订阅生命周期。正常结束的单次等待者应移除自己的订阅,同时让共享生产者继续为 B 提供服务。

要重启 A,请在旧任务完成后创建新的订阅和新任务。不要尝试重置已取消的任务,也不要重复使用已终止的流。在支持重启的界面中,应等待旧任务完成后再安装替代任务,或者以其他方式区分前后两次订阅生命周期,避免旧的清理操作移除新状态。

此示例刻意限制为每个视图实例运行一次。全局关闭会在主 actor 上使重复定时器失效,因为定时器就是在该处安装的。Apple 说明,失效的定时器不能重复使用;要重启,必须创建另一个定时器。Timer 生命周期。

独立取消直接源于独立的订阅所有权。让定时器留在定时器中心,为每个使用者提供单独的流和任务句柄,并在使用者完成时只移除其订阅。这样,桌面控件就能停止一个等待中的显示,同时让其他显示继续接收相同的合成计算结果。

来源