async/await en Swift: concurrencia moderna con structured concurrency

Antes de Swift 5.5, la concurrencia en iOS y macOS se manejaba principalmente con closures de completion, DispatchQueue y, en algunos casos, Combine. Funcionaba, pero el código resultante era propenso a errores, difícil de leer y una pesadilla para el manejo de errores. En 2021, Swift 5.5 introdujo async/await junto con un modelo completo de concurrencia estructurada que cambia la forma de pensar en las operaciones asíncronas. Este artículo explica cómo funciona y por qué importa entender structured concurrency para sacarle partido.

async/await: lo básico

Una función async puede suspenderse mientras espera una operación sin bloquear el hilo. await marca cada punto de suspensión posible:

func cargarPerfil(id: String) async throws -> Perfil {
    let datos = try await red.fetchDatos("/perfil/(id)")
    let perfil = try JSONDecoder().decode(Perfil.self, from: datos)
    return perfil
}

// Llamada desde contexto async:
let perfil = try await cargarPerfil(id: "usuario123")

Comparado con el equivalente con completion handler, la diferencia es enorme. Sin callbacks anidados, sin captura de self con [weak self], sin necesidad de llamar al completion en cada rama de error.

Qué significa structured concurrency

La concurrencia estructurada es el principio de que las tareas asíncronas tienen un ciclo de vida definido y acotado: nacen en un punto del código, viven dentro de ese ámbito y terminan antes de que el ámbito termine. Esto contrasta con lanzar hilos o tareas «flotantes» que el código no controla directamente.

En Swift, structured concurrency se implementa principalmente a través de async let y grupos de tareas (TaskGroup).

async let: paralelismo fácil

func cargarDashboard() async throws -> Dashboard {
    // Las tres llamadas se lanzan en paralelo
    async let perfil = cargarPerfil(id: currentUser)
    async let notificaciones = cargarNotificaciones()
    async let estadisticas = cargarEstadisticas()

    // Aquí se espera a que terminen las tres
    return try await Dashboard(
        perfil: perfil,
        notificaciones: notificaciones,
        estadisticas: estadisticas
    )
}

Si cualquiera de las tres lanza un error, las otras se cancelan automáticamente. Sin código extra, sin coordinación manual.

TaskGroup: paralelismo dinámico

Cuando no sabes de antemano cuántas tareas vas a lanzar, usas un TaskGroup:

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

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

El grupo garantiza que todas las tareas hijo terminan (o son canceladas) antes de que la función retorne. No hay fugas de tareas.

Cancelación cooperativa

Swift usa un modelo de cancelación cooperativa: las tareas no se matan, se les notifica que deben cancelarse y son ellas las que comprueban el estado:

func procesarLote(_ items: [Item]) async throws -> [Resultado] {
    var resultados: [Resultado] = []
    for item in items {
        // Lanza CancellationError si la tarea fue cancelada
        try Task.checkCancellation()
        let resultado = try await procesar(item)
        resultados.append(resultado)
    }
    return resultados
}

También puedes comprobar el estado sin lanzar: Task.isCancelled devuelve un booleano que puedes usar para limpiar recursos antes de salir.

Unstructured tasks: cuándo y cómo

A veces necesitas lanzar trabajo asíncrono desde un contexto síncrono (un handler de botón, un delegado de UIKit). Para eso existen las tareas no estructuradas:

// Task hereda el contexto del actor actual (ej: @MainActor)
Task {
    await viewModel.recargar()
}

// Task.detached no hereda ningún contexto de actor
Task.detached(priority: .background) {
    await cache.limpiarEntradas()
}

La regla práctica: usa Task {} cuando necesitas lanzar trabajo asíncrono desde código síncrono y quieres heredar el actor actual. Usa Task.detached solo cuando explícitamente no quieres ese contexto, que es bastante raro.

AsyncSequence: iterar sobre flujos de datos

AsyncSequence es el equivalente asíncrono de Sequence, permite iterar sobre valores que llegan a lo largo del tiempo:

func escucharEventos() async {
    for await evento in servidorWebSocket.eventos {
        switch evento {
        case .mensaje(let texto): manejar(texto)
        case .cierre: break
        }
    }
}

El bucle for await se suspende entre iteraciones en lugar de bloquear el hilo. URLSession expone sus respuestas como AsyncBytes, lo que permite leer streams HTTP de forma eficiente.

Diferencias con otros lenguajes

Si vienes de JavaScript, el modelo te resultará familiar. Si vienes de Go, es diferente: Swift no usa goroutines ni canales explícitos, sino un modelo basado en actores y structured concurrency que el compilador puede verificar estáticamente en Swift 6. La comparación con goroutines y channels de Go resulta interesante: Go favorece la comunicación explícita a través de canales; Swift favorece el aislamiento a través de actores.

El modelo de async/await en Rust también es diferente: en Rust no hay runtime de concurrencia incluido en el lenguaje, necesitas un crate como Tokio. Swift incluye su propio runtime de concurrencia en la biblioteca estándar, lo que simplifica el setup pero reduce el control fino sobre la planificación.

Imagen: Pexels / Stanislav Kondratiev

COMPARTE ESTE ARTÍCULO

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