Saltar al contenido
← Todos los posts

Concurrencia en Swift: adiós al event loop. async let, TaskGroup, actores y Swift 6

Swift Concurrency para quien viene de JavaScript: por qué no hay event loop, cómo async let y TaskGroup reemplazan a Promise.all, qué son los actores y @MainActor, qué exige Sendable y por qué en Swift 6 un data race es un error de compilación.

Ilustración de varias tareas que corren en paralelo dentro de un mismo marco y convergen en un único punto de salida, con un actor aislado a un lado

JavaScript tiene un solo hilo y un event loop, y esa restricción es también su garantía: dos funciones nunca corren a la vez, así que un objeto compartido nunca se corrompe a mitad de una escritura. async/await en JavaScript es azúcar sobre promesas que se resuelven cuando el loop les da turno. Swift usa las mismas palabras, async y await, sobre un modelo completamente distinto: hay varios hilos de verdad, el código después de un await puede continuar en otro, y dos tareas pueden tocar el mismo dato al mismo tiempo. Swift 6 convierte ese riesgo en un error de compilación. Este es el post más técnico de la serie porque el modelo de concurrencia es la parte de Swift que no se parece a nada que hayas usado en React Native, y también la que, una vez entendida, más confianza da en el código.

Resumen para perezosos
  • No hay event loop: await suspende la función y libera el hilo, y la continuación puede correr en otro hilo. async let y TaskGroup son el Promise.all, pero con paralelismo real y cancelación que baja a las hijas.
  • Un actor es una clase cuyo estado solo se toca desde dentro, una llamada a la vez. @MainActor es el actor del hilo principal, donde vive la UI. Sendable es la marca de que un valor puede cruzar entre actores sin riesgo.
  • Con el modo estricto de Swift 6, compartir un dato mutable entre tareas sin protección no compila. Los errores son ruidosos al principio y valen cada minuto: eliminan la clase de bug más difícil de reproducir.

En este artículo:

Sin event loop: qué significa await en Swift

En JavaScript, await significa “devuelve el control al event loop y sigue cuando la promesa se resuelva”. Como hay un solo hilo, al continuar sabes que nadie más tocó nada mientras esperabas: cualquier estado que leas después del await puede haber cambiado por otra tarea que corrió en ese turno, pero no simultáneamente.

En Swift, await marca un punto de suspensión: la función se pausa, el hilo queda libre para otras tareas, y cuando el resultado está listo la función continúa en algún hilo del pool cooperativo, no necesariamente el mismo. Hay tantos hilos como núcleos, y dos funciones async pueden estar ejecutándose al mismo tiempo, en núcleos distintos.

func loadDashboard() async throws -> Dashboard {
    let user = try await api.user()          // suspensión: el hilo se libera
    // Aquí puede que estés en otro hilo.
    let orders = try await api.orders()      // otra suspensión
    return Dashboard(user: user, orders: orders)
}

Las dos consecuencias que cambian todo:

  1. Las llamadas secuenciales siguen siendo secuenciales. El código de arriba espera al usuario y después pide los pedidos, igual que en JavaScript. Para que corran a la vez hay que pedirlo explícitamente, y eso es la siguiente sección.
  2. El estado compartido es peligroso de verdad. Si dos tareas modifican el mismo array desde hilos distintos, el resultado no es “una gana”: es memoria corrupta y un crash sin stack trace útil. JavaScript no tenía este problema porque no podía tenerlo. Swift lo resuelve con actores y con el compilador, no con disciplina.

Hay una parte que sí es igual: una función async solo se llama desde un contexto async, y el punto de entrada desde código síncrono es Task { ... }, el equivalente de llamar a una función async sin await en JavaScript. En SwiftUI, .task { } es ese punto de entrada con cancelación automática al salir de pantalla.

Tareas y concurrencia estructurada: async let y TaskGroup

Promise.all([a(), b()]) tiene dos traducciones, según si sabes cuántas operaciones hay en tiempo de compilación.

async let cuando el número es fijo. Cada async let lanza una tarea hija de inmediato; el await posterior recoge los resultados:

func loadDashboard() async throws -> Dashboard {
    async let user = api.user()              // empieza ya
    async let orders = api.orders()          // empieza ya, en paralelo
    async let promos = api.promotions()
    return try await Dashboard(user: user, orders: orders, promos: promos)
}

TaskGroup cuando el número es dinámico, como un Promise.all(ids.map(fetch)):

func loadThumbnails(for ids: [Photo.ID]) async throws -> [Photo.ID: UIImage] {
    try await withThrowingTaskGroup(of: (Photo.ID, UIImage).self) { group in
        for id in ids {
            group.addTask { (id, try await api.thumbnail(id)) }
        }
        var result: [Photo.ID: UIImage] = [:]
        for try await (id, image) in group {     // llegan en orden de finalización
            result[id] = image
        }
        return result
    }
}

La diferencia con Promise.all no está en la sintaxis sino en la palabra “estructurada”. Las tareas hijas de un async let o de un TaskGroup no pueden sobrevivir a la función que las creó. Si la función termina, lanza un error o se cancela, las hijas se cancelan y se esperan antes de salir. No existe la promesa que quedó corriendo después de que el componente se desmontó, ni el useEffect que actualiza estado de una pantalla que ya no está. El árbol de tareas refleja el árbol de llamadas.

loadDashboard()

   ├── async let user     ──► api.user()
   ├── async let orders   ──► api.orders()
   └── async let promos   ──► api.promotions()

   ▼ (espera a las tres; si una lanza, cancela a las otras dos y propaga)
return Dashboard

Task { } y Task.detached { } son las tareas no estructuradas: viven por su cuenta y hay que guardarlas y cancelarlas a mano. Se usan en los bordes (el @main, un botón, un manejador de notificación), no dentro de la lógica.

Actores y @MainActor: el estado que solo se toca desde dentro

Un actor es un tipo de referencia cuyo estado mutable solo puede leerse o modificarse desde dentro del propio actor, y el actor ejecuta una sola operación a la vez. Desde fuera, cada acceso es await, porque puede que el actor esté ocupado:

actor ImageCache {
    private var storage: [URL: UIImage] = [:]

    func image(for url: URL) -> UIImage? {
        storage[url]
    }

    func store(_ image: UIImage, for url: URL) {
        storage[url] = image
    }
}

let cache = ImageCache()
await cache.store(image, for: url)      // await: entra al actor cuando esté libre
let cached = await cache.image(for: url)

Es la solución al problema de la sección anterior: el diccionario no puede corromperse porque nunca lo tocan dos hilos a la vez. En JavaScript, cualquier objeto era un actor implícito porque había un solo hilo. En Swift, lo declaras.

Un detalle que cuesta al principio: dentro de un método del actor, un await a algo externo suspende el método y deja entrar a otras llamadas. El estado puede haber cambiado cuando el método continúa. Es el mismo comportamiento de JavaScript después de un await, y la regla es la misma: no asumas que lo que leíste antes del await sigue siendo cierto después.

actor ImageCache {
    func load(_ url: URL) async throws -> UIImage {
        if let cached = storage[url] { return cached }
        let image = try await downloader.fetch(url)   // otras llamadas entran aquí
        storage[url] = image                            // puede sobrescribir una que ya entró
        return image
    }
}

Para deduplicar de verdad, se guarda la Task en curso por clave y se espera esa tarea si ya existe. Es el patrón que el post de data mencionaba para evitar dos refresh() simultáneos.

@MainActor es un actor global que representa el hilo principal. Todo lo que toca la UI vive ahí: las vistas de SwiftUI, los ViewModels que observan, cualquier cosa de UIKit. Marcar una clase con @MainActor garantiza que sus métodos y propiedades solo se acceden desde el hilo principal, y el compilador obliga a hacer await para entrar desde fuera:

@Observable
@MainActor
final class OrdersViewModel {
    private(set) var orders: [Order] = []

    func load() async {
        // Este await corre en el pool; el actor queda libre.
        let fetched = try? await repository.fetchAll()
        // La continuación vuelve al MainActor: asignar aquí es seguro.
        orders = fetched ?? []
    }
}

Esto reemplaza al DispatchQueue.main.async que verías en código antiguo, y a la pregunta “¿estoy en el hilo principal?” que en React Native no existía porque el puente ya lo resolvía. Con @MainActor la respuesta la da el tipo, no el contexto de la llamada.

Sendable: qué puede cruzar la frontera

Si un actor protege su estado, ¿qué pasa con los valores que entran y salen de él? Un struct de solo valores se copia, y la copia es segura. Una class con propiedades mutables, no: el actor recibiría una referencia que otro hilo podría estar modificando.

Sendable es el protocolo que marca “este tipo puede cruzar entre dominios de aislamiento sin riesgo”. Los struct y enum cuyas propiedades son Sendable lo son automáticamente. Las clases, solo si son final, inmutables (let) o protegen su estado (por ejemplo, un lock interno, declarado con @unchecked Sendable bajo tu responsabilidad). Los actores son Sendable por definición. Las closures que cruzan se marcan @Sendable y solo pueden capturar valores Sendable.

struct Order: Sendable {              // struct de valores: Sendable automático
    let id: String
    let items: [OrderItem]            // requiere OrderItem: Sendable
}

final class Session: Sendable {       // clase: solo si todo es inmutable
    let token: String
    let expiresAt: Date
}

@Observable
final class CartStore { ... }         // no Sendable: mutable y sin actor

Es el motivo por el que el post de Swift insistía en modelar con struct: no solo por la semántica de valor, sino porque los struct cruzan actores gratis. Un modelo hecho de clases mutables es un modelo que el compilador de Swift 6 va a rechazar en cada frontera.

Swift 6: el data race como error de compilación

Con el lenguaje en modo Swift 6 (la opción de compilación -strict-concurrency=complete, que es la predeterminada en un proyecto nuevo con Swift 6), el compilador verifica todo lo anterior y rechaza el código que pueda producir un data race:

  • Pasar una clase no Sendable a una Task o a un actor.
  • Capturar una variable mutable en una closure @Sendable.
  • Tocar una propiedad @MainActor desde un contexto que no lo es sin await.
  • Una variable global mutable sin aislamiento (var a nivel de módulo).

Cada uno era un crash intermitente en producción que no se reproducía en desarrollo. Ahora son líneas rojas en Xcode antes de correr nada.

// Swift 6: error. `logger` es una clase mutable capturada por una closure Sendable.
final class Logger { var lines: [String] = [] }
let logger = Logger()
Task { logger.lines.append("hola") }

// Solución: hacerlo actor.
actor Logger {
    private var lines: [String] = []
    func log(_ line: String) { lines.append(line) }
}
let logger = Logger()
Task { await logger.log("hola") }

La experiencia de migrar un proyecto existente a Swift 6 es ruidosa: decenas o cientos de errores en la primera compilación, la mayoría de la misma familia. El camino que funciona es por módulos: activar el modo estricto package por package (una razón más para la modularización), empezando por el dominio (todo struct, cero errores) y terminando por la app. En un proyecto nuevo, se arranca en Swift 6 y los errores llegan de uno en uno, en el momento en que escribes el código que los causa.

Swift 6.2 agregó una opción importante para reducir ese ruido: el aislamiento a MainActor por defecto en un módulo (-default-isolation MainActor). Con eso, todo lo que no está marcado se asume en el hilo principal, que es donde vive la mayor parte del código de una app, y solo declaras explícitamente lo que sale de ahí (nonisolated o un actor propio). Para un target de app, es la configuración que recomiendo desde el día uno; para un package de red o persistencia, no.

Después de migrar un proyecto mediano a Swift 6 estricto, la sensación no fue de haber arreglado bugs sino de haber descubierto cuántos había: cada error del compilador señalaba un sitio donde una clase mutable cruzaba un hilo sin que nadie lo supiera. Ninguno se había reportado como crash todavía.

Cancelación estructurada frente a AbortController

En JavaScript, cancelar un fetch requiere crear un AbortController, pasar su signal, llamar a abort() y limpiar en el cleanup de useEffect. Y la cancelación solo llega a las operaciones a las que pasaste el signal.

En Swift, la cancelación es cooperativa y estructurada. Cancelar una tarea marca la tarea y todas sus hijas como canceladas. Las operaciones del sistema que suspenden (URLSession, Task.sleep) lanzan CancellationError al reanudarse si su tarea está cancelada. El código propio comprueba con Task.checkCancellation() o Task.isCancelled en los puntos donde tenga sentido:

func processAll(_ items: [Item]) async throws -> [Result] {
    var results: [Result] = []
    for item in items {
        try Task.checkCancellation()          // sale con CancellationError si cancelaron
        results.append(try await process(item))
    }
    return results
}

Y en SwiftUI, .task { } cancela sola cuando la vista desaparece, y .task(id:) cancela y relanza cuando el id cambia. Es el useEffect con cleanup y AbortController, sin escribir ninguno de los dos:

struct SearchScreen: View {
    @State private var query = ""
    @State private var results: [Product] = []

    var body: some View {
        List(results) { ProductRow(product: $0) }
            .searchable(text: $query)
            .task(id: query) {
                // Cada cambio de query cancela la búsqueda anterior y lanza esta.
                try? await Task.sleep(for: .milliseconds(300))   // debounce
                guard !Task.isCancelled else { return }
                results = (try? await api.search(query)) ?? []
            }
    }
}

Ese bloque es el debounce con cancelación que en React Native ocupaba un hook custom con useRef, setTimeout, clearTimeout y un AbortController. Aquí la cancelación es parte del modelo: el sleep lanza si cancelaron, y la petición de red no se dispara.

Los errores comunes al venir de JavaScript

  1. Poner await en serie cuando querías paralelo. Dos await seguidos son secuenciales. Si no dependen entre sí, async let.
  2. Usar Task { } para todo. Una Task no estructurada dentro de un método async rompe la cancelación y el manejo de errores. Dentro de código async, se usa async let o TaskGroup; Task { } solo en los bordes síncronos.
  3. Capturar self mutable en Task desde un ViewModel sin @MainActor. Swift 6 lo rechaza. Marca el ViewModel con @MainActor y desaparece el problema.
  4. Hacer un @Observable un actor. No funciona bien: SwiftUI necesita leer las propiedades de forma síncrona en body. Los objetos que la UI observa van en @MainActor, y el trabajo pesado se delega a actores propios o a funciones nonisolated.
  5. Olvidar que un actor “reentra” en cada await. Un chequeo antes del await no vale después. Se guarda la tarea en curso o se vuelve a comprobar.
  6. Bloquear el hilo con trabajo síncrono pesado dentro de una función async. El pool tiene tantos hilos como núcleos; un bucle de un segundo en uno de ellos frena a los demás. El trabajo de CPU se parte, o se marca nonisolated y se corre en una Task.detached con prioridad baja.
  7. Sincronizar con DispatchQueue o semáforos desde código async. Mezclar los dos modelos produce bloqueos que el runtime no puede detectar. Dentro de async, solo actores y await.

Preguntas frecuentes

¿Entonces DispatchQueue y GCD ya no se usan?

En código nuevo, casi nunca. Existen, son compatibles, y vas a verlos en librerías y ejemplos anteriores a 2021. La regla que sigo: dentro de un módulo escrito con async/await, no aparece DispatchQueue; en la frontera con una API que solo ofrece callbacks, withCheckedContinuation la convierte en async una vez, y el resto del módulo no se entera.

¿Cómo convierto una API de callbacks o un delegate a async/await?

Con withCheckedThrowingContinuation para una llamada única (un callback que se invoca exactamente una vez) y con AsyncStream para eventos repetidos (un delegate que emite varias veces). La regla dura: la continuación se reanuda exactamente una vez; hacerlo cero o dos veces es un crash en desarrollo, que es lo que “checked” significa.

¿Los actores son lentos? ¿Debería evitar await en caliente?

Un salto de actor cuesta más que una llamada directa, pero está en el orden de los microsegundos. Donde sí se nota es en bucles con miles de await a un actor: ahí conviene exponer un método que haga el trabajo en lote dentro del actor, en vez de mil entradas y salidas. Para el resto de una app, no es una preocupación.

¿@MainActor en todo el ViewModel no bloquea la UI?

No. @MainActor dice dónde se ejecuta el código síncrono del ViewModel, no dónde se espera. Cuando el ViewModel hace await repository.fetchAll(), el hilo principal queda libre durante la espera; solo la asignación del resultado vuelve al hilo principal, que es exactamente lo que quieres. Lo que bloquea es trabajo de CPU síncrono dentro del ViewModel, y eso se mueve a un actor o a una función nonisolated.

¿Cómo testeo código concurrente?

Las funciones async se testean con tests async (Swift Testing y XCTest los soportan). Para controlar el tiempo, se inyecta un reloj (any Clock) en vez de usar Task.sleep directo, y el test usa un reloj de prueba que avanza a mano. Para actores, se testea el comportamiento observable desde fuera; la garantía de exclusión la da el lenguaje, no hace falta testearla. El post de testing entra en detalle.

Conclusión

Swift usa async y await como JavaScript, pero sin event loop: hay hilos reales, la continuación puede cambiar de hilo, y el estado compartido puede corromperse. La respuesta del lenguaje es un sistema, no una convención: tareas estructuradas cuyo ciclo de vida sigue al de la función que las creó, actores que ejecutan una operación a la vez, @MainActor para todo lo que toca la UI, Sendable para lo que puede cruzar fronteras, y un compilador que en Swift 6 rechaza el código que pueda producir un data race. La cancelación deja de ser un AbortController que pasas a mano y pasa a ser una propiedad del árbol de tareas.

Para empezar: activa Swift 6 estricto desde el primer commit, marca los ViewModels con @MainActor, modela el dominio con struct para que sea Sendable sin esfuerzo, usa async let cuando dos llamadas no dependan entre sí, y confía en .task para cancelar. El siguiente post baja a disco: SwiftData, GRDB, Core Data y Keychain, con la decisión de cuál usar y por qué Realm dejó de ser una opción.

Seguir leyendo