wandres.dev
ACTORES A FONDO · global actors y reentrada

`nonisolated` y parámetros `isolated`: escapes con permiso

El aislamiento tiene dos válvulas. Con `nonisolated` una declaración renuncia al dominio de su tipo; con un parámetro `isolated` una función libre adopta el dominio de otro. Dos direcciones opuestas y un mismo objetivo: que la frontera esté donde tú decidas.

⏱ 20 min

Un modelo de aislamiento que solo supiera decir «dentro» y «fuera» sería inutilizable. Un actor necesita conformar Hashable sin que comparar dos identificadores cueste una suspensión; una función de utilidad escrita fuera del actor necesita operar sobre su estado sin convertirse en un método más; una biblioteca genérica necesita ejecutarse donde ejecute quien la llama, sea quien sea. Swift resuelve las tres cosas con dos anotaciones que apuntan en sentidos contrarios. nonisolated saca una declaración del dominio de su tipo. Un parámetro isolated mete una función libre en el dominio de un actor. Juntas convierten la frontera de aislamiento en algo que se coloca con precisión quirúrgica, en vez de coincidir siempre con el borde del tipo.

🎯 Al terminar esta lección sabrás
  • Decidir cuándo un miembro debe ser nonisolated y qué restricciones acepta a cambio.
  • Escribir funciones con un parámetro isolated y explicar por qué los métodos de actor son azúcar de esa misma construcción.
  • Usar el aislamiento heredado del llamante para escribir utilidades agnósticas del dominio.
  • Valorar el coste real de nonisolated(unsafe) y acotar los casos en que resulta defendible.

nonisolated: renunciar al turno

Marcar un miembro como nonisolated significa declarar que no toca estado mutable aislado y que, por tanto, puede llamarse desde cualquier dominio sin await. El compilador verifica la promesa.

actor Documento: Hashable, CustomStringConvertible {
    let id: UUID
    private var contenido: String

    init(id: UUID, contenido: String) {
        self.id = id
        self.contenido = contenido
    }

    nonisolated var description: String { "Documento \(id)" }

    nonisolated static func == (a: Documento, b: Documento) -> Bool { a.id == b.id }
    nonisolated func hash(into h: inout Hasher) { h.combine(id) }

    // nonisolated func vaciar() { contenido = "" }   // error: toca estado aislado

    func reemplazar(por texto: String) { contenido = texto }
}

Sin nonisolated, conformar Hashable sería imposible: los requisitos del protocolo son síncronos y un método aislado no puede satisfacerlos desde fuera. Con la anotación, el actor participa en el mundo síncrono en todo aquello que no dependa de su estado mutable, y sigue protegido en lo demás. Repara en que solo las propiedades computadas pueden ser nonisolated: una propiedad almacenada mutable no aislada sería, por definición, una carrera. Las almacenadas inmutables de tipo Sendable ya lo son de forma implícita, así que la anotación sobra.

El mismo razonamiento se aplica a los inicializadores y al destructor. Un init que no sea aislado puede llamarse desde cualquier parte porque todavía nadie comparte la instancia; un deinit no está aislado porque se ejecuta cuando ya nadie puede pedir turno, y por eso no puede tocar el estado protegido sin ceremonia adicional.

isolated: adoptar el turno de otro

La dirección contraria es menos conocida y bastante más elegante. Un parámetro puede marcarse isolated, y entonces el cuerpo de la función se ejecuta dentro del dominio de ese actor:

func transferir(_ cantidad: Decimal,
                desde origen: isolated Cuenta,
                hacia destino: Cuenta) async throws {
    // origen es accesible de forma sincrona: estamos en su turno
    guard origen.saldo >= cantidad else { throw ErrorBanco.fondosInsuficientes }
    origen.saldo -= cantidad
    // destino no lo es: sigue siendo otro dominio
    await destino.ingresar(cantidad)
}

La función no pertenece al actor, no aparece en su interfaz y sin embargo opera sobre él con la misma inmediatez que un método. Eso permite mover a extensiones o a módulos separados operaciones que solo estaban dentro del actor por necesidad técnica, y deja el tipo con la superficie mínima.

Aquí conviene detenerse en la equivalencia que ilumina todo el modelo: un método de actor es azúcar sintáctico de una función con self isolated. Escribir func ingresar(_ n: Decimal) dentro de Cuenta es exactamente declarar func ingresar(_ n: Decimal, self: isolated Cuenta). De ahí se sigue, sin necesidad de reglas nuevas, que solo puede haber un parámetro isolated por función —una función se ejecuta en un dominio, no en dos— y que llamar a esa función desde fuera exige await, igual que llamar al método.

flowchart LR
subgraph dominio[Dominio del actor Cuenta]
  m[Metodo del actor]
  f[Funcion libre con parametro isolated]
end
fuera[Codigo en otro dominio] -->|await| m
fuera -->|await| f
n[Miembro nonisolated] -->|sin await| fuera
m --> est[Estado protegido]
f --> est
style dominio fill:#313244,color:#cdd6f4
style est fill:#f38ba8,color:#11111b

Heredar el aislamiento del llamante

Queda un caso que ninguna de las dos anotaciones cubre por separado: la utilidad genérica que debe ejecutarse en el dominio de quien la llame, sea el actor principal, un actor de instancia o ninguno. La solución es un parámetro isolated opcional de tipo actor existencial, con un valor por defecto que el compilador rellena con el aislamiento del punto de llamada:

func conReintentos<T>(_ intentos: Int,
                      aislamiento: isolated (any Actor)? = #isolation,
                      operacion: () async throws -> T) async throws -> T {
    var ultimo: Error?
    for _ in 0..<intentos {
        do { return try await operacion() }
        catch { ultimo = error }
    }
    throw ultimo!
}

@MainActor
func refrescar() async throws {
    // La utilidad corre en el actor principal, sin saltos de dominio
    try await conReintentos(3) { try await cargarPantalla() }
}

La closure no necesita ser Sendable porque nunca sale del dominio, y eso es más importante de lo que parece: sin este mecanismo, cualquier función de orden superior obligaría a marcar Sendable todo lo capturado, contagiando restricciones a código que jamás cruzó una frontera. La herencia de aislamiento es lo que permite escribir combinadores reutilizables sin imponer un dominio a quien los usa.

La evolución reciente del lenguaje generaliza esa idea hasta convertirla en el comportamiento por defecto de las funciones asíncronas no aisladas: en lugar de saltar a un ejecutor genérico, se ejecutan en el del llamante y solo salen de él cuando se las marca explícitamente para correr en paralelo. El cambio elimina de golpe una categoría entera de saltos de dominio innecesarios y, de paso, hace que el aislamiento se comporte como cabría esperar de una anotación ausente: no imponer nada.

🚪

nonisolated

Sale del dominio. La declaración renuncia al estado mutable y gana la posibilidad de llamarse sin await desde cualquier sitio.

🎟️

isolated

Entra en el dominio. La función adopta el turno del actor recibido y accede a su estado como si fuera un método suyo.

La puerta trasera y su factura

Existe una tercera anotación, nonisolated(unsafe), que aplica la exención sin verificación alguna. Sirve para variables globales y propiedades almacenadas cuya seguridad se garantiza por medios que el compilador no puede ver: un candado propio, una inicialización única antes de cualquier concurrencia, una constante que el tipo no puede declarar Sendable por razones de interoperación.

// Justificado: la sincronizacion la aporta el candado, no el compilador
nonisolated(unsafe) private var contadorGlobal = 0
private let candado = NSLock()

func incrementar() {
    candado.lock(); defer { candado.unlock() }
    contadorGlobal += 1
}

La regla de uso es sencilla y poco negociable: cada aparición debe ir acompañada de una frase que explique qué mecanismo sustituye a la comprobación perdida. Sin esa frase, la anotación no es una decisión de diseño, es un silenciador de diagnósticos, y devuelve el programa exactamente al estado anterior a Swift 6 pero con la falsa tranquilidad de que compila sin avisos.

El aislamiento como parámetro, no como propiedad del tipo

La lección profunda de esta pareja de anotaciones es que en Swift el dominio de ejecución dejó de ser un atributo fijo del código para convertirse en algo parametrizable. En los modelos clásicos —hilos, colas, bucles de eventos— el dominio era una propiedad del sitio donde la función se ejecutaba, invisible en su firma y descubrible solo leyendo a quien llamaba. Al convertir el aislamiento en un parámetro con tipo, Swift lo somete a las reglas ordinarias del sistema de tipos: se declara, se infiere, se propaga, se comprueba y, sobre todo, se puede abstraer. Esa última capacidad es la que faltaba en todos los diseños anteriores. Una función con isolated (any Actor)? está cuantificada universalmente sobre dominios, igual que una función genérica lo está sobre tipos; su corrección no depende de dónde se la invoque, y por eso puede vivir en una biblioteca que no conoce ninguno de los actores de su cliente. El paralelismo con la historia del polimorfismo es exacto: primero cada función se escribía para un tipo concreto, luego aparecieron los genéricos y con ellos el código que se escribe una vez y vale para todos. Aquí ocurre lo mismo con el lugar de ejecución. Y como en el caso de los genéricos, el precio es que la firma se vuelve más larga y honesta: la información que antes vivía en la cabeza del programador, o en un comentario que nadie actualizaba, pasa a ocupar espacio visible en el contrato. Ese espacio no es burocracia, es el sitio donde ahora se comprueban las cosas que antes fallaban en producción.

⚔️ Coloca la frontera
  1. Haz que un actor tuyo conforme Hashable y Comparable usando solo miembros nonisolated. Intenta acceder a una propiedad mutable desde uno de ellos y estudia el diagnóstico.
  2. Extrae a una función libre con parámetro isolated una operación compleja que hoy es un método de tu actor. Comprueba que el llamante externo sigue necesitando await.
  3. Escribe una función con dos parámetros isolated y explica, con el modelo de turnos, por qué el compilador la rechaza.
  4. Implementa un combinador conTiempoLimite que herede el aislamiento del llamante con #isolation y verifica que su closure no necesita ser Sendable.
  5. Busca en tu código un nonisolated(unsafe) o un @unchecked Sendable y escribe al lado la frase que justifica la exención. Si no consigues escribirla, tienes un bug pendiente de descubrir.