Reentrancia: la trampa de los actores
Al terminar: Entender la reentrancia de un actor y evitar sus carreras lógicas.
Al terminar esta lección verás la trampa que un actor no evita, la reentrancia: dos goles a la vez que acaban en «1 goles de 2». Sabrás por qué pasa aunque nadie toque el dato a la vez, cómo se arregla, y tendrás en MarcadorSeguro los dos métodos que lo enseñan, con sus pruebas.
Importa porque es un fallo que el compilador no ve: el programa compila, no se queja y da un número mal, solo cuando dos tareas coinciden. Temas: reentrancia y puntos de suspensión (un punto de suspensión es cada await: el sitio donde una función puede quedarse esperando y soltar el turno).
La reentrancia: el actor no protege «a través» de un await
El actor protege su estado entre dos await. Pero cada await dentro de un método del actor es un punto de suspensión, una pausa: mientras ese método espera, otra tarea puede entrar al actor y cambiar el estado. Eso es la reentrancia (reentrancy). Lo que se leyó antes de esperar puede estar viejo al volver.
Los dos métodos siguientes están dentro de un actor, el MarcadorSeguro de la lección anterior (por eso usan marcador sin más):
func golLocalConRevisionIncorrecta() async {
let golesAntes = marcador.local // lee 0
try? await Task.sleep(for: .milliseconds(50)) // aquí entra otra tarea
marcador = Marcador(local: golesAntes + 1, visitante: marcador.visitante) // escribe 1
}func golLocalConRevision() async {
try? await Task.sleep(for: .milliseconds(50))
marcador = Marcador(local: marcador.local + 1, visitante: marcador.visitante)
}Por qué falla: con dos llamadas a la vez, las dos leen 0 antes de esperar y las dos escriben 1. La regla: lee y decide sin await en medio, o vuelve a leer después de cada espera.
Esto no es un data race: nunca hay dos hilos tocando el dato a la vez, porque el actor lo impide. Es un fallo de lógica (un dato viejo), y el compilador de Swift 6 no lo detecta: el actor protege el acceso, no el razonamiento.
Verlo con una hucha
El mismo fallo en pequeño, con una hucha. Además estrena async let con un tipo Void: lanza una llamada que no devuelve nada y sigue sin esperarla; las dos se esperan después, juntas, con _ = await (uno, dos) (el guion bajo dice «no me interesa el resultado»). Fíjate en que la línea del async let no lleva await delante, aunque llame a un actor: lanzar la llamada no espera nada, y el await va donde se recoge el resultado, en _ = await (uno, dos).
actor Hucha {
private(set) var euros = 0
// MAL: lee `euros` antes de esperar y lo escribe después.
func meterMal(_ cantidad: Int) async {
let antes = euros // lee, por ejemplo, 0
try? await Task.sleep(for: .milliseconds(50)) // pausa: aquí entra la otra llamada
euros = antes + cantidad // escribe con el valor viejo
}
// BIEN: espera primero y suma sobre lo que haya al volver.
func meterBien(_ cantidad: Int) async {
try? await Task.sleep(for: .milliseconds(50))
euros += cantidad // lee y escribe sin pausa en medio
}
}
let mala = Hucha()
async let uno: Void = mala.meterMal(10) // lanza la primera llamada y sigue
async let dos: Void = mala.meterMal(10) // lanza la segunda a la vez
_ = await (uno, dos) // espera a las dos, sin guardar nada
print("Mal: \(await mala.euros) euros")
let buena = Hucha()
async let tres: Void = buena.meterBien(10)
async let cuatro: Void = buena.meterBien(10)
_ = await (tres, cuatro)
print("Bien: \(await buena.euros) euros")Lo que imprime, siempre:
Mal: 10 euros
Bien: 20 eurosLas dos llamadas de meterMal leyeron 0 antes de esperar y las dos escribieron 0 + 10: faltan 10 euros. Las de meterBien leen y escriben sin pausa en medio.
Tabla Kotlin ↔ Swift
| Kotlin | Swift | |
|---|---|---|
| Reentrancia | No hay hueco: la lambda de update no puede esperar dentro | Sí: cada await dentro del actor deja entrar a otra tarea |
Reto · Ejercicio: la última entrada de la taquilla
En tu Playground, escribe un actor, Taquilla, con una entrada libre, que desde fuera se lee pero no se cambia. Dale dos maneras de vender:
venderMal: comprueba que queda alguna (si no, devuelve falso), espera 50 ms (el cobro con tarjeta), descuenta y devuelve verdadero;vender: comprueba y descuenta sin esperar nada en medio, y deja el cobro para el final.
Lanza dos compras a la vez con la manera mala sobre una taquilla, y dos con la buena sobre otra. Para cada una, imprime cuántas ventas salieron bien y cuántas libres quedan. Antes de ejecutar, apunta qué esperas. Lo que debe salir:
Mal: vendidas 2, libres -1
Bien: vendidas 1, libres 0Ver solución
actor Taquilla {
private(set) var libres = 1
// MAL: comprueba, espera el cobro y DESPUÉS descuenta.
func venderMal() async -> Bool {
guard libres > 0 else { return false }
try? await Task.sleep(for: .milliseconds(50)) // aquí entra la otra compra
libres -= 1
return true
}
// BIEN: comprueba y descuenta sin esperar en medio; el cobro va al final.
func vender() async -> Bool {
guard libres > 0 else { return false }
libres -= 1
try? await Task.sleep(for: .milliseconds(50))
return true
}
}
let mala = Taquilla()
async let a = mala.venderMal()
async let b = mala.venderMal()
let malas = [await a, await b].filter { $0 }.count
print("Mal: vendidas \(malas), libres \(await mala.libres)")
let buena = Taquilla()
async let c = buena.vender()
async let d = buena.vender()
let buenas = [await c, await d].filter { $0 }.count
print("Bien: vendidas \(buenas), libres \(await buena.libres)")Con venderMal se venden dos entradas con una sola libre: las dos comprobaciones pasaron antes de que ninguna descontara, porque la espera del cobro estaba en medio. Con vender, la comprobación y el descuento son un solo bloque sin await, así que nadie entra entre los dos: una compra sale bien y la otra no. Es la misma regla que golLocalConRevision. (Se cuentan las ventas en vez de imprimir los dos resultados porque cuál de las dos compras entra primero puede cambiar de una ejecución a otra.)
Paso · la reentrancia en MarcadorSeguro
Objetivo: Añadir a MarcadorSeguro los dos métodos que enseñan la reentrancia, la prueba que la demuestra y otras cinco pruebas que trae el botón de copiar. Al terminar, las pruebas imprimen Reentrancia, versión incorrecta: 1 goles de 2 y el paquete llega a 125 tests.
Trabajas en el mismo paquete, ~/Marcador-Swift/Torneo. No hay ficheros nuevos: los dos métodos van al final de MarcadorSeguro, dentro del actor, y la prueba al final de ActoresTests.
Piezas nuevas de este paso: ninguna. Task.sleep(for:) es de async/await (el try? ignora el error de una espera cancelada), y async let …: Void con _ = await (uno, dos) es el de la hucha de arriba.
Datos para escribirlo: los dos métodos reciben con la etiqueta msDeRevision los milisegundos que tarda la revisión del VAR (la comprobación del árbitro con vídeo), y los dos esperan antes de sumar el gol local. Las pruebas de la lección usan esos nombres: golLocalConRevisionIncorrecta(msDeRevision:) y golLocalConRevision(msDeRevision:).
Intenta escribirlo tú, en este orden:
- En
MarcadorSeguro,golLocalConRevisionIncorrecta, que lee los goles antes de esperar y escribe después, ygolLocalConRevision, que espera primero y lee después. - En
ActoresTests, una prueba que lanza dos goles a la vez con la revisión incorrecta de 50 ms y afirma que solo quedó 1 gol de 2 (la prueba afirma el error para que se vea).
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
Al final de MarcadorSeguro.swift, justo antes de la llave que cierra el actor:
// ---- REENTRANCIA (la trampa de los actores) ----
//
// Un actor protege su estado MIENTRAS ejecuta código sin pausas. Pero cada
// `await` dentro de un método del actor es un punto donde el método se PAUSA
// y OTRA tarea puede entrar al actor y cambiar `marcador`. Al volver, lo que
// se leyó antes de pausar puede estar viejo.
// MAL: lee el marcador ANTES de esperar la revisión del VAR y lo escribe DESPUÉS.
// Con dos goles a la vez, los dos leen 0 y los dos escriben 1: se pierde un gol.
public func golLocalConRevisionIncorrecta(msDeRevision: Int) async {
let golesAntes = marcador.local
try? await Task.sleep(for: .milliseconds(msDeRevision))
marcador = Marcador(local: golesAntes + 1, visitante: marcador.visitante)
}
// BIEN: después de esperar se vuelve a leer el estado actual.
public func golLocalConRevision(msDeRevision: Int) async {
try? await Task.sleep(for: .milliseconds(msDeRevision))
marcador = Marcador(local: marcador.local + 1, visitante: marcador.visitante)
}Y la prueba, dentro de la struct ActoresTests, tras la última que tienes:
@Test func reentranciaPierdeUnGolSiSeLeeAntesDeEsperar() async {
let seguro = MarcadorSeguro()
async let uno: Void = seguro.golLocalConRevisionIncorrecta(msDeRevision: 50)
async let dos: Void = seguro.golLocalConRevisionIncorrecta(msDeRevision: 50)
_ = await (uno, dos)
let final = await seguro.marcador
print("Reentrancia, versión incorrecta: \(final.local) goles de 2")
#expect(final.local == 1) // dos goles, uno perdido
}Hay cinco pruebas más (goles de los dos equipos a la vez, registrar eventos en el actor, la reentrancia arreglada, un partido en vivo y dos partidos a la vez): son del mismo tipo que las tuyas, así que las trae el botón Copiar fichero (copia el contenido al portapapeles sin tocar tu proyecto; lo viste en Clases y objetos). El fichero que se copia es el completo, con tus dos pruebas de la lección anterior, la de reentrancia y las otras cinco: 8 pruebas, y sustituye a lo que hayas escrito. Los pasos son estos:
- Abre
ActoresTests.swift(Tests → TorneoTests). - Pulsa el botón de abajo. Aparecerá un aviso de que se ha copiado.
- Haz clic dentro del fichero, selecciona todo con Cmd+A y pega con Cmd+V.
Qué hace cada pieza:
| Pieza | Por qué está así |
|---|---|
golLocalConRevisionIncorrecta | Lee antes de esperar: las dos llamadas parten del mismo valor viejo |
golLocalConRevision | Espera primero y lee después: cada llamada suma sobre lo que hay al volver |
#expect(final.local == 1) en la reentrancia | Dos goles, uno perdido: la prueba afirma el error para que se vea |
Punto de control
-
Desde
~/Marcador-Swift/Torneo,xcrun swift run torneo-clisigue terminando con las mismas tres líneas que en la lección anterior (1000-1000y2-1). -
xcrun swift test --filter ActoresTestspasa las 8 pruebas e imprime algo como esto (el número de la clase es siempre menor que 10 000 y cambia en cada ejecución; varía según la máquina):
Con una clase: 9929 goles de 10000
Con un actor: 10000 goles de 10000
Reentrancia, versión incorrecta: 1 goles de 2xcrun swift testcompleto termina conTest run with 125 tests in 16 suites passed, yxcrun swift build --build-tests(compila también los tests y muestra sus avisos) termina sin ningúnwarning:.
Comprueba lo que entendiste
4 preguntas · necesitas 3 para completar la lección
1.Dos llamadas a
golLocalConRevisionIncorrectaa la vez dan «1 goles de 2». ¿Cuál es la causa?2.¿Cómo se arregla la reentrancia de esa función?
3.¿Es la reentrancia un data race?
4.En la taquilla con una entrada libre,
venderMalcomprueba, espera el cobro y descuenta. Dos compras a la vez venden dos entradas. ¿Por qué?