Task Cancellation Shields en Swift 6.4: proteger el cleanup sin ignorar la cancelación
Arturo Rivas Arias
🛡️ Swift 6.4 introduce withTaskCancellationShield(operation:), una API pequeña pero muy interesante dentro del modelo de concurrencia de Swift. Su objetivo no es cambiar cómo funciona la cancelación de tareas, sino cubrir un caso muy concreto: ejecutar un bloque de código para restaurar el estado aunque la tarea ya haya sido cancelada.
🧩 En Swift, la cancelación de tareas es cooperativa. Cancelar una Task no detiene el código de forma brusca. Lo que ocurre es que la tarea queda marcada como cancelada, y el propio código decide cuándo reaccionar consultando Task.isCancelled o llamando a Task.checkCancellation().
func loadDashboard() async throws -> Dashboard {
let summary = try await API.fetchSummary()
try Task.checkCancellation()
let activity = try await API.fetchRecentActivity()
return Dashboard(summary: summary, activity: activity)
}
Este modelo es el adecuado en la mayoría de casos. Si el usuario abandona una pantalla, si una vista de SwiftUI desaparece o si una tarea padre se cancela, normalmente queremos que el trabajo asociado pare cuanto antes.
🧼 El problema aparece con el código de restauración de estado o limpieza. Hay operaciones que no deberían omitirse solo porque la tarea esté cancelada: cerrar un recurso, liberar un bloqueo, revertir una transacción, borrar ficheros temporales o finalizar una sesión iniciada previamente.
Imagina un servicio que genera un informe en un directorio temporal. La generación puede cancelarse, pero el directorio temporal debería eliminarse siempre:
struct ReportExporter {
func exportMonthlyReport() async throws -> URL {
let workspace = try await TemporaryWorkspace.create(prefix: "monthly-report")
defer {
await withTaskCancellationShield {
try? await workspace.remove()
}
}
try Task.checkCancellation()
let rawData = try await ReportAPI.fetchMonthlyData()
let fileURL = try await PDFRenderer.render(rawData, in: workspace)
try Task.checkCancellation()
return fileURL
}
}
La intención es clara: la carga de datos y el renderizado del PDF pueden cancelarse, pero la limpieza del fichero temporal no debería dejar de ejecutarse por estar dentro de una tarea ya cancelada.
🔍 Lo importante es entender qué hace realmente withTaskCancellationShield. Dentro del bloque protegido, Task.isCancelled devuelve false y Task.checkCancellation() no lanza CancellationError, aunque la tarea haya sido cancelada desde fuera. Al salir del bloque, el estado real de cancelación vuelve estar vigente.
let task = Task {
print("Antes:", Task.isCancelled)
await withTaskCancellationShield {
print("Dentro:", Task.isCancelled)
try? Task.checkCancellation()
}
print("Después:", Task.isCancelled)
}
task.cancel()
El escudo no elimina la cancelación. La tarea sigue cancelada. Lo único que cambia es que, durante ese ámbito, el código no la observa mediante las APIs contextuales de la tarea actual.
⚠️ Por eso no conviene usarlo como un modo de “ignorar cancelación”. Este sería un mal uso:
// ❌ Mal uso: protegemos lógica de negocio que sí debería cancelarse
await withTaskCancellationShield {
let products = try await catalogService.fetchProducts()
let recommendations = try await recommendationService.calculate(from: products)
await database.save(recommendations)
}
Si el usuario ha cerrado la pantalla o la tarea padre ya no necesita el resultado, mantener vivo todo ese trabajo puede gastar batería, red y CPU sin necesidad. La regla práctica es sencilla: protege la limpieza y la restauración del estado, no lógica de negocio.
✅ Un uso mucho más razonable sería proteger únicamente la parte que deja el sistema en un estado seguro:
final class InventoryTransaction {
func run() async throws {
let transaction = try await database.beginTransaction()
do {
try await applyStockChanges(using: transaction)
try await transaction.commit()
} catch {
await withTaskCancellationShield {
try? await transaction.rollback()
}
throw error
}
}
}
En este ejemplo, la operación principal sigue respetando la cancelación. Lo que se protege es el rollback, porque dejar una transacción abierta o a medias sería peor que invertir unos milisegundos más en cerrarla correctamente.
🧠 Hay un detalle importante con Task.isCancelled. La propiedad estática Task.isCancelled es contextual y respeta los escudos activos. En cambio, la propiedad de instancia task.isCancelled consulta el estado real del manejador de la tarea, sin tener en cuenta el ámbito protegido.
let task = Task {
await withTaskCancellationShield {
print(Task.isCancelled) // false dentro del escudo
}
}
task.cancel()
print(task.isCancelled) // true desde fuera
Esto puede parecer contradictorio, pero tiene sentido. El código que se ejecuta dentro del escudo ve la cancelación protegida. El código que tiene una referencia a la tarea sigue viendo que esa tarea ha sido cancelada realmente.
👶 También hay matices con tareas hijas. Si una tarea estructurada se crea dentro de un withTaskCancellationShield, no hereda automáticamente la cancelación externa del padre. Sin embargo, eso no significa que sea imposible cancelarla. Una cancelación explícita del propio grupo sigue funcionando.
await withTaskCancellationShield {
await withTaskGroup(of: Void.self) { group in
group.addTask {
print(Task.isCancelled) // false si solo venimos de una cancelación externa protegida
}
group.cancelAll()
group.addTask {
print(Task.isCancelled) // true, porque el grupo se ha cancelado explícitamente
}
}
}
La idea es que el escudo protege frente a la cancelación que viene del contexto exterior, pero no convierte el código en algo imposible de cancelar.
🪤 Otra trampa frecuente está en proteger solo la llamada a addTask. Eso no protege necesariamente el cuerpo de la tarea hija, porque el bloque puede ejecutarse después, cuando el ámbito del escudo ya ha terminado.
await withTaskGroup(of: Void.self) { group in
group.cancelAll()
// ❌ El escudo solo cubre la creación, no el trabajo real de la tarea hija
withTaskCancellationShield {
group.addTask {
print(Task.isCancelled) // true
}
}
}
Si el trabajo de la tarea hija necesita protección, el escudo debe estar dentro del propio cuerpo:
await withTaskGroup(of: Void.self) { group in
group.cancelAll()
// ✅ El código protegido es el que realmente se ejecuta dentro de la tarea hija
group.addTask {
withTaskCancellationShield {
print(Task.isCancelled) // false durante este scope
}
}
}
🧪 Swift 6.4 añade además Task.hasActiveCancellationShield, una propiedad pensada para inspeccionar si la tarea actual se está ejecutando dentro de un escudo de cancelación. La documentación de la librería estándar de Swift la presenta como una herramienta de depuración y comprensión de jerarquías complejas, no como una API para escribir lógica de aplicación.
func logCancellationContext(_ label: String) {
print(label)
print("cancelled:", Task.isCancelled)
print("shield:", Task.hasActiveCancellationShield)
}
let task = Task {
logCancellationContext("Antes")
await withTaskCancellationShield {
logCancellationContext("Dentro")
}
logCancellationContext("Después")
}
task.cancel()
Usarla para trazas, tests o investigación puede ser útil. Usarla para decidir si una app debe ejecutar una rama u otra suele ser una señal de que el diseño de cancelación se está complicando demasiado.
📦 En aplicaciones para sistemas Apple, este tipo de API encaja muy bien con SwiftUI. Muchas tareas están ligadas al ciclo de vida de una vista mediante .task, .refreshable o acciones async lanzadas desde botones. Que esas tareas se cancelen cuando la vista desaparece es correcto. Lo que no siempre es correcto es que esa cancelación impida limpiar recursos que la propia tarea creó antes de cancelarse.
@MainActor
final class BackupViewModel: ObservableObject {
@Published private(set) var status: BackupStatus = .idle
private let service = BackupService()
func startBackup() async {
status = .running
do {
let result = try await service.createBackup()
status = .finished(result)
} catch is CancellationError {
status = .cancelled
} catch {
status = .failed(error)
}
}
}
struct BackupService {
func createBackup() async throws -> BackupResult {
let lock = try await BackupLock.acquire()
defer {
await withTaskCancellationShield {
try? await lock.release()
}
}
try Task.checkCancellation()
let archive = try await ArchiveBuilder.build()
let uploadedURL = try await CloudUploader.upload(archive)
return BackupResult(url: uploadedURL)
}
}
El backup puede cancelarse si la pantalla desaparece o si el usuario interrumpe la operación. Pero el bloqueo adquirido para evitar backups simultáneos debe liberarse igualmente.
🧭 La llegada de await dentro de defer en Swift 6.4 hace que esta API sea mucho más natural. Antes, la limpieza asíncrona obligaba a estructurar el flujo de forma más incómoda, duplicar rutas de salida o lanzar tareas no estructuradas desde lugares poco adecuados. Ahora se puede expresar la intención justo donde corresponde: al lado del recurso que se ha adquirido.
func importLocalDatabase(from url: URL) async throws {
let stagingArea = try await StagingArea.prepare()
defer {
await withTaskCancellationShield {
try? await stagingArea.clean()
}
}
let records = try await DatabaseFileParser.parse(url)
try Task.checkCancellation()
try await stagingArea.write(records)
try await DatabaseImporter.commit(from: stagingArea)
}
Esta combinación de defer asíncrono y withTaskCancellationShield permite escribir código con razonamiento más local, más legible y más seguro ante cancelaciones.
✅ La conclusión es que withTaskCancellationShield no cambia la filosofía de la concurrencia de Swift. La cancelación sigue siendo cooperativa y la mayoría del trabajo debería seguir cancelándose cuanto antes. Lo que aporta Swift 6.4 es una forma explícita de decir: “esta pequeña parte no es trabajo opcional, sino una limpieza necesaria”.
Usado con moderación y en el lugar adecuado, el escudo de cancelación evita estados intermedios peligrosos sin convertir nuestras tareas en procesos zombi. La clave está en mantenerlo alrededor de código corto, acotado y orientado a dejar el sistema limpio: rollback, release, close, remove, unlock.