Dominios de aislamiento: quién protege qué
Qué protege exactamente un actor y qué deja fuera, las tres formas de aislamiento del lenguaje, el papel de `nonisolated` y de `nonisolated(nonsending)`, y cómo infiere y comprueba el compilador el dominio de cada declaración.
La palabra actor arrastra una intuición engañosa: la de un objeto que “es seguro”. Un actor no protege objetos, protege estado, y solo el estado que declara como propio. Todo lo que sus propiedades apunten sigue estando tan expuesto como antes, y todo lo que salga de él por una llamada deja su protección en la puerta. El concepto que de verdad organiza el modelo no es el actor sino el dominio de aislamiento: una región del programa cuyo estado mutable solo puede tocarse desde un ejecutor que garantiza un acceso simultáneo como máximo. Los actores son una forma de crear dominios; los actores globales, otra; y nonisolated es la declaración explícita de no pertenecer a ninguno. Saber leer a qué dominio pertenece cada línea que escribes es la habilidad central de este nivel, porque es exactamente lo que el compilador está calculando cuando te contradice.
- Delimitar qué estado protege un actor y qué queda fuera de esa protección.
- Distinguir aislamiento por instancia, por actor global y ausencia de aislamiento.
- Usar
nonisolatedynonisolated(nonsending)con criterio y saber qué renuncian. - Reconstruir las reglas de inferencia con que el compilador asigna dominio a cada declaración.
Qué protege un actor y qué no
Un actor garantiza que sus propiedades almacenadas y sus métodos aislados se ejecutan en un ejecutor serial propio: como mucho un hilo dentro a la vez. Esa garantía cubre el almacenamiento del actor, y ahí se detiene.
final class Bandeja { var items: [String] = [] } // clase mutable, no Sendable
actor Buzon {
private var recibidos: [String] = [] // protegido por el actor
private let bandeja = Bandeja() // el puntero es del actor,
// el objeto apuntado no lo es
func exportarBandeja() -> Bandeja { bandeja } // error en modo estricto
}
El compilador rechaza ese método porque devolver la referencia sacaría el objeto del dominio sin que su tipo prometa nada. Esa es la articulación exacta entre las dos lecciones anteriores: el actor decide quién puede tocar el estado, Sendable decide qué puede salir. Sin la segunda mitad, un actor sería un contenedor con agujeros: cualquier método que devolviera una referencia mutable publicaría el estado protegido y anularía la garantía.
De ahí se deduce el criterio de diseño más útil sobre actores: un actor debe exponer valores, no referencias. Sus métodos devuelven copias, resúmenes, tipos de valor inmutables; nunca el almacenamiento interno. Un actor que devuelve sus estructuras internas no es un dominio, es una variable global con ceremonia.
Las tres formas de aislamiento
Toda declaración de un programa Swift 6 tiene exactamente uno de tres estados, y el compilador lo conoce para cada una.
Aislada a una instancia de actor. Es el caso de los miembros declarados dentro de un actor. Cada instancia es su propio dominio: dos objetos del mismo tipo actor no comparten protección ni se estorban.
Aislada a un actor global. Un actor global es un dominio único para todo el programa, identificado por un tipo con @globalActor. El caso omnipresente es @MainActor, que representa el hilo principal, pero puedes declarar los tuyos para agrupar, por ejemplo, todo el acceso a una base de datos bajo un único dominio con nombre propio.
Sin aislamiento. Es lo que declara nonisolated. El código puede ejecutarse en cualquier contexto, y precisamente por eso solo puede tocar estado inmutable o seguro. No es una tercera protección: es la ausencia de ella, compensada por una restricción sobre lo que le está permitido tocar.
@globalActor
actor AlmacenGlobal {
static let shared = AlmacenGlobal()
}
@AlmacenGlobal
final class IndiceDeBusqueda { // toda la clase vive en ese dominio
private var terminos: [String: Int] = [:]
nonisolated let version = 3 // constante segura: fuera del dominio
}
flowchart TB
D[Declaracion] --> Q1{Esta dentro de un actor}
Q1 -->|si| I[Aislada a la instancia]
Q1 -->|no| Q2{Lleva un actor global o lo hereda del tipo}
Q2 -->|si| G[Aislada al actor global]
Q2 -->|no| N[Sin aislamiento]
I --> R[Cruzar exige await y valor enviable]
G --> R
N --> L[Solo puede tocar estado inmutable o Sendable]
style I fill:#cba6f7,color:#11111b
style G fill:#89b4fa,color:#11111b
style N fill:#f9e2af,color:#11111bCruzar de un dominio a otro exige dos cosas simultáneas: una suspensión marcada con await, porque hay que esperar a que el ejecutor de destino esté libre, y que los argumentos y el resultado puedan atravesar la frontera. Esa segunda condición es la que resuelven Sendable y el análisis por regiones de la lección siguiente.
nonisolated, y la trampa que escondía
nonisolated se usa por tres motivos legítimos y muy distintos. El primero es exponer constantes: una propiedad let de tipo seguro dentro de un actor no necesita protección alguna y marcarla evita que sus lectores tengan que esperar. El segundo es conformar a protocolos síncronos: implementar un requisito no aislado, como hash(into:) o description, obliga a que el método salga del dominio, y por eso solo puede apoyarse en estado inmutable. El tercero es hacer trabajo puro: una función que solo transforma sus argumentos no gana nada perteneciendo a un dominio y sí pierde, porque obliga a una suspensión en cada llamada.
actor Sesion {
nonisolated let id: UUID // legible sin await desde cualquier sitio
private var eventos: [Evento] = []
nonisolated func resumir(_ datos: [Evento]) -> String {
datos.map(\.nombre).joined(separator: ", ") // no toca estado del actor
}
}
Hay un cuarto uso que no es legítimo y conviene reconocerlo: marcar nonisolated para librarse de un await molesto. Si la función necesitaba estado del actor, sacarla del dominio no la arregla, solo traslada el error un nivel más adentro, hasta el primer acceso que sí esté protegido. El diagnóstico que obtienes entonces —una propiedad aislada referenciada desde un contexto sin aislamiento— es literalmente el mismo problema visto desde otro sitio.
Hasta Swift 6.1, una función async marcada nonisolated tenía un comportamiento poco intuitivo: al llamarla desde un actor, abandonaba ese dominio y se ejecutaba en el conjunto de hilos concurrentes. El resultado era un cambio de dominio invisible en el punto de llamada y una avalancha de errores en cuanto los argumentos no eran seguros. Swift 6.2 corrigió el defecto invirtiendo el valor por omisión: una función asíncrona sin aislamiento se ejecuta ahora en el dominio de quien la llama, comportamiento que puede escribirse explícitamente como nonisolated(nonsending). Para pedir lo contrario —salir de verdad al conjunto concurrente— existe el atributo @concurrent, que hace visible en la firma una decisión que antes estaba implícita.
Antes de escribir una anotación, contesta a esta pregunta sobre la función: ¿toca estado mutable de alguien? Si toca el de un actor, pertenece a ese actor y no hay más que hablar. Si no toca ninguno, nonisolated es la respuesta correcta y además la más barata, porque elimina una suspensión. Los conflictos irresolubles casi siempre vienen de funciones que tocan estado de dos dominios distintos: eso no es un problema de anotación, es una función que debería ser dos.
Cómo razona el compilador
El aislamiento no se escribe entero: en su mayor parte se infiere, y conocer las reglas de inferencia es lo que hace predecibles los diagnósticos.
La inferencia baja del contenedor a lo contenido. Los miembros de un actor son aislados a la instancia salvo marca contraria; los miembros de un tipo anotado con un actor global heredan ese dominio; y una extensión hereda el del tipo que extiende. También baja por la conformidad: implementar un requisito de protocolo que está aislado arrastra a la implementación al mismo dominio, y por eso un protocolo marcado con @MainActor propaga esa exigencia a todos sus conformantes.
@MainActor
protocol Presentable { // el protocolo arrastra a sus conformantes
func presentar()
}
struct Panel: Presentable {
func presentar() { } // aislada al actor principal sin escribirlo
}
extension Panel {
func refrescar() { } // hereda el dominio del tipo, no del archivo
}
Lo que Swift 6 eliminó fue la inferencia hacia arriba. En versiones anteriores, un property wrapper aislado a un actor global contagiaba su aislamiento al tipo que lo contenía, de modo que una estructura acababa siendo @MainActor sin que su autor lo hubiera escrito ni pudiera verlo. Esa regla desapareció porque violaba el principio que sostiene todo el modelo: el dominio de una declaración debe poder leerse mirando la declaración y su contenedor, nunca sus detalles internos.
Queda una consecuencia que sorprende siempre y que no es un defecto sino la definición misma del mecanismo: los actores son reentrantes. Dentro de un método aislado, cada await libera el ejecutor y permite que otro trabajo del mismo actor se intercale. No hay data race, porque los accesos siguen serializados; pero cualquier invariante que hubieras establecido antes de la suspensión puede haber caducado después. La regla de oro heredada de los monitores —“comprueba y actúa sin soltar el cerrojo”— se transforma aquí en otra: no supongas nada que hayas comprobado antes de un await.
Exponer valores, no referencias
Un actor cuyos métodos devuelven sus estructuras internas ha dejado de proteger nada. Devuelve copias y tipos de valor.
Una frontera visible por diseño
Cada await entre dominios es un lugar donde el estado puede cambiar. Que sea obligatorio escribirlo es la característica, no la molestia.
Dominio por función, no por tipo
Una función que necesita estado de dos dominios distintos está mal cortada. Divídela antes de buscar la anotación que la salve.
La idea de agrupar estado mutable con las operaciones autorizadas a tocarlo bajo un mecanismo de exclusión mutua tiene más de cincuenta años: son los monitores de Hoare y Brinch Hansen, de 1974, y su parentesco con los actores de Swift es tan directo que casi todo el vocabulario se traduce término a término. Pero hay una diferencia que lo cambia todo y explica la mitad de las sorpresas de este modelo. Un monitor clásico bloquea: quien encuentra el monitor ocupado detiene su hilo hasta que se libera, y por tanto quien está dentro tiene la garantía de permanecer dentro hasta salir, con sus invariantes intactos de principio a fin. Un actor de Swift no bloquea nunca: suspende. Al llegar a un await la función se guarda, el hilo se marcha a hacer otra cosa y el actor queda libre para atender a otro. Esa decisión es imprescindible en un sistema de miles de tareas ligeras sobre un puñado de hilos, porque bloquear un hilo del sistema para esperar a otro produce agotamiento del pool y, con reentrancia prohibida, interbloqueos triviales. Pero el precio es que la exclusión mutua deja de ser un intervalo continuo y pasa a ser un conjunto de fragmentos entre suspensiones. La consecuencia es profunda y hay que interiorizarla: los actores garantizan atomicidad de fragmento, no de método. Todo el razonamiento sobre invariantes debe reorganizarse en torno a esa unidad, lo que en la práctica significa que la sección de código entre dos await es la verdadera unidad crítica y que cualquier decisión tomada antes de una suspensión debe revalidarse después. Quien traslada mentalmente la intuición del cerrojo a los actores escribe código que compila sin un solo error de concurrencia y falla exactamente igual que antes, con la agravante de creerse protegido. El compilador te da la ausencia de data races; la corrección de los invariantes sigue siendo tuya, y ahora se juega en un tablero con más casillas.
Un dominio de aislamiento agrupa estado mutable bajo un ejecutor que garantiza un acceso simultáneo como máximo. Un actor protege su almacenamiento, no lo que sus propiedades apuntan, y por eso debe exponer valores en vez de referencias. Hay tres estados posibles: aislado a una instancia, aislado a un actor global o sin aislamiento. nonisolated renuncia al dominio a cambio de tocar solo estado seguro, y desde Swift 6.2 las funciones asíncronas sin aislamiento se ejecutan en el dominio de quien llama salvo que pidas lo contrario con @concurrent. La inferencia baja del contenedor a lo contenido y ya no sube. Y como los actores suspenden en lugar de bloquear, la unidad de atomicidad es el fragmento entre suspensiones, no el método.
- Toma un actor tuyo y localiza todos los métodos que devuelven referencias a estado interno; convierte cada uno en un método que devuelva valores.
- Declara un actor global propio para un subsistema y anota con él los tipos implicados; anota qué llamadas pasan a requerir
await. - Escribe una función aislada que no toque estado del actor, márcala
nonisolatedy compara los puntos de llamada antes y después. - Construye un método aislado con un
awaiten medio que rompa un invariante comprobado antes, y después reescríbelo revalidando tras la suspensión. - Anota tres funciones de tu proyecto con
@concurrenty razona en cada caso si el salto al conjunto concurrente está justificado por el coste del trabajo.