Saltar al contenido
K
Swift: memoria y concurrenciaPaso a paso33 min de lectura · 5 min de máquina Intermedio

Cancelar una tarea

Al terminar: Cancelar una Task y hacer que el código lo respete.

Al terminar tendrás en el paquete Torneo una probabilidadDeVictoria que se deja cancelar, un main.swift que cancela un partido a los 100 ms y siete tests de async, entre ellos el que comprueba que cancelar corta el partido. Sabrás por qué cancel() no detiene nada por sí solo y qué tiene que hacer tu código para enterarse.

Importa porque en una app todo lo que arrancas hay que poder pararlo: el usuario sale de la pantalla, la descarga ya no hace falta, el partido en directo se cierra. Una tarea que ignora la cancelación sigue gastando batería y red para nadie.

Temas: cancelación cooperativa, Task.checkCancellation(), Task.isCancelled, result, URLSession y el error de una descarga cancelada.

La cancelación es cooperativa

tarea.cancel() no detiene nada: solo marca la tarea como cancelada. Quien tiene que enterarse es el código de dentro. Task.sleep mira la marca y lanza CancellationError, un error normal que sube por la función. Un bucle que solo calcula, sin esperar nunca, tiene que preguntar él mismo con try Task.checkCancellation(); si no, sigue hasta el final aunque lo hayan cancelado.

ejemplo mínimo: cancelar una tarea (con comentarios)
// Una tarea que da cinco vueltas, con una pausa de 100 ms entre una y otra.
let larga = Task {
    for i in 1...5 {
        try Task.checkCancellation()                     // ¿me han cancelado? Si sí, lanza el error
        print("vuelta \(i)")
        try await Task.sleep(for: .milliseconds(100))    // la pausa también mira la marca
    }
}
// Esperamos 250 ms: caben tres vueltas.
try? await Task.sleep(for: .milliseconds(250))
larga.cancel()                                           // solo la marca
// `result` espera a que la tarea termine y la entrega como un `Result`.
let resultado = await larga.result
if case .failure(let error) = resultado {
    print("Cancelada:", error is CancellationError)
}

Salida:

vuelta 1
vuelta 2
vuelta 3
Cancelada: true

await larga.result espera a que la tarea termine y devuelve un Result: .success con lo que devolvió, o .failure con el error con el que salió. Aquí acaba en .failure porque la tarea salió con CancellationError. Result ya lo conoces de Errores: throws, try y do/catch; is contesta si el error es de ese tipo. Y if case .failure(let error) = resultado es «si el resultado es un fallo, llama error a su error»; se puede escribir también if case let .failure(error) = … (con let delante): es lo mismo.

Las tres formas de preguntar

KotlinSwift
job.cancel()tarea.cancel(): solo marca
ensureActive(): lanza si te cancelarontry Task.checkCancellation(): lanza CancellationError
isActive: true o false, sin lanzarTask.isCancelled: true si cancelaron la tarea actual, sin lanzar

Task.isCancelled es la pregunta sin lanzar nada: contesta true o false y tú decides qué hacer (salir del bucle, limpiar, devolver lo que lleves). Sirve cuando quieres terminar con elegancia en vez de con un error.

ejemplo mínimo: Task.isCancelled (con comentarios)
// Una tarea que espera 50 ms y después mira si la cancelaron.
// Task<String, Never>: el tipo de una tarea tiene dos partes, lo que devuelve
// (aquí un String) y el error con el que puede fallar. `Never` significa «ninguno»:
// esta tarea nunca falla, así que se espera con `await tarea.value`, sin `try`.
func miradora() -> Task<String, Never> {
    Task {
        try? await Task.sleep(for: .milliseconds(50))    // si cancelan, vuelve enseguida
        return Task.isCancelled ? "me cancelaron" : "terminé"   // ternario: si es true, el primero; si no, el segundo
    }
}
 
let tranquila = miradora()
print(await tranquila.value)       // espera sus 50 ms completos
 
let cancelada = miradora()
cancelada.cancel()                 // solo la marca
print(await cancelada.value)
terminé
me cancelaron

El condición ? a : b de la línea del return es el operador ternario que viste en Genéricos: devuelve a si la condición es cierta y b si no. Y Task<String, Never> dice lo que ves en el comentario: la tarea devuelve un String y su fallo es Never, es decir, no puede fallar.

Fíjate en el try? de la pausa: convierte la CancellationError en nil y la función sigue. Con try? delante de una espera, la cancelación se pierde sin avisar; por eso hay que usarlo con cuidado y mirar después Task.isCancelled si te importa.

Una descarga cancelada falla con otro error

Las funciones de red de Apple avisan de la cancelación con su propio tipo de error, no con CancellationError: un URLError con el código cancelled. URLSession es la clase de Apple para hablar con la red; su función data(from:) es async throws y devuelve los datos de la página y la respuesta del servidor (la usarás a fondo en Red: HTTP, JSON y URLSession). Aquí no usas lo descargado: lanzas una descarga, la cancelas y miras qué error sale. Antes, una pieza nueva: error as? URLError intenta ver el error como un URLError y da un opcional, nil si no lo es; de ahí sale su code. Hace falta internet:

ejemplo mínimo: cancelar una descarga (con comentarios)
import Foundation
 
let url = URL(string: "https://example.com")!
// Lanzamos la descarga en una tarea y la cancelamos en la línea siguiente.
let descarga = Task { try await URLSession.shared.data(from: url) }
descarga.cancel()
do {
    _ = try await descarga.value
} catch {
    // ¿Es un CancellationError? ¿Es un URLError con código `cancelled`?
    print("cancelada:", error is CancellationError, (error as? URLError)?.code == .cancelled)
}
cancelada: false true

Por eso, al atrapar errores de una llamada de red, no basta con mirar is CancellationError.

Reto · Ejercicio: tareas atentas y distraídas

En tu Playground (reutiliza el mismo y borra lo anterior), escribe:

  1. Una función que pueda pausarse y fallar y que sume los números del 1 hasta uno dado. Antes de empezar descansa 50 milisegundos, ignorando cualquier error de esa espera. Recibe además un parámetro de sí o no (un Bool): con él en «sí», cada mil números comprueba si la han cancelado; en «no», nunca lo mira.
  2. Otra función que solo duerma un segundo y devuelva «desperté».
  3. Tres tareas: una que sume hasta 10 000 comprobando, otra igual sin comprobar y otra que duerma. Cancela las tres en las líneas siguientes.
  4. Para cada una, espera su resultado (con .result, que da un Result) e imprime su nombre y su total, o «cancelada» y si el error es el de cancelación.

Lo que debe salir:

con comprobación: cancelada (true)
sin comprobación: 50005000
dormida: cancelada (true)

Pistas: % da el resto de la división, así que numero % 1_000 == 0 es cierto cada mil números; en Swift 10 000 se escribe 10_000 (los guiones bajos solo separan los miles al leer); y si escribes una función auxiliar para imprimir cada resultado, su parámetro puede ser un Result<Int, any Error> (un resultado cuyo éxito es un Int y cuyo fallo es cualquier error). Para pensar: ¿por qué la segunda termina aunque la cancelaste? ¿Y por qué ninguna se paró en el descanso del principio?

Ver solución
solución: tareas atentas y distraídas
func sumar(hasta n: Int, comprobando: Bool) async throws -> Int {
    try? await Task.sleep(for: .milliseconds(50))   // calienta; try? se traga la cancelación
    var total = 0
    for numero in 1...n {
        if comprobando && numero % 1_000 == 0 { try Task.checkCancellation() }
        total += numero
    }
    return total
}
 
func dormir() async throws -> String {
    try await Task.sleep(for: .seconds(1))
    return "desperté"
}
 
func informe(_ nombre: String, _ resultado: Result<Int, any Error>) {
    switch resultado {
    case let .success(total):
        print("\(nombre): \(total)")
    case let .failure(error):
        print("\(nombre): cancelada (\(error is CancellationError))")
    }
}
 
let atenta = Task { try await sumar(hasta: 10_000, comprobando: true) }
let distraida = Task { try await sumar(hasta: 10_000, comprobando: false) }
let dormida = Task { try await dormir() }
atenta.cancel()
distraida.cancel()
dormida.cancel()
 
informe("con comprobación", await atenta.result)
informe("sin comprobación", await distraida.result)
switch await dormida.result {
case let .success(texto):
    print("dormida: \(texto)")
case let .failure(error):
    print("dormida: cancelada (\(error is CancellationError))")
}

La segunda termina porque nadie en su interior pregunta por la cancelación: cancel() solo puso la marca y el bucle no la mira. Ninguna se paró en el descanso del principio porque ese try? convierte la CancellationError en nil y la función sigue; la que comprueba se entera después, en la vuelta mil. La que duerme sí se corta: Task.sleep mira la marca y lanza el error, y a esa no le hace falta ninguna comprobación.

Paso · probabilidadDeVictoria y los tests de async

Objetivo: una probabilidadDeVictoria que se deja cancelar (la usan los tests; main.swift no la llama), un partido cancelado desde main.swift y los siete tests de SimulacionTests.swift. Al terminar, torneo-cli cancela un partido a los 100 ms y xcrun swift test pasa 102 tests.

Trabajas en el mismo paquete Torneo de la lección anterior (~/Marcador-Swift/Torneo).

Torneo
Sources
Torneo
Simulacion.swiftse modifica
torneo-cli
main.swiftse modifica
Tests
TorneoTests
SimulacionTests.swiftse crea y se rellena con «Copiar fichero»
Dos ficheros que cambian y un test que se copia. Así los ves en el Project navigator.

Paso 1 · probabilidadDeVictoria, la función que mira cada mil vueltas

Piezas nuevas de este paso:

  • Int.random(in: 0..<n): un entero al azar de 0 a n-1 (0..<n es un rango que excluye el final; el de tres puntos, 1...n, lo incluye, como viste en Tu primer test).
  • 5_000_000: los guiones bajos solo separan los miles al leer; vale cinco millones.
  • vuelta % 1_000 == 0: % da el resto de la división, así que la condición es cierta cada mil vueltas.
  • Double(victoriasLocales) / Double(simulaciones): la fracción de victorias; Double(…) convierte un entero en decimal, como en Tu primer paquete de Swift.
  • Nombres exactos que exigen los tests copiados: probabilidadDeVictoria recibe el partido sin etiqueta y, con la etiqueta simulaciones, cuántas hacer.

Las piezas juntas, con unos dados en vez de partidos. Pruébalo en tu Playground:

ejemplo mínimo: un bucle que solo calcula y mira si lo cancelan (con comentarios)
// Un bucle que solo calcula: tiene que preguntar él si lo han cancelado.
func tirarDados(_ veces: Int) async throws -> Int {
    var seises = 0
    for vuelta in 1...veces {
        if vuelta % 1_000 == 0 { try Task.checkCancellation() }  // cada mil vueltas
        if Int.random(in: 1..<7) == 6 { seises += 1 }            // 1..<7: del 1 al 6
    }
    return seises
}
 
print(try await tirarDados(6_000) > 0)       // true (casi seguro)
true

Intenta escribirla tú, al final de Simulacion.swift, debajo de jugarPartido: probabilidadDeVictoria recibe un partido y un número de simulaciones (cinco millones si no se dice) y devuelve qué fracción gana el local. En cada simulación, el local gana con una probabilidad proporcional a su fuerza frente a la suma de las dos, donde la fuerza de cada equipo es sus goles más uno. La idea para convertirlo en código: saca un número al azar entre 0 y la suma de las dos fuerzas (sin incluir esta); si cae por debajo de la fuerza del local, gana el local. Cada mil vueltas comprueba si la han cancelado. Cuando termines, abre el bloque y compara con la solución.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
Simulacion.swift: añade al final
// La cancelación es COOPERATIVA: cancelar solo marca la tarea. `Task.sleep`
// mira la marca y lanza `CancellationError`. Un bucle que solo calcula, sin
// esperar nunca, tiene que mirar él mismo con `Task.checkCancellation()`; si no,
// no se entera (el `ensureActive()` de Kotlin).
public func probabilidadDeVictoria(_ partido: Partido, simulaciones: Int = 5_000_000) async throws -> Double {
    let fuerzaLocal = partido.golesLocal + 1
    let fuerzaVisitante = partido.golesVisitante + 1
    var victoriasLocales = 0
    for vuelta in 1...simulaciones {
        // Cada mil vueltas, ¿sigo vivo? Lanza `CancellationError` si no.
        if vuelta % 1_000 == 0 { try Task.checkCancellation() }
        if Int.random(in: 0..<(fuerzaLocal + fuerzaVisitante)) < fuerzaLocal { victoriasLocales += 1 }
    }
    return Double(victoriasLocales) / Double(simulaciones)
}

Paso 2 · Cancela un partido desde main.swift

Antes, el switch sobre el resultado de una Task, con una función que solo espera:

ejemplo mínimo: cancelar una Task y leer su resultado (con comentarios)
func esperar(_ ms: Int) async throws -> String {
    try await Task.sleep(for: .milliseconds(ms))
    return "esperé \(ms) ms"
}
 
let tarea = Task { try await esperar(1_000) }  // arranca y sigue sin esperar
tarea.cancel()                                 // solo la marca
switch await tarea.result {                    // espera a que acabe; da un Result
case let .success(texto):
    print(texto)
case let .failure(error):
    print(error is CancellationError)          // true: Task.sleep vio la marca
}
true

Abre main.swift (dentro de Sources → torneo-cli). Intenta escribir, al final del fichero, una parte con su título: «== cancelar un partido ==». Lanza el mismo partido de Rayo contra Toros en una tarea aparte, espera 100 ms, la cancela, espera a que acabe e imprime «Terminó (no debía)» si terminó o, si falló, «Cancelado:» seguido de CancellationError si el error es de ese tipo (o el propio error si es de otro).

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
main.swift: añade al final
print("== cancelar un partido ==")
// Lanza el partido en una `Task` y sigue sin esperarlo.
let tarea = Task { try await jugarPartido(Ejemplo.rayoContraToros) }
// Espera 100 ms (el partido sigue jugándose mientras tanto)…
try? await Task.sleep(for: .milliseconds(100))
// …y entonces lo cancela: solo lo marca como cancelado.
tarea.cancel()
// `result` espera a que la tarea termine y la entrega como `Result`.
switch await tarea.result {
case .success:
    print("Terminó (no debía)")
case let .failure(error):
    // `is` contesta si el error es de ese tipo.
    let nombre = error is CancellationError ? "CancellationError" : "\(error)"
    print("Cancelado: \(nombre)")
}

Ejecuta desde ~/Marcador-Swift/Torneo:

xcrun swift run torneo-cli

Sin contar las líneas de título que empiezan por ==, la salida termina con algo como esto (Duró es unos 1100 ms y varía según la máquina):

Final: 2-1 con 5 eventos
Duró 1100 ms
Cancelado: CancellationError

Paso 3 · Los tests de async

Los siete tests de SimulacionTests.swift no los escribes: cópialos. Crea el fichero en la carpeta de tests: clic derecho sobre TorneoTests (dentro de Tests) → New File from Template… → Swift File → Next, escribe SimulacionTests y pulsa Create (con la Terminal: touch Tests/TorneoTests/SimulacionTests.swift). Después, el orden de siempre: pulsa Copiar fichero, haz clic dentro del editor, selecciona todo con Cmd+A y pega con Cmd+V.

SimulacionTests.swift

Son async: un test también puede ser asíncrono. El que enseña algo comprueba que cancelar la tarea corta el partido:

el test que enseña algo
    @Test func cancelarLaTareaCortaElPartido() async {
        let reloj = ContinuousClock()
        let inicio = reloj.now
        let tarea = Task { try await jugarPartido(Ejemplo.rayoContraToros, msPorMinuto: 10) }
        try? await Task.sleep(for: .milliseconds(50))
        tarea.cancel()
        let resultado = await tarea.result
        let duracion = milisegundos(inicio.duration(to: reloj.now))
        if case let .failure(error) = resultado {
            #expect(error is CancellationError)
        } else {
            Issue.record("Debía fallar con CancellationError")
        }
        #expect(duracion < 500)
    }

Issue.record("…") marca un fallo con ese mensaje. Un partido entero dura unos 1100 ms; cancelado a los 50 ms tiene que acabar mucho antes, y el test pide menos de 500 ms. Los tests miden tiempo real con márgenes amplios: si alguno de tiempo falla porque el Mac está muy ocupado, vuelve a ejecutarlos. Ejecuta, desde ~/Marcador-Swift/Torneo:

xcrun swift test

(En Xcode, Product → Test, Cmd+U.) Termina con una línea como Test run with 102 tests in 13 suites passed.

Punto de control

  • Desde ~/Marcador-Swift/Torneo, xcrun swift test termina con una línea como Test run with 102 tests in 13 suites passed.
  • xcrun swift run torneo-cli imprime Final: 2-1 con 5 eventos y Cancelado: CancellationError.
  • Sabes decir por qué cancel() no detiene un bucle que solo calcula y cómo se entera: Task.checkCancellation() o Task.isCancelled.
  • Sabes qué pasa con try? delante de una espera cuando cancelan la tarea.

Comprueba lo que entendiste

6 preguntas · necesitas 5 para completar la lección

  1. 1.Llamas a tarea.cancel() sobre una tarea que ejecuta un bucle que solo calcula, sin ninguna espera ni comprobación. ¿Qué pasa?

  2. 2.¿Cuál es el equivalente en Swift de ensureActive() de Kotlin?

  3. 3.¿Qué devuelve await tarea.result?

  4. 4.Dentro de una tarea haces try? await Task.sleep(for: .seconds(1)) y la cancelan mientras duerme. ¿Qué pasa?

  5. 5.Cancelas una descarga de URLSession a mitad. ¿Con qué error falla?

  6. 6.¿Qué diferencia hay entre try Task.checkCancellation() y Task.isCancelled?

Faltan 6 de 6