Para cancelar un esperador de temporizador de forma independiente, dale a cada consumidor su propio AsyncStream y un identificador de tarea. Comparte el temporizador de Foundation y distribuye sus valores en flujos separados. Así, al cancelar el consumidor A, puede terminar su espera mientras el consumidor B continúa.
Este tutorial crea una pequeña aplicación de escritorio para Mac con dos indicadores numéricos sintéticos y un botón Cancel A. Ambos indicadores reciben valores de un mismo temporizador. La tarea concreta consiste en detener el consumo asíncrono de A sin detener B ni invalidar el temporizador compartido.
¿Por qué cancelar a un esperador puede afectar a otros consumidores?
AsyncSequence describe la iteración asíncrona; el comportamiento de cancelación depende de la implementación concreta de la secuencia. En el caso de AsyncStream, el código fuente actual de la biblioteca estándar documenta que cancelar una tarea suspendida en next() termina el flujo. La llamada puede devolver nil de inmediato o en llamadas posteriores. Crear un iterador nuevo sobre ese mismo flujo no establece una suscripción independiente. Código fuente de la biblioteca estándar de Swift: AsyncStream.swift.
Por eso es importante la propiedad de los recursos. Si dos tareas consumen el mismo flujo, tratar la cancelación como una operación privada de un solo iterador no es el contrato adecuado para esta tarea. En su lugar, crea dos flujos con continuaciones separadas y envía explícitamente cada valor generado a ambos. Apple describe AsyncStream como un puente entre las devoluciones de llamada y la iteración asíncrona, que usa yield(_:) para proporcionar elementos y finish() para finalizar un flujo. AsyncStream.
La cancelación de tareas también es cooperativa: llamar a cancel() marca la tarea y ejecuta sus controladores de cancelación, pero no interrumpe código arbitrario. El consumidor debe comprobar la cancelación antes de aplicar un valor recibido. La cancelación también se propaga a las tareas secundarias, por lo que cancelar un padre común puede tener un efecto más amplio que cancelar el identificador del consumidor previsto. Task.cancel().
Para esta aplicación, aplica las siguientes reglas de propiedad:
| Operación | Efecto previsto |
|---|---|
| Cancelar la tarea de A | Terminar el consumo de A y eliminar su suscripción |
| Finalizar la continuación de A | Terminar normalmente el flujo de A |
| Cancelar la tarea de B | Terminar el consumo de B de forma independiente |
| Detener el concentrador del temporizador | Invalidar el temporizador y finalizar todos los flujos restantes |
¿Qué valores deben recibir los consumidores del temporizador?
El ejemplo usa un contrato de valores deliberadamente sencillo. Cada llamada procesada del temporizador incrementa un entero del 1 al 1.000 y vuelve a 1 al llegar al límite. Cada indicador muestra ese entero y su cuadrado. Por ejemplo, un valor ilustrativo de 7 produce n=7, square=49. El límite mantiene pequeño el cálculo durante los ciclos repetidos.
Cada suscripción almacena en búfer solo el valor pendiente más reciente. Esto es adecuado para un indicador cuya función es mostrar el último estado sintético: un indicador lento puede omitir valores intermedios. No es adecuado cuando deben procesarse todos los eventos generados. Apple documenta tanto el almacenamiento en búfer limitado como el búfer ilimitado predeterminado de AsyncStream. Almacenamiento en búfer de AsyncStream.
El contador mide las llamadas procesadas, no el tiempo transcurrido. Los temporizadores de Foundation dependen de su bucle de ejecución, pueden dispararse tarde y no reproducen todas las activaciones omitidas. El intervalo de dos segundos de abajo facilita observar la interacción con la cancelación; no establece un reloj preciso. Timer.
¿Cómo se crean las suscripciones independientes?
Usa en Xcode un destino de aplicación SwiftUI para macOS, con una cadena de herramientas de Swift 6 y un destino de implementación de macOS 14 o posterior. Añade los siguientes tres archivos, sustituyendo el punto de entrada de la aplicación y la vista de la plantilla. En conjunto forman un ejemplo completo; no requieren paquetes externos, acceso a la red ni datos almacenados. El código se ha revisado, pero no se ha compilado en este entorno de investigación.
Crear el concentrador del temporizador compartido
El concentrador es propietario del temporizador y de un diccionario de continuaciones de suscripción. Cada llamada a subscribe() crea un flujo nuevo. Su función de terminación elimina solo el identificador correspondiente. Apple documenta que cancelar la iteración activa de un flujo invoca onTermination y termina la iteración con nil. AsyncStream.Continuation.onTermination.
Pon este código en 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()
}
}
}
El inicializador del temporizador crea uno sin programar, así que el concentrador lo añade explícitamente al bucle de ejecución principal. La llamada de retorno entra en una tarea del actor principal antes de acceder al estado del concentrador. La comprobación timer != nil evita que una llamada de retorno ya encolada emita valores después del apagado. Timer.init(timeInterval:repeats:block:) y RunLoop.add(_:forMode:).
Todo el estado del concentrador está aislado al MainActor, cuyo ejecutor equivale a la cola principal de despacho. La función de terminación programa allí su limpieza, en vez de acceder directamente al diccionario desde el contexto de ejecución de la llamada de retorno. MainActor.
Dar a cada consumidor un identificador de tarea independiente
Pon el siguiente código en TimerDemoModel.swift. Las dos llamadas a subscribe() son esenciales: crean ciclos de vida de flujo independientes. Los bloques defer también cancelan la suscripción cuando un consumidor termina, tanto si el bucle sale explícitamente como si se cancela.
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
}
}
Observa que cancelA() realiza exactamente una operación de cancelación. No llama a hub.stop() ni accede a la tarea de B. El bucle comprueba Task.isCancelled después de recibir un número y antes de cambiar el indicador, siguiendo el modelo de cancelación cooperativa documentado por Swift. El lenguaje de programación Swift — Concurrencia.
Las dos rutas de limpieza pueden intentar eliminar A: la función de terminación y defer de la tarea. Aquí es intencional. unsubscribe(_:) primero elimina la entrada y luego finaliza la continuación obtenida. Una eliminación posterior no encuentra nada y deja intactas las demás suscripciones.
Añadir los controles de escritorio
Pon este código en TimerWaiterDemoApp.swift. Mantén una sola declaración de aplicación @main en el destino.
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 proporciona al modelo un almacenamiento estable durante el ciclo de vida del contenedor de vistas, y los cambios publicados actualizan las vistas de texto dependientes. La acción al desaparecer ofrece limpieza a nivel de vista; su momento exacto depende de la vista. El botón explícito Stop all también permite apagarlo directamente. StateObject y onDisappear(perform:).
¿Cómo se comprueba que la cancelación se limita a A?
Ejecuta la aplicación y sigue esta secuencia de aceptación. Los números de abajo son observaciones ilustrativas que debes buscar, no resultados de pruebas registrados.
- Espera a que avancen ambos indicadores. Normalmente deberían mostrar el mismo número más reciente y su cuadrado.
- Haz clic en
Cancel Aentre activaciones del temporizador. El estado de A cambia aA cancelledy su valor mostrado queda fijo. - Espera al menos dos activaciones más. B debería seguir avanzando mientras A permanece fijo.
- Haz clic en
Stop all. B también debería dejar de avanzar. - Vuelve a iniciar la aplicación y cancela A inmediatamente, antes de que reciba su primer valor. A debería permanecer en
Waiting, mientras B empieza a recibir números.
Esta secuencia comprueba la cancelación tanto después de que comience el consumo como antes de que llegue el primer valor útil. En este diseño, la acción de cancelación y las actualizaciones del indicador en el actor principal se serializan. Una actualización completada antes de la acción del botón sigue visible; la comprobación de cancelación impide que una iteración posterior de A aplique otro valor.
Si el comportamiento es distinto, revisa el límite de propiedad antes de cambiar el intervalo del temporizador:
| Síntoma | Primer aspecto que debes revisar |
|---|---|
| B se detiene al cancelar A | Busca un flujo compartido o una limpieza que llame a hub.stop() |
| Los indicadores reciben subconjuntos distintos de valores | Confirma que cada consumidor obtiene un resultado subscribe() independiente |
| No aparecen valores | Confirma que se inició el temporizador y que se añadió al bucle de ejecución principal activo de la aplicación |
| A veces se omiten valores | Comprueba si la política de almacenamiento en búfer del valor más reciente se ajusta a los requisitos del indicador |
| El trabajo continúa después de que desaparezca la vista | Confirma que el apagado llega a ambos identificadores de tarea y al concentrador |
¿Qué cambia en el caso de un esperador de una sola ejecución o de un reinicio?
Para un esperador de una sola ejecución, mantén privada su suscripción, procesa el primer valor aceptable y sal del bucle. Conserva la operación para cancelar la suscripción defer: deja explícito el ciclo de vida previsto de la suscripción, incluso cuando el bucle termina mediante break. Una finalización normal de una sola ejecución debería eliminar esa suscripción y dejar el productor compartido disponible para B.
Para reiniciar A, crea una suscripción nueva y una tarea nueva después de que termine la tarea anterior. No intentes restablecer la tarea cancelada ni reutilizar su flujo terminado. En una interfaz que permita reiniciar, espera a que finalice la tarea anterior antes de instalar un reemplazo o distingue de otro modo los ciclos de vida de suscripción sucesivos para que la limpieza antigua no pueda eliminar el estado nuevo.
El ejemplo ofrece deliberadamente una sola ejecución por instancia de vista. El apagado global invalida el temporizador repetitivo en el actor principal, donde se instaló. Apple documenta que los temporizadores invalidados no se pueden reutilizar; para reiniciar, hay que crear otro temporizador. Ciclo de vida de Timer.
La cancelación independiente se deriva directamente de la propiedad independiente de cada suscripción. Mantén el temporizador en el concentrador, da a cada consumidor un flujo y un identificador de tarea separados, y elimina solo la suscripción de ese consumidor cuando termine. Así, un control de escritorio puede detener un indicador en espera mientras el otro sigue recibiendo el mismo cálculo sintético.
Fuentes
- AsyncStream.swift — Proyecto Swift.
- AsyncStream — Apple.
- onTermination — Apple.
- cancel() — Apple.
- Concurrencia — Proyecto Swift.
- Timer — Apple.
- init(timeInterval:repeats:block:) — Apple.
- add(_:forMode:) — Apple.
- MainActor — Apple.
- StateObject — Apple.
- onDisappear(perform:) — Apple.