Ubicación transparente: llamar a un actor que quizá no está aquí
Un `distributed actor` conserva el aislamiento del actor local y añade una promesa nueva: la llamada puede cruzar un proceso, una máquina o un continente sin que la firma cambie. Qué significa exactamente esa transparencia, qué obliga el compilador a escribir y en qué punto la ilusión deja de sostenerse.
El actor local resolvió un problema de espacio: varias tareas quieren tocar el mismo estado y hay que serializar sus turnos. El actor distribuido resuelve uno de dirección: el estado ya no está necesariamente en tu proceso, pero quieres seguir hablándole como si lo estuviera. La palabra clave distributed no añade una biblioteca de red al lenguaje; añade una abstracción sobre dónde vive una instancia, de modo que una referencia pueda apuntar a un objeto de tu montículo o a un objeto que existe en otra máquina y el código de llamada sea idéntico en ambos casos. Esta lección explica qué garantiza esa promesa, qué precio cobra el compilador por ella y por qué la transparencia de ubicación nunca ha significado, ni en Swift ni en ningún sistema serio, que puedas olvidarte de la red.
- Explicar qué es la transparencia de ubicación y en qué se diferencia de la independencia de ubicación.
- Declarar un
distributed actorcon sus miembrosdistributedy entender qué sintetiza el compilador. - Justificar por qué toda llamada distribuida es
try awaitincluso cuando la instancia resulta ser local. - Identificar las decisiones de diseño que la transparencia no elimina: fallo parcial, latencia y evolución del contrato.
El actor que quizá no está en esta máquina
Un actor distribuido se declara casi como uno normal. La diferencia es que declara a qué sistema pertenece y marca explícitamente qué parte de su superficie es alcanzable desde fuera del proceso.
import Distributed
distributed actor Termostato {
typealias ActorSystem = ClusterSystem
private var objetivo: Double = 21.0
private var historial: [Double] = []
distributed func ajustar(a grados: Double) {
historial.append(objetivo)
objetivo = grados
}
distributed var lectura: Double { objetivo }
// Sin `distributed`: solo alcanzable si la instancia es local
func compactarHistorial() { historial = Array(historial.suffix(50)) }
}
El compilador sintetiza dos propiedades que no escribes: id, la identidad estable de esta instancia dentro de su sistema, y actorSystem, el transporte que sabe cómo hacerle llegar mensajes. Ambas son nonisolated, porque leerlas no requiere entrar en el actor: son metadatos sobre la referencia, no estado protegido.
De esa identidad cuelgan más cosas de las que parece. La igualdad y el hash de un actor distribuido se derivan del id, no de la dirección de memoria, con lo que dos referencias obtenidas por caminos distintos al mismo actor remoto son iguales y colisionan en el mismo cubo de un diccionario. Es exactamente lo que quieres y no es gratis: significa que la identidad de un objeto ha dejado de ser una propiedad del proceso para ser una propiedad del sistema.
let a = try Termostato.resolve(id: identidad, using: sistema)
let b = try Termostato.resolve(id: identidad, using: sistema)
a == b // true: misma identidad logica
var vistos: Set<Termostato> = [a]
vistos.insert(b) // no crece: el hash sale del id
De ahí sale la operación que hace visible toda la idea. resolve toma una identidad y un sistema y devuelve una referencia del tipo declarado, sin decirte cuál de los dos mundos te ha tocado.
let ref = try Termostato.resolve(id: identidad, using: sistema)
try await ref.ajustar(a: 19.5) // puede ser una llamada local
let actual = try await ref.lectura // o un viaje de ida y vuelta por la red
Si la identidad corresponde a una instancia viva en este proceso, resolve devuelve esa misma instancia y las llamadas son despachos de actor normales. Si no, devuelve un proxy remoto: un objeto del mismo tipo cuyo cuerpo no existe, y cuyas llamadas el runtime redirige al sistema para que las empaquete y las envíe. El tipo estático es el mismo; el comportamiento en tiempo de ejecución no.
flowchart LR C[Codigo llamante] -->|try await ajustar| R[Referencia distribuida] R -->|caso local| I[Instancia real en este proceso] R -->|caso remoto| S[Sistema de actores] S -->|invocacion serializada| N[Nodo remoto] N --> J[Instancia real alli] style R fill:#89b4fa,color:#11111b style S fill:#f9e2af,color:#11111b
Lo que el compilador te obliga a escribir
La transparencia de Swift es deliberadamente honesta: no oculta que la llamada puede fallar ni que puede tardar. Toda invocación de un miembro distributed desde fuera del actor exige try await, y las dos mitades tienen motivos distintos.
El await está porque la llamada puede suspender: incluso en el caso local es una llamada a un actor, y en el remoto hay una ida y vuelta por la red. El try es la parte interesante, porque aparece aunque el método declarado no lance nada. La razón es que existe una clase de fallo que el método no controla y que el llamante no puede ignorar: el nodo destino se ha caído, la conexión se cortó, el mensaje no se pudo serializar, la respuesta no llegó antes del plazo. Esos errores son del transporte, no de la lógica, y el sistema de tipos los mete en el mismo canal que ya existía para los errores del dominio.
Un distributed func sin throws sigue obligando al llamante a escribir try. Si te encuentras poniendo try! sobre una llamada distribuida porque «ese método no falla», estás afirmando que la red no falla. Es la afirmación más desmentida de la historia de la computación.
Hay más restricciones, y todas apuntan al mismo sitio. Los parámetros y el tipo de retorno de un miembro distributed deben cumplir el requisito de serialización del sistema, que en la práctica casi siempre es Codable. El inicializador debe recibir el actorSystem y asignarlo antes de terminar, porque es el sistema quien fabrica y registra la identidad. Y un miembro sin distributed sigue siendo invocable solo cuando tienes la instancia local, cosa que se comprueba con whenLocal.
// Acceso a la superficie no distribuida, solo si estamos en el mismo proceso
await ref.whenLocal { local in
await local.compactarHistorial()
}
whenLocal es la grieta autorizada en la abstracción, y su forma dice mucho. No devuelve un booleano que puedas consultar y luego usar: entrega la instancia dentro de una clausura, de modo que el compilador sepa que ahí dentro, y solo ahí, la referencia es un actor local con toda su superficie disponible. Si la referencia era un proxy, la clausura no corre y obtienes nil. Es el mismo patrón de las asociaciones opcionales que ya conoces, aplicado a la pregunta «estamos en el mismo espacio de direcciones».
Conviene además fijar quién es ActorSystem. Se declara con un typealias en cada actor o, si prefieres uno por módulo, con un typealias DefaultDistributedActorSystem a nivel de fichero, que el compilador toma como implícito para cualquier actor distribuido que no diga otra cosa. Ese tipo condiciona todo lo anterior: qué forma tiene la identidad, qué protocolo exige a los argumentos y qué errores puede lanzar el transporte.
// Toda la superficie distribuida de este modulo usa un unico sistema
typealias DefaultDistributedActorSystem = ClusterSystem
Transparencia no es invisibilidad
Conviene separar dos conceptos que se confunden todo el tiempo. La transparencia de ubicación dice que la sintaxis de la llamada no depende de dónde esté el destinatario. La independencia de ubicación diría, mucho más fuerte, que el comportamiento tampoco depende. Swift ofrece la primera y no promete la segunda, y esa modestia es lo que lo separa de una larga lista de intentos anteriores.
La consecuencia práctica es que hay decisiones que el modelo te deja intactas sobre la mesa. Cuántas llamadas haces por operación, porque cada una es un viaje completo. Qué ocurre si la respuesta no llega, porque el silencio no distingue entre no hizo nada y lo hizo y se cayó. Qué haces cuando el destinatario ya no existe, porque una referencia distribuida puede quedar apuntando a un actor muerto sin que el sistema de tipos se entere. Y cómo evolucionan las firmas, porque ahora hay dos binarios que deben coincidir. Ninguna de las cuatro tiene respuesta en la sintaxis; las cuatro se responden en las lecciones siguientes.
// Lo que la firma no dice y tu diseno debe decidir
try await inventario.reservar(sku) // si esto tarda 5 s, que hacemos
try await pagos.cobrar(importe) // si esto falla, hay que deshacer lo anterior
try await envios.programar(pedido) // y si nadie responde, quien lo sabe
Misma firma
El tipo, el nombre y los argumentos son idénticos en local y en remoto. Solo cambia quién ejecuta el cuerpo.
Distinto coste
Una llamada local cuesta nanosegundos; una remota, milisegundos y a veces nunca. La firma no lo dice: tu diseño debe decirlo.
Identidad, no puntero
Una referencia distribuida transporta un ID interpretable por el sistema, no una dirección de memoria.
Durante treinta años la industria intentó exactamente lo que aquí se anuncia y fracasó de forma tan sistemática que el fracaso tiene bibliografía propia. Sun RPC, CORBA, DCOM, Java RMI y media docena más partieron todos de la misma premisa —hacer que una llamada remota se parezca a una local— y todos chocaron con el argumento que Waldo, Wyant, Wollrath y Kendall formularon en 1994 en A Note on Distributed Computing: los objetos locales y los remotos no difieren en grado sino en clase, y las diferencias son cuatro, irreductibles y no negociables. Latencia, que convierte en inviable un patrón de llamadas que en local era gratuito. Memoria, porque no hay un espacio de direcciones común y por tanto pasar una referencia significa algo radicalmente distinto a cada lado. Concurrencia, porque un objeto remoto recibe peticiones de fuentes que no coordinaste. Y sobre todo fallo parcial, la única de verdad letal: en un proceso, si el destinatario falla tú también has fallado, y por eso la lógica de recuperación puede vivir fuera; entre procesos, el destinatario puede desaparecer mientras tú sigues perfectamente vivo, con una petición en vuelo cuyo destino desconoces y sin ninguna forma de distinguir «se cayó antes de aplicar el cambio» de «lo aplicó y se cayó al responder». Ninguna capa de transporte puede resolver esa ambigüedad; solo puede exponerla o esconderla. Lo que hicieron mal los sistemas clásicos no fue proponer una sintaxis uniforme, fue proponer además una semántica uniforme: un stub de CORBA fingía que la llamada era normal y lanzaba una excepción que nadie declaraba ni comprobaba, de modo que el fallo parcial entraba en el programa por una puerta trasera. Swift acepta la primera mitad y rechaza la segunda con una precisión quirúrgica: unifica la sintaxis del despacho, y hace obligatorio el try y el await para que la latencia y el fallo parcial estén tecleados en cada punto de llamada, visibles al leer y verificados por el compilador. El resultado no es que la red desaparezca, sino que la red se vuelve un dato del tipo. Ese es el patrón general que merece la pena llevarse a cualquier diseño: cuando una abstracción no puede eliminar una dificultad, su única salida honesta es hacerla imposible de no ver.
distributed no es una biblioteca de red, es una abstracción sobre la ubicación de una instancia. resolve devuelve la instancia real o un proxy, y el llamante no distingue. El try await obligatorio es la señalización de latencia y fallo parcial en el punto exacto donde ocurren.
- Declara un
distributed actorcon dos métodosdistributedy uno normal, y comprueba qué error da el compilador al llamar al tercero desde fuera. - Usa
LocalTestingDistributedActorSystempara crear una instancia y llamarla; observa que eltrysigue siendo obligatorio pese a no cruzar ninguna red. - Añade un parámetro cuyo tipo no sea
Codabley transcribe el mensaje de error completo. Explica qué requisito exacto está comprobando. - Escribe una función que reciba una referencia y use
whenLocalpara tomar un atajo cuando la instancia esté en el mismo proceso. Razona si ese atajo puede cambiar el comportamiento observable. - Enumera, para tu propio dominio, tres invariantes que hoy das por ciertas y que dejarían de serlo si el destinatario pudiera desaparecer a mitad de una llamada.