Swift y Objective-C

[Swift avanzado #2] actor de Swift: guía completa para evitar carreras de datos

actor es el cuarto tipo de Swift y protege su propio estado. Explicamos cómo serializar el acceso permite al compilador detectar carreras de datos, incluida la trampa clásica de la reentrada.

5 min de lectura
Imagen de portada de [Swift avanzado #2] actor de Swift: guía completa para evitar carreras de datos

El protagonista de la segunda entrega de Swift Concurrency es actor: un cuarto tipo, distinto de class y struct.

Que hiciera falta un nuevo tipo indica que existía un problema grave. Empecemos por las carreras de datos.

Carreras de datos: las condiciones del peor bug

Una carrera de datos tiene condiciones claras: dos o más hilos acceden simultáneamente a la misma memoria y al menos uno escribe.

Cuando se cumplen, el comportamiento no está definido: los valores pueden corromperse, la app puede fallar o no pasar nada visible.

El resultado depende de la suerte del planificador ese día.

final class Counter {
    var value = 0
    func increment() { value += 1 }  // pasos de leer-sumar-escribir, 3
}
// si dos hilos llaman a la vez increment()
// 1000aunque se llame valuepuede no ser 1000

value += 1no es atómico. Si otro hilo se intercala entre leer, sumar y escribir, el incremento desaparece.

Este bug es malicioso porque cuesta reproducirlo. Solo aparece con el momento exacto: las pruebas pasan y, tras publicar, llegan fallos intermitentes.

En la entrega sobre tipos por valor dije que, al no compartirse, desaparece la premisa de la carrera. Quedan los tipos por referencia, pensados para compartir.

La solución tradicional era usar bloqueos, semáforos y una DispatchQueue serial.

Todos funcionan, pero dependen por completo de la disciplina del desarrollador.

Basta olvidar el bloqueo en un sitio; el compilador no puede detectar ese error.

Este patrón resulta familiar. Como la comprobación de nil en la entrega de opcionales o las referencias circulares en la de ARC, Swift lleva al sistema de tipos lo que dependía de la disciplina.

Ahora les toca a las carreras de datos.

actor: el tipo que protege su estado

En una frase, actor es un tipo por referencia con protección de estado integrada.

actor Counter {
    var value = 0
    func increment() { value += 1 }
}

Convertir una class en actor garantiza que solo una ejecución acceda a sus propiedades almacenadas a la vez.

Cada actor tiene un ejecutor serial; las llamadas se ponen en cola y se procesan una a una. Nada se intercala entre los tres pasos de increment, así que la carrera no puede darse.

La clave es que el compilador lo impone. Desde fuera de un actor, acceder a su estado o métodos requiere await para compilar.

let counter = Counter()
await counter.increment()          // fuera await obligatorio
print(await counter.value)

await existe por el mismo concepto de suspensión explicado en la primera parte. Si el actor está ocupado, la petición espera en cola y cede sin retener un hilo.

No duerme un hilo como un bloqueo. La función se suspende y continúa cuando llega su turno.

Dentro del actor se puede acceder libremente sin await porque ya se está en aislamiento.

Esta frontera entre dentro y fuera es el aislamiento presentado en la primera parte.

@MainActor es la versión global: convierte el hilo principal en un contexto serial y aísla el estado de la UI.

El principio es idéntico al de un actor personalizado.

Diagrama que contrasta un cristal roto por acceso concurrente con un acceso seguro en cola
Al poner en cola el acceso concurrente, la carrera no puede darse

reentrada: la trampa más famosa de actor

actor puede parecer万能, pero tiene una trampa de diseño: permite la reentrada.

Al encontrar await dentro de un método de actor, este se suspende y el actor queda disponible. Otra llamada puede entrar y ejecutarse mientras tanto.

Al reanudarse el método original, el estado del actor puede haber cambiado.

actor ImageCache {
    var cache: [URL: Image] = [:]

    func image(for url: URL) async -> Image {
        if let cached = cache[url] { return cached }
        let image = await download(url)   // punto de suspensión: otra llamada puede entrar durante este intervalo
        cache[url] = image                // el mismo URLpodría estar guardado ya
        return image
    }
}

Si llegan casi a la vez dos peticiones para la misma URL, ambas ven un fallo de caché y descargan. Los datos no se dañan —actor lo evita—, pero la lógica se ejecuta dos veces.

La regla clave: actor evita carreras de datos de bajo nivel, pero no conserva la coherencia lógica a través de await.

Hay que volver a validar las suposiciones sobre el estado antes y después de await. Aquí, guardar en la caché el Task de descarga en curso evita duplicados.

¿Por qué? Prohibir la reentrada obligaría a bloquear el actor durante await, lo que permitiría un deadlock permanente entre actores que se esperan mutuamente.

Swift elige un sistema sin deadlocks y deja la coherencia lógica al desarrollador. Entender el intercambio hace predecible la trampa.

Aplicación práctica: dónde usar actor

actor encaja como propietario de estado mutable compartido por varios contextos asíncronos.

Cachés, pools de conexiones, gestores de descargas y almacenes de sesión. Allí donde haya que responder «¿quién protege este estado?», actor es candidato.

También hay casos donde no encaja. Primero: estado no compartido.

Para un view model usado por una sola pantalla, conviene una clase @MainActor. Un actor personalizado solo añade await innecesarios al acceso a la UI.

Segundo: datos inmutables. Si no hay carrera, basta con struct o let: prioridad a los tipos por valor.

Tercero: rutas críticas con frecuencia extrema. Cruzar el límite de actor tiene coste de serialización; cientos de miles de llamadas por segundo indican que hay que revisar el diseño.

Y uno más: actor puede ser excesivo para proteger un único contador o indicador atómico.

Ahí, una herramienta de bajo nivel como Mutex del módulo Synchronization de Swift 6 es más ligera y no requiere await. actor brilla cuando estado y lógica forman un conjunto.

Ilustración de reentrada: otra llamada entra durante await y cambia el estado
Reentrada: otra llamada puede entrar durante await y cambiar el estado

Resumen

  • Una carrera de datos es comportamiento indefinido causado por acceso concurrente y al menos una escritura; es especialmente perversa porque cuesta reproducirla. Los bloqueos funcionan, pero dependen de la disciplina.
  • actor es un tipo por referencia con protección integrada. Su ejecutor serial pone el acceso en cola y el compilador exige await desde fuera.
  • @MainActor convierte el hilo principal en un actor global y es el estándar para el aislamiento de la UI.
  • actor permite reentrada. El estado puede cambiar alrededor de await, así que la coherencia lógica requiere protección explícita además de evitar carreras de bajo nivel.
  • Usa actor como propietario de estado mutable compartido. Es excesivo para estado no compartido, datos inmutables y rutas críticas extremas.

La próxima entrega trata Sendable, la última pieza del aislamiento: tipos seguros al cruzar límites y cómo leer las advertencias de strict concurrency de Swift 6 en código existente.

Seguir leyendo