El actor como tipo: estado que se defiende solo
Un `actor` no es una clase con un candado escondido dentro. Es un tipo con dominio de aislamiento propio, un ejecutor que serializa los accesos y una frontera que convierte toda llamada externa en una operación asíncrona.
Durante décadas, proteger un estado compartido consistió en recordar. Recordar tomar el candado antes de leer, recordar soltarlo en todas las salidas, recordar que ese campo también se toca desde el hilo de red. La corrección vivía en la disciplina del programador y el compilador miraba hacia otro lado. El actor de Swift traslada esa responsabilidad al sistema de tipos: declara que ciertos datos pertenecen a un dominio, que solo el código de ese dominio puede tocarlos de forma síncrona, y que cualquiera de fuera tendrá que pedir turno. Lo interesante no es que el lenguaje añada un candado automático —no lo hace— sino que convierta una propiedad dinámica, imposible de verificar en tiempo de compilación, en una propiedad estática que se comprueba antes de ejecutar una sola línea.
- Explicar qué es un dominio de aislamiento y qué garantiza exactamente el ejecutor serie de un actor.
- Distinguir el acceso interno síncrono del acceso externo, que exige
awaity valoresSendable. - Justificar por qué un método declarado sin
asyncse comporta como asíncrono visto desde fuera. - Identificar qué clase de errores desaparecen con un actor y cuáles siguen intactos.
El estado que nadie más puede tocar
Un actor se declara como una clase, pero cada propiedad almacenada nace marcada: pertenece al dominio de aislamiento de esa instancia.
actor Cuenta {
let titular: String // let de tipo Sendable: accesible desde fuera sin await
private var saldo: Decimal // aislado
init(titular: String, saldo: Decimal) {
self.titular = titular
self.saldo = saldo
}
func ingresar(_ cantidad: Decimal) {
saldo += cantidad // sincrono: ya estamos dentro del dominio
}
func retirar(_ cantidad: Decimal) throws -> Decimal {
guard saldo >= cantidad else { throw ErrorBanco.fondosInsuficientes }
saldo -= cantidad
return saldo
}
}
Dentro del actor, el código es tan aburrido como el de un struct: se lee y se escribe saldo sin ceremonia. Esa ausencia de ruido es el resultado buscado, y descansa sobre una promesa muy concreta: el compilador garantiza que nunca habrá dos ejecuciones simultáneas de código aislado a esta instancia. La exclusión mutua no se pide, se hereda de la declaración del tipo.
La palabra clave es simultáneas, no entrelazadas. El actor asegura que dos hilos no pisen el mismo campo a la vez; no asegura que una operación se ejecute de principio a fin sin que nada más ocurra en medio. Esa distinción, que parece un matiz, es el tema completo de la siguiente lección.
La frontera: dentro es síncrono, fuera es asíncrono
Desde fuera, el mismo método cambia de naturaleza:
let cuenta = Cuenta(titular: "Ada", saldo: 1_000)
print(cuenta.titular) // ok: let inmutable y Sendable
await cuenta.ingresar(250) // el await es obligatorio
let restante = try await cuenta.retirar(100)
// cuenta.saldo // error: propiedad aislada al actor
ingresar no lleva async en su declaración y, sin embargo, el llamante externo debe escribir await. No es una excepción caprichosa: al cruzar la frontera de aislamiento, el compilador reescribe el tipo de la función. Lo que dentro del actor tiene tipo (Decimal) -> Void, visto desde otro dominio tiene tipo (Decimal) async -> Void. El async no describe lo que hace el cuerpo del método, sino lo que cuesta llegar hasta él: hay que ceder el hilo, encolar el trabajo en el ejecutor del actor y esperar a que le toque el turno.
De ahí se deduce el resto de las reglas sin memorizar ninguna. El acceso a saldo desde fuera está prohibido incluso para leer, porque una lectura remota devolvería una copia caducada en cuanto la instrucción siguiente se ejecutase. Los argumentos y el valor de retorno tienen que ser Sendable, porque van a viajar entre dominios y una referencia mutable compartida reabriría exactamente el agujero que el actor cierra. Y titular se salva del await porque es una constante de tipo Sendable: leerla no puede producir una carrera, así que el compilador la trata como no aislada.
La consecuencia práctica es que el diseño de la API de un actor deja de ser cosmético. Cada método público define una unidad de trabajo que se ejecutará sin interrupciones internas, así que la granularidad de los métodos es la granularidad de la atomicidad:
actor Inventario {
private var existencias: [String: Int] = [:]
// Mala frontera: el llamante compone dos turnos distintos
func cantidad(de sku: String) -> Int { existencias[sku] ?? 0 }
func fijar(_ n: Int, para sku: String) { existencias[sku] = n }
// Buena frontera: la decision y la escritura viajan en el mismo turno
func reservar(_ n: Int, de sku: String) -> Bool {
let disponible = existencias[sku] ?? 0
guard disponible >= n else { return false }
existencias[sku] = disponible - n
return true
}
}
Los dos primeros métodos son correctos por separado y peligrosos en combinación: entre el await de la lectura y el await de la escritura hay una grieta por la que se cuela cualquier otra tarea. El tercero no la tiene. Un actor bien diseñado expone verbos de negocio, no accesores.
flowchart TB ext[Codigo externo hace await sobre un metodo] --> cola[Cola del actor con los mensajes pendientes] cola --> exe[Ejecutor serie del actor] exe --> est[Estado aislado de la instancia] int[Codigo dentro del actor] --> est exe --> pool[Pool cooperativo de hilos] style est fill:#f38ba8,color:#11111b style exe fill:#89b4fa,color:#11111b style cola fill:#cba6f7,color:#11111b
No es un candado: es una cola
Conviene desmontar la analogía fácil. Un actor no equivale a una clase con un NSLock en cada método, y la diferencia se nota en tres puntos.
Clase con candado
El hilo llamante se bloquea y se queda ocupado esperando. Si dos objetos se toman los candados en orden distinto, hay interbloqueo. El compilador no verifica nada: olvidar un lock compila igual de bien.
Actor con ejecutor
El llamante suspende la tarea y libera el hilo, que se dedica a otra cosa. No hay espera activa ni orden de adquisición que respetar, así que un ciclo de llamadas mutuas no puede producir el interbloqueo clásico. Y el aislamiento es una propiedad del tipo, comprobada en compilación.
El ejecutor serie es la pieza que hace todo esto posible. Cada actor tiene el suyo —accesible como unownedExecutor, porque actor conforma implícitamente el protocolo Actor— y su contrato es mínimo: ejecutar de uno en uno los fragmentos de trabajo que le llegan. Los hilos los pone el pool cooperativo del runtime, dimensionado según los núcleos de la máquina. Un actor, por tanto, no posee un hilo, no lo bloquea y no cuesta lo que costaba una cola serie de GCD: cuesta un objeto y una cola de mensajes.
Un tipo de referencia con reglas nuevas
El actor es un tipo de referencia, con identidad y con deinit, pero no es una clase disfrazada. Tres diferencias marcan la distancia y conviene tenerlas presentes antes de modelar nada:
actor Sesion: Identifiable {
nonisolated let id: UUID = UUID()
private var eventos: [String] = []
func registrar(_ e: String) { eventos.append(e) }
deinit {
// El deinit se ejecuta fuera del turno del actor:
// aqui solo se puede tocar estado no aislado.
print("sesion terminada")
}
}
// Es Sendable de forma automatica: puede cruzar dominios sin ceremonia.
func repartir(_ s: Sesion) async { await s.registrar("visita") }
Primero, no hay herencia entre actores: un actor no puede derivar de otro, aunque sí conformar protocolos. La razón es de coherencia del aislamiento —una subclase podría añadir estado o sobrescribir métodos con reglas distintas— y el efecto secundario es saludable, porque empuja hacia la composición. Segundo, todo actor conforma Sendable automáticamente y sin escapatoria: la referencia puede viajar entre dominios precisamente porque el estado que apunta no es accesible desde ninguno de ellos sin pedir turno. Y tercero, el deinit es un lugar peculiar: se ejecuta cuando ya no queda nadie que pueda pedir turno, así que no está aislado y solo puede tocar lo que no lo esté.
Lo que ocurre aquí es un desplazamiento epistemológico, no una comodidad sintáctica. Antes de los actores, la ausencia de carreras de datos era una propiedad dinámica del programa: dependía de qué hilos existían en ejecución, de qué orden tomaban los candados y de qué caminos habían sido probados. Como toda propiedad dinámica sobre entrelazados arbitrarios, era efectivamente indecidible por inspección: un programa podía funcionar durante años y romperse el día que una máquina con más núcleos hizo probable un entrelazado que antes era improbable. El actor cambia el cuantificador. En lugar de afirmar «en ninguna ejecución observada hubo acceso concurrente», el sistema de tipos afirma «no existe ningún programa bien tipado en el que ese acceso ocurra». Para lograrlo hace lo mismo que hizo el sistema de propiedad exclusiva con la memoria: convierte una relación de proceso en una relación de tipos. Cada referencia aislada lleva escrito su dominio, cada cruce de frontera exige un valor Sendable y una suspensión explícita, y el await se vuelve un marcador visible de que aquí termina un fragmento atómico y empieza otro. Ese es el precio real y merece decirse sin adornos: el compilador no te regala la corrección, te obliga a escribir dónde están las costuras. Un programa con actores no tiene menos puntos de entrelazado que uno con candados; tiene los mismos, pero todos son visibles a simple vista, y esa visibilidad es justo lo que hacía falta para poder razonar.
El aislamiento elimina las carreras de datos —dos accesos simultáneos a la misma memoria con al menos uno de escritura—. No elimina las carreras de lógica: dos tareas que consultan el saldo, deciden y actúan sobre una foto ya vencida producen un resultado incorrecto sin que ninguna instrucción de memoria se solape. Un actor protege campos; la invariante de negocio la sigues diseñando tú.
- Escribe un
actor Contadorcon un métodoincrementary lanza mil tareas concurrentes que lo llamen. Comprueba que el resultado final es exacto y explica por qué la versión conclassfalla bajo el sanitizador de hilos. - Añade al actor una propiedad
let identificador: UUIDy otravar etiqueta: String. Intenta leer ambas desde fuera sinawaity razona el mensaje del compilador en cada caso. - Declara un método que reciba un tipo de clase no
Sendablecomo parámetro y describe el diagnóstico exacto que aparece al llamarlo desde otro dominio. - Escribe una función libre que reciba un
Cuentay devuelva su saldo. Observa que la función debe volverseasyncy argumenta por qué ese contagio es una consecuencia necesaria del modelo. - Consulta el protocolo
Actory su propiedadunownedExecutor. Explica qué implicaría poder sustituir ese ejecutor por uno propio y qué garantía habría que preservar a cambio.