← Biblioteca técnica
Concurrency · APUNTE DE ESTUDIO

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.

Networking con async/await
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.

Hijos conocidos
func loadDashboard() async throws -> (User, [String]) {
    async let user = loadCurrentUser()
    async let accounts = loadAccountNames()
    return try await (user, accounts)
}
TaskGroup dinámico
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 básico con estado protegido
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.

Token refresh con Task compartida
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.

Modelo para UI aislado
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.

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

Trade-offs
HerramientaCuándo usarPrecaución
async/awaitFlujo asíncrono secuencialNo implica paralelismo
async letTareas independientes conocidasScope estructurado
TaskGroupParalelismo dinámicoOrden de finalización y cancelación
actorEstado mutable compartidoReentrancy con await
@MainActorEstado de UIEvitar cálculo síncrono costoso
Task.detachedContexto independiente intencionalGestionar ciclo de vida
DispatchQueueInterop con callbacks y colasNo 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.

Task creada desde MainActor
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.
Adaptar un callback de GCD
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.

FUENTES Y DOCUMENTACIÓN

Notas personales de estudio. Verifica los matices según la versión de Swift/iOS y la documentación oficial.