Saltar al contenido
K
Swift: memoria y concurrenciaConcepto30 min de lectura · 5 min de máquina Intermedio

@MainActor y el hilo principal

Al terminar: Aislar el código de pantalla al hilo principal con @MainActor y entender qué cambia en un proyecto de Xcode 26.

Al terminar esta lección sabrás qué significa @MainActor, qué errores da cuando lo olvidas y cómo leerlos, en qué se diferencian Task y Task.detached, para qué sirve nonisolated, qué quiere decir el error sending … risks causing data races y qué cambia en un proyecto de app de Xcode 26. No escribes código de Marcador en esta lección: es la explicación que necesita la siguiente, Sendable y el modo Swift 6, donde la usas.

Importa porque casi toda la interfaz de una app vive en el actor principal, y la mayoría de los errores de concurrencia que verás en Swift 6 hablan de él. Temas: @MainActor, nonisolated y Task frente a Task.detached, y GCD en un recuadro.

@MainActor: el actor del hilo principal

@MainActor es un atributo (una etiqueta para el compilador) que se pone a una clase, un método o una propiedad y significa «esto solo se toca desde el actor principal». Es un actor como el de Actores, pero único y con un hilo concreto: el que dibuja la pantalla. Desde dentro de ese actor se usa sin más; desde cualquier otro sitio hay que entrar con await, y en una función que no puede esperar, no se puede entrar.

Aislamiento es la palabra para esto: un dato o una función está aislado a un actor cuando solo se puede tocar desde él. El código que no pertenece a ningún actor se llama nonisolated («no aislado»).

ejemplo mínimo: una clase de pantalla (con comentarios)
@MainActor                                  // etiqueta: todo lo que hay dentro es del actor principal
final class Pantalla {                      // una clase (final: nadie hereda de ella)
    var titulo = "Sin partido"              // propiedad: un dato de la pantalla, con valor inicial
    func cambiar(_ nuevo: String) {         // método: una función que vive dentro de la clase
        titulo = nuevo                      // dentro de la clase se usa el título sin await
    }
    nonisolated func saludo() -> String {   // «no aislado»: se puede llamar desde cualquier sitio
        "Hola"                              // no toca el título, así que no necesita el actor principal
    }
}

El error típico: tocar el actor principal desde fuera

Una función normal (nonisolated, sin @MainActor) intenta usar la clase:

con el bug: una función normal toca la pantalla (con comentarios)
func desdeUnaFuncionNormal(_ p: Pantalla) {   // función normal: no es del actor principal
    print(p.titulo)                            // leer el título desde fuera: error
    p.cambiar("x")                             // llamar al método desde fuera: error
}
error: main actor-isolated property 'titulo' can not be referenced from a nonisolated context
error: call to main actor-isolated instance method 'cambiar' in a synchronous nonisolated context [#ActorIsolatedCall]
corregido: dos salidas (con comentarios)
@MainActor
func desdeElActorPrincipal(_ p: Pantalla) {   // la función también es del actor principal
    print(p.titulo)
    p.cambiar("x")
}
 
func desdeCualquierSitio(_ p: Pantalla) async {   // o pide turno con await
    print(await p.titulo)
    await p.cambiar("x")
}

Por qué falla: titulo solo se puede tocar desde el actor principal, y una función normal no está en él ni puede esperar a que le toque. Las dos salidas son estar dentro (@MainActor) o pedir turno (async y await). Los números de línea y las notas que acompañan al error dependen de tu código. Al final de algunos mensajes el compilador añade una etiqueta entre corchetes, como [#ActorIsolatedCall]: es el nombre del tipo de error y puede aparecer o no.

nonisolated: quitar el aislamiento

nonisolated quita el aislamiento a algo que no toca estado de la clase. saludo() no lee ni escribe titulo, así que no necesita el actor principal y se llama desde cualquier sitio sin await. Si dentro de un nonisolated tocas una propiedad aislada, el compilador da el mismo error de arriba.

Task frente a Task.detached

Una tarea creada con Task { … } hereda el aislamiento de donde se crea: dentro de un método @MainActor, su cuerpo también corre en el actor principal y toca las propiedades sin await. Task.detached { … } no hereda nada: corre fuera del actor.

ejemplo mínimo: Task frente a Task.detached (con comentarios)
@MainActor
final class Seguimiento {
    var lineas: [String] = []
    func anotar(_ texto: String) { lineas.append(texto) }
 
    func seguir() {
        Task {                        // hereda: corre en el actor principal
            self.lineas.append("gol")     // sin await
        }
        Task.detached {               // no hereda: corre fuera del actor
            await self.anotar("gol")      // aquí hace falta await para entrar
        }
    }
}

Dentro de una Task { … } se escribe self. delante de lo de la clase (self.lineas): la tarea guarda el objeto mientras dura, y Swift pide que esa captura se vea escrita. Hereda no significa que bloquee la interfaz: cada await dentro de la tarea la pausa y suelta el hilo principal mientras espera (por ejemplo, entre un valor y el siguiente de un for await).

Mandar un valor a otra tarea: el error de sending

Un dominio de concurrencia es una zona con sus propias reglas de acceso: un actor, el actor principal o una tarea. Un valor de una clase con estado que cambia no es Sendable (la etiqueta que explica la lección siguiente: «se puede pasar de un dominio a otro sin riesgo»), porque dos tareas a la vez podrían pisarse. Aun así Swift deja mandarlo a otro dominio si tú dejas de usarlo desde donde lo mandas: se entrega, y ya no es tuyo. Esa es la idea de sending («enviando»): un parámetro sending es uno que se entrega a otro dominio.

Estos errores salen en el modo Swift 6, el mismo que usará el paquete Torneo a partir de la lección siguiente. Pega los ejemplos en tu Playground, que ya compila en ese modo: los errores salen marcados en rojo en la línea. Si prefieres, léelos como lectura: el mensaje está debajo de cada bloque.

con el bug: enviarlo y seguir usándolo (con comentarios)
final class Anotador { var lineas: [String] = [] }   // una clase con estado que cambia
 
@MainActor func mostrar(_ a: Anotador) { print(a.lineas.count) }   // función del actor principal
 
func preparar() async {
    let anotador = Anotador()
    await mostrar(anotador)              // se entrega al actor principal
    anotador.lineas.append("gol")        // ...y aquí se sigue usando: dos dominios, un objeto
}
error: sending 'anotador' risks causing data races [#SendingRisksDataRace]
note: sending 'anotador' to main actor-isolated global function 'mostrar' risks causing data races between main actor-isolated and local nonisolated uses
corregido: usarlo antes de entregarlo (con comentarios)
func preparar() async {
    let anotador = Anotador()
    anotador.lineas.append("gol")        // se termina de usar aquí
    await mostrar(anotador)              // se entrega y no se vuelve a tocar
}

Por qué compila la versión corregida: anotador sigue sin ser Sendable, pero después de mostrar nadie lo toca desde preparar: solo un dominio lo usa en cada momento. El error avisa de que los dos lo usaban.

El mismo criterio explica por qué Task { … } dentro de un método @MainActor puede usar un objeto no Sendable de esa clase: la tarea hereda el actor principal, así que el objeto no sale del actor. Con Task.detached { … }, que corre fuera, el compilador da un error de la misma familia (passing closure as a 'sending' parameter risks causing data races, con la nota closure captures … which is accessible to main actor-isolated code): la tarea corre fuera mientras el objeto sigue siendo del actor principal.

Los errores de sending los da el compilador en modo Swift 6: en tu Playground, como arriba, y en el paquete Torneo a partir de la lección siguiente.

Un proyecto de Xcode 26 no es un paquete

Un paquete como Torneo no aísla nada por defecto: cada tipo es nonisolated. En las plantillas de proyecto de app de Xcode 26.2, en cambio, el ajuste Default Actor Isolation (aislamiento por defecto del actor) viene en MainActor: todo el código de la app es @MainActor sin escribirlo. Aún no tienes ninguna app (la creas en Tu primera app de iOS, y ahí verás el ajuste en el proyecto), así que aquí lo estudias en el paquete, donde el mismo efecto se pide con swiftSettings: [.defaultIsolation(MainActor.self)] en el .target correspondiente de Package.swift.

Así, el mismo código se comporta distinto según dónde vive: una clase sin atributo es nonisolated en tu paquete Torneo y @MainActor en la app de iOS. Con el ajuste activado en Torneo, hasta Marcador pasaría a ser @MainActor y el actor de datos MarcadorSeguro no podría crearlo: crear un Marcador sería entrar al actor principal desde otro actor, y eso pide un await que un init síncrono no puede escribir.

No lo dejes activado en Torneo. Si quieres verlo, es un experimento de dos minutos:

  1. Abre Package.swift (en Xcode, en el panel izquierdo, Torneo → Package.swift). Es el mismo tipo de cambio que ya conoces de la receta de Closures: un ajuste del manifiesto, que swift build lee cada vez. Busca el bloque .target( de Torneo y añade una coma después de name: "Torneo" y, debajo, una línea nueva con swiftSettings (la lista de ajustes de ese objetivo):
Package.swift: el bloque .target(…), antes
        .target(
            name: "Torneo"
        ),
Package.swift: el bloque .target(…), después
        .target(
            name: "Torneo",
            swiftSettings: [.defaultIsolation(MainActor.self)]   // todo el módulo pasa a @MainActor
        ),
  1. En la Terminal, desde ~/Marcador-Swift/Torneo, ejecuta xcrun swift build.
  2. Verás avisos como este (los números de línea dependen de tu fichero):
Sources/Torneo/MarcadorSeguro.swift:20:20: warning: call to main actor-isolated initializer 'init(local:visitante:)' in a synchronous actor-isolated context [#ActorIsolatedCall]

Son avisos y no errores porque tu paquete sigue en el modo de lenguaje Swift 5, y la compilación termina con Build complete! (este aviso sale sin pedir ninguna comprobación extra; otros solo aparecen si enciendes la comprobación estricta, como verás en la lección siguiente). En el modo Swift 6 de esa lección, estos mismos mensajes serían errores.

  1. Deshaz el cambio del paso 1 (borra la coma y la línea de swiftSettings, para que el bloque vuelva a ser el de «antes») y vuelve a ejecutar xcrun swift build: ya no sale ningún aviso de este tipo.

El reparto de esta guía es: el dominio en un paquete, sin ese ajuste, y solo lo de pantalla en el proyecto de la app, donde ya viene marcado; lo verás al crear la app de iOS.

Y el código de main.swift. Ya lo dijimos arriba: el nivel superior de un ejecutable, como Sources/torneo-cli/main.swift, corre siempre en el actor principal en Swift 6, y puede usar una clase @MainActor sin await. Eso es distinto del ajuste anterior, que decide el aislamiento de todos los tipos de un módulo.

Kotlin y Swift, lado a lado

KotlinSwift
Hilo de la interfazDispatchers.Main, un valor que pasas a withContext o launch@MainActor, un atributo que el compilador comprueba en cada acceso
Volver al hilo principalwithContext(Dispatchers.Main)await sobre algo @MainActor
Una corrutina/tarea nuevalaunch usa el contexto del ámbito donde se creaTask { } hereda el actor de donde se crea; Task.detached no
OlvidarloCompila; falla al ejecutar (o corrompe datos)No compila

Reto · Ejercicio: el reloj de la pantalla

En tu Playground (borra lo anterior), escribe una clase final, Reloj, que viva en el actor principal. Lleva unos segundos que empiezan en 0 y solo cambia ella; un método, tic, que suma uno; y otro, nombre, que devuelve el texto «Reloj» y queda fuera del aislamiento.

Escribe además dos funciones. darTres es del actor principal y llama tres veces a tic. unoMas es asíncrona, no es del actor principal, y llama una vez.

Crea un reloj. Llama a darTres, imprime el nombre y los segundos; llama a unoMas e imprime otra vez. Por último, lanza una tarea que no herede el actor y haga otro tic, espera a que termine e imprime de nuevo. Lo que debe salir:

Reloj: 3
Reloj: 4
Reloj: 5

Para pensar: quita a darTres lo que la mete en el actor principal. ¿Qué error da, y en qué línea?

Ver solución
solución: MainActor, nonisolated y Task.detached
@MainActor
final class Reloj {
    private(set) var segundos = 0
    func tic() { segundos += 1 }
    nonisolated func nombre() -> String { "Reloj" }
}
 
@MainActor
func darTres(_ reloj: Reloj) {              // del actor principal: llama sin await
    for _ in 1...3 { reloj.tic() }
}
 
func unoMas(_ reloj: Reloj) async {         // fuera del actor: pide turno con await
    await reloj.tic()
}
 
let reloj = Reloj()
darTres(reloj)
print("\(reloj.nombre()): \(reloj.segundos)")
await unoMas(reloj)
print("\(reloj.nombre()): \(reloj.segundos)")
let suelta = Task.detached { await reloj.tic() }   // no hereda el actor: necesita await
await suelta.value
print("\(reloj.nombre()): \(reloj.segundos)")

Sin @MainActor, darTres da el error en la llamada a tic(), no al crear el reloj: call to main actor-isolated instance method 'tic()' in a synchronous nonisolated context, con una nota que sugiere añadir @MainActor a la función. Y si nombre devolviera segundos, el compilador daría el mismo error de propiedad aislada que viste arriba. El código de nivel superior de un Playground (y de main.swift) corre en el actor principal, por eso reloj.segundos se lee sin await.

Comprueba lo que entendiste

6 preguntas · necesitas 5 para completar la lección

  1. 1.¿Qué significa poner @MainActor a una clase?

  2. 2.¿Qué diferencia hay entre Task { } y Task.detached { } dentro de un método @MainActor?

  3. 3.¿Para qué sirve nonisolated en un método de una clase @MainActor?

  4. 4.Mandas un objeto de una clase mutable a una función @MainActor con await y después sigues cambiándolo. ¿Qué pasa, y cómo se arregla?

  5. 5.¿En qué se diferencia @MainActor de Dispatchers.Main de Kotlin?

  6. 6.Escribes una clase sin ningún atributo. ¿Qué aislamiento tiene en un proyecto de app de Xcode 26 y cuál en el paquete Torneo?

Faltan 6 de 6