wandres.dev
CONCURRENCIA EN LA UI · MainActor y tareas

El actor principal: MainActor y la interfaz

Por qué todo lo que toca la pantalla vive en un único actor, qué significa exactamente anotar un tipo con @MainActor, y cómo salir del hilo principal y volver sin romper el aislamiento.

⏱ 15 min

UIKit y AppKit siempre exigieron lo mismo: la interfaz se toca desde el hilo principal. Durante quince años esa regla vivió en la documentación y en la memoria del programador, y se rompía en silencio hasta que la app se corrompía sin patrón reconocible. Swift moderno la convirtió en un tipo. @MainActor no es una anotación de cortesía: es una promesa que el compilador verifica, y comprenderla transforma una clase entera de fallos aleatorios en errores de compilación.

🎯 Al terminar esta lección sabrás
  • Entender por qué la interfaz exige un único hilo y qué modela @MainActor.
  • Anotar tipos, métodos y propiedades con el aislamiento correcto.
  • Salir del actor principal para trabajar y volver para publicar el resultado.
  • Usar nonisolated y Sendable con criterio bajo concurrencia estricta.

Por qué la interfaz cabe en un solo hilo

El árbol de vistas, la jerarquía de capas, el gestor de eventos y el ciclo de dibujo son un grafo mutable enorme y sin sincronización interna. Protegerlo con locks sería catastrófico: el coste de adquirir y liberar candados en cada acceso a cada propiedad destruiría el rendimiento, y la disciplina de orden entre miles de locks sería inmantenible. La alternativa que eligieron todos los sistemas de ventanas relevantes es más barata: confinar el grafo entero a un hilo y exigir que cualquier mutación llegue por ahí.

@MainActor es la expresión de ese confinamiento en el sistema de tipos. Es un actor global: un actor único para todo el proceso cuyo ejecutor es la cola principal. Marcar algo con él significa que su estado solo se toca desde ese ejecutor, y el compilador se encarga de que sea cierto.

@MainActor
final class SesionViewModel {
    var usuario: Usuario?     // solo mutable desde el actor principal
    var cargando = false

    func iniciar() { cargando = true }   // método aislado
}

Merece la pena subrayar una asimetría del diseño. Un actor normal es una isla anónima: el runtime le asigna hilos del pool cooperativo según convenga y no prometes nada sobre cuál. @MainActor es un actor con ejecutor fijado: su cola es la principal, la misma que atiende eventos táctiles y dibujo, y esa identidad concreta es justo lo que el sistema de ventanas necesita. De ahí que sea el único actor global que casi todo el mundo va a usar a diario.

Desde iOS 17 hay un detalle que se agradece: los tipos View, App y Scene de SwiftUI ya están anotados, y lo mismo hace la macro @Observable para los modelos que se pintan. Por eso escribir una vista y tocar su estado funciona sin que hayas anotado nada: el aislamiento ya venía puesto.

Qué significa exactamente estar aislado

Anotar un tipo aísla todas sus propiedades almacenadas y todos sus métodos. Eso tiene tres consecuencias que conviene tener presentes.

  • Desde dentro, el acceso es síncrono. Un método aislado llama a otro método aislado sin await: ya estáis en el mismo actor.
  • Desde fuera, el acceso es asíncrono. Cualquier contexto no aislado que quiera leer o llamar necesita await, y ese await es un punto de suspensión real donde otras cosas pueden ocurrir.
  • La anotación se hereda a la baja. Un tipo aislado propaga el aislamiento a sus miembros salvo que los marques explícitamente al contrario.
func desdeFuera() async {
    let modelo = await SesionViewModel()   // hasta el init está aislado
    await modelo.iniciar()                 // salto al actor principal
}

El await no significa “esto tarda”. Significa “aquí puede haber un cambio de contexto y el mundo puede haber cambiado cuando vuelvas”. Esa lectura es la única correcta y la que evita el error del que hablaremos en la lección 15.5.

El aislamiento convierte una convención en una prueba

Antes de los actores, “toca la UI solo en el hilo principal” era una regla cultural: se enseñaba, se olvidaba y se violaba. La violación no producía un fallo inmediato sino una corrupción latente —una vista que se dibuja a medias, un crash tres pantallas después, un informe irreproducible—, y la herramienta para detectarla era una comprobación en tiempo de ejecución que solo saltaba si tenías suerte de pasar por ahí. @MainActor mueve esa comprobación al compilador. La diferencia epistemológica es enorme: pasas de no haber observado un fallo a haber demostrado que no puede ocurrir. Y el precio de esa demostración es real: el compilador te obligará a declarar fronteras que antes cruzabas sin pensar, a marcar tipos como Sendable, a insertar await en sitios donde no esperabas suspender. Cada una de esas fricciones es un sitio donde tu código anterior podía romperse y tú no lo sabías. La molestia no es el impuesto del modelo; es la factura de la deuda que ya tenías.

Salir a trabajar y volver a publicar

El patrón central de toda pantalla que hace algo no trivial es este: recoger la intención en el actor principal, salir a hacer el trabajo caro fuera, y volver a publicar el resultado. Con async y await, ese viaje de ida y vuelta se escribe casi sin ceremonia.

@MainActor
final class ImportadorViewModel {
    var progreso = 0.0
    var resumen: Resumen?

    func importar(_ url: URL) async {
        progreso = 0                                   // main actor
        let datos = try? await Almacen.leer(url)        // sale del actor
        let procesado = await Procesador.shared.analizar(datos)
        resumen = procesado                             // vuelve al actor
    }
}

El detalle clave es que no hay salto explícito. Cuando await invoca una función aislada a otro actor, o una función nonisolated que corre en el pool cooperativo, el runtime cambia de ejecutor; al volver de ese await, el runtime restaura el ejecutor del contexto llamante. Escribir DispatchQueue.main.async al final es, en este modelo, redundante y sospechoso: si necesitaste escribirlo, es que perdiste el aislamiento en alguna frontera.

Conviene además distinguir dos cosas que se confunden con facilidad. MainActor.run ejecuta un bloque en el actor principal desde un contexto que no lo está, y solo tiene sentido cuando de verdad estás fuera; escribirlo dentro de una función ya aislada es ruido que sugiere que no confías en el aislamiento que ya tenías. MainActor.assumeIsolated, en cambio, no salta a ninguna parte: afirma que ya estás en el actor principal y falla en tiempo de ejecución si mientes. Es la herramienta correcta para adaptar callbacks de frameworks que garantizan por contrato entregar en el hilo principal, y una bomba si te equivocas al suponerlo.

Cuando de verdad hay que forzar la vuelta —típicamente desde una API antigua basada en callbacks— la forma correcta es acotada y explícita:

locationManager.onUpdate = { coord in
    Task { @MainActor in
        self.ultimaPosicion = coord
    }
}
flowchart LR
A[Gesto del usuario] --> B[Metodo aislado en MainActor]
B --> C[await a trabajo fuera del actor]
C --> D[Pool cooperativo o actor dedicado]
D --> E[Retorno al ejecutor principal]
E --> F[Mutacion del estado observable]
F --> G[SwiftUI redibuja]
style B fill:#89b4fa,color:#11111b
style D fill:#f9e2af,color:#11111b
style F fill:#a6e3a1,color:#11111b

nonisolated, Sendable y el modo estricto

Aislar de más tiene un coste: si todo vive en el actor principal, todo compite por el mismo ejecutor y acabas serializando trabajo que no lo necesitaba. nonisolated marca los miembros que no tocan estado mutable y por tanto pueden ejecutarse en cualquier parte.

@MainActor
final class Catalogo {
    var items: [Item] = []

    nonisolated let idioma: String            // inmutable: seguro fuera

    nonisolated func normalizar(_ s: String) -> String {
        s.folding(options: .diacriticInsensitive, locale: nil)
    }
}
🔒

Sendable

Un tipo Sendable puede cruzar fronteras de actor con seguridad. Los struct de valores inmutables lo son casi siempre; una class mutable no lo es sin sincronización propia.

🌀

nonisolated

Libera de aislamiento lo que no toca estado mutable: cálculos puros, constantes, conformidades a protocolos que no pueden ser asíncronas.

⚠️

Modo estricto

Con concurrencia estricta activada, cada cruce sin garantías es un error de compilación. Actívalo pronto: la deuda crece más rápido que el proyecto.

Un cuarto elemento completa el cuadro y evita el error más común al empezar: @MainActor no es una propiedad del hilo, es una propiedad del tipo. Anotar una función no la mueve a ninguna parte por sí sola; declara dónde puede ejecutarse y obliga al compilador a insertar el salto en cada llamada que venga de fuera. Por eso una función aislada llamada desde otra función aislada no cuesta nada, y por eso una función aislada llamada mil veces desde un bucle en el pool cooperativo cuesta mil cambios de ejecutor. El coste no está en la anotación sino en las fronteras que cruzas, y las fronteras se ven leyendo dónde están los await.

La heurística que envejece bien: aísla al actor principal el modelo que se pinta y nada más. Los servicios de red, los almacenes, los parsers y los cálculos deberían ser nonisolated o vivir en sus propios actores, y comunicarse con la UI devolviendo valores Sendable.

⚔️ Pon fronteras explícitas
  1. Anota con @MainActor un modelo observable tuyo y elimina cualquier DispatchQueue.main.async que quedara dentro.
  2. Activa la comprobación de concurrencia estricta en los ajustes del target y anota los errores que aparecen.
  3. Marca como nonisolated al menos una función pura de ese modelo y comprueba que el compilador la acepta.
  4. Convierte un callback de una API antigua a un Task con @MainActor explícito.
  5. Identifica un tipo que cruza fronteras y decide, argumentándolo, si debe ser Sendable o quedarse dentro de un actor.