Actores
Al terminar: Proteger estado compartido con un actor y entender la reentrancia.
Al terminar esta lección, el marcador de un partido vivirá dentro de un actor (MarcadorSeguro) y verás con tus propios ojos por qué hace falta. Primero perderás goles: 10 000 tareas que suman un gol a la vez a una clase normal dejan un total casi siempre menor que 10 000, y el número cambia en cada ejecución. Después cambias la clase por el actor y salen 10 000 siempre.
Importa porque en cuanto dos tareas tocan el mismo dato, el fallo no es un error: es un número mal, de vez en cuando, que ninguna prueba repite igual. Temas: actores y aislamiento. La trampa que el actor no evita, la reentrancia, es la lección siguiente.
Qué es un actor
Un data race (carrera de datos) ocurre cuando una tarea lee o escribe un dato mientras otra lo está modificando. Para que ocurra hace falta paralelismo real: dos hilos ejecutando a la vez en núcleos distintos (un núcleo es cada una de las unidades de trabajo del procesador: tu Mac tiene varias, y cada una puede ejecutar un hilo a la vez). Las tareas que lanza un TaskGroup (lo viste en TaskGroup y errores en tareas hijas) se reparten entre varios hilos, así que sí pueden hacerlo.
Un actor se declara como una clase y es también un tipo de referencia (dos variables pueden apuntar al mismo actor). Cambia una cosa: el compilador solo deja tocar su estado dentro del actor. Desde fuera, cada llamada es async y se escribe con await, porque puede tener que esperar su turno. A eso se le llama aislamiento (isolation).
actor Contador { // `actor` en vez de `class`: su estado queda protegido
private(set) var valor = 0 // solo se puede escribir desde dentro
func sumar() { valor += 1 } // dentro del actor: sin await, nadie más entra
}
let contador = Contador() // se crea como una clase
await contador.sumar() // desde fuera: await, porque puede haber cola
print(await contador.valor) // leer también requiere awaitLo que imprime:
1Los fallos típicos al llamarlo desde fuera tienen su mensaje literal:
| Lo que escribes | Lo que dice el compilador |
|---|---|
contador.sumar() en una función async, sin await | error: actor-isolated instance method 'sumar()' cannot be called from outside of the actor |
contador.valor sin await | error: actor-isolated property 'valor' cannot be accessed from outside of the actor |
await contador.valor = 5 | error: cannot assign to property: 'valor' setter is inaccessible |
Los nombres cambian con tu código; el patrón es siempre actor-isolated … from outside of the actor. La tercera no se arregla añadiendo await, por dos motivos que se suman: private(set) cierra la escritura desde fuera, y un actor no deja escribir sus propiedades desde fuera aunque no la tuvieran. (La tercera fila trae además un aviso, no 'async' operations occur within 'await' expression, porque ese await no espera nada.)
Una clase que pierde goles
Una clase normal compartida entre tareas pierde datos, porque marcador = Marcador(local: marcador.local + 1, …) son tres pasos (leer, sumar, escribir) y otra tarea puede meterse entre dos de ellos.
En 10_000 el guion bajo no hace nada: Swift lo permite para leer mejor los números largos, y vale 10000. Este ejemplo no lo pruebes en el Playground: en modo Swift 6 no compila (Swift 6 rechaza compartir una clase así entre tareas, porque podría provocar carreras de datos). Se ve en el paso de abajo, en tu paquete, que sigue en Swift 5.
final class MarcadorInseguro {
var marcador = Marcador(local: 0, visitante: 0)
func golLocal() {
marcador = Marcador(local: marcador.local + 1, visitante: marcador.visitante)
}
}
let inseguro = MarcadorInseguro()
await withTaskGroup(of: Void.self) { grupo in
for _ in 0..<10_000 { grupo.addTask { inseguro.golLocal() } }
}
print(inseguro.marcador.local) // casi siempre menos de 10 000, y distinto en cada ejecuciónAhora, la misma prueba con el actor. MarcadorSeguro es el que escribirás en el paso de abajo: como Contador, pero con un marcador y un golLocal() que suma uno. Aquí solo se lee, no hace falta que exista todavía.
let seguro = MarcadorSeguro()
await withTaskGroup(of: Void.self) { grupo in
for _ in 0..<10_000 { grupo.addTask { await seguro.golLocal() } }
}
print(await seguro.marcador.local) // 10 000, siemprePor qué falla la clase: dos tareas leen marcador.local == 5 a la vez, las dos escriben 6 y se pierde un gol. No hay ningún error ni aviso: el programa termina y da un número casi correcto. En el paso verás las dos salidas reales.
Tabla Kotlin ↔ Swift
| Kotlin | Swift | |
|---|---|---|
| Proteger un dato compartido | update de un StateFlow (un valor) o un cerrojo que tú pones alrededor de cada acceso | actor |
| ¿Lo comprueba el compilador? | No: si te olvidas del cerrojo, compila | Sí: tocar el estado desde fuera sin await no compila |
Un actor con los tres detalles que faltan
Antes del ejercicio, un último ejemplo con tres piezas que aún no habías visto juntas: un actor public, una constante static leída con Self y las tareas de un grupo que no devuelven nada (Void). Usa una hucha en vez de un marcador:
public actor Hucha {
public private(set) var euros = 0 // se lee desde fuera; solo el actor lo escribe
public init() {} // vacío y public: se crea desde otro módulo
public func meter(_ cantidad: Int) { // sin await dentro: nadie entra a la vez
euros += cantidad
}
}
struct PruebaDeHucha {
static let veces = 500 // static: es del tipo, no de cada instancia
func llenar() async -> Int {
let hucha = Hucha()
await withTaskGroup(of: Void.self) { grupo in // Void: las hijas no devuelven nada
for _ in 0..<Self.veces { // Self: el propio tipo, PruebaDeHucha
grupo.addTask { await hucha.meter(1) } // await: desde fuera hay que esperar turno
}
}
return await hucha.euros // leer también lleva await
}
}
print(await PruebaDeHucha().llenar())Lo que imprime, siempre:
500Reto · Ejercicio: un monedero que comparten muchas tareas
En tu Playground, escribe un actor, Monedero, que empiece con 100 euros y cuente sus movimientos. Desde fuera se pueden leer los dos datos, pero solo el monedero los cambia. Tiene un método para meter una cantidad y otro para sacarla: este último solo resta si hay bastante y dice si pudo. Lanza a la vez 100 tareas que meten 2 euros y 50 que sacan 1. Cuando terminen todas, imprime los euros y los movimientos. Lo que debe salir, siempre igual:
En el monedero: 250 euros
Movimientos: 150Para pensar: ¿por qué, aunque una tarea de sacar llegue antes que todas las de meter, el resultado no cambia?
Ver solución
// Un monedero que comparten muchas tareas a la vez.
actor Monedero {
private(set) var euros = 100 // empieza con 100; solo el monedero lo cambia
private(set) var movimientos = 0 // cuántas veces se ha metido o sacado
func meter(_ cantidad: Int) { // sin await dentro: nadie entra a la vez
euros += cantidad
movimientos += 1
}
func sacar(_ cantidad: Int) -> Bool { // devuelve si pudo sacar
guard euros >= cantidad else { return false }
euros -= cantidad
movimientos += 1
return true
}
}
let monedero = Monedero()
await withTaskGroup(of: Void.self) { grupo in
for _ in 0..<100 { grupo.addTask { await monedero.meter(2) } }
for _ in 0..<50 { grupo.addTask { _ = await monedero.sacar(1) } } // `_ =` tira el Bool que devuelve: aquí no nos importa
}
print("En el monedero: \(await monedero.euros) euros")
print("Movimientos: \(await monedero.movimientos)")Siempre salen 250 euros y 150 movimientos: empieza con 100, entran 200 y salen 50. Aunque una tarea de sacar llegue antes que las de meter, el monedero ya empieza con 100 euros, así que hay de sobra y ninguna retirada se rechaza; y como cada método se ejecuta entero antes de que entre otra tarea, ninguna suma o resta se pierde. Si sacar devolviera falso, ese movimiento no se contaría.
Paso · MarcadorSeguro, el actor que protege el marcador
Objetivo: Crear el actor MarcadorSeguro, que guarda un Marcador y lo actualiza sin perder goles; dárselo a PartidoEnVivo; y escribir las pruebas que enseñan el data race. Al terminar, xcrun swift run torneo-cli imprime 1000-1000 tras 2000 goles a la vez, y las pruebas imprimen un número menor que 10 000 con la clase y 10000 con el actor.
Trabajas en el paquete Torneo, en ~/Marcador-Swift/Torneo. Los comandos de esta lección se escriben en la Terminal, dentro de esa carpeta (cd ~/Marcador-Swift/Torneo).
Crea los dos ficheros nuevos. En el panel izquierdo de Xcode, clic derecho sobre Sources → Torneo → New File from Template… → Swift File → Next, escribe MarcadorSeguro y pulsa Create; y lo mismo sobre Tests → TorneoTests con el nombre ActoresTests. En un paquete no eliges «target» como en una app: el fichero pertenece a la carpeta donde lo creas. Si el menú de tu versión de Xcode se llama distinto, crea los ficheros desde la Terminal con touch Sources/Torneo/MarcadorSeguro.swift Tests/TorneoTests/ActoresTests.swift. Lo que ya existe se modifica.
Piezas nuevas de este paso:
actor MarcadorSeguro { … }: como una clase, con estado aislado, que ya viste en el ejemploContador.await unActor.metodo(): llamada a un actor desde fuera.static let goles = 10_000ySelf.goles: la constante del tipo y su lectura, como en el ejemploHucha.
Ya las has visto: private(set) var (struct frente a class); @discardableResult (esa misma lección: permite no usar el valor que devuelve una función); static (el ejemplo Hucha de arriba); withTaskGroup (TaskGroup y errores en tareas hijas); marcador.despuesDe(evento, en: partido), el método de Marcador de El marcador gol a gol; y @Test con #expect (La regla de los puntos y tu primer test).
Nombres exactos (los usan main.swift, las pruebas y las lecciones siguientes): los tres métodos del actor son golLocal(), golVisitante() y registrar(_:en:); la propiedad de PartidoEnVivo se llama marcadorSeguro, y el método que juega el partido, jugar(), devuelve el marcador final. En PartidoEnVivo.swift ya tienes, de lecciones anteriores, las propiedades alLiberarse y narrador (las creaste en ARC): marcadorSeguro va entre las dos, y jugar() va dentro de la clase, justo antes de public var marcadores.
Intenta escribirlo tú, en este orden:
MarcadorSeguro, un actor público con un marcador que empieza en 0-0 y que solo él escribe, y tres métodos: un gol local, un gol visitante y registrar un evento de un partido.- En
PartidoEnVivo, la propiedadmarcadorSeguroyjugar, que recorre los eventos del partido anotándolos en el actor y devuelve el marcador final (el resultado se puede no usar). - Al final de
main.swift, un título, 1000 goles locales y 1000 visitantes lanzados a la vez sobre unMarcadorSeguro, el marcador resultante, y el marcador final de jugar en vivo Rayo contra Toros a 2 ms por minuto. - Las dos pruebas de
ActoresTests.swift: con una clase normal compartida, 10 000 goles a la vez pueden perderse (nunca sobra ninguno); con el actor, salen los 10 000 y ningún visitante. Cada prueba imprime una línea:Con una clase: N goles de 10000yCon un actor: 10000 goles de 10000.
Lo que debes ver al ejecutar, al final de la salida:
== dos goles a la vez ya no se pisan ==
Marcador tras 1000 goles de cada equipo a la vez: 1000-1000
Marcador final del partido en vivo: 2-1Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// El marcador de un partido, compartido por varias tareas a la vez (la pantalla
// que lo lee, el árbitro que anota goles, el narrador…).
//
// Un `actor` es como una clase, pero Swift garantiza que SOLO UNA tarea a la vez
// ejecuta su código: el estado interno queda protegido. Desde fuera, cada
// llamada al actor es `async` y se escribe con `await`, porque puede tener que
// esperar su turno.
public actor MarcadorSeguro {
public private(set) var marcador = Marcador(local: 0, visitante: 0)
public init() {}
// Sin `async` ni `await` dentro: mientras corre, nadie más entra al actor.
// `marcador` se lee y se escribe sin que otra tarea pueda meterse en medio.
public func golLocal() {
marcador = Marcador(local: marcador.local + 1, visitante: marcador.visitante)
}
public func golVisitante() {
marcador = Marcador(local: marcador.local, visitante: marcador.visitante + 1)
}
public func registrar(_ evento: EventoDePartido, en partido: Partido) {
marcador = marcador.despuesDe(evento, en: partido)
}
}En PartidoEnVivo.swift, la propiedad nueva va justo debajo de alLiberarse y antes de narrador:
public let marcadorSeguro = MarcadorSeguro()Y jugar() es un método nuevo, dentro de la clase, justo antes de public var marcadores:
// Juega el partido entero y va anotando cada evento en el actor. Devuelve el
// marcador final. Cada `await` sobre el actor es un posible punto de espera.
@discardableResult
public func jugar() async -> Marcador {
for await evento in eventos {
await marcadorSeguro.registrar(evento, en: guion)
}
return await marcadorSeguro.marcador
}En Sources → torneo-cli → main.swift, al final del fichero, tras la última línea que ya tienes:
// Un título para separar esta parte de la salida anterior.
print("== dos goles a la vez ya no se pisan ==")
// Un marcador protegido por un actor.
let seguro = MarcadorSeguro()
// Un grupo de tareas, como en la lección de `TaskGroup`.
await withTaskGroup(of: Void.self) { grupo in
for _ in 0..<1_000 {
// Una tarea suma un gol local y otra uno visitante, todas a la vez.
// `await` porque desde fuera hay que esperar el turno del actor.
grupo.addTask { await seguro.golLocal() }
grupo.addTask { await seguro.golVisitante() }
}
}
// Tiene que salir 1000-1000: ningún gol se pierde.
print("Marcador tras 1000 goles de cada equipo a la vez: \(await seguro.marcador)")
// Un partido en vivo rápido (2 ms por minuto de juego).
let enDirecto = PartidoEnVivo(guion: Ejemplo.rayoContraToros, msPorMinuto: 2)
// `jugar()` lo juega entero, anotando cada evento en el actor, y devuelve el marcador final.
print("Marcador final del partido en vivo: \(await enDirecto.jugar())")En ActoresTests.swift, el fichero es nuevo. La clase MarcadorInseguro va fuera de la struct, encima, y es privada:
import Testing
@testable import Torneo
private final class MarcadorInseguro {
var marcador = Marcador(local: 0, visitante: 0)
func golLocal() {
marcador = Marcador(local: marcador.local + 1, visitante: marcador.visitante)
}
}
struct ActoresTests {
static let goles = 10_000
@Test func laClaseCompartidaSePisa() async {
let inseguro = MarcadorInseguro()
await withTaskGroup(of: Void.self) { grupo in
for _ in 0..<Self.goles {
grupo.addTask { inseguro.golLocal() }
}
}
print("Con una clase: \(inseguro.marcador.local) goles de \(Self.goles)")
#expect(inseguro.marcador.local <= Self.goles)
}
@Test func elActorNoPierdeNingunGol() async {
let seguro = MarcadorSeguro()
await withTaskGroup(of: Void.self) { grupo in
for _ in 0..<Self.goles {
grupo.addTask { await seguro.golLocal() }
}
}
let final = await seguro.marcador
print("Con un actor: \(final.local) goles de \(Self.goles)")
#expect(final.local == Self.goles)
#expect(final.visitante == 0)
}
}Con la clase, tu número casi siempre será menor que 10 000 y distinto en cada ejecución. Por eso la prueba solo afirma que nunca sobra ninguno (<= 10000): afirmar que sale distinto de 10 000 la haría inestable, porque de vez en cuando podría salir bien por casualidad.
Qué hace cada pieza:
| Pieza | Por qué está así |
|---|---|
actor en vez de class | Solo una tarea a la vez ejecuta golLocal(): la lectura y la escritura no se separan |
golLocal() sin async | Sin await dentro no hay pausa, así que nadie entra en medio |
private(set) var marcador | Nadie escribe el marcador desde fuera: se pasa por los métodos |
MarcadorInseguro en las pruebas | Existe solo para enseñar el fallo; en el código de la librería no hay ninguna clase así |
Este código compila porque el paquete está todavía en modo Swift 5. La lección Sendable y el modo Swift 6 lo pasa a Swift 6, y entonces el compilador rechazará la prueba de la clase compartida.
Punto de control
- Desde
~/Marcador-Swift/Torneo,xcrun swift run torneo-clitermina sin errores y las tres últimas líneas son estas:
== dos goles a la vez ya no se pisan ==
Marcador tras 1000 goles de cada equipo a la vez: 1000-1000
Marcador final del partido en vivo: 2-1xcrun swift test --filter ActoresTestspasa las 2 pruebas e imprime algo como esto (el número de la clase es siempre menor que 10 000, cambia en cada ejecución y varía según la máquina):
Con una clase: 9944 goles de 10000
Con un actor: 10000 goles de 10000xcrun swift testcompleto termina conTest run with 119 tests in 16 suites passed, yxcrun swift build --build-teststermina sin ningúnwarning:(--build-testshace que se compilen también los tests: con unswift builda secas no se compilarían y sus avisos no saldrían).
Falta lo que el actor no protege. La lección siguiente, Reentrancia: la trampa de los actores, enseña la trampa en la que cae casi todo el que usa actores y la arregla.
Comprueba lo que entendiste
4 preguntas · necesitas 3 para completar la lección
1.10 000 tareas suman un gol a la vez a una clase normal compartida. ¿Qué pasa?
2.¿Qué garantiza un
actor?3.¿Por qué
await seguro.golLocal()llevaawaitsigolLocal()no esasync?4.Escribes
contador.sumar()sinawaiten una funciónasync, ysumar()es un método de un actor. ¿Qué dice el compilador?