Concurrencia Swift avanzada: custom executors, aislamiento de datos y actores globales propios

La concurrencia avanzada de Swift va más allá de async/await y los actores básicos. Los custom executors permiten controlar exactamente en qué hilo corre un actor; los actores globales propios (@globalActor) crean dominios de aislamiento reutilizables; nonisolated declara que un método no necesita aislamiento; y assumeIsolated resuelve los casos donde el compilador no puede verificar el aislamiento pero el desarrollador sabe que es correcto.

Custom executors con SerialExecutor

Por defecto, los actores usan el cooperative thread pool de Swift. Un custom executor redirige la ejecución a un hilo específico, útil para integrar con APIs que exigen ejecutarse siempre en el mismo hilo (OpenGL, SQLite en modo WAL, librerías C con estado por hilo):

import Foundation

// 1. Crear el executor: un SerialDispatchQueueExecutor
final class HiloFijoExecutor: SerialExecutor {
    private let queue: DispatchQueue

    init(etiqueta: String) {
        self.queue = DispatchQueue(label: etiqueta)
    }

    func enqueue(_ trabajo: consuming ExecutorJob) {
        let trabajoCopy = UnownedJob(trabajo)
        queue.async {
            trabajoCopy.runSynchronously(on: self.asUnownedSerialExecutor())
        }
    }

    func asUnownedSerialExecutor() -> UnownedSerialExecutor {
        UnownedSerialExecutor(ordinary: self)
    }
}

// 2. Actor que usa el executor personalizado
actor BaseDatosActor {
    // El executor garantiza que todo el código del actor
    // corre en esta queue específica
    nonisolated var unownedExecutor: UnownedSerialExecutor {
        ejecutor.asUnownedSerialExecutor()
    }

    private let ejecutor = HiloFijoExecutor(etiqueta: "com.miapp.bbdd")
    private var conexion: OpaquePointer?  // Conexión SQLite, por ejemplo

    init() {
        // La inicialización también corre en el executor
    }

    func consultar(_ sql: String) -> [[String: Any]] {
        // Corre en el hilo de 'ejecutor', seguro para SQLite
        []
    }
}

Definir un @globalActor propio

@MainActor es el actor global más conocido. Puedes crear los tuyos para aislar dominios de trabajo específicos (base de datos, rendering, lógica de audio):

// Definir el actor global
@globalActor
actor AudioActor {
    static let shared = AudioActor()
    private init() {}
}

// Anotar clases o métodos que pertenecen a este dominio
@AudioActor
class MotorAudio {
    var volumen: Float = 1.0

    func reproducir(archivo: URL) {
        // Todo este código corre en AudioActor.shared
        print("Reproduciendo: (archivo.lastPathComponent)")
    }
}

// Función global aislada al AudioActor
@AudioActor
func ajustarVolumen(_ nivel: Float) {
    MotorAudio().volumen = nivel
}

// Llamar desde código no aislado:
Task {
    await ajustarVolumen(0.8)  // await obligatorio fuera del actor
}

// Dentro de @AudioActor no hace falta await:
@AudioActor
func inicializarAudio() {
    ajustarVolumen(1.0)  // sin await: mismo dominio de aislamiento
}

nonisolated: métodos sin aislamiento

A veces un método de un actor no accede a estado mutable y puede ejecutarse desde cualquier contexto sin necesidad de await. nonisolated lo declara explícitamente:

actor GestorSesion {
    var token: String?
    let appID: String  // inmutable, puede leerse sin aislamiento

    init(appID: String) {
        self.appID = appID
    }

    // nonisolated: no accede a estado mutable del actor
    nonisolated func urlBase() -> URL {
        URL(string: "https://api.ejemplo.com/(appID)")!
    }

    // nonisolated + Hashable/Equatable son necesarios en Swift 6
    nonisolated var id: String { appID }
}

// urlBase() se puede llamar sin await desde cualquier contexto:
let gestor = GestorSesion(appID: "miapp")
let url = gestor.urlBase()  // sin await

assumeIsolated: cuando el compilador no puede verificar

Hay situaciones donde el desarrollador sabe que el código corre en el contexto correcto pero el compilador no puede verificarlo —por ejemplo, dentro de un callback de una librería C que el desarrollador llama siempre desde el hilo correcto—:

@MainActor
class VistaController {
    var estado: String = "inicial"

    func configurarCallbackLibreriaC() {
        // La librería C llama este closure en el hilo principal,
        // pero el compilador no lo sabe
        LibreriaC.setCallback {
            // Sin assumeIsolated: error de compilación en Swift 6
            // porque el closure no está aislado a @MainActor

            MainActor.assumeIsolated {
                // Dentro: el compilador confía en nuestra afirmación
                self.estado = "actualizado"  // acceso seguro
            }
        }
    }
}

assumeIsolated lanza un error en runtime si la afirmación es falsa (el código no corre en ese actor), por lo que es más seguro que un cast sin verificación.

Precauciones y antipatrones

Precauciones y antipatrones

El antipatrón más frecuente con custom executors es crear un executor por actor cuando en realidad todos podrían compartir el mismo pool. Un executor por actor significa un hilo por actor, lo que puede saturar el sistema con cientos de hilos activos. En la mayoría de casos, el executor cooperativo por defecto de Swift es la elección correcta y los custom executors quedan reservados para integración con librerías que requieren afinidad de hilo.

// MAL: executor propio cuando no hace falta
actor MiActor {
    nonisolated var unownedExecutor: UnownedSerialExecutor {
        // No es necesario si el código no tiene requisitos de hilo específicos
        miQueue.asUnownedSerialExecutor()
    }
}

// BIEN: dejar el executor por defecto (cooperative pool)
actor MiActorSimple {
    var estado: String = ""
    func actualizar(_ nuevo: String) { estado = nuevo }
}

Resumen

Los custom executors con SerialExecutor dan control total sobre el hilo de ejecución de un actor, necesario para librerías con afinidad de hilo. Los actores globales con @globalActor crean dominios de aislamiento reutilizables más allá de @MainActor. nonisolated elimina el overhead de await en métodos que no acceden a estado mutable. Y assumeIsolated resuelve los casos donde la seguridad de aislamiento se garantiza por contrato externo pero el compilador no puede verificarla. Juntos, estos cuatro mecanismos completan el modelo de concurrencia de Swift para los escenarios más exigentes.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP