wandres.dev
ACTORES A FONDO · global actors y reentrada

Global actors: `@MainActor` y el arte de aislar un dominio entero

Un actor protege una instancia. Un actor global protege un concepto: todas las declaraciones marcadas con él comparten un único ejecutor, estén donde estén. Cómo funciona `@MainActor`, cómo se declara el tuyo y hasta dónde llega la inferencia.

⏱ 19 min

El actor de instancia resuelve un problema de propiedad: este estado es mío y se accede por turnos. Pero hay recursos que no pertenecen a ningún objeto y que aun así exigen un turno único en todo el proceso: la jerarquía de vistas, un contexto de base de datos, un motor gráfico que solo tolera un hilo de comandos. Para ellos, Swift ofrece el actor global: un atributo que se aplica a tipos, funciones, propiedades o closures dispersos por todo el programa, y que los recoge a todos bajo el mismo ejecutor. @MainActor es el ejemplar más famoso, pero no tiene nada de especial salvo el ejecutor que elige. Entender que es un caso particular de un mecanismo general cambia por completo la forma de estructurar una aplicación concurrente.

🎯 Al terminar esta lección sabrás
  • Explicar la relación entre el atributo @globalActor, el protocolo GlobalActor y la instancia shared.
  • Aplicar @MainActor a tipos, miembros y closures, y saber cuándo hace falta MainActor.run o assumeIsolated.
  • Declarar un actor global propio y aislar con él todo un subsistema, incluidas conformidades de protocolo.
  • Anticipar las reglas de inferencia de aislamiento y las trampas de anotar de más o de menos.

Un aislamiento sin instancia

Un actor global es un tipo marcado con @globalActor que expone una instancia única. El compilador usa esa instancia como el ejecutor de todo lo que lleve la anotación.

@globalActor
actor ActorDatos {
    static let shared = ActorDatos()
}

@ActorDatos
final class Repositorio {
    private var filas: [Fila] = []          // aislado a ActorDatos.shared
    func insertar(_ f: Fila) { filas.append(f) }
}

@ActorDatos
func compactar(_ r: Repositorio) {
    // Sincrono: ambos viven en el mismo dominio, no hace falta await
    r.insertar(.marcaDeCompactacion)
}

Lo importante está en la última línea del comentario. Repositorio y compactar son declaraciones sin ninguna relación de propiedad —una es una clase, la otra una función libre— y sin embargo se llaman entre sí de forma síncrona, porque comparten dominio de aislamiento. Eso es exactamente lo que un actor de instancia no puede darte: dos actores distintos siempre se hablan con await, por muy relacionados que estén conceptualmente.

@MainActor es literalmente esto, con el ejecutor atado al hilo principal:

@MainActor
final class ModeloPantalla: ObservableObject {
    @Published private(set) var titulo = ""

    func cargar(id: Int) async {
        let texto = await servicio.titulo(de: id)   // sale del hilo principal
        titulo = texto                              // vuelve, y el compilador lo sabe
    }
}

La disciplina que antes se escribía a mano —comprobar Thread.isMainThread, despachar a la cola principal, rezar— pasa a ser una propiedad del tipo. Y las dos utilidades que quedan cubren los bordes:

// Desde cualquier contexto asincrono, entrar un momento en el dominio principal
await MainActor.run { panel.titulo = "listo" }

// Desde un metodo sincrono que el framework garantiza en el hilo principal
nonisolated func urlSession(_ s: URLSession, didFinish t: URLSessionTask) {
    MainActor.assumeIsolated { panel.terminado = true }
}

MainActor.run ejecuta un bloque en el dominio principal desde un contexto asíncrono cualquiera y devuelve su resultado. MainActor.assumeIsolated es distinto y más delicado: no cambia de dominio, sino que afirma que ya estamos en él, y aborta el proceso si la afirmación resulta falsa. Es la herramienta para implementar delegados heredados de frameworks que prometen el hilo principal en su documentación pero no lo declaran en su firma.

flowchart TB
ga[ActorDatos punto shared] --> exe[Un unico ejecutor serie]
t1[Clase Repositorio anotada] --> exe
t2[Funcion compactar anotada] --> exe
t3[Propiedad global anotada] --> exe
t4[Closure anotada] --> exe
exe --> inv[Invariante: nunca dos a la vez en este dominio]
style exe fill:#89b4fa,color:#11111b
style inv fill:#a6e3a1,color:#11111b

Aislar un subsistema completo

La potencia real aparece cuando el actor global se usa como frontera arquitectónica. En vez de anotar métodos sueltos, se anota el protocolo que define el subsistema y la anotación desciende a todo lo que conforme:

@ActorDatos
protocol AlmacenPersistente {
    var pendientes: Int { get }
    func guardar(_ f: Fila) throws
    func purgar() throws
}

// La conformidad hereda el aislamiento: no hace falta repetir el atributo
final class AlmacenSQLite: AlmacenPersistente {
    private var conexion: OpaquePointer?
    var pendientes: Int { /* ... */ 0 }
    func guardar(_ f: Fila) throws { /* ... */ }
    func purgar() throws { /* ... */ }
}

Con eso, todo el subsistema de persistencia vive en un único ejecutor serie, sus tipos internos se llaman entre sí sin await, y la única frontera asíncrona del programa es la que separa el almacén del resto. El código interno recupera la simplicidad del monohilo sin renunciar a la garantía; el coste asíncrono se paga una vez, en el borde, y no en cada llamada intermedia.

🎭

Actor de instancia

Aísla el estado de un objeto. Cada instancia tiene su ejecutor, así que dos instancias progresan en paralelo. Ideal para entidades que se replican.

🌐

Actor global

Aísla un concepto en todo el proceso. Un solo ejecutor para todas las declaraciones anotadas. Ideal para recursos singulares: la interfaz, un motor, una conexión.

Inferencia: lo que no escribes también cuenta

El aislamiento se propaga por caminos que conviene conocer, porque explican tanto los errores inesperados como los aciertos silenciosos. Un miembro hereda el aislamiento de su tipo contenedor. Una conformidad hereda el del protocolo. Una subclase hereda el de su superclase. Una closure no Sendable hereda el del contexto donde se escribió, mientras que una que sí lo sea no hereda nada y arranca sin aislamiento. Y una propiedad envuelta con un wrapper anotado propaga la anotación al miembro que la usa, que es la razón por la que las vistas de SwiftUI acaban en el hilo principal sin que nadie lo escriba.

@MainActor
final class Panel {
    var contador = 0

    func programar() {
        Task {                       // hereda MainActor: no hay salto de dominio
            contador += 1
        }
        Task.detached {              // no hereda nada: aislamiento vacio
            await self.sumar()       // await obligatorio
        }
    }

    func sumar() { contador += 1 }
}

La versión moderna del lenguaje va un paso más allá y permite elegir el aislamiento por defecto de un módulo entero, de modo que todo lo que no diga lo contrario quede en el actor principal y solo lo explícitamente marcado salga de él. Para una aplicación de interfaz, ese ajuste invierte la carga de la prueba en la dirección correcta: lo excepcional es la concurrencia, no la seguridad.

💡
Anotar de más también es un error

Marcar todo con @MainActor compila y elimina diagnósticos, pero convierte el hilo principal en el cuello de botella del programa. La anotación debe seguir al recurso, no al miedo: si un tipo no toca la interfaz ni ningún recurso singular, no pertenece al actor principal.

Un ejecutor como nombre de un invariante

Lo que un actor global reifica no es un hilo, sino una regla de exclusión que hasta ahora solo existía en la documentación. «Esta API debe llamarse desde el hilo principal» era una frase en un comentario, un aserto en tiempo de ejecución con suerte, y una fuente inagotable de fallos que solo aparecían bajo carga. El atributo convierte esa frase en un tipo, y al hacerlo la somete a las mismas operaciones que cualquier otro tipo: se comprueba en compilación, se propaga por conformidad y herencia, se documenta solo, y aparece en la firma de cada función que participe. Hay aquí una idea que trasciende a Swift. Los sistemas concurrentes siempre han estado organizados por dominios de exclusión —el hilo de audio, la cola de la base de datos, el bucle de eventos— pero esos dominios eran entidades sociales, sostenidas por convenciones entre personas, no por el lenguaje. Cuando el dominio se vuelve nombrable, se puede razonar sobre el grafo de dominios de un programa igual que se razona sobre su grafo de módulos: cuántos hay, quién habla con quién, dónde están las fronteras asíncronas. Y aparece la pregunta de diseño que realmente importa, que ya no es «¿qué hilo ejecuta esto?» sino «¿cuántos dominios de exclusión necesita mi programa y por qué?». La respuesta suele ser un número pequeño y perfectamente justificable, y descubrir cuál es en tu caso vale más que cualquier optimización posterior. Que el ejecutor del actor principal resulte ser el hilo de la interfaz es, visto así, un accidente de implementación: lo esencial es que existe un nombre para el invariante, y que ese nombre viaja con el código.

⚔️ Diseña tus dominios
  1. Declara un actor global ActorRed y aísla en él un cliente HTTP completo. Comprueba que sus tipos internos se llaman sin await y que solo el borde exige suspensión.
  2. Anota un protocolo con tu actor global y verifica que una conformidad sin anotar hereda el aislamiento. Intenta luego conformar desde un tipo @MainActor y explica el diagnóstico.
  3. Escribe un método de delegado de un framework antiguo que garantice el hilo principal y resuélvelo con MainActor.assumeIsolated. Razona qué ocurre si la garantía fuese falsa.
  4. Compara Task y Task.detached dentro de un tipo @MainActor imprimiendo el aislamiento efectivo en cada caso.
  5. Toma un módulo tuyo y dibuja su grafo de dominios: cuántos ejecutores distintos hay, qué recurso justifica cada uno y cuántas fronteras asíncronas cruza una operación típica de usuario.