@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»).
@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:
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]@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.
@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.
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 usesfunc 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:
- 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, queswift buildlee cada vez. Busca el bloque.target(deTorneoy añade una coma después dename: "Torneo"y, debajo, una línea nueva conswiftSettings(la lista de ajustes de ese objetivo):
.target(
name: "Torneo"
), .target(
name: "Torneo",
swiftSettings: [.defaultIsolation(MainActor.self)] // todo el módulo pasa a @MainActor
),- En la Terminal, desde
~/Marcador-Swift/Torneo, ejecutaxcrun swift build. - 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.
- 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 ejecutarxcrun 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
| Kotlin | Swift | |
|---|---|---|
| Hilo de la interfaz | Dispatchers.Main, un valor que pasas a withContext o launch | @MainActor, un atributo que el compilador comprueba en cada acceso |
| Volver al hilo principal | withContext(Dispatchers.Main) | await sobre algo @MainActor |
| Una corrutina/tarea nueva | launch usa el contexto del ámbito donde se crea | Task { } hereda el actor de donde se crea; Task.detached no |
| Olvidarlo | Compila; 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: 5Para pensar: quita a darTres lo que la mete en el actor principal. ¿Qué error da, y en qué línea?
Ver solución
@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.¿Qué significa poner
@MainActora una clase?2.¿Qué diferencia hay entre
Task { }yTask.detached { }dentro de un método@MainActor?3.¿Para qué sirve
nonisolateden un método de una clase@MainActor?4.Mandas un objeto de una clase mutable a una función
@MainActorconawaity después sigues cambiándolo. ¿Qué pasa, y cómo se arregla?5.¿En qué se diferencia
@MainActordeDispatchers.Mainde Kotlin?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?