Cancelación y limpieza: cerrar bien un flujo
Qué le ocurre exactamente a un bucle asíncrono cuando su tarea se cancela, por qué la cancelación es cooperativa y no una interrupción, el contrato de `onTermination` como único punto fiable de liberación, `withTaskCancellationHandler` para secuencias propias y los patrones que evitan suscripciones huérfanas.
Un flujo bien construido que nadie sabe cerrar es una fuga con buena sintaxis. Al otro lado de una AsyncStream casi siempre hay un recurso vivo —un observador registrado, un socket abierto, un temporizador corriendo, un sensor consumiendo batería— y ese recurso no sabe nada de la tarea que lo consume: seguirá trabajando aunque el bucle haya desaparecido, aunque la vista se haya cerrado y aunque el usuario esté ya en otra pantalla. La concurrencia estructurada resuelve la mitad del problema al propagar la cancelación hacia abajo por el árbol de tareas; la otra mitad, la que va del bucle hacia la fuente, la tienes que escribir tú, y el lugar donde se escribe se llama onTermination.
- Describir qué le sucede a un
for awaitcuando la tarea que lo ejecuta se cancela. - Enunciar el contrato de
onTermination: cuándo se llama, con qué motivo y bajo qué garantías. - Implementar cancelación correcta en una
AsyncSequencepropia conwithTaskCancellationHandler. - Detectar suscripciones huérfanas y aplicar los patrones que atan recurso y consumidor.
Cancelar no es interrumpir
La cancelación en Swift es cooperativa: cancel no detiene nada, marca la tarea y despierta sus suspensiones. Quien decide qué hacer con esa marca es el código que está corriendo.
let tarea = Task {
for await p in posiciones { // cada await es un punto de observacion
dibujar(p)
}
print("bucle terminado limpiamente")
}
tarea.cancel()
En un bucle sobre una AsyncStream, la cancelación tiene un comportamiento definido y cómodo: el iterador termina, next devuelve nil, el bucle sale por la puerta normal y el código posterior se ejecuta. No hay excepción que capturar. En un AsyncThrowingStream el desenlace es el mismo salvo que la implementación decida lanzar CancellationError, y ambas variantes son legítimas. Por eso la primera pregunta ante una secuencia ajena es siempre cómo señala la cancelación: terminando o lanzando.
Lo que no ocurre por sí solo es que la fuente se entere. El sensor sigue emitiendo, la notificación sigue registrada, el socket sigue abierto. La cancelación viaja de la tarea al bucle; del bucle a la fuente hay que tender un cable, y ese cable es la clausura de terminación.
Conviene además distinguir tres finales que el código suele tratar igual y que no lo son: agotamiento —la fuente dijo que había acabado—, fallo —un error interrumpió el recorrido— y abandono —el consumidor se marchó por cancelación, por un break o por un return. Los tres deben liberar el recurso; solo el segundo suele merecer un mensaje de error, y solo el tercero puede ocurrir cuando la fuente aún tenía cosas que decir.
Una suscripción huérfana no siempre retiene memoria, así que puede no salir en un análisis de fugas. Se manifiesta como consumo de batería, como trabajo de red que nadie mira o como callbacks que llegan a un objeto que ya no pinta nada. Si tu flujo envuelve algo que gasta, la prueba obligatoria es cancelar el consumidor y verificar que la fuente se detiene de verdad.
El contrato de onTermination
Es la única pieza pensada para esto, y su contrato tiene cinco cláusulas que conviene saber de memoria.
func posiciones(del sensor: Sensor) -> AsyncStream<Posicion> {
AsyncStream { continuation in
sensor.alRecibir = { continuation.yield($0) }
sensor.empezar()
continuation.onTermination = { motivo in
sensor.parar() // se ejecuta pase lo que pase
sensor.alRecibir = nil
}
}
}
Primera: se invoca exactamente una vez, tanto si el flujo terminó con finish como si el consumidor fue cancelado, y también si la continuación se liberó sin cerrar. Segunda: recibe el motivo, .finished o .cancelled, de modo que puedes distinguir un final ordenado de un abandono. Tercera: la clausura es @Sendable y puede ejecutarse en cualquier contexto, incluso durante la destrucción de un objeto, así que no debe suponer aislamiento ni tocar estado de un actor sin más. Cuarta: es síncrona; no puedes esperar dentro de ella. Quinta: si la asignas cuando el flujo ya terminó, se llama de inmediato, lo que evita perder la limpieza por una carrera pero obliga a asignarla pronto.
La cuarta cláusula es la que más molesta en la práctica, porque muchas liberaciones reales son asíncronas: cerrar una sesión, confirmar una escritura, avisar a un actor. La salida es lanzar una tarea sin estructura desde dentro, y aquí hay un detalle fino que casi nadie ve la primera vez.
continuation.onTermination = { _ in
Task { // no hereda la cancelacion del consumidor
await sesion.cerrar()
}
}
Una Task creada así no es hija de la tarea cancelada, de modo que no nace cancelada y puede completar su trabajo. Si en cambio la limpieza se escribiera dentro del cuerpo del bucle consumidor, cualquier await posterior a la cancelación fallaría de inmediato, y la sesión quedaría abierta. Es el motivo por el que el bloque defer de un bucle asíncrono es un mal sitio para liberar recursos con operaciones que suspenden.
flowchart TB A[Tarea consumidora cancelada] --> B[El await del bucle se despierta] B --> C[next devuelve nil o lanza CancellationError] C --> D[El bucle sale y destruye el iterador] D --> E[Se dispara onTermination con motivo cancelled] E --> F[Se detiene la fuente y se libera el recurso] G[La fuente llama finish] --> H[Se entregan los elementos ya encolados] H --> I[Se dispara onTermination con motivo finished] I --> F style A fill:#f38ba8,color:#11111b style F fill:#a6e3a1,color:#11111b
Secuencias propias y cancelación explícita
Si implementas AsyncIteratorProtocol a mano, nada comprueba la cancelación por ti: un next que hace un cálculo largo sin suspender puede ignorar la cancelación indefinidamente. Hay dos herramientas.
La primera es preguntar, en el punto de la iteración donde tenga sentido salir.
mutating func next() async throws -> Elemento? {
try Task.checkCancellation() // lanza si la tarea esta cancelada
guard let crudo = await fuente.siguiente() else { return nil }
return procesar(crudo)
}
La segunda es withTaskCancellationHandler, que ejecuta una clausura en el instante mismo de la cancelación, sin esperar a que la operación llegue a un punto de comprobación. Es lo que hace falta cuando la espera no es un await de Swift sino un recurso externo que debe interrumpirse.
try await withTaskCancellationHandler {
try await lector.leerHastaElFinal()
} onCancel: {
lector.abortar() // sincrono, Sendable, y quiza en otro hilo
}
La clausura de cancelación tiene el mismo perfil que onTermination: síncrona, @Sendable, ejecutable desde cualquier contexto y capaz de dispararse incluso antes de que la operación empiece, si la tarea ya venía cancelada. Todo lo que toque desde ahí debe ser seguro en concurrencia por su cuenta.
Ata el recurso al consumidor
Quien crea la suscripción debe ser quien la cierra, y en el mismo objeto. Un registro hecho en un sitio y deshecho en otro acaba sin deshacerse.
Limpieza sin aislamiento
onTermination no hereda actor ni prioridad. Trátala como código que puede correr en cualquier parte y en cualquier momento.
Ámbito antes que bandera
Prefiere que el flujo viva dentro de una tarea con ámbito claro a controlarlo con variables booleanas repartidas por el tipo.
Patrones que funcionan
Tres idiomas resuelven la mayoría de los casos reales sin inventar nada.
Ámbito de vista. En SwiftUI, el modificador de tarea ata el bucle al ciclo de vida de la vista: al desaparecer, la tarea se cancela, el iterador se destruye y onTermination limpia. No hace falta ninguna bandera ni ningún deinit.
.task {
for await p in modelo.posiciones { modelo.aplicar(p) }
}
Vida del productor. Cuando la fuente vive en un objeto, atar el cierre a su destrucción garantiza que no queden flujos colgando.
deinit { continuacion.finish() }
Tarea guardada. Si el bucle no puede vivir dentro de un ámbito estructurado, guarda la tarea y cancélala explícitamente en el punto donde el trabajo deja de importar. Es la solución menos elegante y a veces la única.
private var vigilancia: Task<Void, Never>?
func empezar() { vigilancia = Task { for await p in flujo { aplicar(p) } } }
func parar() { vigilancia?.cancel(); vigilancia = nil }
Los antipatrones son la imagen especular de estos tres: lanzar el bucle en una tarea desligada que nadie guarda, olvidar onTermination porque la fuente parece inofensiva, y suponer que la cancelación del consumidor detiene por sí sola algo que ocurre al otro lado del adaptador.
Detrás de onTermination hay un problema que ninguna sintaxis puede resolver sola: en un sistema con partes que viven a ritmos distintos, alguien tiene que ser dueño de cada trabajo en curso, y ese dueño debe estar identificado sin ambigüedad en el momento en que el trabajo deja de tener sentido. La cancelación cooperativa es la respuesta de Swift, y su elección de fondo merece atención porque es contraintuitiva: podría haberse implementado como una interrupción, matando la tarea allí donde estuviera, y sería mucho más contundente; pero una interrupción arbitraria deja invariantes rotos —un fichero a medio escribir, un bloqueo tomado, una estructura en estado intermedio— y la experiencia acumulada de décadas, desde los hilos que podían matarse en Java hasta las señales de POSIX, es que la terminación forzosa produce sistemas imposibles de razonar. Al hacerla cooperativa, Swift convierte la cancelación en un dato observable que se propaga por el árbol de tareas y que cada trozo de código atiende cuando sus invariantes están sanos, lo cual traslada una responsabilidad real al programador: si tu código nunca observa la cancelación, tu código no es cancelable, por muchas veces que alguien llame a cancel. La segunda idea, más específica de los flujos, es que la concurrencia estructurada solo estructura lo que ocurre dentro del árbol de tareas, y un adaptador de callbacks es por definición un puente hacia fuera de ese árbol: el sensor, el observador o el socket no son hijos de nadie, no reciben la señal y no la recibirían aunque quisieran. onTermination es exactamente la costura donde se vuelve a coser lo que el puente descosió, y por eso escribirla no es una buena práctica opcional sino la condición para que el flujo tenga siquiera un ciclo de vida definido. Lleva esto a cualquier sistema que construyas, con Swift o sin él: por cada recurso que empieza a trabajar por encargo de alguien, debe existir un camino, único y alcanzable desde todos los finales posibles —éxito, error y abandono—, que lo detenga; y si al mirar tu código no sabes decir en qué línea vive ese camino, es que no existe y solo estás confiando en que el usuario no cierre la pantalla demasiado pronto.
- Cancela la tarea que consume una
AsyncStreamy comprueba con trazas queonTerminationrecibe el motivo.cancelled. - Provoca los tres finales —agotamiento, error y abandono— y verifica que en los tres se libera el recurso una sola vez.
- Elimina
onTerminationde un flujo que envuelve un temporizador y mide el trabajo que sigue ocurriendo tras cancelar. - Implementa un iterador propio con un cálculo largo y hazlo cancelable primero con
Task.checkCancellationy después conwithTaskCancellationHandler. - Escribe una limpieza asíncrona dentro de
onTerminationcon y sin una tarea nueva, y explica por qué una de las dos no llega a completarse.