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

Migrar código legado sin naufragar

La comprobación estricta se activa por fases y por módulo, no con un interruptor. El orden correcto de adopción, el catálogo de errores frecuentes con su lectura real, las decisiones de diseño que eliminan cientos de diagnósticos y cómo mantener auditable la deuda de `@unchecked` y `@preconcurrency`.

⏱ 22 min

Casi todos los relatos de migración fallida a Swift 6 empiezan igual: alguien cambia el modo de lenguaje del proyecto entero un viernes, obtiene mil cuatrocientos errores y concluye que el modelo es impracticable. El diagnóstico es correcto sobre el método y falso sobre el modelo. La comprobación estricta no se diseñó como un interruptor sino como una rampa: modos de comprobación graduales, características activables una a una, adopción módulo a módulo, y anotaciones específicas para convivir con dependencias que aún no han migrado. Y hay algo más importante que la mecánica: la mayor parte de esos mil cuatrocientos errores no son mil cuatrocientos problemas, son unas pocas decenas de decisiones de propiedad que nunca se tomaron, repetidas en todos los sitios donde faltaban. Migrar bien consiste en encontrar esas decisiones, no en apagar diagnósticos uno por uno.

🎯 Al terminar esta lección sabrás
  • Ordenar la adopción por fases y por módulos según el grafo de dependencias.
  • Traducir los diagnósticos frecuentes al problema de diseño que los origina.
  • Aplicar las tres decisiones estructurales que eliminan diagnósticos en masa.
  • Mantener localizable y con dueño la deuda que dejan las anotaciones de escape.

El orden correcto: fases, no un interruptor

La secuencia tiene cuatro escalones y saltárselos es la causa habitual del naufragio.

El primero es activar la comprobación en modo intermedio y después completo, todavía en modo de lenguaje 5, donde los problemas aparecen como avisos. Eso permite medir el tamaño real del trabajo sin bloquear la compilación ni detener al equipo.

El segundo es habilitar las características futuras de una en una, cada una con su propio ciclo de corrección: la comprobación estricta, el aislamiento de variables globales, los valores por omisión aislados, el análisis por regiones, la inferencia de seguridad en capturas y la eliminación de la inferencia hacia arriba. Cada bandera acota el tipo de error que verás, y esa acotación es justamente lo que hace tratable la corrección.

// Package.swift
.target(
    name: "Nucleo",
    swiftSettings: [
        .enableUpcomingFeature("StrictConcurrency"),
        .enableUpcomingFeature("GlobalConcurrency"),
        .enableUpcomingFeature("RegionBasedIsolation"),
        .swiftLanguageMode(.v6)
    ]
)

El tercero es elegir el orden de los módulos, y aquí la regla es contraintuitiva para mucha gente: se migra desde las hojas hacia la raíz. Un módulo base ya anotado ofrece a sus consumidores información correcta sobre aislamiento y seguridad, de modo que los errores que aparezcan arriba serán reales. Migrar primero la capa de interfaz sobre dependencias sin anotar produce una avalancha de diagnósticos falsos que desaparecerán solos cuando las bases migren, y corregirlos es trabajo tirado.

El cuarto es el modo de lenguaje 6 por módulo, cuando el anterior ya está limpio. Para el módulo de una aplicación con interfaz gráfica hay un atajo que cambia la escala del problema: desde Swift 6.2 puede fijarse el aislamiento por omisión al actor principal, de modo que todo el código del módulo pertenece al hilo principal salvo que se diga lo contrario. La mayoría de un programa de interfaz vive ahí de todos modos, y esa única opción elimina la mayor parte de los diagnósticos de golpe, dejando el trabajo de aislamiento donde de verdad importa: en las pocas rutas que hacen trabajo pesado en segundo plano.

flowchart TB
A[Comprobacion completa como avisos] --> B[Medir el volumen real]
B --> C[Activar caracteristicas futuras una a una]
C --> D[Migrar modulos de las hojas hacia la raiz]
D --> E[Modo de lenguaje 6 por modulo]
E --> F{Es el modulo de la app}
F -->|si| G[Aislamiento por omision al actor principal]
F -->|no| H[Aislamiento explicito por tipo]
style A fill:#f9e2af,color:#11111b
style G fill:#a6e3a1,color:#11111b
style H fill:#89b4fa,color:#11111b

El catálogo de errores y lo que dicen de verdad

Cuatro familias cubren casi todo lo que verás, y cada una apunta a un problema de diseño distinto.

Variable global mutable. El diagnóstico dice que una variable global no es segura para acceso concurrente. Lo que dice de verdad es que tienes estado compartido sin dueño. Las salidas honestas son tres: convertirla en let de un tipo seguro si nunca cambia, aislarla a un actor global si pertenece a un subsistema, o moverla dentro de un actor. La cuarta, nonisolated(unsafe), no es una salida sino un aplazamiento con nombre.

// Antes: singleton mutable sin dueño
final class Ajustes { static var compartidos = Ajustes() }

// Despues: el subsistema entero declara su dominio
@MainActor final class Ajustes { static let compartidos = Ajustes() }

Captura no segura en una tarea o una clausura. El diagnóstico habla de una captura cuyo tipo no es Sendable. Antes de tocar el tipo, comprueba si el análisis por regiones lo resuelve solo reordenando: en muchísimos casos el objeto es local y basta con no guardarlo en ningún sitio antes de enviarlo. Cuando el objeto sí debe seguir vivo en el origen, no hay reordenación que valga y toca decidir dueño o copia.

Conformidad con aislamiento incompatible. Un tipo aislado al actor principal que intenta conformar a un protocolo cuyos requisitos no lo están. Es el error más frecuente al integrar con protocolos heredados de Objective-C, donde el patrón de delegado es omnipresente. Desde Swift 6.2 existe la conformidad aislada a un actor global, que expresa exactamente esa situación en lugar de obligar a romper el aislamiento del tipo; cuando no aplica, la respuesta correcta suele ser separar en dos tipos la parte de interfaz y la parte de datos, que casi siempre estaban mezcladas por comodidad y no por diseño.

Diagnósticos originados en una dependencia. Un módulo o una biblioteca de C sin anotar produce errores sobre código que no es tuyo. Para eso está @preconcurrency import, que degrada esos errores mientras la dependencia se pone al día, y @preconcurrency sobre una conformidad concreta cuando el problema es un único protocolo importado.

⚠️
No corrijas errores, corrige decisiones

El error de método más caro de toda migración es tratar la lista de diagnósticos como una cola de tareas y atacarla por orden. Los diagnósticos no son independientes: cincuenta de ellos suelen ser el mismo tipo mal ubicado, visto desde cincuenta llamadas. Ordena la lista por tipo implicado, no por archivo, decide dónde vive ese tipo, y observa cómo desaparecen en bloque. Quien corrige uno a uno acaba con anotaciones contradictorias repartidas por el proyecto y sin ninguna arquitectura de aislamiento.

Las decisiones que resuelven casi todo

Tres decisiones estructurales, tomadas antes de escribir la primera anotación, hacen la diferencia entre una migración de días y una de meses.

La primera es dibujar el mapa de dominios del módulo antes de tocar nada: qué estado mutable existe, quién lo lee, quién lo escribe y desde dónde. La mayoría de los proyectos descubre en este paso que tiene tres o cuatro dominios naturales —interfaz, persistencia, red, un caché— y que el noventa por ciento de sus tipos pertenece sin discusión a uno de ellos.

La segunda es decidir el dueño de cada estado mutable y hacerlo explícito. Un tipo tiene tres destinos posibles y solo tres: convertirse en tipo de valor y viajar por copia, quedar bajo un actor o un actor global, o permanecer como clase con sincronización interna y conformidad no comprobada. Elegir conscientemente entre esos tres es el trabajo real de la migración; todo lo demás es notación.

// Destino 1: era una clase compartida sin necesidad
struct Sesion: Sendable { let id: UUID; let inicio: Date }

// Destino 2: estado que muta y tiene un subsistema propio
actor RegistroDeEventos { private var pendientes: [Evento] = [] }

// Destino 3: envoltorio sobre algo ya sincronizado por debajo
final class ColaNativa: @unchecked Sendable { /* cerrojo propio */ }

La tercera es separar el trabajo de interfaz del trabajo pesado. Con el aislamiento por omisión al actor principal, la migración se reduce a identificar las rutas que hacen cómputo o entrada y salida costosos y sacarlas explícitamente del hilo principal. Ese conjunto suele ser mucho más pequeño de lo que la gente teme, y tenerlo enumerado es valioso mucho más allá de la concurrencia.

🗺️

Mapa antes que anotaciones

Dibuja los dominios del módulo antes de escribir la primera marca. Las anotaciones sin mapa producen aislamiento inconsistente y regresiones de rendimiento.

🍃

De las hojas a la raíz

Migrar primero las dependencias base convierte los errores de arriba en errores reales. El orden inverso genera trabajo que se tirará.

🧾

Deuda con nombre y fecha

Cada @unchecked, cada nonisolated(unsafe) y cada @preconcurrency debe llevar comentario, responsable y motivo. Son localizables por búsqueda: aprovéchalo.

Presupuesto, trampas y deuda

Conviene entrar con expectativas calibradas. La curva de errores no es descendente: sube de golpe al activar cada característica, se derrumba cuando se toman las decisiones de propiedad, y deja una cola larga de casos individuales que exigen pensar. El tramo doloroso es el primero de cada bandera, no el conjunto, y por eso activarlas todas a la vez multiplica la sensación de desastre sin acelerar nada.

Las trampas son pocas y muy reconocibles. La primera es anotar hasta que compile: repartir el actor principal por todo el proyecto silencia los diagnósticos y traslada al hilo de interfaz trabajo que nunca debió estar ahí, con un coste que aparecerá como falta de fluidez semanas después. La segunda es envolver en tareas: rodear cada llamada conflictiva con una tarea nueva convierte errores de compilación en race conditions de lógica, que es exactamente el intercambio contrario al que ofrece Swift 6. La tercera es marcar tipos como seguros para callar al compilador, que ya no es un aplazamiento sino una mentira incorporada al sistema de tipos y sobre la que se construirá código futuro.

Hay además dos herramientas de convivencia que evitan bloqueos mientras las dependencias se ponen al día, y conviene usarlas con intención en lugar de a ciegas.

@preconcurrency import BibliotecaAntigua   // degrada errores de ese modulo

extension TipoImportado: @retroactive @unchecked Sendable { }  // ultimo recurso

La primera línea es un acuerdo temporal con un módulo que todavía no describe su aislamiento, y su vida útil termina cuando la dependencia migra. La segunda es una afirmación sobre un tipo que no es tuyo y que puede cambiar en cualquier versión: sirve para desbloquear una tarde, no para quedarse un año.

La deuda, en cambio, es perfectamente manejable si se trata como tal. Las tres anotaciones de escape son cadenas de texto buscables, así que su recuento es una métrica objetiva del proyecto, medible en cada integración continua. Un módulo migrado con doce excepciones documentadas y con dueño está en mucha mejor situación que uno con cero excepciones y el modo estricto desactivado, porque en el primero la deuda está localizada y en el segundo está en todas partes.

Por qué toda migración es una auditoría de arquitectura disfrazada

Hay un patrón que se repite en cada gran migración de Swift y que conviene reconocer, porque cambia por completo la actitud con la que se aborda esta. Cuando llegaron los opcionales, la queja fue que el lenguaje obligaba a escribir ceremonia alrededor de valores que “obviamente” nunca serían nulos; lo que ocurrió en realidad fue que miles de programas descubrieron que nunca habían decidido qué valores podían faltar, y la migración consistió en tomar esa decisión, no en escribir signos de interrogación. Cuando llegó ARC, la queja fue sobre los ciclos de referencia y las anotaciones débiles; lo que ocurrió fue que los programas tuvieron que declarar por fin qué objeto poseía a cuál, algo que la gestión manual permitía dejar deliberadamente ambiguo. El modo estricto es el tercer episodio de la misma serie, y su pregunta es la más difícil de las tres: quién es el propietario de cada estado mutable de tu programa. Casi ningún sistema construido antes de 2021 tiene esa respuesta escrita en ninguna parte, porque las herramientas de la época —colas, cerrojos, convenciones documentadas en un wiki— permitían que la propiedad fuera implícita y se sostuviera en la memoria colectiva del equipo. El compilador no participa de esa memoria y no puede inferir intención, así que exige que la respuesta esté en el código. Ahí está la clave para interpretar el volumen de errores: no miden la dificultad del modelo, miden la cantidad de decisiones de propiedad que tu arquitectura tenía pendientes. Un proyecto con dominios bien definidos migra en días; uno con estado compartido difuso migra en meses, y no porque Swift 6 sea exigente, sino porque estaba construido sobre una ambigüedad que ahora hay que resolver. Vale la pena aceptar además el corolario incómodo: buena parte del código que el modo estricto rechaza funcionaba, sí, pero funcionaba por suerte, por planificadores benévolos, por hardware con modelos de memoria fuertes y por cargas de trabajo que nunca alcanzaron la concurrencia necesaria para revelar el fallo. La migración no introduce el riesgo, lo revela; y el valor duradero que deja no es un proyecto que compila en modo 6, sino un proyecto donde por fin está escrito quién manda sobre qué.

📝
Lo esencial de la migración

Se migra por fases y por módulos: primero comprobación completa como avisos, luego las características futuras una a una, luego los módulos de las hojas hacia la raíz, y por último el modo de lenguaje 6. En el módulo de una aplicación, fijar el aislamiento por omisión al actor principal elimina la mayoría de los diagnósticos de una vez. Los errores se agrupan en cuatro familias —global sin dueño, captura no segura, conformidad con aislamiento incompatible y ruido de dependencias sin anotar— y cada una apunta a una decisión de propiedad pendiente. Corrige decisiones, no diagnósticos; evita anotar hasta que compile, envolver en tareas y mentir con conformidades; y trata las tres anotaciones de escape como deuda buscable, con dueño y motivo.

⚔️ Planifica una migración real
  1. Activa la comprobación completa como avisos en un proyecto y anota el recuento por módulo antes de corregir nada.
  2. Dibuja el grafo de dependencias entre módulos y escribe el orden de migración desde las hojas; justifica el primero de la lista.
  3. Ordena los diagnósticos de un módulo por tipo implicado y calcula cuántos desaparecerían con cinco decisiones de propiedad.
  4. Activa el aislamiento por omisión al actor principal en el módulo de la aplicación y mide la caída del recuento; después identifica las rutas que deben salir de ese dominio.
  5. Añade a la integración continua un recuento de @unchecked, nonisolated(unsafe) y @preconcurrency, y fija un límite que no pueda subir sin revisión.