CodeGym /Cursos /Swift SELF /Errores y cancelacion en TaskGroup

Errores y cancelacion en TaskGroup

Swift SELF
Nivel 70, Lección 0
Disponible

1. Estado de medio escrito y politica de gestion de errores

Cuando empiezas a paralelizar tareas por primera vez, la cabeza se alegra: "Oh, ahora ira mas rapido". Y luego llega la realidad y pregunta: "¿Y que pasa si la tercera tarea falla, cancelan la septima y la primera ya logro escribir algo?". Eso es exactamente lo que se llama estado "medio escrito": una parte de los cambios esta aplicada, otra no, y el resultado recuerda a un libro en el que la mitad de las paginas esta impresa y la otra mitad es solo el indice.

En una aplicacion CLI como nuestra LibraryCLI esto es especialmente molesto. Por ejemplo, el comando fetch-many puede ir a buscar datos por varios id y luego escribirlos en el almacenamiento local. Si en mitad del lote ocurre un error o una cancelacion, corremos el riesgo de obtener una "medio base": algunos libros se actualizaron, otros no, el indice de busqueda se reconstruyo a medias y el archivo JSON se guardo de tal forma que en el solo hay la mitad de los datos.

Aqui esta la moraleja importante (y un poco de autoironia): el problema no es que Swift Concurrency sea malo. El problema es que los efectos secundarios (escribir en el repositorio, en un archivo, actualizar el estado compartido) no llevan bien que se les haga "poquito a poquito" desde tareas paralelas.

Dos politicas: fail-fast y partial success — no puedes "no elegirlas"

Normalmente queremos "comernos el pez y que la base siga intacta". Pero en operaciones por lotes casi siempre tienes que decidir de antemano que politica de error va hoy en el menu. Si no, el codigo queda contradictorio: en unas partes nos comportamos como fail-fast y en otras como partial success, y luego nadie (incluido el autor del codigo una semana despues) entiende cual es el resultado correcto.

Una pequena tabla para que el cerebro deje de fingir que es "lo mismo":

Politica Significado Herramienta tipica
Fail-fast Cualquier error convierte el resultado de toda la operacion en un error withThrowingTaskGroup y try await group.next()
Partial success El error de una tarea no impide reunir los exitos de las demas withTaskGroup, y cada tarea devuelve Result (el error como valor)

En la leccion anterior ya hicimos partial success. Aqui el foco esta en la otra mitad "peligrosa" del dia: errores, cancelacion e integridad del estado, especialmente en escenarios fail-fast, donde es muy facil dejar el sistema en un estado "medio hecho".

2. withThrowingTaskGroup: donde exactamente se manifiesta el error

La group throwing tiene una peculiaridad sencilla, pero traicionera: las tareas hijas pueden empezar a ejecutarse justo despues de addTask, pero el error se vuelve "visible" en el momento en que el padre intenta obtener el resultado. Por eso la lectura de resultados se hace mediante try await group.next(), y es precisamente ahi donde puede producirse el lanzamiento del error.

Primero haremos un mini modelo "como si fuera red", pero sin red real, para no distraernos:

import Foundation

struct Book {
    let id: Int
    let title: String
}

enum FetchError: Error {
    case notFound(Int)
}

func fetchBook(id: Int) async throws -> Book {
    try await Task.sleep(nanoseconds: UInt64(id) * 20_000_000) // simulacion de retardo
    if id == 13 { throw FetchError.notFound(id) }
    return Book(id: id, title: "Book #\(id)")
}

Ahora el clasico batch fail-fast:

import Foundation

func fetchAllOrNothing(ids: [Int]) async throws -> [Book] {
    try await withThrowingTaskGroup(of: Book.self, returning: [Book].self) { group in
        for id in ids {
            group.addTask { try await fetchBook(id: id) }
        }

        var books: [Book] = []
        while let book = try await group.next() { // aqui puede "salir" el error
            books.append(book)
        }
        return books
    }
}

Un matiz importante: si desde withThrowingTaskGroup un error "sale" al exterior, la group cancela las tareas restantes y espera a que terminen antes de propagar el error mas arriba. Esto forma parte del modelo "estructural": las tareas hijas no quedan huerfanas, estan obligadas a terminar su vida dentro del alcance de la group.

Suena seguro... hasta que empiezas a hacer efectos secundarios dentro de las tareas hijas.

3. Cancelacion en Swift Concurrency y group.cancelAll()

Por que cancelAll() no es un "boton stop", sino una cancelacion cooperativa

Cuando hablamos de "cancelacion", los principiantes suelen esperar un comportamiento del estilo: "Pulso un boton y todo se apaga al instante". Pero en Swift la cancelacion es cooperativa: la tarea debe llegar a un punto donde pueda notar la cancelacion (normalmente puntos de await o comprobaciones explicitas), y solo entonces finalizar correctamente.

Para TaskGroup es importante recordar que la group puede quedar cancelada de tres formas basicas: si desde body se lanza un error, si se cancela la tarea padre, o si nosotros mismos llamamos a group.cancelAll(). No son "casos raros", son escenarios normales que hay que saber leer con los ojos.

Para que la cancelacion funcione rapido, la operacion hija debe "colaborar". El ejemplo mas simple es comprobar la cancelacion antes y despues de las esperas:

import Foundation

func fetchBookCancellable(id: Int) async throws -> Book {
    try Task.checkCancellation()                  // lanzara CancellationError si se cancelo
    try await Task.sleep(nanoseconds: 80_000_000) // sleep al cancelarse tambien lanza CancellationError
    try Task.checkCancellation()
    return Book(id: id, title: "Book #\(id)")
}

Fijaos en el detalle que destroza muchos nervios: Task.sleep(...) en Swift es throwing, y al cancelarse lanza CancellationError. Si haces try await Task.sleep(...), ya estas participando en el "protocolo de cancelacion", aunque no hayas escrito ni una comprobacion de Task.isCancelled.

Cuando y por que cancelar un lote de tareas

A veces la cancelacion no es un error, sino una optimizacion. Por ejemplo, buscas "el primer resultado que sirva" y no quieres esperar al resto. En esos casos, el estilo normal es: obtuviste lo que necesitabas → pediste a los demas que se detengan.

Aqui va un ejemplo minimo: "devuelve el primer libro que haya terminado de cargarse" (si, una logica de negocio un poco rara, pero muestra muy bien la mecanica):

import Foundation

func firstFinishedBook(ids: [Int]) async -> Book? {
    await withTaskGroup(of: Book.self, returning: Book?.self) { group in
        for id in ids {
            group.addTask { try await fetchBookCancellable(id: id) }
        }

        let first = await group.next()
        group.cancelAll() // pedimos a los demas que se detengan
        return first
    }
}

Aqui hay que entender dos cosas.

Primero, cancelAll() es una solicitud de cancelacion: marca las tareas como canceladas, pero si dentro de las tareas no hay await ni comprobaciones de cancelacion, pueden llegar a la meta igualmente.

Segundo, la cancelacion del lote no debe dejar efectos secundarios, o obtendras un estado raro: "en teoria cancelamos, pero la mitad de las tareas ya escribio algo".

4. Regla de integridad: las tareas hijas calculan, el padre aplica

Si de esta leccion te quedas con una sola idea, que sea esta: no hagas side effects dentro de las tareas hijas de una group. Que las tareas hijas obtengan datos (o calculen cambios), y que el codigo padre decida ya que y cuando aplicar.

Esto esta directamente relacionado con la propiedad de TaskGroup: la group garantiza que todas las tareas hijas terminaran antes de salir del alcance, pero no garantiza que tu archivo/repositorio/indice no vaya a quedar "tocadito" por escrituras paralelas.

Imaginemos nuestro LibraryCLI a nivel de idea. El comando fetch-many hace algo parecido a esto:

flowchart TD
    A[IDs del comando] --> B[TaskGroup: obtener datos en paralelo]
    B --> C[Reunir resultados en memoria]
    C --> D{¿Errores? ¿cancelacion?}
    D -->|si| E[No aplicar nada / devolver error]
    D -->|no| F[Aplicar cambios de forma secuencial]
    F --> G[Una escritura en disco / un solo save]

Hagamos explicito el "cambio" como tipo. Esto ayuda al cerebro: en lugar de "estamos mutando algo por ahi", tenemos un objeto de "que queremos aplicar exactamente".

import Foundation

struct BookUpdate {
    let id: Int
    let book: Book
}

Ahora el "calculo paralelo de updates":

import Foundation

func computeUpdates(ids: [Int]) async throws -> [BookUpdate] {
    try await withThrowingTaskGroup(of: BookUpdate.self, returning: [BookUpdate].self) { group in
        for id in ids {
            group.addTask {
                let book = try await fetchBookCancellable(id: id)
                return BookUpdate(id: id, book: book)
            }
        }

        var updates: [BookUpdate] = []
        while let u = try await group.next() {
            updates.append(u)
        }
        return updates
    }
}

Y aparte, la "aplicacion secuencial". Para la leccion haremos un repositorio simple en memoria:

import Foundation

final class InMemoryLibraryRepository {
    private var storage: [Int: Book] = [:]

    func apply(_ updates: [BookUpdate]) {
        for u in updates {
            storage[u.id] = u.book
        }
    }
}

Y la "orquestacion" (ese mismo "reunir → comprobar → aplicar"):

import Foundation

func fetchMany(ids: [Int], repo: InMemoryLibraryRepository) async throws {
    let updates = try await computeUpdates(ids: ids) // parte paralela
    repo.apply(updates)                              // parte secuencial
}

¿Por que esto salva del estado "medio escrito"? Porque si computeUpdates falla con un error, ni siquiera llegamos a repo.apply. Fail-fast se convierte en "no aplicamos nada" — y eso es mucho mas facil de explicar al usuario y mucho mas facil de mantener que "aplicamos el 37%".

5. Como no confundir CancellationError con un error "real"

Cuando haces una operacion batch, en produccion aparece muy rapido la necesidad de distinguir "se cancelo" de "fallo". La cancelacion suele ser un escenario normal: el usuario interrumpio el comando, se activo un timeout mas arriba en la pila, o nosotros decidimos parar mediante group.cancelAll(). En cambio, FetchError.notFound(13) ya es otro tema: es un problema de datos.

Mini ejemplo de gestion en el nivel superior (de forma condicional, dentro del comando CLI):

import Foundation

func runFetchMany(ids: [Int], repo: InMemoryLibraryRepository) async {
    do {
        try await fetchMany(ids: ids, repo: repo)
        print("Listo: actualizamos \(ids.count) libros") // texto aproximado
    } catch is CancellationError {
        print("Operacion cancelada")                  // CancellationError es "normal"
    } catch {
        print("Error: \(error)")                   // los demas errores son "fallo"
    }
}

Desde el punto de vista del modelo structured concurrency, CancellationError es la forma estandar de decir "se pidio a la tarea que se detuviera". Esto esta especialmente relacionado con el hecho de que Task.sleep lanza CancellationError al cancelarse, asi que lo veras con mas frecuencia de la que esperas si tienes timeouts o cancelacion explicita.

6. Que no hacer dentro de TaskGroup

Hay una serie de acciones que casi garantizan dolor si se hacen dentro de tareas hijas. Y el dolor aqui no es filosofico, sino muy practico: "¿por que se corrompio el JSON?" o "¿por que el indice no coincide con los datos?".

El anti-patron mas tipico es este: "obtener datos → escribirlos enseguida en el repositorio directamente en la tarea". Aunque "a veces funcione", hace que el comportamiento sea impredecible ante errores y cancelaciones. Ademas, este tipo de codigo suele ser dificil de probar: no verificas el resultado de una funcion, sino los efectos intermedios.

Para que sea mas claro, aqui va un ejemplo que parece tentador, pero conceptualmente es incorrecto (y en modos estrictos del compilador de Swift 6 este tipo de cosas ademas puede no pasar las comprobaciones de acceso concurrente):

import Foundation

func wrongIdea(ids: [Int], repo: InMemoryLibraryRepository) async throws {
    try await withThrowingTaskGroup(of: Void.self) { group in
        for id in ids {
            group.addTask {
                let book = try await fetchBookCancellable(id: id)
                // repo.apply([BookUpdate(...)])  // ❌ side effect dentro de la tarea hija
            }
        }
        while let _ = try await group.next() { }
    }
}

Incluso si sacas apply a un metodo aparte, la idea misma de "escribir en un sistema compartido desde tareas paralelas" rompe el principio: primero reunimos, luego aplicamos en un solo paso ordenado.

7. Errores tipicos

Error n. 1: esperar que group.cancelAll() mate las tareas al instante.
cancelAll() no hace un "kill -9" magico. Marca las tareas como canceladas, y luego la tarea debe notar eso por si misma en un punto de await o mediante una comprobacion (Task.isCancelled, Task.checkCancellation()). Si dentro de la operacion no hay puntos de suspension ni comprobaciones, puede terminar como si nada, y eso no sera un bug de Swift, sino tu sueno incumplido de telepatia instantanea.

Error n. 2: confundir fail-fast y partial success, y luego sorprenderse de que "unas veces desaparecen los errores y otras todo falla".
Si usas withThrowingTaskGroup, en cierto modo te estas suscribiendo al modo "el error es control de flujo". Si querias reunir el maximo de resultados, entonces los lanzamientos de errores deben convertirse en Result dentro de las tareas hijas (eso lo hicimos en la leccion anterior). Mezclar estos enfoques en un mismo escenario normalmente significa que todavia no has decidido que quieres del producto.

Error n. 3: olvidar try en group.next() en una group throwing.
En withThrowingTaskGroup la lectura del resultado se ve como try await group.next(), porque precisamente en ese momento puede manifestarse el error de la tarea hija. Si por costumbre escribes await group.next(), el compilador, por supuesto, te detendra, pero el problema es mas profundo: es importante entender no solo la sintaxis, sino el sentido — el error sale a la superficie en la frontera "el padre espera el resultado".

Error n. 4: escribir en un archivo/repositorio dentro de las tareas hijas y luego no entender por que la base quedo "medio viva".
Esta es la causa clasica del estado "medio escrito". En cuanto haces un side effect en una tarea hija, pierdes el control sobre que parte del lote llego a aplicarse antes del error o la cancelacion. Se soluciona con disciplina: las tareas hijas devuelven datos o un "plan de cambios", y el padre aplica los cambios de forma secuencial, en una fase clara.

Error n. 5: considerar CancellationError un error "real" y mostrarlo al usuario como una catastrofe.
La cancelacion suele ser un resultado normal: el usuario cambio de opinion, se activo un timeout, o tu mismo detuviste el lote mediante group.cancelAll(). En esos escenarios es mejor separar CancellationError del resto de errores y formular el mensaje con mas suavidad. Y mas aun porque incluso Task.sleep normal lanza CancellationError al cancelarse, asi que lo encontraras mas a menudo de lo que parece.

1
Tarea
Swift SELF, nivel 70, lección 0
Bloqueada
Lista de nombres
Lista de nombres
1
Tarea
Swift SELF, nivel 70, lección 0
Bloqueada
Primera respuesta
Primera respuesta
1
Tarea
Swift SELF, nivel 70, lección 0
Bloqueada
Actualización atómica
Actualización atómica
1
Tarea
Swift SELF, nivel 70, lección 0
Bloqueada
Cancelación de la carga
Cancelación de la carga
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION