async/await en Swift: Task, structured concurrency y cancelación

Swift 5.5 introdujo async/await como sintaxis nativa para el código asíncrono, pero la concurrencia estructurada va mucho más allá de esa primera capa. Task, async let, withTaskGroup y la cancelación cooperativa son las herramientas que marcan la diferencia entre un código simplemente asíncrono y uno verdaderamente eficiente y seguro.

Task: la unidad básica de trabajo asíncrono

Un Task encapsula una unidad de trabajo asíncrono que el runtime de Swift puede planificar y ejecutar de forma concurrente. Se crea con un closure que puede contener código asíncrono:

Task {
    let resultado = await fetchDatos()
    print(resultado)
}

Los tasks heredan el contexto del actor en el que se crean. Si los lanzas desde el hilo principal (actor principal), heredarán ese contexto a menos que uses Task.detached:

// Hereda el contexto actual (actor principal si estás en SwiftUI)
Task {
    await actualizarUI()
}

// No hereda ningún contexto — útil para trabajo en background
Task.detached(priority: .background) {
    await procesarImagenes()
}

La prioridad acepta valores como .high, .medium, .low, .background y .utility. El runtime los usa como sugerencia, no como garantía de orden.

async let: paralelismo sin grupos

Cuando necesitas lanzar varias operaciones asíncronas simultáneamente y esperar a todas, async let es la forma más concisa:

func cargarPerfil(id: Int) async throws -> PerfilCompleto {
    async let usuario = fetchUsuario(id: id)
    async let pedidos = fetchPedidos(userId: id)
    async let recomendaciones = fetchRecomendaciones(userId: id)

    // Las tres peticiones se lanzan en paralelo
    // Aquí esperamos a las tres antes de continuar
    return PerfilCompleto(
        usuario: try await usuario,
        pedidos: try await pedidos,
        recomendaciones: try await recomendaciones
    )
}

Sin async let, las tres llamadas se ejecutarían en serie, multiplicando el tiempo de espera. Con async let, el tiempo total es el de la operación más lenta, no la suma de todas.

withTaskGroup: paralelismo dinámico

Cuando el número de tareas paralelas no se conoce en tiempo de compilación, withTaskGroup permite crear grupos dinámicos de tareas:

func descargarImagenes(urls: [URL]) async throws -> [UIImage] {
    try await withThrowingTaskGroup(of: UIImage.self) { group in
        for url in urls {
            group.addTask {
                let (data, _) = try await URLSession.shared.data(from: url)
                guard let imagen = UIImage(data: data) else {
                    throw ImagenError.datosInvalidos
                }
                return imagen
            }
        }

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

Si alguna tarea lanza un error, el grupo cancela las tareas restantes automáticamente. Existen dos variantes: withTaskGroup para grupos que no lanzan errores y withThrowingTaskGroup para los que sí pueden fallar.

Task.sleep: suspensión sin bloqueo

A diferencia de Thread.sleep, Task.sleep suspende la tarea actual sin bloquear el hilo subyacente, permitiendo que otras tareas usen ese hilo mientras tanto:

// Swift 5.7+: duración tipada
try await Task.sleep(for: .seconds(2))
try await Task.sleep(for: .milliseconds(500))

// Swift 5.5-5.6: nanosegundos
try await Task.sleep(nanoseconds: 2_000_000_000)

Task.sleep puede lanzar CancellationError si la tarea es cancelada durante la espera, por lo que es un punto de cancelación natural.

Cancelación cooperativa

La cancelación en Swift es cooperativa: el sistema no fuerza la terminación de una tarea, sino que le notifica que debe cancelarse. El código de la tarea debe comprobar y respetar esa notificación.

func procesarLote(items: [Item]) async throws {
    for item in items {
        // Punto de cancelación explícito
        try Task.checkCancellation()

        await procesarItem(item)
    }
}

Task.checkCancellation() lanza CancellationError si la tarea está cancelada. Otra opción es Task.isCancelled, que devuelve un booleano sin lanzar:

if Task.isCancelled {
    return nil // limpieza manual y retorno temprano
}

withTaskCancellationHandler: limpieza al cancelar

Cuando necesitas ejecutar código de limpieza en el momento exacto de la cancelación (no al finalizar la tarea), usa withTaskCancellationHandler:

func descargarConCancelacion(url: URL) async throws -> Data {
    let sesion = URLSession.shared
    var tarea: URLSessionDataTask?

    return try await withTaskCancellationHandler {
        try await withCheckedThrowingContinuation { continuation in
            tarea = sesion.dataTask(with: url) { data, _, error in
                if let error = error {
                    continuation.resume(throwing: error)
                } else if let data = data {
                    continuation.resume(returning: data)
                }
            }
            tarea?.resume()
        }
    } onCancel: {
        tarea?.cancel()
    }
}

El closure onCancel se ejecuta de forma sincrónica en el momento de la cancelación, en el contexto del que cancela. No es async, así que no puede await nada, pero sí puede cancelar recursos síncronos como tareas URLSession o cerrar conexiones.

Structured concurrency: jerarquía de tareas

La concurrencia estructurada en Swift garantiza que ninguna tarea hija sobreviva a su tarea padre. Esto elimina toda una categoría de bugs donde tareas en background seguían ejecutándose tras la destrucción de su contexto:

func cargarDashboard() async throws -> Dashboard {
    // Si este método es cancelado, async let cancela
    // automáticamente las subtareas antes de retornar
    async let metricas = fetchMetricas()
    async let alertas = fetchAlertas()
    async let actividad = fetchActividad()

    return Dashboard(
        metricas: try await metricas,
        alertas: try await alertas,
        actividad: try await actividad
    )
}

Si cargarDashboard se cancela mientras espera, Swift cancela las tres subtareas y no retorna hasta que todas han terminado (con éxito o con error). La jerarquía garantiza que no hay fugas de tareas.

Prioridades y herencia

Las tareas hijas heredan la prioridad de la tarea padre por defecto. Puedes sobrescribirla, pero el sistema puede elevar la prioridad de tareas de baja prioridad si una tarea de alta prioridad las está esperando (priority inversion avoidance):

Task(priority: .high) {
    // Alta prioridad
    await withTaskGroup(of: Void.self) { group in
        group.addTask(priority: .low) {
            // El sistema puede elevar esta a .high
            // porque la tarea padre es .high y la espera
            await tareaSecundaria()
        }
    }
}

Ejemplo completo: descarga con progreso y cancelación

actor DescargaManager {
    private var tareaActiva: Task?

    func descargar(url: URL, progreso: (Double) -> Void) async throws -> Data {
        cancelar()

        let tarea = Task {
            try await withTaskCancellationHandler {
                var datos = Data()
                let (stream, respuesta) = try await URLSession.shared.bytes(from: url)
                let total = Double(respuesta.expectedContentLength)

                for try await byte in stream {
                    try Task.checkCancellation()
                    datos.append(byte)
                    progreso(Double(datos.count) / total)
                }
                return datos
            } onCancel: {
                // URLSession cancelará automáticamente cuando
                // la tarea sea cancelada
            }
        }

        tareaActiva = tarea
        return try await tarea.value
    }

    func cancelar() {
        tareaActiva?.cancel()
        tareaActiva = nil
    }
}

Resumen

La concurrencia estructurada de Swift proporciona tres garantías fundamentales que el modelo de callbacks o las operaciones manuales con DispatchQueue no ofrecen: las tareas hijas no sobreviven a sus padres, la cancelación se propaga automáticamente por la jerarquía, y el código tiene flujo lineal y predecible aunque sea asíncrono. Dominar Task, async let y withTaskGroup es el paso que separa usar async/await de entender realmente la concurrencia en Swift.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP