Swift 6 llegó en septiembre de 2024 junto a Xcode 16 con una de las novedades más esperadas y, al mismo tiempo, más polémicas de los últimos años: la verificación estricta de concurrencia en tiempo de compilación. Si has migrado un proyecto mediano a Swift 6, ya sabes de qué hablamos: cientos de errores y advertencias que antes pasaban desapercibidos. Este artículo explica qué hay detrás de todo eso, cómo funcionan los actores y Sendable, y cuál es la ruta práctica para migrar sin morir en el intento.
Por qué Swift 6 es diferente
Desde Swift 5.5 (2021) teníamos async/await y actores disponibles, pero su uso era en gran medida opcional. El compilador avisaba sobre algunos problemas de concurrencia, pero no los bloqueaba. Swift 6 cambia eso: activa por defecto el modo de concurrencia estricta, lo que significa que el compilador rechaza cualquier código que pueda generar una carrera de datos.
El objetivo es ambicioso: garantizar a nivel estático que dos hilos no acceden simultáneamente al mismo estado mutable sin protección. Lo que en Rust se resuelve con el sistema de ownership, Swift lo hace con actores y el protocolo Sendable.
Actores: aislamiento por defecto
Un actor en Swift es parecido a una clase, pero con una diferencia fundamental: el compilador garantiza que solo un hilo accede a sus propiedades mutables a la vez.
actor CuentaBancaria {
private var saldo: Double = 0
func depositar(_ cantidad: Double) {
saldo += cantidad
}
func consultarSaldo() -> Double {
return saldo
}
}
// Uso desde código asíncrono:
let cuenta = CuentaBancaria()
await cuenta.depositar(100)
let total = await cuenta.consultarSaldo()
Cualquier acceso a una propiedad o método de un actor desde fuera de él requiere await, porque el actor puede estar ocupado atendiendo otra tarea. Internamente, sin embargo, el código es síncrono y no necesita locks manuales.
El actor principal: MainActor
@MainActor es un actor global que representa el hilo principal. Toda actualización de UI debe ejecutarse en él. En Swift 6 esto se vuelve explícito:
@MainActor
class VistaViewModel: ObservableObject {
@Published var texto: String = ""
func cargarDatos() async {
// Esta función se ejecuta en el hilo principal
let resultado = await servicio.fetchTexto()
texto = resultado
}
}
Si intentas modificar texto desde un contexto que no es @MainActor, Swift 6 lo rechaza en compilación. No en runtime: en compilación.
Sendable: qué puede cruzar fronteras de concurrencia
Sendable es un protocolo marcador que indica que un tipo es seguro para compartir entre concurrency domains distintos. El compilador lo verifica automáticamente para structs con propiedades inmutables o que también sean Sendable.
struct Mensaje: Sendable {
let texto: String
let timestamp: Date
}
// Esto funciona: Mensaje es Sendable
Task {
let m = Mensaje(texto: "Hola", timestamp: .now)
await procesador.enviar(m)
}
Las clases son más complicadas porque son tipos por referencia. Una clase puede conformar Sendable solo si todas sus propiedades son inmutables o si proteges el acceso tú mismo (con @unchecked Sendable, que desactiva la verificación).
// Sendable con clase: solo si es final e inmutable
final class Configuracion: Sendable {
let host: String
let puerto: Int
init(host: String, puerto: Int) {
self.host = host
self.puerto = puerto
}
}
Migrar a Swift 6 paso a paso
La migración no tiene por qué ser un salto al vacío. Apple recomienda hacerlo de forma incremental usando los niveles de rigor del compilador.
Paso 1: activar advertencias antes que errores
En tu Package.swift o en la configuración de Xcode, puedes activar el modo de concurrencia como advertencia antes de que sea un error:
// Package.swift
targets: [
.target(
name: "MiApp",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency")
]
)
]
Esto te muestra todos los problemas potenciales sin bloquear la compilación. Una vez resueltos, subes a Swift 6 completo.
Paso 2: anotar los puntos de entrada UI
El grueso de los errores suele estar en ViewModels y código de UI que actualiza propiedades en threads de fondo. Anotar las clases con @MainActor resuelve la mayoría de ellos de golpe.
Paso 3: aislar el estado compartido con actores
Cualquier singleton o caché que se lea y escriba desde varios contextos async es candidato a convertirse en actor. El refactor es directo: cambias class por actor y añades await donde el compilador lo pida.
Paso 4: revisar closures capturadas
Las closures que escapan (@escaping) y capturan estado mutable son una fuente habitual de problemas. Swift 6 exige que lo capturado sea Sendable. Si no puede serlo, la solución más limpia es convertir el estado en un actor.
Compatibilidad con código Objective-C
Si tu proyecto mezcla Swift y Objective-C, la migración es más delicada. Las APIs de Objective-C no tienen anotaciones de concurrencia por defecto, así que el compilador las trata con más laxitud. Puedes usar @preconcurrency para importar APIs antiguas sin bloquear la compilación:
@preconcurrency import MiFrameworkObjC
Para proyectos con mucho Objective-C, este modificador da margen para migrar por módulos en lugar de hacerlo todo de golpe.
Lo que cambia en la práctica
Swift 6 no añade nuevas APIs de concurrencia respecto a Swift 5.5-5.9. Lo que cambia es que el compilador exige que uses las herramientas correctamente. Si ya escribías código async/await con actores y Sendable, la migración es casi automática. Si usabas DispatchQueue con closures y singletons compartidos, tienes trabajo por delante.
La curva inicial es pronunciada, pero el resultado compensa: un código donde las carreras de datos son imposibles por construcción, sin necesidad de tests de concurrencia ni sanitizers en tiempo de ejecución. Para proyectos donde la concurrencia es crítica, como las series de goroutines en Go o el modelo de ownership de Rust con async, este enfoque en Swift es el paso natural hacia código concurrente seguro en el ecosistema Apple.
Imagen: Pexels / Markus Winkler
