Saltar al contenido
K
Swift: memoria y concurrenciaPaso a paso30 min de lectura · 10 min de máquina Intermedio

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):

error típico: leer antes de esperar, escribir despué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
}
corregido: leer después de esperar
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).

ejemplo mínimo: la reentrancia en una hucha (con comentarios)
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 euros

Las 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

KotlinSwift
ReentranciaNo hay hueco: la lambda de update no puede esperar dentroSí: 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 0
Ver solución
solución: comprobar y descontar sin esperar en medio
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.

Torneo
Sources
Torneo
MarcadorSeguro.swiftse modifica
Tests
TorneoTests
ActoresTests.swiftse modifica
Dos ficheros que cambian. En Xcode, en el panel izquierdo (el Project navigator), con el paquete Torneo abierto.

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:

  1. En MarcadorSeguro, golLocalConRevisionIncorrecta, que lee los goles antes de esperar y escribe después, y golLocalConRevision, que espera primero y lee después.
  2. 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:

Torneo/Sources/Torneo/MarcadorSeguro.swift
    // ---- 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:

Torneo/Tests/TorneoTests/ActoresTests.swift
    @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:

  1. Abre ActoresTests.swift (Tests → TorneoTests).
  2. Pulsa el botón de abajo. Aparecerá un aviso de que se ha copiado.
  3. Haz clic dentro del fichero, selecciona todo con Cmd+A y pega con Cmd+V.
ActoresTests.swift

Qué hace cada pieza:

PiezaPor qué está así
golLocalConRevisionIncorrectaLee antes de esperar: las dos llamadas parten del mismo valor viejo
golLocalConRevisionEspera primero y lee después: cada llamada suma sobre lo que hay al volver
#expect(final.local == 1) en la reentranciaDos goles, uno perdido: la prueba afirma el error para que se vea

Punto de control

  • Desde ~/Marcador-Swift/Torneo, xcrun swift run torneo-cli sigue terminando con las mismas tres líneas que en la lección anterior (1000-1000 y 2-1).

  • xcrun swift test --filter ActoresTests pasa 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 2
  • xcrun swift test completo termina con Test run with 125 tests in 16 suites passed, y xcrun swift build --build-tests (compila también los tests y muestra sus avisos) termina sin ningún warning:.

Comprueba lo que entendiste

4 preguntas · necesitas 3 para completar la lección

  1. 1.Dos llamadas a golLocalConRevisionIncorrecta a la vez dan «1 goles de 2». ¿Cuál es la causa?

  2. 2.¿Cómo se arregla la reentrancia de esa función?

  3. 3.¿Es la reentrancia un data race?

  4. 4.En la taquilla con una entrada libre, venderMal comprueba, espera el cobro y descuenta. Dos compras a la vez venden dos entradas. ¿Por qué?

Faltan 4 de 4