La concurrencia es de esos temas que dan miedo hasta que alguien te los explica bien. Antes de Swift 5.5 escribíamos código asíncrono con completion handlers anidados, DispatchQueue por todos lados y bugs de threading imposibles de reproducir. La concurrencia moderna de Swift cambió eso: hoy el código asíncrono se lee casi como código secuencial, y el compilador te protege de los data races.
En esta guía repasamos, de lo básico a lo avanzado, las piezas que necesitas para escribir concurrencia correcta en Swift moderno: async/await, Task, concurrencia estructurada, actor, @MainActor y Sendable.
El problema que resuelve
Una app no puede quedarse congelada esperando a que termine una descarga o una consulta a disco. Ese trabajo debe correr “en segundo plano” mientras el hilo principal sigue atendiendo la interfaz. El enfoque clásico eran los completion handlers:
func cargarPerfil(id: Int, completion: @escaping (Result<Perfil, Error>) -> Void) {
descargarUsuario(id) { resultadoUsuario in
switch resultadoUsuario {
case .success(let usuario):
descargarAvatar(usuario.avatarURL) { resultadoAvatar in
// ... y así seguimos anidando
}
case .failure(let error):
completion(.failure(error))
}
}
}
Esto es la famosa “pirámide de la perdición”: difícil de leer, difícil de manejar errores y muy fácil de olvidar llamar al completion en algún camino. La concurrencia moderna elimina todo eso.
async / await — la base de todo
Una función marcada con async es una función que puede suspenderse: pausa su ejecución mientras espera un resultado y libera el hilo para que haga otra cosa. Cuando el resultado llega, la función se reanuda justo donde estaba.
Para llamar a una función async usas await, que marca el punto de suspensión:
func cargarPerfil(id: Int) async throws -> Perfil {
let usuario = try await descargarUsuario(id)
let avatar = try await descargarAvatar(usuario.avatarURL)
return Perfil(usuario: usuario, avatar: avatar)
}
Compara esto con la versión de completion handlers: se lee de arriba abajo, el manejo de errores es un try normal, y no hay forma de “olvidar” devolver el resultado. El await no bloquea el hilo — lo libera mientras espera.
Reglas clave:
- Solo puedes usar
awaitdentro de un contexto asíncrono (otra funciónasynco unaTask). asyncythrowsse combinan:async throwses lo habitual para trabajo que puede fallar.- El orden se respeta: en el ejemplo, el avatar se descarga después del usuario, porque uno depende del otro.
Task — el puente desde código síncrono
¿Y cómo llamas a una función async desde un contexto que no lo es, como el action de un botón en SwiftUI? Con Task. Una Task crea un nuevo contexto asíncrono y ejecuta el trabajo:
Button("Cargar") {
Task {
do {
let perfil = try await cargarPerfil(id: 42)
self.perfil = perfil
} catch {
self.error = error
}
}
}
La Task hereda el contexto desde donde se crea (prioridad y actor). Además puedes cancelarla: guarda la referencia y llama a cancel(). Dentro del trabajo asíncrono, revisa Task.isCancelled o usa try Task.checkCancellation() para abortar de forma cooperativa.
let tarea = Task {
for pagina in 1...100 {
try Task.checkCancellation() // lanza si fue cancelada
await procesar(pagina)
}
}
// más tarde…
tarea.cancel()
Concurrencia estructurada — hacer varias cosas a la vez
Hasta ahora todo era secuencial. Pero muchas veces quieres lanzar tareas en paralelo y esperar a que todas terminen. Para eso está la concurrencia estructurada.
async let
Cuando sabes de antemano cuántas tareas paralelas hay, async let es lo más directo:
func cargarDashboard() async throws -> Dashboard {
async let usuario = descargarUsuario(id)
async let noticias = descargarNoticias()
async let clima = descargarClima()
// Las tres descargas ya están corriendo en paralelo.
// await espera a las tres:
return try await Dashboard(usuario: usuario, noticias: noticias, clima: clima)
}
Las tres descargas arrancan a la vez; el await del return espera a que las tres terminen. Si cada una tarda 1 segundo, el total es ~1 segundo, no 3.
TaskGroup
Cuando el número de tareas es dinámico (por ejemplo, descargar N imágenes), usa un TaskGroup:
func descargarImagenes(_ urls: [URL]) async throws -> [Imagen] {
try await withThrowingTaskGroup(of: Imagen.self) { grupo in
for url in urls {
grupo.addTask { try await descargarImagen(url) }
}
var imagenes: [Imagen] = []
for try await imagen in grupo {
imagenes.append(imagen)
}
return imagenes
}
}
Lo bello de la concurrencia estructurada es su nombre: es estructurada. Si una tarea hija falla o el padre se cancela, las demás hijas se cancelan automáticamente. No hay tareas huérfanas corriendo por ahí.
Actors — adiós a los data races
Un data race ocurre cuando dos hilos acceden a la misma memoria mutable al mismo tiempo y al menos uno escribe. Son la fuente de los bugs de concurrencia más difíciles de cazar. Un actor los elimina de raíz: serializa el acceso a su estado, garantizando que solo una tarea toca su interior a la vez.
actor Contador {
private var valor = 0
func incrementar() {
valor += 1
}
func total() -> Int {
valor
}
}
Desde fuera, un actor se comporta casi como una clase, pero acceder a su estado es asíncrono (porque puede que tengas que esperar tu turno):
let contador = Contador()
await contador.incrementar()
let total = await contador.total()
Ese await es la clave: el compilador te obliga a esperar, y el actor se asegura de que no haya dos accesos concurrentes. Adiós a los locks manuales y a los data races.
@MainActor — la UI siempre en el hilo principal
Todo lo que toca la interfaz debe correr en el hilo principal. @MainActor es un actor global que representa ese hilo. Anota con @MainActor una clase, una función o una propiedad y garantizas que ese código corre en el main thread:
@MainActor
final class PerfilViewModel: ObservableObject {
@Published var perfil: Perfil?
func cargar(id: Int) async {
// Esto corre en el hilo principal, seguro para actualizar la UI.
perfil = try? await cargarPerfil(id: id)
}
}
En SwiftUI, las vistas ya están aisladas al @MainActor, así que actualizar @State desde una Task dentro de una vista es seguro. Cuando necesites saltar al hilo principal manualmente:
await MainActor.run {
self.label.text = "Listo"
}
Sendable — seguridad entre límites de concurrencia
Cuando un valor cruza de una tarea o actor a otro, Swift necesita garantizar que se puede transferir con seguridad. El protocolo Sendable marca justamente eso: tipos seguros de compartir entre contextos de concurrencia.
- Los tipos por valor con miembros
Sendable(structs deInt,String, etc.) sonSendableautomáticamente. - Las clases mutables no son
Sendablepor defecto (podrían tener estado compartido mutable). - Puedes marcar una clase inmutable como
Sendablesi garantizas que es segura.
struct Usuario: Sendable { // structs de valores Sendable → Sendable gratis
let id: Int
let nombre: String
}
final class Cache: Sendable { // clase inmutable, segura de compartir
let capacidad: Int
init(capacidad: Int) { self.capacidad = capacidad }
}
Con el modo estricto de concurrencia de Swift 6, el compilador te avisa si intentas pasar un tipo no-Sendable a través de un límite de concurrencia. Es molesto al principio, pero es exactamente lo que evita los data races en tiempo de compilación.
AsyncSequence — un stream de valores en el tiempo
Así como Sequence produce valores uno tras otro, AsyncSequence produce valores asíncronos que puedes recorrer con for await:
for await notificacion in centro.notificaciones {
manejar(notificacion)
}
Es ideal para streams: eventos, mensajes de un WebSocket, líneas de un archivo grande. Y como todo en la concurrencia moderna, respeta la cancelación.
Errores comunes que conviene evitar
- Bloquear con
awaitpensando que es síncrono.awaitno bloquea el hilo; lo suspende. No lo uses dentro de bucles ajustados esperando que “espere sin más”. - Crear
Task { }sueltas sin manejar cancelación. Si la vista desaparece, esas tareas siguen. Guarda la referencia o usa.task { }en SwiftUI, que cancela sola. - Actualizar la UI fuera del
@MainActor. Si ves warnings de “publishing changes from background threads”, te falta aislar al main actor. - Ignorar los warnings de
Sendable. No los silencies con@unchecked Sendablea la ligera; suelen apuntar a un data race real.
Un ejemplo completo
Para verlo todo junto, este es un ejemplo listo para pegar en Xcode. Al tocar el botón se lanza una Task que “trabaja” 1.5 segundos con Task.sleep (sin bloquear la interfaz) y luego actualiza el estado. Fíjate cómo la vista sigue respondiendo mientras tanto y cómo la actualización de @State desde la Task es segura porque la vista está aislada al @MainActor.
import SwiftUI
struct ContentView: View {
@State private var estado = "Toca para cargar"
@State private var cargando = false
var body: some View {
VStack(spacing: 24) {
Text(estado)
.font(.title3).bold()
if cargando {
ProgressView()
}
Button("Cargar datos") {
cargando = true
estado = "Cargando…"
Task {
try? await Task.sleep(for: .seconds(1.5))
estado = "✅ Datos listos"
cargando = false
}
}
.buttonStyle(.borderedProminent)
.disabled(cargando)
}
.padding()
}
}
Resumen
- async/await: escribe código asíncrono que se lee como secuencial.
awaitsuspende, no bloquea. - Task: el puente desde código síncrono hacia el mundo asíncrono; soporta cancelación cooperativa.
- Concurrencia estructurada (
async let,TaskGroup): lanza trabajo en paralelo con cancelación automática de las tareas hijas. - actor: serializa el acceso a estado mutable y elimina los data races por diseño.
- @MainActor: garantiza que el código de UI corre en el hilo principal.
- Sendable: el compilador verifica en tiempo de compilación qué es seguro cruzar entre contextos de concurrencia.
La concurrencia moderna de Swift no solo hace el código más legible: mueve toda una clase de bugs de tiempo de ejecución a tiempo de compilación. Vale la pena pelear con los warnings de Sendable al principio; a cambio, obtienes código concurrente que es correcto por construcción.