Concurrencia en Swift 6: async/await, grupos, actores y GCD
Repaso avanzado con código de async/await, TaskGroup, actores, reentrancy, Sendable, cancelación y GCD.
1. async/await: suspensión, no hilo nuevo
Una función async puede suspenderse sin bloquear el hilo. await marca un posible punto de suspensión, no garantiza paralelismo. Prefiere APIs async de URLSession para I/O.
import Foundation
struct User: Decodable, Sendable { let id: Int }
func loadUser(from url: URL) async throws -> User {
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(User.self, from: data)
}2. Paralelismo estructurado: async let y TaskGroup
Usa async let cuando conoces el número de operaciones y TaskGroup cuando es dinámico. Los hijos permanecen ligados al scope, se espera a su finalización y la cancelación se propaga. Un grupo devuelve resultados según terminan, no en el orden de entrada.
func loadDashboard() async throws -> (User, [String]) {
async let user = loadCurrentUser()
async let accounts = loadAccountNames()
return try await (user, accounts)
}func fetchTitles(ids: [Int]) async throws -> [String] {
try await withThrowingTaskGroup(of: String.self) { group in
for id in ids {
group.addTask { try await fetchTitle(id: id) }
}
var titles: [String] = []
for try await title in group { titles.append(title) }
return titles
}
}Qué es un actor y qué problema resuelve
Un actor es un tipo por referencia, similar en apariencia a una clase, que aísla su estado mutable. Solo una tarea puede acceder a sus propiedades y métodos actor-isolated en un momento dado. Desde fuera del actor se utiliza await para cruzar el límite de aislamiento cuando es necesario. Esto protege frente a accesos simultáneos no sincronizados; no impide todos los errores lógicos ni sustituye el diseño correcto de las operaciones.
actor DownloadCounter {
private var downloads = 0
func recordDownload() {
downloads += 1
}
func total() -> Int {
downloads
}
}
func report(counter: DownloadCounter) async -> Int {
await counter.recordDownload()
return await counter.total()
}Actor no equivale a hilo ni a cola dedicada
El actor gestiona acceso aislado mediante su ejecutor, no promete ejecutar siempre en un mismo hilo. La propiedad nonisolated se reserva para elementos que no necesitan estado aislado. Los métodos síncronos aislados pueden ejecutarse sin await desde el mismo actor; desde otro contexto asíncrono se necesita el salto adecuado.
Reentrancy: una operación con await no es atómica
Si el método del actor llega a await, puede suspenderse y permitir que otra tarea entre antes de que termine. Por ejemplo, dos peticiones podrían comprobar que no hay token refrescándose y ambas iniciar uno si no guardas la Task antes del await. Guarda y comprueba un estado que sobreviva al punto de suspensión; vuelve a validar invariantes tras un await cuando sea necesario.
Trade-offs y errores de entrevista
Los actores simplifican la protección de estado compartido, pero añaden saltos asíncronos y no resuelven de forma automática cancelación, reentrancy, orden de respuesta ni consistencia entre varios actores. Usa un actor cuando realmente exista estado mutable compartido; no conviertas indiscriminadamente todas las clases en actores.
3. Actors y reentrancy
Un actor serializa el acceso aislado a su estado, pero no convierte en atómica una operación con await. Al suspenderse puede entrar otra llamada. Para evitar refrescos repetidos de token se guarda una Task en vuelo antes del await.
actor TokenRefresher {
private var inFlight: Task<String, any Error>?
private let refresh: @Sendable () async throws -> String
init(refresh: @escaping @Sendable () async throws -> String) {
self.refresh = refresh
}
func token() async throws -> String {
if let inFlight { return try await inFlight.value }
let task = Task { try await refresh() }
inFlight = task
defer { inFlight = nil }
return try await task.value
}
}
// Considera qué sucede si hay cancelaciones y llamadas solapadas.4. MainActor y Sendable
@MainActor aísla el estado de interfaz; no convierte una operación síncrona pesada en trabajo de background. Una Task creada en MainActor hereda ese contexto. Sendable modela valores seguros entre dominios de concurrencia.
import Observation
@MainActor @Observable
final class AccountsModel {
private(set) var names: [String] = []
func refresh() async throws {
let loaded = try await loadAccountNames()
names = loaded
}
}5. Cancelación cooperativa
Cancelar marca la tarea y propaga la señal, pero tu código debe cooperar. En bucles largos usa Task.checkCancellation() y APIs que respondan a la cancelación.
func process(ids: [Int]) async throws {
for id in ids {
try Task.checkCancellation()
try await processItem(id)
}
}6. GCD frente a concurrencia estructurada
GCD programa cierres en colas; Swift Concurrency modela tareas, suspensión, propagación de errores, ciclo de vida e aislamiento. GCD sigue siendo útil en interoperabilidad con APIs heredadas; no envuelvas llamadas async innecesariamente en DispatchQueue. Task.detached rompe la herencia de aislamiento y exige cuidado con cancelación y ciclo de vida.
| Herramienta | Cuándo usar | Precaución |
|---|---|---|
| async/await | Flujo asíncrono secuencial | No implica paralelismo |
| async let | Tareas independientes conocidas | Scope estructurado |
| TaskGroup | Paralelismo dinámico | Orden de finalización y cancelación |
| actor | Estado mutable compartido | Reentrancy con await |
| @MainActor | Estado de UI | Evitar cálculo síncrono costoso |
| Task.detached | Contexto independiente intencional | Gestionar ciclo de vida |
| DispatchQueue | Interop con callbacks y colas | No hay árbol estructurado |
Matiz de Swift 6
La versión del compilador no es lo mismo que el modo de lenguaje del target. Los diagnósticos de seguridad y el aislamiento por defecto dependen del modo de Swift y de ajustes del proyecto. Comprueba el target antes de asumir aislamiento predeterminado.
7. Task no estructurada, detached y puente desde GCD
Task { } crea una tarea no estructurada y hereda el contexto del actor desde el que se crea. No es hija de withTaskGroup: tienes que controlar su ciclo de vida. Task.detached renuncia intencionadamente a ese aislamiento. Prefiere tareas estructuradas cuando el padre debe poseer cancelación y resultados. Una API heredada basada en callbacks puede adaptarse con continuations si el callback finaliza exactamente una vez.
import SwiftUI
@MainActor
final class AccountsController {
func refreshTapped() {
Task {
// Hereda MainActor en este contexto.
let accounts = try? await loadAccountNames()
if let accounts { updateUI(with: accounts) }
}
}
func updateUI(with accounts: [String]) { /* update state */ }
}
// loadAccountNames() está implementada en otro módulo.import Foundation
func waitForCallback() async -> Int {
await withCheckedContinuation { continuation in
DispatchQueue.global().async {
let result = 42
continuation.resume(returning: result)
}
}
}
// Hay que reanudar la continuation exactamente una vez.Ojo: cancelar la Task no cancela automáticamente el bloque GCD heredado. Nunca reanudes la misma continuation dos veces y define una estrategia de cancelación si el callback lo requiere.
Notas personales de estudio. Verifica los matices según la versión de Swift/iOS y la documentación oficial.