SwiftConcurrenciaasync/awaitiOSSwiftUI

Concurrencia estructurada en la práctica: async let, TaskGroup, cancelación y actors

En el post anterior sobre concurrencia moderna en Swift repasamos los bloques básicos: async/await, Task, actor, @MainActor y Sendable. Vimos qué es la concurrencia estructurada y cómo se ve async let y TaskGroup en su forma más simple.

Este post es la continuación práctica. En vez de explicar la sintaxis otra vez, vamos a mirar dónde se usa de verdad la concurrencia estructurada en una app real: cargar una pantalla desde varias fuentes en paralelo, procesar N items en lote, cancelar una búsqueda mientras el usuario sigue escribiendo, manejar errores parciales, compartir estado con un actor y convertir APIs de callbacks en AsyncStream. Todos los ejemplos están listos para adaptar a tu código.

1. Cargar una pantalla desde varias fuentes — async let

Es el caso más común de todos. Una pantalla de detalle necesita tres cosas independientes: el perfil del usuario, sus publicaciones y sus estadísticas. Vienen de tres endpoints distintos y ninguno depende del otro. Cargarlas en serie sería tirar el tiempo:

// ❌ En serie: 3 viajes de red, uno tras otro. Si cada uno tarda 400 ms → 1.2 s.
func cargarPantalla(id: Usuario.ID) async throws -> PantallaPerfil {
    let perfil = try await api.perfil(id)
    let posts  = try await api.posts(de: id)
    let stats  = try await api.estadisticas(de: id)
    return PantallaPerfil(perfil: perfil, posts: posts, stats: stats)
}

Con async let las tres arrancan a la vez y el total es el de la más lenta, no la suma:

// ✅ En paralelo: las 3 corren juntas. Total ≈ 400 ms.
func cargarPantalla(id: Usuario.ID) async throws -> PantallaPerfil {
    async let perfil = api.perfil(id)
    async let posts  = api.posts(de: id)
    async let stats  = api.estadisticas(de: id)

    return try await PantallaPerfil(
        perfil: perfil,
        posts:  posts,
        stats:  stats
    )
}

Tres detalles que importan:

  • El trabajo arranca en la línea del async let, no en el await. Para cuando llegas al return, las tres peticiones ya llevan rato corriendo.
  • Si una lanza, el resto se cancela. El try await del return propaga el primer error y cancela automáticamente las otras dos tareas hijas. No quedan peticiones huérfanas.
  • async let es para un número fijo y conocido de tareas. Si el número es dinámico, necesitas un TaskGroup (siguiente sección).

Conectándolo a SwiftUI

En un modelo @Observable (o un ObservableObject) esto vive detrás de un método cargar() que la vista dispara con .task:

@MainActor
@Observable
final class PerfilModel {
    private(set) var estado: Estado = .cargando
    private let api: API

    enum Estado {
        case cargando
        case listo(PantallaPerfil)
        case error(String)
    }

    init(api: API) { self.api = api }

    func cargar(id: Usuario.ID) async {
        estado = .cargando
        do {
            estado = .listo(try await cargarPantalla(id: id))
        } catch is CancellationError {
            // La vista desapareció: no es un error real, no mostramos nada.
        } catch {
            estado = .error(error.localizedDescription)
        }
    }

    private func cargarPantalla(id: Usuario.ID) async throws -> PantallaPerfil {
        async let perfil = api.perfil(id)
        async let posts  = api.posts(de: id)
        async let stats  = api.estadisticas(de: id)
        return try await PantallaPerfil(perfil: perfil, posts: posts, stats: stats)
    }
}
struct PerfilView: View {
    @State private var model = PerfilModel(api: .live)
    let id: Usuario.ID

    var body: some View {
        contenido
            .task { await model.cargar(id: id) }   // se cancela sola al desaparecer la vista
    }
}

El .task es la pieza clave: crea una Task ligada al ciclo de vida de la vista y la cancela automáticamente cuando la vista desaparece. Por eso capturamos CancellationError sin tratarlo como fallo.

2. Procesar N items en lote — TaskGroup

Cuando el número de tareas es dinámico —descargar 40 imágenes, generar miniaturas de una galería, geocodificar una lista de direcciones— async let no sirve. Ahí entra TaskGroup.

El ejemplo canónico es un lote de descargas:

func descargarImagenes(_ urls: [URL]) async throws -> [UIImage] {
    try await withThrowingTaskGroup(of: UIImage.self) { grupo in
        for url in urls {
            grupo.addTask { try await self.descargarImagen(url) }
        }

        var imagenes: [UIImage] = []
        for try await imagen in grupo {
            imagenes.append(imagen)
        }
        return imagenes
    }
}

Funciona, pero esconde dos trampas que en producción sí duelen.

Trampa 1: el orden no se conserva

Los resultados de un TaskGroup llegan en orden de finalización, no en orden de envío. Si mandas las URLs [A, B, C] y B termina primero, tu array queda [B, …]. Para una galería donde el orden importa, hay que reasociar cada resultado con su índice:

func descargarImagenes(_ urls: [URL]) async throws -> [UIImage] {
    try await withThrowingTaskGroup(of: (Int, UIImage).self) { grupo in
        for (indice, url) in urls.enumerated() {
            grupo.addTask { (indice, try await self.descargarImagen(url)) }
        }

        // Recolectamos en un diccionario por índice…
        var porIndice: [Int: UIImage] = [:]
        for try await (indice, imagen) in grupo {
            porIndice[indice] = imagen
        }
        // …y reconstruimos el orden original.
        return urls.indices.compactMap { porIndice[$0] }
    }
}

Trampa 2: no lances 10.000 tareas de golpe

addTask en un bucle sobre 10.000 URLs crea 10.000 tareas hijas de inmediato. El sistema no va a correr 10.000 descargas en paralelo, pero sí va a reservar memoria para todas y saturar el pool de conexiones. La solución es una ventana deslizante: arrancas como mucho maxEnVuelo tareas y, cada vez que una termina, metes la siguiente.

func descargarImagenes(_ urls: [URL], maxEnVuelo: Int = 6) async throws -> [UIImage] {
    try await withThrowingTaskGroup(of: (Int, UIImage).self) { grupo in
        var porIndice: [Int: UIImage] = [:]
        var siguiente = 0

        // Arranca la primera tanda.
        for _ in 0..<min(maxEnVuelo, urls.count) {
            let i = siguiente
            grupo.addTask { (i, try await self.descargarImagen(urls[i])) }
            siguiente += 1
        }

        // Por cada resultado que llega, mete uno nuevo.
        for try await (indice, imagen) in grupo {
            porIndice[indice] = imagen
            if siguiente < urls.count {
                let i = siguiente
                grupo.addTask { (i, try await self.descargarImagen(urls[i])) }
                siguiente += 1
            }
        }
        return urls.indices.compactMap { porIndice[$0] }
    }
}

Nunca hay más de maxEnVuelo descargas activas, pero el grupo siempre está lleno hasta agotar la lista. Este patrón es la diferencia entre una app que respeta la red del usuario y una que la ahoga.

3. Cancelación de verdad — búsqueda con debounce

La cancelación es donde la concurrencia estructurada brilla, y el caso más real es la búsqueda mientras el usuario escribe. Sin cancelación, teclear “swift” dispara cinco búsquedas (“s”, “sw”, “swi”…) y la respuesta de “sw” puede llegar después de la de “swift” y pisar los resultados correctos.

La herramienta perfecta en SwiftUI es .task(id:): cuando el id cambia, cancela la tarea anterior y arranca una nueva. Combínalo con un Task.sleep al inicio para el debounce:

struct BusquedaView: View {
    @State private var texto = ""
    @State private var resultados: [Resultado] = []
    let api: API

    var body: some View {
        List(resultados) { Text($0.titulo) }
            .searchable(text: $texto)
            .task(id: texto) {
                // 1. Debounce: espera 300 ms. Si el usuario sigue tecleando,
                //    el id cambia, esta Task se cancela y sleep lanza CancellationError.
                do { try await Task.sleep(for: .milliseconds(300)) }
                catch { return }

                guard !texto.isEmpty else { resultados = []; return }

                // 2. La búsqueda. Si el id cambia mientras corre, se cancela sola.
                do {
                    resultados = try await api.buscar(texto)
                } catch is CancellationError {
                    // Búsqueda reemplazada por una más nueva: ignorar.
                } catch {
                    resultados = []
                }
            }
    }
}

Lo elegante: no necesitas guardar referencias a Task ni llamar a cancel() a mano. .task(id:) lo hace por ti. El Task.sleep lanza CancellationError en cuanto la tarea se cancela, así que el debounce y la cancelación son el mismo mecanismo.

Cancelación cooperativa en tu propio código

La cancelación en Swift es cooperativa: nadie mata tu tarea a la fuerza. Tu código tiene que revisar si fue cancelado y parar. Las APIs del sistema (URLSession, Task.sleep) ya lo hacen. En un bucle propio de trabajo pesado, hazlo tú:

func procesarLote(_ items: [Item]) async throws {
    for item in items {
        try Task.checkCancellation()   // lanza CancellationError si fue cancelado
        await procesar(item)
    }
}

Si prefieres salir sin lanzar, revisa la propiedad:

for item in items {
    if Task.isCancelled { break }
    await procesar(item)
}

Un TaskGroup propaga la cancelación a todas sus hijas automáticamente, así que si el .task de la vista se cancela, todo el árbol de descargas de la sección 2 se detiene solo.

4. Errores parciales en un grupo

Por defecto, withThrowingTaskGroup tiene semántica de “todo o nada”: la primera hija que lanza cancela a las demás y el error sube. Es lo correcto para el dashboard de la sección 1 —si no puedes cargar el perfil, no hay pantalla que mostrar.

Pero muchas veces quieres lo contrario: procesar todo lo que se pueda y reportar qué falló. Sincronizar 50 archivos y que 3 fallen no debería tirar los otros 47. El truco es no dejar que el error se propague fuera de la tarea hija: captúralo dentro y devuélvelo como un Result.

enum ResultadoSync {
    case ok(Archivo.ID)
    case fallo(Archivo.ID, Error)
}

func sincronizar(_ archivos: [Archivo]) async -> [ResultadoSync] {
    // Ojo: withTaskGroup (sin "Throwing"), porque las hijas ya no lanzan.
    await withTaskGroup(of: ResultadoSync.self) { grupo in
        for archivo in archivos {
            grupo.addTask {
                do {
                    try await self.subir(archivo)
                    return .ok(archivo.id)
                } catch {
                    return .fallo(archivo.id, error)   // el error queda dentro de la hija
                }
            }
        }

        var resultados: [ResultadoSync] = []
        for await resultado in grupo {
            resultados.append(resultado)
        }
        return resultados
    }
}

Ahora arriba puedes separar el grano de la paja y, por ejemplo, reintentar solo los que fallaron:

let resultados = await sincronizar(archivos)
let fallidos = resultados.compactMap { r -> Archivo.ID? in
    if case .fallo(let id, _) = r { return id } else { return nil }
}
if !fallidos.isEmpty {
    // mostrar "3 archivos no se pudieron subir · Reintentar"
}

La regla mental: ¿un fallo invalida todo el resultado? Si sí, deja que se propague (withThrowingTaskGroup). Si no, captura dentro de la hija y devuelve un Result (withTaskGroup).

5. Actors para caché y estado compartido

Una caché la tocan muchas tareas a la vez, y eso es exactamente lo que provoca data races. Un actor los elimina serializando el acceso. Una caché de imágenes ingenua se ve así:

actor CacheImagenes {
    private var cache: [URL: UIImage] = [:]

    func imagen(_ url: URL) async throws -> UIImage {
        if let cacheada = cache[url] {
            return cacheada
        }
        let imagen = try await descargar(url)
        cache[url] = imagen
        return imagen
    }
}

Funciona, pero tiene un bug sutil de concurrencia que conviene entender: la reentrancy de los actors.

El gotcha de la reentrancy

Cuando un método de actor hace await, suspende y libera el actor para que otras llamadas entren. En el código de arriba, si dos vistas piden la misma URL casi a la vez, las dos pasan el if let (aún no hay nada en caché), las dos hacen await descargar(url)… y descargas la misma imagen dos veces. El actor te protege de corromper el diccionario, pero no de duplicar trabajo, porque el estado puede cambiar durante el await.

La solución es cachear la tarea en vuelo, no solo el resultado. Así la segunda llamada encuentra la Task ya arrancada y se cuelga de ella:

actor CacheImagenes {
    private enum Entrada {
        case enProgreso(Task<UIImage, Error>)
        case lista(UIImage)
    }
    private var entradas: [URL: Entrada] = [:]

    func imagen(_ url: URL) async throws -> UIImage {
        // ¿Ya está lista o descargándose? Reusa.
        if let entrada = entradas[url] {
            switch entrada {
            case .lista(let imagen):
                return imagen
            case .enProgreso(let tarea):
                return try await tarea.value   // espera a la descarga en curso
            }
        }

        // Nadie la ha pedido: arranca la descarga y guárdala ANTES de await.
        let tarea = Task { try await descargar(url) }
        entradas[url] = .enProgreso(tarea)

        do {
            let imagen = try await tarea.value
            entradas[url] = .lista(imagen)
            return imagen
        } catch {
            entradas[url] = nil   // falló: permite reintentar luego
            throw error
        }
    }
}

Ahora N vistas que piden la misma URL disparan una sola descarga y todas reciben el mismo resultado. Guardar la Task en el diccionario antes del primer await es lo que cierra la ventana de la reentrancy. Este patrón —de-duplicar peticiones en vuelo— aparece en cualquier caché, cliente de red o cargador de recursos serio.

6. AsyncStream para eventos

async/await resuelve “un valor que llega después”. Pero muchas cosas en iOS son “muchos valores que llegan con el tiempo”: ubicaciones del GPS, notificaciones, ticks de un timer, mensajes de un WebSocket. Para eso está AsyncSequence, y la forma más fácil de crear la tuya es AsyncStream.

Su mejor uso es envolver una API vieja de delegados o callbacks y presentarla como un for await. Un timer, por ejemplo:

func tics(cada intervalo: TimeInterval) -> AsyncStream<Date> {
    AsyncStream { continuation in
        let timer = Timer.scheduledTimer(withTimeInterval: intervalo, repeats: true) { _ in
            continuation.yield(Date())          // emite un valor
        }
        continuation.onTermination = { _ in     // limpieza al cancelar/terminar
            timer.invalidate()
        }
    }
}

Y se consume como cualquier secuencia asíncrona, con cancelación gratis:

.task {
    for await instante in tics(cada: 1) {
        reloj = instante   // se detiene solo cuando la vista desaparece
    }
}

El onTermination es crucial: se dispara cuando el consumidor deja de escuchar (la vista se fue, la tarea se canceló) y es donde apagas el recurso subyacente. Sin él, el timer seguiría vivo para siempre.

Envolver un delegado

El caso real más frecuente es adaptar un delegado clásico. Con AsyncStream.makeStream puedes separar el emisor del consumidor limpiamente:

final class Ubicaciones: NSObject, CLLocationManagerDelegate {
    private let manager = CLLocationManager()
    private var continuation: AsyncStream<CLLocation>.Continuation?

    func stream() -> AsyncStream<CLLocation> {
        let (stream, continuation) = AsyncStream.makeStream(of: CLLocation.self)
        self.continuation = continuation
        manager.delegate = self
        manager.startUpdatingLocation()

        continuation.onTermination = { [weak manager] _ in
            manager?.stopUpdatingLocation()
        }
        return stream
    }

    func locationManager(_ m: CLLocationManager, didUpdateLocations locs: [CLLocation]) {
        for loc in locs { continuation?.yield(loc) }
    }
}

Un detalle de producción: los eventos pueden llegar más rápido de lo que el consumidor los procesa. Controla el buffer para no acumular memoria sin límite —en un stream de ubicaciones casi siempre quieres solo la última:

AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in
    // …
}

Errores comunes de la concurrencia estructurada

  • Convertir async let en secuencial sin querer. Si haces let x = await api.a() antes de declarar el siguiente async let, ya perdiste el paralelismo. Declara todos los async let juntos y haz await al final.
  • Olvidar que TaskGroup desordena. Si el orden importa, reasocia por índice (sección 2). Es el bug más silencioso del grupo.
  • Crear Task { } sueltas en vez de .task. Una Task manual en onAppear no se cancela al desaparecer la vista. Usa .task / .task(id:) salvo que tengas una razón fuerte.
  • Asumir que el actor te salva de duplicar trabajo. Te salva de corromper el estado, no de la reentrancy. Para de-duplicar, cachea la Task (sección 5).
  • AsyncStream sin onTermination. Es una fuga de recursos garantizada: el timer, el listener o el socket quedan vivos.
  • Tragarse CancellationError. Está bien ignorarlo en la UI (una búsqueda reemplazada), pero no lo confundas con un fallo de red ni muestres una alerta de error por él.

Resumen

Necesitas…Herramienta
Cargar N fuentes fijas en paraleloasync let + await al final
Procesar una lista dinámica de itemsTaskGroup (con ventana e índice)
Cancelar al cambiar la entrada.task(id:) + Task.sleep para debounce
Continuar pese a fallos parcialeswithTaskGroup + Result dentro de la hija
Compartir estado sin data racesactor (cachea la Task, no solo el valor)
Consumir eventos en el tiempoAsyncStream con onTermination

La concurrencia estructurada no es solo “correr cosas en paralelo”. Es un modelo donde el árbol de tareas tiene un dueño claro, la cancelación se propaga sola y los errores no dejan trabajo huérfano. Una vez que piensas en términos de “quién es el padre de esta tarea y cuándo muere”, la mitad de los bugs de concurrencia desaparecen antes de escribirse.

Si te saltaste los fundamentos, empieza por Concurrencia moderna en Swift y vuelve aquí para los casos reales.