wandres.dev
SWIFT 6 Y AISLAMIENTO · data-race safety

La promesa: del bug fantasma al error de compilación

Swift 6 eleva la ausencia de data races a propiedad verificada estáticamente. Qué es exactamente un data race, de dónde sale la garantía, dónde termina —`@unchecked`, punteros, C, race conditions de lógica— y qué obligación nueva traslada al programador.

⏱ 20 min

Durante cuarenta años el data race fue el bug que definía la ingeniería concurrente: no reproducible, dependiente del planificador, invisible en el código fuente y capaz de sobrevivir años en producción hasta manifestarse como una corrupción de memoria en un dispositivo que no tienes delante. Swift 6 hace una afirmación que conviene tomarse en serio antes de discutirla: en modo de lenguaje 6, un programa que compila sin @unchecked, sin punteros crudos y sin interoperabilidad sin anotar no puede contener un data race. No es una heurística de análisis estático ni una recomendación de estilo: es una propiedad del sistema de tipos, verificada en compilación, del mismo rango que la ausencia de errores de tipo. Entender qué se promete exactamente, de qué mecanismos sale la garantía y dónde se acaba es la única forma de que el modo estricto sea una herramienta y no una pelea.

🎯 Al terminar esta lección sabrás
  • Definir data race con precisión y separarlo de la race condition de lógica.
  • Reconstruir la cadena de mecanismos que hace verificable la ausencia de carreras.
  • Delimitar las fronteras de la garantía y catalogar sus axiomas no comprobados.
  • Formular la obligación de diseño que la promesa traslada al programador.

Qué es un data race, con precisión

Un data race no es “un resultado raro”. Es una condición formal sobre el modelo de memoria: dos accesos a la misma dirección desde dos hilos distintos, al menos uno de ellos de escritura, sin ninguna relación de orden establecida por sincronización entre ambos. Cuando eso ocurre, el estándar no dice que el resultado sea uno de los dos valores posibles; dice que el programa carece de significado definido.

final class Contador {
    var valor = 0
    func incrementar() { valor += 1 }
}

let c = Contador()
// Dos tareas concurrentes sobre el mismo objeto
Task.detached { for _ in 0..<10_000 { c.incrementar() } }
Task.detached { for _ in 0..<10_000 { c.incrementar() } }

Es tentador leer ese código como “puede que salga menos de 20000”. La realidad es peor. El compilador tiene permiso para mantener valor en un registro durante todo el bucle porque, si no hay carreras, nadie más puede observarlo; el procesador puede reordenar la escritura respecto a otras; y si el campo fuese una referencia, el recuento de ARC podría desincronizarse y liberar un objeto vivo. La consecuencia no es un número equivocado, es memoria corrupta y un fallo que aparecerá en otro sitio, mucho más tarde, sin relación aparente con la causa.

ℹ️
Data race y race condition no son lo mismo

Un data race es un defecto de memoria: accesos no sincronizados. Una race condition es un defecto de lógica: el resultado depende del orden en que ocurren operaciones que, cada una por separado, están perfectamente sincronizadas. Comprobar el saldo y después retirarlo, con otro hilo retirando en medio, es una race condition sin ningún data race. Swift 6 elimina la primera clase por completo y no toca la segunda en absoluto. Confundirlas produce una falsa sensación de seguridad, y es el malentendido más caro de todo el modelo.

actor Cuenta {
    private var saldo = 100
    func consultar() -> Int { saldo }
    func retirar(_ n: Int) { saldo -= n }
}

// Ni un solo data race, y aun asi incorrecto
if await cuenta.consultar() >= 100 {
    await cuenta.retirar(100)   // otro puede haber retirado entre ambas lineas
}

Las dos llamadas están serializadas por el ejecutor del actor y ninguna de las dos puede corromper memoria. Entre ellas, en cambio, no hay ninguna garantía: la comprobación y la acción son dos secciones críticas distintas separadas por una suspensión. El compilador aprueba el fragmento sin objeciones, y con razón, porque el defecto es de granularidad y no de sincronización. La cura no es una anotación sino un método aislado que haga las dos cosas juntas.

De dónde sale la garantía

Que un compilador pueda prometer esto no es obvio: exige que sepa enumerar todos los puntos donde un valor puede pasar de un contexto de ejecución a otro. En C o en Java ese conjunto es infinito, porque cualquier escritura de un puntero en un campo alcanzable publica el objeto. En Swift el conjunto es finito y sintácticamente visible, y esa es la propiedad que lo hace todo posible.

La cadena tiene cuatro eslabones, y cada uno se estudia en una lección de este nivel. El primero es la semántica de valor: la mayoría de los tipos del lenguaje se copian al asignarse, de modo que compartir es la excepción explícita, no el estado natural. El segundo es Sendable, una marca en el sistema de tipos que responde a la pregunta de si un tipo es seguro atravesando fronteras. El tercero son los dominios de aislamiento: los actores agrupan estado mutable bajo un ejecutor serial que garantiza un único acceso simultáneo. El cuarto es el análisis por regiones, que añade sensibilidad al flujo para permitir lo que la marca de tipo prohibía en exceso.

flowchart TB
A[Semantica de valor: copiar es lo normal] --> B[Sendable: que tipos cruzan fronteras]
B --> C[Dominios de aislamiento: quien posee el estado mutable]
C --> D[Regiones: que valor concreto es seguro ahora]
D --> E[Todo cruce de frontera es visible en el codigo]
E --> F[Ausencia de data races verificable]
style F fill:#a6e3a1,color:#11111b
style E fill:#89b4fa,color:#11111b

El resultado práctico es que todo cruce de dominio queda marcado en el texto del programa, y el catálogo completo cabe en tres líneas:

Task { modelo.mutar() }                    // 1: creacion de una tarea nueva
await archivo.recibir(modelo)              // 2: llamada a otro dominio
let f: @Sendable () -> Void = { modelo.mutar() }  // 3: clausura enviable

No hay ninguna cuarta forma, y sobre todo no hay publicación silenciosa: en Swift no existe el equivalente a guardar un puntero en una estructura global compartida sin que el compilador lo vea. Donde el conjunto de cruces es finito y sintácticamente reconocible, la comprobación deja de ser un problema abierto y pasa a ser decidible. Esa es toda la magia, y es una propiedad del diseño del lenguaje, no de la sofisticación del análisis.

Dónde termina la promesa

Una garantía formal solo vale lo que valen sus axiomas, y este sistema tiene varios. Conviene tenerlos escritos, porque son exactamente los sitios donde volverá a aparecer el bug que creías extinguido.

Los axiomas que tú firmas. @unchecked Sendable desactiva la comprobación y traslada la carga de la prueba a tu cabeza. nonisolated(unsafe) hace lo mismo sobre una única variable. Ambos son declaraciones del tipo “confía en mí”: legítimas cuando hay un cerrojo real detrás, catastróficas cuando se usan para silenciar un diagnóstico.

La frontera con C y Objective-C. Un tipo importado puede estar anotado como seguro y no serlo, o no estar anotado y serlo. @preconcurrency import degrada errores a avisos precisamente porque el compilador reconoce que ahí su información es incompleta. Todo lo que pase por punteros sin comprobar queda fuera del alcance del análisis, por construcción.

La lógica. Ni las race conditions, ni los interbloqueos, ni la inversión de prioridad, ni la reentrancia de actores entran en la promesa. Este último caso merece atención porque sorprende a todo el mundo: dentro de un actor, cada await es un punto de suspensión donde otro trabajo puede intercalarse y modificar el estado.

actor Almacen {
    private var saldo = 100

    func retirar(_ cantidad: Int) async throws {
        guard saldo >= cantidad else { throw Error.fondos }
        try await auditoria.registrar(cantidad)   // aqui se suspende
        saldo -= cantidad                         // el guard puede haber caducado
    }
}

No hay data race: cada acceso a saldo ocurre en el ejecutor del actor. Hay un invariante roto, y el compilador no dirá nada. Merece la pena insistir en que esto no es un defecto del modelo sino su definición: la reentrancia existe porque bloquear un hilo del sistema mientras se espera a otro agota el conjunto de hilos y produce interbloqueos triviales, de modo que suspender en vez de bloquear no era una alternativa entre varias, era la única compatible con miles de tareas ligeras sobre unos pocos hilos. La regla que sustituye al viejo “mantén el cerrojo” es: reestablece tus invariantes justo después de cada suspensión, nunca antes.

Lo que te exige a cambio

La promesa no es gratis, y su precio no se paga en anotaciones sino en decisiones. La más importante es que el aislamiento pasa a formar parte del contrato público de tus tipos, con el mismo rango que throws o que la mutabilidad.

public func cargar() async -> Datos              // llamable desde cualquier dominio
@MainActor public func cargar() async -> Datos   // exige cruzar al hilo principal

Esas dos firmas no se diferencian en el cuerpo sino en el contrato, y sustituir la primera por la segunda rompe a todo el que llamaba desde fuera del actor principal. Lo mismo ocurre en sentido inverso con Sendable: declarar la conformidad de un tipo público es una promesa que no podrás retirar sin una versión mayor, porque hay clientes que ya construyeron encima de ella. La consecuencia práctica es que el aislamiento debe diseñarse cuando se diseña la API, no descubrirse cuando el compilador protesta.

🏠

Cada estado mutable con dueño

La pregunta que ordena el diseño no es dónde poner un cerrojo, sino qué dominio es el propietario de este estado. Si no sabes responderla, el compilador tampoco.

✉️

Copiar o compartir, explícito

Un tipo de valor viaja por copia y desaparece del problema. Una clase mutable compartida obliga a un dueño o a una sincronización interna. Elegir mal se paga en toda la API.

🔒

Los atajos son deuda con nombre

Cada @unchecked y cada nonisolated(unsafe) es una obligación de prueba a tu cargo. Que sean localizables por búsqueda de texto es una virtud del diseño, no un accidente.

De la disciplina a la prueba: qué cambia realmente cuando una propiedad sube al sistema de tipos

La historia de la programación puede leerse como una migración lenta de propiedades desde el terreno de la disciplina personal al terreno de la verificación mecánica, y cada etapa provocó exactamente la misma resistencia. La gestión manual de memoria era disciplina hasta que ARC la convirtió en una propiedad del compilador. La nulidad era disciplina hasta que los opcionales la hicieron visible en cada firma. La seguridad de tipos misma fue considerada durante décadas una limitación pedante frente a la flexibilidad del ensamblador. En cada caso el patrón se repite: primero la propiedad se sostiene con convenciones y revisiones de código, luego alguien encuentra un invariante lo bastante fuerte para expresarla en el sistema de tipos, y a partir de ahí el coste se traslada del tiempo de ejecución al tiempo de escritura. La ausencia de data races es la última en cruzar esa línea, y el invariante elegido es sorprendentemente simple de enunciar: ningún estado mutable es alcanzable simultáneamente desde dos dominios de aislamiento. Todo lo demás —Sendable, actores, regiones, sending— es maquinaria para verificar esa única frase. Lo interesante es el precio conceptual: para que la frase sea comprobable, el compilador necesita saber quién posee cada cosa, y “quién posee cada cosa” es precisamente la decisión de arquitectura que la mayoría de los programas concurrentes nunca tomó de forma explícita. Por eso migrar duele: los errores del modo estricto no son quejas sobre sintaxis, son un informe sobre las decisiones de propiedad que tu código nunca llegó a tomar. Y por eso el resultado, cuando termina, no es solo un programa sin carreras, sino un programa donde por primera vez está escrito quién manda sobre qué.

📝
Lo esencial de la promesa

Un data race es acceso concurrente no sincronizado con al menos una escritura, y su consecuencia es comportamiento indefinido, no un valor equivocado. Swift 6 lo convierte en error de compilación porque puede enumerar todos los cruces de dominio: semántica de valor, Sendable, aislamiento y regiones. La garantía termina en @unchecked, en nonisolated(unsafe), en la interoperabilidad sin anotar y en toda la clase de las race conditions de lógica, incluida la reentrancia de actores. El precio es que el aislamiento pasa a ser contrato público y que cada estado mutable necesita un dueño declarado.

⚔️ Mide el alcance de la garantía
  1. Escribe un ejemplo mínimo que en modo de lenguaje 5 compile y en modo 6 no, y explica cuál de los cuatro eslabones lo detecta.
  2. Construye una race condition de lógica que compile limpiamente bajo comprobación completa y describe por qué el compilador no puede verla.
  3. Toma un tipo tuyo marcado con @unchecked Sendable y escribe en tres líneas la demostración informal de que es seguro; si no puedes, tienes un bug.
  4. Reproduce el ejemplo de retirada con await en medio y razona qué invariante se rompe y dónde habría que revalidarlo.
  5. Busca en un proyecto real cuántas apariciones hay de @unchecked, nonisolated(unsafe) y @preconcurrency, y clasifícalas en deuda justificada y deuda por silenciar.