tutoriales.com

Gestión Avanzada de Concurrencia en Swift: Explorando `async/await` y Actores

Descubre cómo `async/await` y los Actores han revolucionado la programación concurrente en Swift. Este tutorial te guiará a través de los conceptos fundamentales, la implementación práctica y las mejores prácticas para construir aplicaciones reactivas y robustas.

Intermedio20 min de lectura17 views11 de marzo de 2026Reportar error

La programación concurrente ha sido históricamente uno de los desafíos más grandes en el desarrollo de software. Con la introducción de async/await y los Actores en Swift 5.5, Apple ha proporcionado herramientas poderosas y elegantes para manejar tareas asíncronas y estados compartidos de manera segura y eficiente. Este tutorial te sumergirá en el mundo de la concurrencia moderna en Swift, desde los fundamentos hasta técnicas avanzadas.

🎯 ¿Qué aprenderás en este tutorial?

  • Entender los problemas tradicionales de la concurrencia.
  • Explorar la sintaxis y los principios de async/await.
  • Dominar el uso de Task y TaskGroup para tareas estructuradas.
  • Comprender el modelo de Actores para el aislamiento de estado.
  • Implementar soluciones concurrentes seguras y eficientes.
  • Aplicar las mejores prácticas para un código asíncrono mantenible.
🔥 Importante: Este tutorial asume un conocimiento básico de Swift. Si eres nuevo en Swift, te recomendamos familiarizarte primero con los fundamentos del lenguaje.

📖 La Concurrencia Antes de async/await

Antes de sumergirnos en lo nuevo, es útil recordar cómo manejábamos la concurrencia en Swift. Principalmente, dependíamos de closures de finalización (completion handlers), GCD (Grand Central Dispatch) y OperationQueues.

Problemas Comunes de la Concurrencia Tradicional:

  • Callback Hell: Anidamiento excesivo de closures, dificultando la lectura y el mantenimiento del código.
  • Gestión de Errores: Propagar errores a través de múltiples callbacks podía ser complejo y propenso a errores.
  • Condiciones de Carrera: Múltiples hilos accediendo y modificando el mismo estado sin sincronización, llevando a comportamientos impredecibles.
  • Inversión de Control: El código se ejecutaba en respuesta a callbacks, rompiendo el flujo de ejecución lineal.
// Ejemplo de 'Callback Hell' simulado
func fetchData(completion: @escaping (Result<String, Error>) -> Void) {
    DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
        // Simular descarga de datos
        let data = "Datos del Servidor"
        completion(.success(data))
    }
}

func processData(data: String, completion: @escaping (Result<Int, Error>) -> Void) {
    DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) {
        // Simular procesamiento de datos
        let processedValue = data.count
        completion(.success(processedValue))
    }
}

func displayResult(value: Int, completion: @escaping (Result<Bool, Error>) -> Void) {
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
        // Simular visualización
        print("Resultado final: \(value)")
        completion(.success(true))
    }
}

fetchData { result in
    switch result {
    case .success(let data):
        processData(data: data) { result in
            switch result {
            case .success(let processedValue):
                displayResult(value: processedValue) { result in
                    switch result {
                    case .success(let displayed):
                        print("Proceso completado: \(displayed)")
                    case .failure(let error):
                        print("Error al mostrar: \(error.localizedDescription)")
                    }
                }
            case .failure(let error):
                print("Error al procesar: \(error.localizedDescription)")
            }
        }
    case .failure(let error):
        print("Error al obtener datos: \(error.localizedDescription)")
    }
}

Este patrón, aunque funcional, puede volverse ingobernable rápidamente en aplicaciones complejas.


async/await: La Nueva Era de la Concurrencia Estructurada

async/await en Swift permite escribir código asíncrono que parece y se comporta como código síncrono. Esto mejora drásticamente la legibilidad, la capacidad de razonamiento y el mantenimiento del código.

Conceptos Clave:

  • async: Marca una función o método como asíncrono, indicando que puede realizar trabajo que puede suspender su ejecución (esperar) sin bloquear el hilo actual. Es como decir: "Esta función podría tardar un tiempo".
  • await: Se utiliza dentro de una función async para esperar a que otra función async complete su trabajo. Cuando se awaita, la ejecución de la función actual se suspende y el hilo se libera para hacer otro trabajo. Una vez que la función awaitada termina, la ejecución de la función actual se reanuda.
💡 Consejo: Piensa en `async` como el permiso para pausar y `await` como el acto de pausar y esperar.

Primer Ejemplo de async/await

Vamos a reescribir el ejemplo anterior usando async/await:

import Foundation

enum DataError: Error {
    case networkError
    case processingError
    case displayError
}

func fetchDataAsync() async throws -> String {
    try await Task.sleep(nanoseconds: 1_000_000_000) // Simular 1 segundo
    print("Datos obtenidos del servidor")
    return "Datos del Servidor"
}

func processDataAsync(data: String) async throws -> Int {
    try await Task.sleep(nanoseconds: 500_000_000) // Simular 0.5 segundos
    print("Datos procesados")
    return data.count
}

func displayResultAsync(value: Int) async throws -> Bool {
    // Asegurarse de que las actualizaciones de UI se hagan en el hilo principal
    await MainActor.run {
        print("Resultado final: \(value)")
    }
    print("Resultado visualizado")
    return true
}

// Para ejecutar funciones async, necesitamos un 'contexto' async.
// Una forma sencilla es usar una Task.
Task {
    do {
        let data = try await fetchDataAsync()
        let processedValue = try await processDataAsync(data: data)
        let displayed = try await displayResultAsync(value: processedValue)
        print("Proceso completado: \(displayed)")
    } catch {
        print("Un error ocurrió: \(error.localizedDescription)")
    }
}

// Mantiene el proceso vivo para ver la salida de Task
// Esto no es necesario en una app real de iOS/macOS
RunLoop.main.run(until: Date(timeIntervalSinceNow: 5))

¡Qué diferencia! El código es lineal, fácil de leer y la gestión de errores se maneja con los bloques do-catch tradicionales. Esto es el poder de la concurrencia estructurada.

Task y TaskGroup para un Control Fino

  • Task: Representa una unidad de trabajo asíncrono. Puedes crear una Task para ejecutar código async en el fondo. Una Task tiene su propio ciclo de vida y puede ser cancelada.

    let task = Task {
        do {
            let result = try await someAsyncOperation()
            print("Resultado de la tarea: \(result)")
        } catch {
            print("La tarea falló: \(error)")
        }
    }
    
    // Para cancelar la tarea si ya no es necesaria
    // task.cancel()
    
  • TaskGroup: Permite lanzar múltiples Tasks y awaitar su finalización de forma estructurada. Es ideal para cuando tienes varias tareas asíncronas que pueden ejecutarse en paralelo y necesitas recolectar sus resultados.

    enum DownloadError: Error { case invalidURL, networkFailure }
    
    func downloadImage(from urlString: String) async throws -> String {
        try await Task.sleep(nanoseconds: 1_000_000_000) // Simular descarga
        guard let _ = URL(string: urlString) else { throw DownloadError.invalidURL }
        print("Descargada: \(urlString.lastPathComponent)")
        return "Imagen de \(urlString)"
    }
    
    func downloadMultipleImages() async {
        let urls = [
            "https://example.com/image1.jpg",
            "https://example.com/image2.png",
            "https://example.com/image3.gif"
        ]
    
        do {
            let images = try await withThrowingTaskGroup(of: String.self) { group -> [String] in
                for url in urls {
                    group.addTask {
                        return try await downloadImage(from: url)
                    }
                }
    
                var collectedImages: [String] = []
                for try await image in group {
                    collectedImages.append(image)
                }
                return collectedImages
            }
            print("Todas las imágenes descargadas: \(images)")
        } catch {
            print("Error al descargar imágenes: \(error.localizedDescription)")
        }
    }
    
    Task {
        await downloadMultipleImages()
    }
    
    // Mantiene el proceso vivo
    RunLoop.main.run(until: Date(timeIntervalSinceNow: 5))
    

Aquí, withThrowingTaskGroup garantiza que si una de las tareas secundarias falla, todo el grupo puede manejar el error.


🛡️ Actores: Aislamiento Seguro de Estado Compartido

Una de las principales fuentes de errores en la concurrencia son las condiciones de carrera (race conditions) al acceder a estados compartidos. Los Actores ofrecen una solución elegante a esto.

¿Qué es un Actor?

Un Actor es un tipo de referencia (class) que encapsula su propio estado mutable y garantiza que solo una pieza de código a la vez pueda acceder y modificar ese estado. Esto se logra encolando y ejecutando las operaciones enviadas al actor de forma serial.

📌 Nota: Los Actores se basan en el modelo de actores, una forma bien establecida de modelar la concurrencia que se originó en la década de 1970.

Sintaxis de un Actor

Los actores se definen de manera similar a las clases, pero usando la palabra clave actor:

actor TemperatureSensor {
    private var currentTemperature: Double = 20.0
    private var historicalReadings: [Double] = []

    func updateTemperature(newTemperature: Double) async {
        // Simular un pequeño retraso para destacar el async
        try? await Task.sleep(nanoseconds: 100_000_000)
        self.currentTemperature = newTemperature
        self.historicalReadings.append(newTemperature)
        print("Temperatura actualizada a: \(newTemperature)°C")
    }

    func getLatestTemperature() -> Double {
        return currentTemperature
    }

    func getHistoricalReadings() -> [Double] {
        return historicalReadings
    }

    func resetReadings() async {
        self.historicalReadings = []
        print("Lecturas históricas reiniciadas.")
    }
}

Accediendo a un Actor

Cuando interactúas con un actor desde fuera de él, sus métodos async deben ser awaitados. Esto se debe a que el actor podría estar ocupado procesando otro mensaje, y tu solicitud se pondrá en cola.

let sensor = TemperatureSensor()

Task {
    print("Temperatura inicial: \(sensor.getLatestTemperature())°C") // Acceso síncrono si no es 'async'

    // Estos accesos son asíncronos y requieren 'await'
    await sensor.updateTemperature(newTemperature: 22.5)
    await sensor.updateTemperature(newTemperature: 23.1)

    let latest = sensor.getLatestTemperature() // Síncrono
    print("Última temperatura leída: \(latest)°C")

    await sensor.resetReadings()
    let history = await sensor.getHistoricalReadings() // Asíncrono porque accede a estado mutable
    print("Historial después de reset: \(history)")

    // Lanzar múltiples tareas para demostrar el aislamiento del actor
    Task.detached {
        await sensor.updateTemperature(newTemperature: 25.0)
    }
    Task.detached {
        await sensor.updateTemperature(newTemperature: 20.0)
    }
    Task.detached {
        await sensor.updateTemperature(newTemperature: 21.5)
    }
    // Esperar un poco para que las tareas terminen
    try? await Task.sleep(nanoseconds: 2_000_000_000)
    print("Estado final del sensor: \(await sensor.getHistoricalReadings())")
}

RunLoop.main.run(until: Date(timeIntervalSinceNow: 10))

Incluso si múltiples tareas intentan llamar a updateTemperature simultáneamente, el actor garantiza que las actualizaciones se serialicen, evitando condiciones de carrera en currentTemperature y historicalReadings.

MainActor: El Hilo Principal Seguro

El MainActor es un actor global especial que representa el hilo principal de la aplicación. Todas las actualizaciones de UI deben realizarse en el MainActor. Puedes marcar clases, métodos o closures con @MainActor para asegurar que su ejecución se programe en el hilo principal.

import SwiftUI // O UIKit

@MainActor
class UIManager: ObservableObject {
    @Published var message: String = "Iniciando..."

    func updateMessage(newMessage: String) async {
        // Este método se ejecuta automáticamente en el MainActor
        // No se necesita Dispatch.main.async
        try? await Task.sleep(nanoseconds: 500_000_000)
        self.message = newMessage
        print("Mensaje actualizado en UI: \(newMessage)")
    }

    func doWorkInBackground() async {
        // Esto se ejecuta en un hilo de fondo por defecto
        let heavyComputation = 10 * 10
        await updateMessage(newMessage: "Cálculo completado: \(heavyComputation)")
    }
}

// En una vista SwiftUI o ViewController
// let uiManager = UIManager()
// Task { await uiManager.doWorkInBackground() }

// Ejemplo de uso con Task y MainActor.run
Task {
    let manager = UIManager()
    print("Manager creado. Mensaje inicial: \(manager.message)")
    await manager.updateMessage(newMessage: "Cargando datos...")

    // Si tuvieras una función async que no es @MainActor, pero necesitas actualizar UI:
    func fetchDataAndUpdateUI() async {
        try? await Task.sleep(nanoseconds: 1_000_000_000) // Simular fetch
        let data = "Datos del servidor"
        await MainActor.run {
            manager.message = "Datos recibidos: \(data)"
            print("Actualización UI desde MainActor.run: \(manager.message)")
        }
    }
    await fetchDataAndUpdateUI()
}

RunLoop.main.run(until: Date(timeIntervalSinceNow: 5))

El uso de @MainActor simplifica enormemente la gestión del hilo principal, eliminando la necesidad de DispatchQueue.main.async en muchos escenarios.


🛠️ Herramientas Adicionales y Conceptos Avanzados

Sendable y Aislamiento de Actores

El sistema de concurrencia de Swift se apoya en el protocolo Sendable para garantizar la seguridad en el paso de datos entre tareas y actores. Un tipo Sendable puede ser compartido de forma segura entre dominios de concurrencia.

  • Tipos de valor (structs, enums) sin tipos de referencia non-Sendable son Sendable por defecto.
  • Actores son Sendable (y sus referencias).
  • Clases son non-Sendable a menos que se marquen con @Sendable y cumplan ciertas condiciones (p. ej., ser inmutables o tener estado protegido por un actor).
⚠️ Advertencia: Intentar enviar un tipo `non-Sendable` a través de un límite de aislamiento de concurrencia (como a un Actor o una `Task.detached`) resultará en un error en tiempo de compilación.

async let para Paralelismo Sencillo

async let es una forma concisa de ejecutar múltiples funciones async en paralelo y awaitar sus resultados posteriormente. Es ideal para cuando necesitas los resultados de varias operaciones independientes para continuar.

func fetchWeatherData() async throws -> String {
    try await Task.sleep(nanoseconds: 1_500_000_000)
    return "Sunny, 25°C"
}

func fetchNewsFeed() async throws -> String {
    try await Task.sleep(nanoseconds: 1_000_000_000)
    return "Top headlines: ..."
}

func fetchStockPrices() async throws -> String {
    try await Task.sleep(nanoseconds: 2_000_000_000)
    return "AAPL: +1.5%"
}

func loadDashboardData() async {
    print("Iniciando carga de datos del dashboard...")
    do {
        async let weather = fetchWeatherData()
        async let news = fetchNewsFeed()
        async let stocks = fetchStockPrices()

        let finalWeather = try await weather // Esto espera solo si weather no ha terminado
        let finalNews = try await news
        let finalStocks = try await stocks

        print("\n--- Datos del Dashboard --- ")
        print("Tiempo: \(finalWeather)")
        print("Noticias: \(finalNews)")
        print("Bolsa: \(finalStocks)")
        print("-------------------------")
    } catch {
        print("Error al cargar el dashboard: \(error.localizedDescription)")
    }
}

Task {
    await loadDashboardData()
}

RunLoop.main.run(until: Date(timeIntervalSinceNow: 5))

Este ejemplo demuestra cómo async let permite que las tres operaciones se inicien casi simultáneamente y luego se awaiten de forma individual, bloqueando solo si es necesario.

Diagrama de Flujo: Concurrencia Estructurada con async/await y Actores

Aquí tienes una representación visual de cómo interactúan estos componentes:

Modelo de Concurrencia en Swift Contexto Task Unidad de trabajo asíncrono async func fetch() async func process() Actor Aislamiento de Estado (Acceso Serializado) await await safe access TaskGroup Concurrencia Estructurada Tarea Hija 1 Tarea Hija 2 Tarea Hija 3 @MainActor Hilo Principal (UI) Actualización UI Resultados Seguridad en hilos y rendimiento optimizado por el compilador.

Tabla Comparativa: Concurrencia Antigua vs. Concurrencia Nueva

CaracterísticaGCD/Completion Handlersasync/await y Actores Swift
SintaxisAnidamiento profundo (callback hell), closuresLineal, secuencial, similar a síncrono
LegibilidadBaja en tareas complejasAlta, muy expresiva
Gestión de ErroresPropagación manual, Result en closuresdo-catch tradicional, throws
CancelaciónManual y compleja, basada en tokensAutomática y estructurada con Task.cancel()
Aislamiento EstadoSemáforos, bloqueos, GCD sync (propenso a errores)Actores (serialización automática y segura)
Hilo PrincipalDispatchQueue.main.async@MainActor, MainActor.run (integrado)
ParalelismoDispatchGroup, OperationQueueTaskGroup, async let

✅ Buenas Prácticas y Consejos

  • Prioriza async/await y Actores: Úsalos como tus herramientas principales para la concurrencia. Solo recurre a GCD/OperationQueue si tienes requisitos muy específicos que no pueden ser cubiertos por el nuevo modelo.
  • Minimiza el Estado Compartido: Siempre que sea posible, diseña tu código para evitar el estado mutable compartido. Si es inevitable, usa Actores.
  • Usa @MainActor: Marca tus clases de ViewModels o ViewControllers (si aún usas UIKit) con @MainActor para asegurar que todo el trabajo relacionado con la UI se haga en el hilo principal.
  • Maneja la Cancelación: Las tareas pueden ser canceladas. Comprueba Task.isCancelled o usa try Task.checkCancellation() en operaciones de larga duración para responder a la cancelación de manera adecuada.
  • Evita el await bloqueante en el MainActor: Nunca llames a await en una función que sepas que tomará mucho tiempo desde el MainActor, a menos que el trabajo largo se delegue a una Task o a un actor.
  • Conoce Sendable: Entiende cuándo tus tipos son Sendable para evitar errores en tiempo de compilación y garantizar la seguridad de los datos.
¿Por qué `async/await` es mejor que los `completion handlers`? `async/await` elimina el "callback hell" al permitir un flujo de control lineal y una gestión de errores con `do-catch` que es mucho más natural. También proporciona concurrencia estructurada, lo que facilita el razonamiento sobre el ciclo de vida de las tareas y la propagación de la cancelación.
¿Cuándo debo usar un `Actor`? Debes usar un `Actor` cuando tienes un estado mutable que necesita ser accedido y modificado por múltiples tareas o hilos concurrentes, y quieres garantizar que estos accesos se serialicen para evitar condiciones de carrera. Son ideales para recursos compartidos como cachés, bases de datos en memoria o gestores de recursos.

🚀 Conclusión

La introducción de async/await y Actores en Swift marca un hito significativo en la forma en que los desarrolladores abordan la concurrencia. Estas herramientas no solo hacen que el código concurrente sea más fácil de escribir y leer, sino que también lo hacen inherentemente más seguro y robusto al eliminar muchas de las trampas comunes de la programación multi-hilo.

Al adoptar estas nuevas características, puedes construir aplicaciones Swift más reactivas, eficientes y fiables, con un código más limpio y fácil de mantener. ¡Es hora de aprovechar al máximo la concurrencia moderna en Swift!

100% Completado

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!