Durante años, una de las partes más difíciles de Swift Concurrency no ha sido aprender async y await, sino entender dónde se ejecuta realmente el código. Una función asíncrona podía abandonar el actor desde el que se llamaba aunque no hubiésemos pedido ningún paralelismo. Swift 6.2 cambia ese comportamiento con una de las piezas más importantes de Approachable Concurrency: NonisolatedNonsendingByDefault.
El cambio parte de una idea sencilla: ser async no debería significar automáticamente “ejecutarse en otro sitio”. Una función asíncrona necesita poder suspenderse, pero eso no implica que deba abandonar el contexto de aislamiento del llamador. Con Swift 6.2, el lenguaje avanza hacia un modelo en el que el código permanece serializado por defecto y somos nosotros quienes expresamos de forma explícita cuándo queremos introducir concurrencia real.
Aquí conviene hacer una distinción importante. Cuando hablamos de Swift Concurrency es más correcto razonar en términos de actores, dominios de aislamiento y executors que de hilos. Un actor determina qué código puede ejecutarse de forma serializada respecto a un estado concreto, mientras que el runtime decide qué hilo físico utiliza para ejecutar ese trabajo. MainActor está asociado al hilo principal, pero fuera de él no deberíamos interpretar un cambio de executor como la promesa de usar un hilo de background concreto.
Antes de Swift 6.2, una función async no aislada tenía un comportamiento que podía resultar sorprendente. Según las reglas introducidas originalmente para las funciones nonisolated async, su ejecución abandonaba el executor del actor del llamador y pasaba al executor genérico. Esto ocurría incluso aunque la función no tuviese ninguna necesidad real de ejecutarse concurrentemente.
@MainActor
final class PlaylistStore {
var songs: [Song] = []
nonisolated func loadMetadata() async throws -> [SongMetadata] {
try await MetadataService.fetch()
}
}
En el modelo anterior, llamar a loadMetadata() desde MainActor implicaba cruzar un límite de aislamiento. Que la función fuese async y nonisolated bastaba para que dejase atrás al actor del llamador. Además de hacer más difícil seguir mentalmente la ejecución, ese salto podía provocar errores relacionados con Sendable si se intentaban transportar valores no seguros entre ambos dominios.
La propuesta SE-0461 cambió esta filosofía en Swift 6.2. Con la característica NonisolatedNonsendingByDefault activada, una función nonisolated async permanece en el actor del llamador por defecto. La función sigue sin pertenecer estáticamente a un actor concreto, pero la llamada no “envía” sus argumentos y resultados a otro dominio de aislamiento.
@MainActor
final class PlaylistStore {
var songs: [Song] = []
nonisolated func loadMetadata() async throws -> [SongMetadata] {
// Con NonisolatedNonsendingByDefault,
// continúa en el contexto de aislamiento del llamador.
try await MetadataService.fetch()
}
}
Esto explica el término nonsending. La función no está aislada de forma permanente a MainActor, ni a ningún otro actor. Puede ser llamada desde distintos contextos. Lo que cambia es que esa llamada concreta conserva el contexto del llamador, evitando transferir innecesariamente valores a otro dominio de aislamiento.
Dicho de otra forma, el comportamiento pasa a depender mucho más del lugar desde el que llamamos a la función. Si una función nonisolated se invoca desde MainActor, continúa allí. Si se invoca desde otro actor, continúa en ese actor. Y si el llamador ya es no aislado, se ejecutará desde ese contexto no aislado.
Esto también desmonta una asociación muy extendida: await no significa cambio de hilo. await marca un punto en el que una tarea puede suspenderse para permitir que otro trabajo avance. Cuando la operación termina, Swift reanuda la tarea respetando su aislamiento. El hilo físico utilizado antes y después puede incluso variar, pero la garantía relevante para nuestro código es el executor y el actor sobre el que se está ejecutando.
¿Qué ocurre entonces cuando sí queremos abandonar deliberadamente el actor actual para ejecutar trabajo concurrente? Swift 6.2 introduce @concurrent precisamente para expresarlo.
struct WaveformAnalyzer {
@concurrent
func calculateSamples(from data: Data) async -> [Float] {
// Trabajo costoso que queremos ejecutar fuera del actor llamador.
analyze(data)
}
private func analyze(_ data: Data) -> [Float] {
// Procesamiento intensivo de CPU
[]
}
}
@concurrent indica que una función asíncrona debe abandonar cualquier actor en el que se encuentre el llamador y ejecutarse sobre el executor genérico concurrente. Además, @concurrent ya implica nonisolated, por lo que escribir @concurrent nonisolated es válido pero redundante.
Esa diferencia tiene consecuencias sobre los datos que cruzan dominios en la llamada. Al usar @concurrent, los argumentos y resultados pueden atravesar un límite de aislamiento, así que Swift vuelve a aplicar sus reglas de seguridad: los valores deberán ser Sendable o encontrarse en una región que el compilador pueda transferir de forma segura. Es precisamente el tipo de comprobación que desaparece cuando una función nonsending permanece en el actor del llamador.
Podemos resumir las reglas de una función asíncrona de esta forma cuando NonisolatedNonsendingByDefault está activado:
| Declaración | Comportamiento |
|---|---|
Función aislada a @MainActor | Se ejecuta en MainActor |
nonisolated func ... async | Conserva el actor del llamador |
nonisolated(nonsending) func ... async | Conserva explícitamente el actor del llamador |
@concurrent func ... async | Abandona el actor del llamador y usa el executor genérico |
nonisolated(nonsending) sigue existiendo porque permite expresar explícitamente esta semántica incluso cuando la opción de compilación no está activada. Sin embargo, con Approachable Concurrency habilitado, un simple nonisolated sobre una función async adopta ese comportamiento por defecto. Esto hace que gran parte del código cotidiano necesite menos anotaciones y, sobre todo, reduzca los saltos de aislamiento que antes aparecían de forma implícita.
Hay otra pieza de Swift 6.2 que encaja perfectamente con este modelo: Default Actor Isolation. La propuesta SE-0466 permite configurar un módulo para que las declaraciones sin una anotación explícita se consideren aisladas a MainActor. En Xcode aparece como Default Actor Isolation, y los nuevos proyectos de aplicación creados con Xcode 26 utilizan MainActor por defecto.
final class ReadingProgress {
var currentPage = 0
func advance() {
currentPage += 1
}
}
Con Default Actor Isolation = MainActor, ese código se comporta conceptualmente como si hubiésemos escrito:
@MainActor
final class ReadingProgress {
var currentPage = 0
func advance() {
currentPage += 1
}
}
El objetivo es lo que el equipo de Swift denomina progressive disclosure: una aplicación sencilla puede mantenerse serializada sobre MainActor sin obligar al desarrollador a entender desde el primer momento Sendable, regiones de aislamiento o transferencias entre actors. La concurrencia aparece cuando realmente hace falta, y entonces se introduce de forma explícita.
Es importante no confundir ambos ajustes. Approachable Concurrency activa un conjunto de cambios del compilador relacionados con la adopción de concurrencia, entre ellos NonisolatedNonsendingByDefault. Default Actor Isolation, en cambio, decide si las declaraciones sin aislamiento explícito se infieren como MainActor o permanecen nonisolated. Son configuraciones independientes aunque Xcode 26 las active conjuntamente en las plantillas de nuevas aplicaciones.
Esto permite construir código con una separación mucho más clara entre estado de interfaz y trabajo realmente concurrente. Por ejemplo, una pantalla puede permanecer en MainActor por defecto mientras delega una operación intensiva a una función marcada explícitamente con @concurrent:
@MainActor
@Observable
final class DocumentStore {
var summary = ""
var isProcessing = false
func process(_ document: Document) async {
isProcessing = true
defer { isProcessing = false }
summary = await DocumentProcessor().summarize(document)
}
}
struct DocumentProcessor {
@concurrent
func summarize(_ document: Document) async -> String {
// Trabajo intensivo que no debe bloquear MainActor.
buildSummary(from: document)
}
private func buildSummary(from document: Document) -> String {
// Algoritmo de análisis
"Resumen"
}
}
El compilador puede verificar aquí la frontera con mucha más precisión. DocumentStore permanece protegido por MainActor, mientras summarize(_:) declara claramente que desea ejecutarse fuera de ese actor. Si Document no puede transferirse con seguridad, Swift mostrará un diagnóstico en esa frontera en lugar de permitir que el riesgo quede oculto detrás de un salto implícito.
Este nuevo modelo también cambia cómo deberíamos decidir cuándo utilizar @concurrent. No es una anotación para añadir automáticamente a cualquier función lenta ni una forma equivalente a DispatchQueue.global().async. Una operación asíncrona de red, por ejemplo, normalmente pasa gran parte de su tiempo suspendida esperando al sistema y no bloquea MainActor mientras está suspendida. @concurrent cobra especial sentido cuando queremos que la ejecución síncrona que existe dentro de una función async pueda avanzar concurrentemente respecto al actor del llamador, como ocurre con cálculos intensivos de CPU.
struct PhotoHasher {
// No necesitamos @concurrent solo porque haya await.
func download(from url: URL) async throws -> Data {
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
// Aquí sí queremos sacar un cálculo pesado del actor llamador.
@concurrent
func calculateHash(for data: Data) async -> String {
expensiveHash(data)
}
private func expensiveHash(_ data: Data) -> String {
"..."
}
}
Desde el punto de vista del rendimiento, la mejora no consiste simplemente en “hacer menos cambios de hilo”. El beneficio conceptual es evitar saltos de aislamiento innecesarios y reducir la cantidad de datos que deben demostrar que son seguros para cruzar entre dominios. El runtime sigue siendo libre de programar tareas sobre distintos hilos cuando sea necesario; lo que Swift 6.2 hace más predecible es la semántica de aislamiento.
🕵️ Para depurar este comportamiento tampoco conviene basarse únicamente en Thread.isMainThread. Puede ser útil para experimentos muy concretos relacionados con MainActor, pero no describe por completo el modelo de concurrencia. Herramientas como MainActor.assertIsolated() permiten comprobar directamente la garantía que realmente nos interesa y evitan convertir los detalles de implementación del runtime en parte de nuestra arquitectura.
@MainActor
func refreshInterface() {
MainActor.assertIsolated()
// La comprobación expresa aislamiento, no una suposición sobre scheduling.
}
El resultado de todos estos cambios es una inversión de filosofía respecto a los primeros años de Swift Concurrency. Antes, una función asíncrona no aislada tendía a abandonar el actor actual y era el desarrollador quien tenía que justificar después por qué los valores podían cruzar esa frontera. Con Swift 6.2, el comportamiento natural es permanecer donde ya estás y pedir concurrencia explícitamente cuando aporta un beneficio.
Approachable Concurrency no elimina conceptos como actores, Sendable o aislamiento. Los hace aparecer en el momento adecuado. Para la mayoría del código de una aplicación Apple, la regla mental puede simplificarse considerablemente: mantén el estado serializado por defecto, deja que async exprese suspensión y usa @concurrent cuando necesites introducir concurrencia de forma deliberada. Es un cambio pequeño en sintaxis, pero uno bastante profundo en la forma de razonar sobre la ejecución de Swift.