Saltar al contenido
K
Swift: memoria y concurrenciaPaso a paso40 min de lectura · 5 min de máquina Básico

TaskGroup y errores en tareas hijas

Al terminar: Lanzar un número variable de tareas con TaskGroup y manejar sus errores.

Al terminar tendrás una jornada que juega una lista de partidos de cualquier tamaño a la vez, y una versión en la que un partido puede fallar sin tumbar a los demás. Con eso, el módulo de concurrencia ya juega, mide y agrupa partidos de verdad.

Importa porque casi nunca sabes cuántas cosas vas a pedir: la lista de partidos, de imágenes o de descargas llega de fuera, con dos elementos o con doscientos. Y cuando una de ellas falla, hay que decidir si se cae todo o solo esa. TaskGroup resuelve las dos cosas.

Temas: TaskGroup (withThrowingTaskGroup, addTask), recoger resultados por orden de llegada, errores en tareas hijas con Result, y las etiquetas @Sendable y @escaping de un tipo de función.

TaskGroup: cuando no sabes cuántas son

async let sirve cuando sabes cuántas tareas hay al escribir el código: tres líneas, tres tareas. Con una lista de tamaño variable no puedes escribir un async let por elemento. Ahí entra el grupo de tareas: withTaskGroup (o withThrowingTaskGroup, si las hijas pueden lanzar errores) te da un grupo al que añades una tarea hija por elemento con addTask, y un for try await recoge los resultados a medida que terminan, no en el orden en que los lanzaste. for await recorre, esperando entre un valor y el siguiente, algo que va entregando valores con el tiempo (en Streams lo verás a fondo); for try await es lo mismo, para cuando esperar puede fallar, y por eso lleva try.

async letTaskGroup
Número de tareasFijo, conocido al escribirVariable, sale de una lista
ResultadoUna variable por tareaUn for await que los va entregando
Orden de los resultadosEl que tú eliges al esperarPor orden de llegada
Kotlinasync { } y await()coroutineScope con un async por elemento

Como el grupo entrega por orden de llegada, cada tarea devuelve también su posición, y al final se coloca cada resultado donde estaba.

En Kotlin, la jornada supervisada recogía los resultados con jugando.map { intentar { it.await() } }, en el orden de la lista (Errores en corrutinas); un grupo de Swift los entrega por orden de llegada, y ese orden lo reconstruyes tú.

Piezas nuevas del ejemplo de abajo:

  • withThrowingTaskGroup(of: (Int, String).self) { grupo in … }: crea el grupo. of: dice qué tipo devuelve cada hija, aquí una tupla con la posición y el texto; of: pide el tipo, y se escribe siempre con .self detrás: copia esa forma. Lo que devuelve la closure es lo que devuelve el grupo entero.
  • for (posicion, nombre) in nombres.enumerated(): enumerated() recorre una lista dando cada elemento junto con su índice: (0, "a"), (1, "b")…
  • [String?](repeating: nil, count: n): un array de n huecos vacíos (nil, el «nada» de los opcionales), para colocar cada resultado en su sitio. Después, compactMap { $0 } los recoge sin los vacíos, como viste en Closures con listas.
ejemplo mínimo: TaskGroup con enumerated y huecos para el orden (con comentarios)
// «Descarga» algo esperando unos milisegundos; puede fallar si cancelan la tarea.
func descargar(_ nombre: String, ms: Int) async throws -> String {
    try await Task.sleep(for: .milliseconds(ms))
    return "\(nombre) listo"
}
 
func descargarTodos(_ nombres: [String]) async throws -> [String] {
    // of: el tipo que devuelve cada hija: aquí, su posición y su texto.
    try await withThrowingTaskGroup(of: (Int, String).self) { grupo in
        // enumerated() da cada elemento con su índice: (0, "a"), (1, "b")…
        for (posicion, nombre) in nombres.enumerated() {
            grupo.addTask {                                   // una tarea hija por nombre
                (posicion, try await descargar(nombre, ms: 100 - posicion * 30))
            }
        }
        // n huecos vacíos, para colocar cada resultado en su sitio.
        var resultado = [String?](repeating: nil, count: nombres.count)
        for try await (posicion, texto) in grupo {            // llegan por orden de llegada
            resultado[posicion] = texto
        }
        return resultado.compactMap { $0 }                    // quita los huecos (ya no queda ninguno)
    }
}
 
print(try await descargarTodos(["a", "b", "c"]))
["a listo", "b listo", "c listo"]

La «c» es la que menos espera (40 ms frente a 100), así que termina la primera; aun así sale en su sitio, porque cada hija trajo su posición.

Errores en tareas hijas

En un grupo normal, si una hija lanza un error, el grupo cancela a las hermanas y relanza el error: un partido roto tumba la jornada (el coroutineScope de Kotlin). Para que cada hija falle por su cuenta, la hija atrapa su error y lo devuelve como valor, dentro de un Result. En Kotlin lo viste como supervisorScope más la función intentar; Swift no tiene un supervisorScope: se logra con este patrón.

Hay un detalle: la cancelación también es un error (CancellationError, la que viste en Cancelar una tarea). Un catch genérico la atraparía y una tarea cancelada seguiría como si nada. Por eso se atrapa aparte y se vuelve a lanzar: catch let cancelacion as CancellationError { throw cancelacion } atrapa solo los errores de ese tipo, y los demás pasan al catch siguiente.

El patrón, con una descarga que se corta en el segundo intento (sin grupo, para ver solo la idea):

ejemplo mínimo: un error propio, catch let … as, Result y as? (con comentarios)
// Un struct que cumple Error: se puede lanzar como cualquier error.
struct SinConexion: Error {
    let intento: Int
}
 
func descargarConCorte(intento: Int) async throws -> String {
    try await Task.sleep(for: .milliseconds(20))
    if intento == 2 { throw SinConexion(intento: intento) }
    return "intento \(intento) bien"
}
 
func intentar(_ intento: Int) async throws -> Result<String, any Error> {
    do {
        return .success(try await descargarConCorte(intento: intento))
    } catch let cancelacion as CancellationError {   // solo los errores de ese tipo
        throw cancelacion                            // la cancelación no se traga: se relanza
    } catch {
        return .failure(error)                       // los demás, guardados como valor
    }
}
 
for intento in 1...2 {
    switch try await intentar(intento) {
    case let .success(texto):
        print(texto)
    case let .failure(error):
        // as?: intenta ver el error como SinConexion; nil si es de otro tipo.
        if let corte = error as? SinConexion {
            print("sin conexión en el intento \(corte.intento)")
        }
    }
}
intento 1 bien
sin conexión en el intento 2

@Sendable y @escaping en un tipo de función

La jornada supervisada recibe un parámetro jugar cuyo tipo es @escaping @Sendable (Partido) async throws -> Partido. Esta lección solo pide reconocer las dos etiquetas; cópialas tal cual:

  • @escaping: la closure puede ejecutarse después de que la función haya terminado (Closures). Como jugar se usa dentro de addTask, cuya closure sí puede «escapar», el compilador te pide marcarlo también: sin la etiqueta dice escaping closure captures non-escaping parameter 'jugar'. Ponla y ya.
  • @Sendable: la closure puede ejecutarse desde otra tarea, a la vez que el resto del código, así que no debe capturar variables que alguien cambie. Cómo lo revisa el compilador es el tema de Sendable y el modo Swift 6.

Reto · Ejercicio: descargas que pueden fallar

En tu Playground, escribe un error propio para un fichero roto que guarde su nombre, y una función que pueda pausarse y fallar: «descarga» un fichero esperando 10 ms por cada letra del nombre, falla con ese error si el nombre empieza por «roto» y, si no, devuelve el número de letras por 100 (los bytes). Para saber si un texto empieza por otro, los textos tienen hasPrefix, que contesta sí o no.

Escribe otra función que descargue una lista de nombres a la vez, con una tarea hija por nombre, y devuelva para cada uno su resultado o su fallo (un Result<Int, any Error>), en el orden de la lista; solo la cancelación se vuelve a lanzar. Junta los dos ejemplos de arriba: el grupo con la posición y los huecos, y, dentro de cada hija, el do/catch que devuelve un Result. Descarga «video.mp4», «roto.txt» y «foto.png» (no terminan en ese orden) e imprime una línea por fichero:

video.mp4: 900 bytes
roto.txt: falló, roto.txt está roto
foto.png: 800 bytes
Ver solución
solución: descargas que pueden fallar
// El error de un fichero roto, con su nombre.
struct FicheroRoto: Error {
    let nombre: String
}
 
// Espera 10 ms por letra; falla si el nombre empieza por «roto».
func descargar(_ nombre: String) async throws -> Int {
    try await Task.sleep(for: .milliseconds(10 * nombre.count))
    if nombre.hasPrefix("roto") { throw FicheroRoto(nombre: nombre) }
    return nombre.count * 100
}
 
// Una hija por nombre; cada una devuelve su posición y un Result.
func descargarTodos(_ nombres: [String]) async throws -> [Result<Int, any Error>] {
    try await withThrowingTaskGroup(of: (Int, Result<Int, any Error>).self) { grupo in
        for (posicion, nombre) in nombres.enumerated() {
            grupo.addTask {
                do {
                    return (posicion, .success(try await descargar(nombre)))
                } catch let cancelacion as CancellationError {
                    throw cancelacion                         // la cancelación se relanza
                } catch {
                    return (posicion, .failure(error))         // lo demás, como valor
                }
            }
        }
        // Huecos para colocar cada resultado en su posición.
        var resultado = [Result<Int, any Error>?](repeating: nil, count: nombres.count)
        for try await (posicion, valor) in grupo {
            resultado[posicion] = valor
        }
        return resultado.compactMap { $0 }
    }
}
 
let nombres = ["video.mp4", "roto.txt", "foto.png"]
let resultados = try await descargarTodos(nombres)
for (posicion, resultado) in resultados.enumerated() {
    switch resultado {
    case let .success(bytes):
        print("\(nombres[posicion]): \(bytes) bytes")
    case let .failure(error):
        if let roto = error as? FicheroRoto {
            print("\(nombres[posicion]): falló, \(roto.nombre) está roto")
        }
    }
}

«roto.txt» y «foto.png» (8 letras) terminan antes que «video.mp4» (9), y aun así salen en su sitio. «roto.txt» falla, pero los otros dos se descargan: el fallo viaja como Result, no tumba el grupo. La lista final sale en el orden de los nombres, no en el de llegada.

Paso · una jornada con lista, y con fallos

Objetivo: jugarJornada para una lista con un TaskGroup y la versión supervisada, en la que un partido que falla no tumba al otro. Al terminar, el paquete tiene 109 tests.

Trabajas en el paquete Torneo (la carpeta Marcador-Swift/Torneo, abierta en Xcode). Para la Terminal: cd ~/Marcador-Swift/Torneo.

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

No hay ficheros nuevos de código: todo se añade al final de Simulacion.swift (en Sources → Torneo), después de estadisticasDe, y de main.swift (en Sources → torneo-cli), después de las estadísticas.

Paso 1 · Una lista de partidos con TaskGroup

Aquí no sabes cuántos partidos hay, así que no puedes escribir un async let por cada uno: se usa un grupo, con lo que acabas de ver en descargarTodos.

Piezas nuevas de este paso:

  • jugarJornada, la versión de la sobrecarga que recibe una lista de partidos sin etiqueta (y msPorMinuto, 10 si no se dice) y devuelve la lista de partidos jugados, en el orden de la lista.

Intenta escribirla tú: la jugarJornada que recibe una lista juega todos los partidos a la vez y los devuelve en el orden de la lista, aunque terminen en otro. Y el bloque de main.swift que la prueba: con el título «== una jornada con TaskGroup ==», juega Rayo contra Toros y Lobos contra Águilas a 2 ms por minuto e imprime la lista de resultados (o «Falló la jornada:» y el error). Lo que debes ver:

["2-1", "1-2"]
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
Torneo/Sources/Torneo/Simulacion.swift
// `async let` sirve cuando sabes CUÁNTAS tareas hay al escribir el código. Con una
// lista de tamaño variable se usa un TaskGroup: `addTask` añade una tarea hija por
// partido y el `for try await` recoge los resultados A MEDIDA QUE TERMINAN, no en
// el orden en que se lanzaron. Por eso cada tarea devuelve también su posición, y
// al final se coloca cada partido donde estaba.
public func jugarJornada(_ partidos: [Partido], msPorMinuto: Int = 10) async throws -> [Partido] {
    try await withThrowingTaskGroup(of: (Int, Partido).self) { grupo in
        for (posicion, guion) in partidos.enumerated() {
            grupo.addTask {
                (posicion, try await jugarPartido(guion, msPorMinuto: msPorMinuto))
            }
        }
        var resultado = [Partido?](repeating: nil, count: partidos.count)
        for try await (posicion, jugado) in grupo {
            resultado[posicion] = jugado
        }
        return resultado.compactMap { $0 }
    }
}
Torneo/Sources/torneo-cli/main.swift
// Un título para separar esta parte.
print("== una jornada con TaskGroup ==")
do {
    // Una lista de dos partidos, jugados muy rápido (2 ms por minuto).
    let jugados = try await jugarJornada([Ejemplo.rayoContraToros, Ejemplo.lobosContraAguilas], msPorMinuto: 2)
    // Un texto «goles local-goles visitante» por cada partido, en su orden original.
    print(jugados.map { "\($0.golesLocal)-\($0.golesVisitante)" })
} catch {
    print("Falló la jornada: \(error)")
}

Al ejecutar verás ["2-1", "1-2"]: los dos resultados, cada uno en su sitio, aunque el grupo los entregue por orden de llegada.

Paso 2 · Que un partido falle sin tumbar a los demás

Es la parte más larga y también es obligatoria: main.swift y los tests copiados la usan, y sin ella no compila (si te atascas, cópiala del bloque de abajo). Se necesitan tres cosas: un error propio, un partido que falla en un minuto dado, y la versión supervisada de la jornada, con el patrón de la sección «Errores en tareas hijas» de arriba.

Piezas nuevas de este paso:

  • SuspendidoPorApagon: un struct propio que cumple Error (y por eso se puede lanzar) y Equatable (se puede comparar con ==, para que los tests comparen dos apagones), y guarda el minuto del apagón.
  • jugarConApagon: recibe el partido sin etiqueta, msPorMinuto (10 si no se dice) y minutoDelApagon; espera hasta ese minuto y lanza el error.
  • jugarJornadaSupervisada: recibe la lista de partidos sin etiqueta y, con la etiqueta jugar, cómo se juega cada uno (una closure con las dos etiquetas @escaping @Sendable que acabas de ver; si no se dice, se juega entero con jugarPartido), y devuelve un Result por partido.
  • textoDelFallo(_ error: any Error) -> String: una función pequeña de main.swift que convierte el error en una frase. Usa if let apagon = error as? SuspendidoPorApagon: as? intenta ver el error como un SuspendidoPorApagon y da nil si es de otro tipo, y if let entra solo si no es nil.
  • En main.swift, partido.local == Ejemplo.lobos: Ejemplo.lobos es el equipo Lobos de los datos de prueba (el fichero Ejemplo.swift que creaste en ARC), y Equipo se puede comparar con ==, así que sirve para preguntar «¿juega el local los Lobos?».
  • La llamada jugarJornadaSupervisada(lista) { partido in … }: la closure va fuera de los paréntesis (closure final, como en Closures) y rellena el parámetro jugar, aunque tenga etiqueta. Dentro, un if/else cuyas dos ramas son un try await …: la rama que se ejecuta es el valor que devuelve la closure.

Intenta escribirlo tú, al final de Simulacion.swift: el error del apagón, el partido que falla en su minuto, y la jornada supervisada, en la que cada partido devuelve su resultado o su fallo como valor y solo la cancelación se vuelve a lanzar; los resultados salen en el orden de la lista. Y en main.swift: textoDelFallo, que convierte un apagón en «se fue la luz en el minuto 30» y cualquier otro error en su texto; y, con el título «== un partido que falla no tumba al otro ==», una jornada supervisada de Rayo-Toros y Lobos-Águilas en la que el partido de los Lobos se queda sin luz en el minuto 30 y el otro se juega entero a 2 ms por minuto, con una línea por resultado. Lo que debes ver al final:

Terminó Rayo FC-Toros: 2-1
Falló: se fue la luz en el minuto 30

En main.swift, la jornada supervisada se llama sin do/catch: esa función solo lanza si la cancelan, y en el nivel superior un error sin atrapar termina el programa.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
Torneo/Sources/Torneo/Simulacion.swift
// Algo que sale mal en mitad de un partido: se va la luz del campo.
public struct SuspendidoPorApagon: Error, Equatable {
    public let minuto: Int
}
 
// Un partido que se juega hasta el minuto del apagón y ahí falla.
public func jugarConApagon(_ partido: Partido, msPorMinuto: Int = 10, minutoDelApagon: Int) async throws -> Partido {
    try await Task.sleep(for: .milliseconds(msPorMinuto * minutoDelApagon))
    throw SuspendidoPorApagon(minuto: minutoDelApagon)
}
 
// En un `withThrowingTaskGroup` normal, si UNA tarea hija lanza un error, el grupo
// cancela a las demás y relanza el error: un partido roto tumba la jornada. Para
// que cada hija falle POR SU CUENTA, la hija atrapa su error y lo devuelve como
// VALOR (un `Result`): al grupo solo le llegan resultados, nunca errores.
//
// La cancelación NO se traga: un `catch` genérico atraparía también la
// `CancellationError`, y una tarea cancelada seguiría como si nada. Se atrapa
// aparte y se vuelve a lanzar; solo los demás errores se guardan como resultado.
public func jugarJornadaSupervisada(
    _ partidos: [Partido],
    jugar: @escaping @Sendable (Partido) async throws -> Partido = { try await jugarPartido($0) }
) async throws -> [Result<Partido, any Error>] {
    try await withThrowingTaskGroup(of: (Int, Result<Partido, any Error>).self) { grupo in
        for (posicion, guion) in partidos.enumerated() {
            grupo.addTask {
                do {
                    return (posicion, .success(try await jugar(guion)))
                } catch let cancelacion as CancellationError {
                    throw cancelacion
                } catch {
                    return (posicion, .failure(error))
                }
            }
        }
        var resultado = [Result<Partido, any Error>?](repeating: nil, count: partidos.count)
        for try await (posicion, valor) in grupo {
            resultado[posicion] = valor
        }
        return resultado.compactMap { $0 }
    }
}
Torneo/Sources/torneo-cli/main.swift
// Convierte un error en una frase para la consola.
func textoDelFallo(_ error: any Error) -> String {
    // Si es el apagón, dice en qué minuto ocurrió.
    if let apagon = error as? SuspendidoPorApagon {
        return "se fue la luz en el minuto \(apagon.minuto)"
    }
    // Cualquier otro error se imprime tal cual.
    return "\(error)"
}
 
// Un título para separar esta parte.
print("== un partido que falla no tumba al otro ==")
// La jornada supervisada; la closure final dice cómo se juega cada partido.
let supervisados = try await jugarJornadaSupervisada([Ejemplo.rayoContraToros, Ejemplo.lobosContraAguilas]) { partido in
    if partido.local == Ejemplo.lobos {
        // Los Lobos se quedan sin luz en el minuto 30…
        try await jugarConApagon(partido, minutoDelApagon: 30)
    } else {
        // …y el otro partido se juega entero.
        try await jugarPartido(partido, msPorMinuto: 2)
    }
}
// Cada resultado es un `Result`: o terminó, o falló.
for resultado in supervisados {
    switch resultado {
    case let .success(partido):
        print("Terminó \(partido.local.nombre)-\(partido.visitante.nombre): \(partido.golesLocal)-\(partido.golesVisitante)")
    case let .failure(error):
        print("Falló: \(textoDelFallo(error))")
    }
}

Ejecuta xcrun swift run torneo-cli. Esta parte termina con:

Terminó Rayo FC-Toros: 2-1
Falló: se fue la luz en el minuto 30

El Lobos-Águilas falló, y aun así el Rayo-Toros terminó.

Paso 3 · Los tests de la jornada

Los siete tests de JornadaTests.swift no los escribes. Crea el fichero en la carpeta de tests: clic derecho sobre TorneoTests (dentro de Tests en el Project navigator) → New File from Template… → Swift File → Next, escribe JornadaTests y pulsa Create. Si el menú de tu versión de Xcode se llama distinto, crea el fichero desde la Terminal con touch Tests/TorneoTests/JornadaTests.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.

JornadaTests.swift

El que enseña algo comprueba que el grupo devuelve los partidos en el orden de la lista, aunque terminen en otro. jugados.map(\.local.nombre) saca el nombre del equipo local de cada partido jugado (\.local.nombre es la ruta a ese dato, una forma corta de escribir { $0.local.nombre }).

el test que enseña algo
    @Test func taskGroupDevuelveLosPartidosEnElOrdenOriginal() async throws {
        // El segundo es más corto en tiempo real, pero tiene que salir segundo.
        let jugados = try await jugarJornada([Ejemplo.rayoContraToros, Ejemplo.lobosContraAguilas, Ejemplo.rayoContraToros], msPorMinuto: 1)
        #expect(jugados.count == 3)
        #expect(jugados.map(\.local.nombre) == ["Rayo FC", "Lobos", "Rayo FC"])
    }

Otros tests de este fichero miden tiempo real con márgenes amplios (si uno 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 Test run with 109 tests in 14 suites passed.

Punto de control

  • Desde ~/Marcador-Swift/Torneo, xcrun swift test termina con una línea como Test run with 109 tests in 14 suites passed.
  • xcrun swift run torneo-cli muestra, tras sus títulos, ["2-1", "1-2"] y la jornada supervisada con Falló: se fue la luz en el minuto 30.
  • Sabes decir cuándo usar async let y cuándo un TaskGroup, y por qué la hija vuelve a lanzar la CancellationError en vez de guardarla en el Result.

Comprueba lo que entendiste

6 preguntas · necesitas 5 para completar la lección

  1. 1.Tienes una lista de partidos que puede tener 2 o 200 elementos y quieres jugarlos a la vez. ¿Qué usas?

  2. 2.En un TaskGroup lanzas tres partidos y recorres el grupo con for try await. ¿En qué orden llegan los resultados?

  3. 3.En un withThrowingTaskGroup normal, una de las tareas hijas lanza un error. ¿Qué pasa?

  4. 4.¿Qué indica @Sendable sobre el tipo de una closure?

  5. 5.Tienes dos funciones llamadas jugarJornada, una que recibe dos partidos y otra que recibe una lista. ¿Cómo se llama eso y cómo sabe Swift cuál usar?

  6. 6.En la hija que guarda sus errores en un Result, ¿por qué hay un catch let cancelacion as CancellationError { throw cancelacion } antes del catch genérico?

Faltan 6 de 6