---
title: "Concurrencia en Swift: adiós al event loop. async let, TaskGroup, actores y Swift 6"
description: "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."
author: Ramón Chancay
date: 2026-09-07
lang: es
tags: [Swift, Concurrencia, async/await, Actores, Swift 6, Sendable, iOS]
canonical: https://www.ramonchancay.me/es/blog/concurrencia-en-swift-adios-al-event-loop
---

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

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.

<details class="rc-tldr">
<summary class="rc-tldr-btn"><span class="rc-tldr-dot"></span> Resumen para perezosos <svg class="rc-tldr-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></summary>
<div class="rc-tldr-body">
<ul>
<li>No hay event loop: <code>await</code> suspende la función y libera el hilo, y la continuación puede correr en otro hilo. <code>async let</code> y <code>TaskGroup</code> son el <code>Promise.all</code>, pero con paralelismo real y cancelación que baja a las hijas.</li>
<li>Un actor es una clase cuyo estado solo se toca desde dentro, una llamada a la vez. <code>@MainActor</code> es el actor del hilo principal, donde vive la UI. <code>Sendable</code> es la marca de que un valor puede cruzar entre actores sin riesgo.</li>
<li>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.</li>
</ul>
</div>
</details>

**En este artículo:**

- **El modelo** — [Sin event loop](#sin-event-loop-qué-significa-await-en-swift) · [Tareas y concurrencia estructurada](#tareas-y-concurrencia-estructurada-async-let-y-taskgroup)
- **El aislamiento** — [Actores y @MainActor](#actores-y-mainactor-el-estado-que-solo-se-toca-desde-dentro) · [Sendable](#sendable-qué-puede-cruzar-la-frontera)
- **La práctica** — [Swift 6 estricto](#swift-6-el-data-race-como-error-de-compilación) · [Cancelación](#cancelación-estructurada-frente-a-abortcontroller) · [Errores comunes](#los-errores-comunes-al-venir-de-javascript)

## 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.

```swift
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:

```swift
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))`:

```swift
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.

```text
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:

```swift
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.

```swift
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](/es/blog/data-en-swift-lo-que-tanstack-query-hacia-por-ti) 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:

```swift
@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`.

```swift
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
// 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](/es/blog/estado-y-arquitectura-de-zustand-a-observable-y-tca)), 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:

```swift
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:

```swift
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](/es/blog/testing-y-tooling-en-ios-para-seniors) 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](/es/blog/persistencia-en-ios-swiftdata-grdb-keychain) 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.
