SwiftConcurrenciaasync/awaitiOSWWDC

Concurrencia que ya no da miedo: los nuevos defaults de Swift (6.2 → 6.4)

Los dos posts anteriores de esta serie enseñan la concurrencia moderna tal como la aprendimos: los fundamentos (async/await, actor, Sendable) y los casos reales (async let, TaskGroup, cancelación, cachés). Pero hay una historia que no conté en ninguno de los dos: Swift cambió los defaults de la concurrencia, y para bien.

Si migraste un proyecto a Swift 6 y acabaste ahogado en warnings de Sendable sobre código que era obviamente de un solo hilo, no eras tú. El modelo original optimizaba para el caso difícil (código paralelo de verdad) y castigaba el caso común (una app que corre casi todo en el hilo principal). Swift 6.2 le dio la vuelta a eso, y 6.3/6.4 —presentados en WWDC 2026— terminaron de pulir las asperezas.

Este post es el mapa de esos cambios: qué se rompió, qué mejoró, y qué activar en tu proyecto hoy.

El problema: la concurrencia era hostil por defecto

Antes de 6.2 había dos fricciones que sufría todo el mundo.

Fricción 1 — warnings de Sendable sobre código de un solo hilo. Un ViewModel que solo toca la UI recibía errores de data-race safety, porque el compilador no asumía nada sobre dónde corría. La respuesta era salpicar @MainActor por todos lados hasta que callara.

Fricción 2 — saltos de hilo inesperados. Una función async sin aislar (nonisolated) saltaba al pool global, aunque la llamaras desde el @MainActor. Esto sorprendía a todo el mundo:

@MainActor
func cargar() async {
    let objeto = NoSendable()
    await objeto.hacerAlgo()   // 😱 esto NO corría en el main thread
}

Ese salto invisible era la fuente de la mitad de los errores de Sendable: de repente tus tipos cruzaban un límite de concurrencia que tú nunca pediste cruzar.

Swift 6.2 — el cambio de defaults

La respuesta del equipo de Swift fue reordenar la concurrencia en fases de complejidad creciente: primero código de un solo hilo (sin concurrencia), después async/await sin errores de data race, y solo al final paralelismo de verdad para optimizar. Los defaults ahora sirven a la fase 1, no a la 3.

Main actor por defecto (SE-0466)

El cambio más grande. Puedes decirle al compilador que todo tu módulo está aislado al @MainActor salvo que digas lo contrario. Adiós a anotar cada clase.

En Xcode es el build setting Default Actor IsolationMainActor. Por línea de comandos es la bandera -default-isolation MainActor. En un paquete SwiftPM:

// swift-tools-version: 6.2
.target(
    name: "MiApp",
    swiftSettings: [
        .defaultIsolation(MainActor.self)
    ]
)

Con eso activado, esto ya no genera ni un warning:

// Sin @MainActor a la vista: el módulo entero ya está en el main actor.
@Observable
final class PerfilModel {
    var perfil: Perfil?

    func cargar() async throws {
        perfil = try await api.perfil()
    }
}

Es exactamente lo que quieres en un target de UI o en un script. Cuando necesites salir del hilo principal, lo pides explícitamente (siguiente sección). El default sirve al 90% del código; el 10% restante se marca a mano.

Las funciones async ya no saltan de hilo (SE-0461)

La segunda fricción también se arregló. Ahora una función async nonisolated corre en el contexto de quien la llama en vez de saltar al ejecutor global. El upcoming feature se llama NonisolatedNonsendingByDefault:

@MainActor
func cargar() async {
    let objeto = NoSendable()
    await objeto.hacerAlgo()   // ✅ ahora SÍ corre en el main thread
}

Y si quieres ser explícito sobre esa semántica, se escribe nonisolated(nonsending). El resultado práctico: el código hace lo que parece que hace, y la mayoría de los errores de Sendable desaparecen porque ya no cruzas límites de concurrencia sin querer.

@concurrent — el opt-in para salir al pool

Si las funciones async ya no saltan de hilo por defecto, ¿cómo mandas trabajo pesado al fondo? Con el nuevo atributo @concurrent, que dice explícitamente “esto sí corre en el pool concurrente, pase lo que pase”:

@MainActor
final class FeedModel {
    func cargarFeed() async throws -> Feed {
        let (data, _) = try await URLSession.shared.data(from: endpoint)
        return try await decodificar(data)   // salta al fondo, a propósito
    }

    @concurrent
    private func decodificar(_ data: Data) async throws -> Feed {
        try JSONDecoder().decode(Feed.self, from: data)   // parseo pesado, fuera del main
    }
}

Esta es la inversión que lo cambia todo: antes salías del hilo principal por accidente y volvías a él a propósito; ahora te quedas en él por defecto y sales a propósito. @concurrent implica nonisolated, así que no necesitas ambos, y no se combina con @MainActor ni con nonisolated(nonsending).

Activarlo todo

En Xcode existe el ajuste Approachable Concurrency, que agrupa los upcoming features de esta tanda. En SwiftPM se activan uno a uno:

// swift-tools-version: 6.2
.target(
    name: "MiPaquete",
    swiftSettings: [
        .enableUpcomingFeature("NonisolatedNonsendingByDefault"),
        .enableUpcomingFeature("InferIsolatedConformances"),
        .enableUpcomingFeature("InferSendableFromCaptures"),
        .enableUpcomingFeature("GlobalActorIsolatedTypesUsability"),
        .enableUpcomingFeature("DisableOutwardActorInference"),
    ]
)

De propina: debugging que ya no es adivinar

Swift 6.2 también arregló la peor parte de la concurrencia: depurarla. Ahora puedes nombrar tareas (Task(name:)) y ese nombre aparece en el debugger y en Instruments; LLDB hace step into de funciones async de forma fiable; y cuando paras en un breakpoint puedes ver en qué tarea estás.

Swift 6.3 y 6.4 — el pulido (WWDC 2026)

Si 6.2 arregló los defaults, 6.3 y 6.4 —los que Apple presentó en la WWDC 2026— limaron las asperezas del día a día. Son cambios pequeños, pero cada uno mata un patrón feo que todos escribíamos.

await dentro de defer (SE-0493)

Por fin. Antes, limpiar un recurso asíncrono desde un defer era imposible: tocaba duplicar la llamada de cleanup en cada camino de salida o lanzar una Task suelta. Ahora:

func importarArticulos() async throws {
    let importador = Importador()
    await importador.abrir()

    defer {
        await importador.cerrar()   // ✅ válido en Swift 6.4
    }

    try await importador.importar()   // si lanza, el cierre igual ocurre
}

withTaskCancellationShield (SE-0504)

El problema clásico: tu tarea se cancela, pero el cleanup también necesita await — y como la tarea ya está cancelada, ese await falla o se salta. Antes había que lanzar una Task detached solo para cerrar bien. Ahora hay un escudo:

func cerrarConexion() async {
    await withTaskCancellationShield {
        // Dentro del escudo, Task.isCancelled es false:
        // el cierre se completa aunque nos hayan cancelado.
        await database.close()
    }
}

El compilador te avisa si te tragas un error (SE-0520)

Este cazaba bugs reales. Un Task { try await … } cuyo error nadie observa desaparecía en silencio. Ahora es un warning:

// ⚠️ warning: Unstructured throwing task was not used
Task {
    try await importarArticulos()
}

// ✅ o lo manejas dentro…
Task {
    do    { try await importarArticulos() }
    catch { log.error("Falló la importación: \(error)") }
}

// ✅ …o guardas la tarea y lo compruebas después
let tarea = Task { try await importarArticulos() }
try await tarea.value

Task con throws tipado (SE-0520)

Las tareas ya pueden declarar un tipo de error concreto, igual que el resto del lenguaje:

let tarea: Task<String, URLError> = Task {
    throw URLError(.badURL)
}

Result asíncrono (SE-0530)

Result tenía un init catching para trabajo síncrono, pero no para async. Escribíamos el do/catch a mano. Ya no:

let resultado = await Result {
    try await importarArticulos()
}
// resultado: Result<Void, Error> — sin do/catch manual

Esto encaja de maravilla con el patrón de errores parciales en un TaskGroup del post anterior: capturar el fallo dentro de la hija ahora es una línea.

weak let (SE-0481, Swift 6.3)

Una propiedad weak obligaba a que fuera var, y una var rompe la conformidad a Sendable → tocaba @unchecked Sendable. Con weak let esa mentira se acaba:

final class VistaPreviaController: Sendable {
    weak let delegate: PreviewDelegate?   // ✅ Sendable de verdad, sin @unchecked
}

~Sendable — decir “esto NO es Sendable”, a propósito (SE-0518)

A veces un tipo no debe cruzar límites de concurrencia y quieres que el compilador lo garantice, no que lo infiera:

enum ResultadoEjecucion: ~Sendable {
    case exito
    case fallo(TipoNoSendable)
}

Cómo migrar sin sufrir

Un orden que funciona:

  1. Activa “Approachable Concurrency” en el target. Es el interruptor que trae los defaults nuevos.
  2. Pon Default Actor Isolation en MainActor en tus targets de UI y de app. Aquí es donde desaparece la mayoría de los warnings de Sendable, no al revés.
  3. Compila y mira qué se queja. Con los defaults nuevos, lo que quede son data races de verdad, no ruido.
  4. Marca con @concurrent lo que sí debe salir del hilo principal: parseo pesado, procesado de imágenes, criptografía. Empieza por lo que Instruments te señale, no por intuición.
  5. Deja las librerías para el final. En paquetes SwiftPM, los .enableUpcomingFeature(…) van target a target; no hace falta migrarlo todo el mismo día.

El orden importa: mucha gente intenta arreglar los warnings antes de cambiar los defaults, y acaba anotando a mano lo que el compilador iba a asumir solo.

Resumen

CambioVersiónQué te da
Main actor por defecto6.2Se acabaron los warnings de Sendable en código de un solo hilo
nonisolated(nonsending)6.2Las funciones async ya no saltan de hilo a tus espaldas
@concurrent6.2El opt-in explícito para mandar trabajo al pool
Tareas con nombre + LLDB6.2Depurar concurrencia deja de ser adivinar
weak let6.3Sendable real, sin @unchecked
await en defer6.4Cleanup asíncrono garantizado
withTaskCancellationShield6.4El cleanup termina aunque te cancelen
Warning por Task ignorada6.4Los errores tragados ahora se ven
Task con throws tipado6.4Tipos de error concretos en tareas
await Result { }6.4Capturar el resultado sin do/catch
~Sendable6.4Declarar lo no-Sendable a propósito

El arco completo cuenta una historia clara: la concurrencia de Swift dejó de optimizar para el caso raro (paralelismo masivo) y empezó a optimizar para el caso real (una app que vive en el main thread y sale al fondo cuando hace falta). Si abandonaste una migración a Swift 6 por frustración, este es el momento de volver a intentarlo — el lenguaje se movió hacia ti.

Si te falta la base, empieza por Concurrencia moderna en Swift, sigue con Concurrencia estructurada en la práctica y vuelve aquí para poner al día los defaults de tu proyecto.


Fuentes: Apple — What’s new in Swift (WWDC26/262) · Swift.org — Swift 6.2 Released · SwiftLee — Approachable Concurrency in Swift 6.2 · SwiftLee — Swift 6.4: What’s New in Concurrency · InfoQ — Swift 6.4 Brings New Language Features