Introducción: La evolución de la concurrencia en Swift
Si llevas un tiempo desarrollando para las plataformas de Apple, seguro que recuerdas la época de DispatchQueue, los completion handlers anidados y esa sensación constante de que algo podía explotar en cualquier momento con un acceso concurrente a datos compartidos. Los famosos data races eran el pan de cada día, y depurar problemas de concurrencia se sentía como buscar una aguja en un pajar. Honestamente, no los echo de menos.
Con Swift 5.5, Apple introdujo un modelo de concurrencia completamente nuevo basado en async/await, actores y concurrencia estructurada. Fue un cambio radical — pero también trajo su cuota de frustraciones. Swift 6 endureció las reglas con verificaciones estrictas de data-race safety en tiempo de compilación, y muchos proyectos existentes se llenaron de advertencias y errores que costaba bastante resolver.
Ahora, con Swift 6.2 (presentado en la WWDC 2025 junto con Xcode 26), el equipo de Swift ha dado un giro importante con lo que llaman Approachable Concurrency (Concurrencia Accesible). La idea central es la divulgación progresiva: Swift solo debería pedirte que entiendas tanta concurrencia como realmente uses. Escribes código secuencial, añades async/await cuando necesitas operaciones asíncronas, y solo cuando introduces paralelismo real tienes que preocuparte por actores y Sendable. Un enfoque mucho más sensato, la verdad.
En esta guía vamos a recorrer el modelo de concurrencia de Swift desde los fundamentos hasta las novedades de Swift 6.2, con ejemplos prácticos que puedes usar en tus proyectos hoy mismo. Construiremos juntos una app de agregador de noticias llamada NoticiasApp para ilustrar cada concepto.
Fundamentos: async/await en Swift
El corazón del modelo de concurrencia moderno de Swift es async/await. Si vienes de JavaScript o Kotlin, el concepto te resultará familiar: marcas una función como async para indicar que puede suspenderse, y usas await para llamarla. Nada del otro mundo.
Tu primera función asíncrona
Veamos cómo se ve una función que descarga datos de una API:
import Foundation
struct Articulo: Codable, Identifiable {
let id: Int
let titulo: String
let contenido: String
let autor: String
let fechaPublicacion: Date
}
enum ErrorRed: Error {
case urlInvalida
case respuestaInvalida(Int)
case decodificacionFallida
}
func obtenerArticulos(desde urlString: String) async throws -> [Articulo] {
guard let url = URL(string: urlString) else {
throw ErrorRed.urlInvalida
}
// await suspende esta función hasta que la red responda
let (datos, respuesta) = try await URLSession.shared.data(from: url)
guard let httpRespuesta = respuesta as? HTTPURLResponse,
(200...299).contains(httpRespuesta.statusCode) else {
let codigo = (respuesta as? HTTPURLResponse)?.statusCode ?? 0
throw ErrorRed.respuestaInvalida(codigo)
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
do {
return try decoder.decode([Articulo].self, from: datos)
} catch {
throw ErrorRed.decodificacionFallida
}
}
La clave aquí es que await marca un punto de suspensión. Cuando la ejecución llega a ese punto, el hilo queda libre para hacer otro trabajo. Cuando la respuesta de red llega, la función se reanuda exactamente donde se quedó. No hay callbacks, no hay @escaping, no hay pirámides de la perdición. Es así de limpio.
Llamando funciones async desde SwiftUI
En SwiftUI, el modificador .task es tu mejor amigo para llamar funciones asíncronas:
import SwiftUI
struct ListaArticulosView: View {
@State private var articulos: [Articulo] = []
@State private var estaCargando = true
@State private var error: ErrorRed?
var body: some View {
NavigationStack {
Group {
if estaCargando {
ProgressView("Cargando artículos...")
} else if let error {
ContentUnavailableView(
"Error al cargar",
systemImage: "exclamationmark.triangle",
description: Text("\(error)")
)
} else {
List(articulos) { articulo in
VStack(alignment: .leading, spacing: 8) {
Text(articulo.titulo)
.font(.headline)
Text(articulo.autor)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
}
.navigationTitle("Noticias")
.task {
// .task gestiona automáticamente la cancelación
await cargarArticulos()
}
}
}
private func cargarArticulos() async {
estaCargando = true
do {
articulos = try await obtenerArticulos(
desde: "https://api.ejemplo.com/articulos"
)
} catch let errorRed as ErrorRed {
error = errorRed
} catch {
// Error inesperado
}
estaCargando = false
}
}
Una ventaja enorme de .task es que gestiona automáticamente la cancelación. Si la vista desaparece antes de que la tarea termine, la tarea se cancela de forma limpia. Eso resuelve un problema clásico de memory leaks y actualizaciones de vistas que ya ni existen — algo que todos hemos sufrido alguna vez.
Concurrencia Estructurada: async let y TaskGroup
Aquí es donde empieza lo bueno. El verdadero poder de Swift Concurrency aparece cuando necesitas ejecutar múltiples operaciones en paralelo. Swift ofrece dos mecanismos principales: async let para un número fijo de tareas paralelas, y TaskGroup para un número dinámico.
async let: Paralelismo simple
Imagina que tu app necesita cargar datos de tres fuentes diferentes al mismo tiempo:
func cargarPantallaInicio() async throws -> PantallaInicio {
// Las tres peticiones se lanzan en paralelo
async let noticias = obtenerArticulos(desde: "https://api.ejemplo.com/noticias")
async let tendencias = obtenerArticulos(desde: "https://api.ejemplo.com/tendencias")
async let favoritos = obtenerArticulos(desde: "https://api.ejemplo.com/favoritos")
// await recopila los resultados cuando todos estén listos
let resultados = try await (noticias, tendencias, favoritos)
return PantallaInicio(
noticias: resultados.0,
tendencias: resultados.1,
favoritos: resultados.2
)
}
struct PantallaInicio {
let noticias: [Articulo]
let tendencias: [Articulo]
let favoritos: [Articulo]
}
Con async let, las tres peticiones se lanzan simultáneamente. Si cada una tarda 1 segundo, el total será aproximadamente 1 segundo en lugar de 3. Y lo mejor: la concurrencia estructurada garantiza que si alguna falla, las demás se cancelan automáticamente. Sin código extra.
TaskGroup: Paralelismo dinámico
Cuando el número de tareas paralelas no es fijo (por ejemplo, descargar imágenes para una lista de artículos), necesitas TaskGroup:
import UIKit
func descargarImagenes(urls: [URL]) async throws -> [URL: UIImage] {
try await withThrowingTaskGroup(
of: (URL, UIImage?).self
) { grupo in
// Agregar una tarea por cada URL
for url in urls {
grupo.addTask {
let (datos, _) = try await URLSession.shared.data(from: url)
let imagen = UIImage(data: datos)
return (url, imagen)
}
}
// Recopilar resultados a medida que terminan
var resultado: [URL: UIImage] = [:]
for try await (url, imagen) in grupo {
if let imagen {
resultado[url] = imagen
}
}
return resultado
}
}
Hay varios aspectos importantes de TaskGroup que conviene tener en cuenta:
- Scope limitado: Ninguna tarea hija puede sobrevivir más allá del cierre de
withTaskGroup. Esto previene fugas de recursos. - Cancelación automática: Si el grupo se cancela (porque su tarea padre fue cancelada), todas las tareas hijas reciben la señal de cancelación.
- Orden de resultados: Los resultados llegan en el orden en que las tareas terminan, no en el orden en que se lanzaron. Esto maximiza el rendimiento, aunque a veces puede sorprender si no lo esperas.
Limitando la concurrencia en TaskGroup
Un error bastante común (y que he cometido más de una vez) es lanzar cientos de tareas simultáneas, lo que puede saturar la red o la memoria. Puedes limitar la concurrencia controlando cuántas tareas agregas antes de esperar resultados:
func descargarImagenesConLimite(
urls: [URL],
maxConcurrentes: Int = 4
) async throws -> [URL: UIImage] {
try await withThrowingTaskGroup(
of: (URL, UIImage?).self
) { grupo in
var iterador = urls.makeIterator()
var resultado: [URL: UIImage] = [:]
// Lanzar las primeras N tareas
for _ in 0..
Este patrón de «ventana deslizante» mantiene un número constante de descargas activas sin desperdiciar recursos. Es una técnica que vale la pena memorizar.
Actores: Protección contra data races
Los actores son el mecanismo de Swift para proteger el estado mutable compartido. Un actor garantiza que solo una tarea puede acceder a sus propiedades a la vez, eliminando los data races por diseño. No por convención, no por "buenas prácticas" — por diseño del lenguaje.
Definiendo un actor
Pensemos en una caché de imágenes para nuestra app:
actor CacheImagenes {
private var cache: [URL: UIImage] = [:]
private var tareasEnProgreso: [URL: Task] = [:]
func imagen(para url: URL) async throws -> UIImage? {
// Si ya está en caché, devolver inmediatamente
if let imagenCacheada = cache[url] {
return imagenCacheada
}
// Si ya hay una descarga en progreso para esta URL, esperar su resultado
if let tareaExistente = tareasEnProgreso[url] {
return try await tareaExistente.value
}
// Crear nueva tarea de descarga
let tarea = Task {
let (datos, _) = try await URLSession.shared.data(from: url)
return UIImage(data: datos)
}
tareasEnProgreso[url] = tarea
do {
let imagen = try await tarea.value
// Guardar en caché y limpiar tarea en progreso
if let imagen {
cache[url] = imagen
}
tareasEnProgreso.removeValue(forKey: url)
return imagen
} catch {
tareasEnProgreso.removeValue(forKey: url)
throw error
}
}
func limpiarCache() {
cache.removeAll()
}
var cantidadEnCache: Int {
cache.count
}
}
El actor CacheImagenes es completamente seguro para acceso concurrente. Múltiples vistas pueden solicitar imágenes simultáneamente sin riesgo de corrupción de datos. Y hay un detalle elegante: si dos vistas piden la misma imagen al mismo tiempo, la segunda reutiliza la descarga en progreso de la primera en vez de lanzar otra petición duplicada.
nonisolated: Cuando no necesitas protección
A veces un actor tiene propiedades o métodos que no necesitan protección porque son inmutables o no acceden al estado protegido:
actor ServicioNoticias {
let baseURL: URL
let sesion: URLSession
private var articulosCache: [String: [Articulo]] = [:]
init(baseURL: URL, sesion: URLSession = .shared) {
self.baseURL = baseURL
self.sesion = sesion
}
// No necesita aislamiento: solo accede a propiedades inmutables
nonisolated func construirURL(categoria: String) -> URL {
baseURL.appendingPathComponent("categorias/\(categoria)/articulos")
}
func obtenerArticulos(categoria: String) async throws -> [Articulo] {
if let cacheados = articulosCache[categoria] {
return cacheados
}
let url = construirURL(categoria: categoria)
let (datos, _) = try await sesion.data(from: url)
let articulos = try JSONDecoder().decode([Articulo].self, from: datos)
articulosCache[categoria] = articulos
return articulos
}
}
@MainActor: El actor principal
@MainActor es un actor global especial que ejecuta código en el hilo principal. Es esencial para cualquier código que actualice la interfaz de usuario:
@MainActor
@Observable
class NoticiasViewModel {
var articulos: [Articulo] = []
var estaCargando = false
var mensajeError: String?
private let servicio: ServicioNoticias
init(servicio: ServicioNoticias) {
self.servicio = servicio
}
func cargarNoticias(categoria: String) async {
estaCargando = true
mensajeError = nil
do {
articulos = try await servicio.obtenerArticulos(categoria: categoria)
} catch {
mensajeError = "No se pudieron cargar las noticias: \(error.localizedDescription)"
}
estaCargando = false
}
func refrescar(categoria: String) async {
await cargarNoticias(categoria: categoria)
}
}
Al marcar la clase con @MainActor, garantizamos que todas las actualizaciones de propiedades observables ocurren en el hilo principal. Adiós al clásico error «Publishing changes from background threads is not allowed» que seguramente has visto más veces de las que te gustaría.
Novedades de Swift 6.2: Approachable Concurrency
Bueno, aquí es donde las cosas se ponen realmente interesantes. Swift 6.2 introduce cambios fundamentales en cómo funciona el aislamiento por defecto, con el objetivo de hacer la concurrencia más accesible sin sacrificar la seguridad.
Default Actor Isolation: MainActor por defecto
El cambio más significativo de Swift 6.2 es la opción de Default Actor Isolation. Cuando activas esta configuración en Xcode 26 (habilitada por defecto en nuevos proyectos), todo tu código se ejecuta en el @MainActor a menos que indiques lo contrario.
¿Qué significa esto en la práctica? Que ya no necesitas marcar explícitamente tus ViewModels, vistas y clases de UI con @MainActor. Todo lo que escribes está en el MainActor por defecto:
// Swift 6.2 con Default Actor Isolation activado
// Ya NO necesitas @MainActor aquí — es el comportamiento por defecto
@Observable
class NoticiasViewModel {
var articulos: [Articulo] = []
var estaCargando = false
// Este código se ejecuta automáticamente en el MainActor
func cargarNoticias() async {
estaCargando = true
// ...
estaCargando = false
}
}
Este cambio refleja una realidad práctica: la mayoría del código en una app de iOS interactúa con la UI y debería ejecutarse en el hilo principal. En lugar de forzarte a anotar todo con @MainActor, Swift 6.2 asume que eso es lo que quieres y te pide que seas explícito solo cuando quieres salir del MainActor. Tiene mucho sentido si lo piensas.
nonisolated(nonsending): El nuevo comportamiento por defecto
En versiones anteriores de Swift, una función async marcada como nonisolated se ejecutaba automáticamente en el ejecutor global (un hilo de fondo). Esto era sorprendente y, francamente, causaba bastantes dolores de cabeza.
Swift 6.2 cambia esto. Ahora, nonisolated significa nonisolated(nonsending) por defecto: la función se ejecuta en el mismo ejecutor que su llamador, no salta a un hilo de fondo:
// En Swift 6.2, estas dos declaraciones son equivalentes:
nonisolated func procesarDatos() async -> [String] {
// Se ejecuta en el ejecutor del LLAMADOR
// Si te llaman desde el MainActor, corres en el MainActor
return datos.map { $0.uppercased() }
}
nonisolated(nonsending) func procesarDatos() async -> [String] {
// Mismo comportamiento: corre donde te llamen
return datos.map { $0.uppercased() }
}
Este cambio unifica el comportamiento de funciones async y no-async. En ambos casos, nonisolated significa «no accedo al estado de ningún actor, pero corro donde me llamen». Más predecible, más fácil de razonar.
@concurrent: Ejecutar explícitamente en segundo plano
Ahora bien, si realmente necesitas ejecutar trabajo pesado fuera del MainActor (procesamiento de imágenes, cálculos intensivos, parsing de un JSON enorme), lo indicas explícitamente con @concurrent:
// @concurrent fuerza la ejecución en el ejecutor global (hilo de fondo)
@concurrent
func procesarImagenPesada(datos: Data) async -> UIImage? {
// Este código SIEMPRE corre en un hilo de fondo,
// sin importar quién lo llame
guard let ciImage = CIImage(data: datos) else { return nil }
let contexto = CIContext()
let filtro = CIFilter.gaussianBlur()
filtro.inputImage = ciImage
filtro.radius = 10
guard let salida = filtro.outputImage,
let cgImage = contexto.createCGImage(salida, from: salida.extent) else {
return nil
}
return UIImage(cgImage: cgImage)
}
// Uso desde el MainActor
@Observable
class EditorImagenViewModel {
var imagenProcesada: UIImage?
var procesando = false
func aplicarFiltro(datos: Data) async {
procesando = true
// La función se ejecuta en segundo plano automáticamente
imagenProcesada = await procesarImagenPesada(datos: datos)
procesando = false // De vuelta en el MainActor
}
}
La filosofía de Swift 6.2 es clara: la concurrencia debe ser una decisión deliberada. No quieres que el código salte a hilos de fondo de forma accidental (créeme, los bugs que eso genera son difíciles de rastrear). Cuando necesites paralelismo real, lo indicas con @concurrent.
Las tres fases de la concurrencia en Swift 6.2
El equipo de Swift describe un modelo de divulgación progresiva en tres fases, y me parece una forma muy útil de pensar en la concurrencia:
- Fase 1 — Código secuencial: Escribe código normal sin preocuparte por concurrencia. Con Default Actor Isolation, todo corre en el MainActor. Perfecto para prototipos y lógica simple de UI.
- Fase 2 — Código asíncrono: Añade
async/awaitpara operaciones de red, base de datos, etc. No necesitas pensar en data-race safety porquenonisolated(nonsending)mantiene todo en el mismo hilo. - Fase 3 — Paralelismo real: Cuando necesitas rendimiento, usa
@concurrent,TaskGroupy actores. Solo aquí debes preocuparte porSendabley aislamiento de datos.
Cancelación y manejo de errores
Un aspecto fundamental de la concurrencia estructurada que muchos desarrolladores pasan por alto es la cancelación cooperativa. En Swift, la cancelación nunca es forzada — las tareas deben verificar activamente si han sido canceladas y responder de forma apropiada. Esto puede parecer tedioso, pero te da un control total sobre la limpieza de recursos.
Verificando la cancelación
func sincronizarArticulos(ids: [Int]) async throws -> [Articulo] {
var articulosSincronizados: [Articulo] = []
for id in ids {
// Verificar cancelación antes de cada operación costosa
try Task.checkCancellation()
let articulo = try await descargarArticulo(id: id)
articulosSincronizados.append(articulo)
}
return articulosSincronizados
}
// Alternativa: verificar sin lanzar error
func sincronizarArticulosSinError(ids: [Int]) async -> [Articulo] {
var articulosSincronizados: [Articulo] = []
for id in ids {
// Verificar cancelación de forma no-throwing
if Task.isCancelled {
break // Salir del bucle limpiamente
}
if let articulo = try? await descargarArticulo(id: id) {
articulosSincronizados.append(articulo)
}
}
return articulosSincronizados // Devuelve lo que logró descargar
}
withTaskCancellationHandler
Para operaciones que necesitan realizar limpieza cuando se cancelan (como cerrar conexiones de red o liberar recursos), puedes usar withTaskCancellationHandler:
func descargarArchivoGrande(url: URL) async throws -> Data {
let delegado = DelegadoDescarga()
return try await withTaskCancellationHandler {
try await delegado.descargar(url: url)
} onCancel: {
// Se ejecuta inmediatamente al cancelar
delegado.cancelarDescarga()
}
}
AsyncSequence y AsyncStream
Las secuencias asíncronas son una herramienta poderosa para trabajar con flujos de datos que llegan con el tiempo. Piensa en ellas como el equivalente async de Sequence — mismo concepto, pero los elementos pueden llegar de forma asíncrona.
Consumiendo AsyncSequence
func monitorearNotificaciones() async {
let centro = NotificationCenter.default
let notificaciones = centro.notifications(named: .noticiaRecibida)
// for await itera sobre cada notificación conforme llega
for await notificacion in notificaciones {
if let articulo = notificacion.object as? Articulo {
await procesarNuevoArticulo(articulo)
}
}
}
Creando AsyncStream personalizado
Cuando necesitas adaptar APIs basadas en callbacks o delegados al mundo async, AsyncStream es tu herramienta. Es especialmente útil con frameworks que todavía usan patrones de delegación (que, seamos honestos, son la mayoría):
import CoreLocation
class ServicioUbicacion: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
private var continuation: AsyncStream.Continuation?
var ubicaciones: AsyncStream {
AsyncStream { continuation in
self.continuation = continuation
manager.delegate = self
manager.startUpdatingLocation()
// Cuando el stream se cancela, detenemos las actualizaciones
continuation.onTermination = { @Sendable _ in
self.manager.stopUpdatingLocation()
}
}
}
func locationManager(
_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]
) {
for ubicacion in locations {
continuation?.yield(ubicacion)
}
}
func detener() {
continuation?.finish()
}
}
// Uso en SwiftUI
struct MapaView: View {
let servicioUbicacion = ServicioUbicacion()
@State private var ubicacionActual: CLLocation?
var body: some View {
Map()
.task {
for await ubicacion in servicioUbicacion.ubicaciones {
ubicacionActual = ubicacion
}
}
}
}
AsyncStream actúa como puente entre el mundo imperativo de delegados y callbacks y el mundo declarativo de async/await. Una vez que le pillas el truco, lo usarás para todo.
Sendable: Compartir datos de forma segura
El protocolo Sendable es la forma en que Swift verifica en tiempo de compilación que un tipo es seguro para enviar entre contextos de concurrencia (entre actores o tareas). Los tipos de valor como Int, String y structs inmutables son automáticamente Sendable.
Conformancia con Sendable
// Los structs con propiedades Sendable son automáticamente Sendable
struct ConfiguracionApp: Sendable {
let apiKey: String
let baseURL: URL
let timeoutSegundos: Int
let maxReintentos: Int
}
// Las clases necesitan ser final e inmutables para ser Sendable
final class TokenAutenticacion: Sendable {
let token: String
let fechaExpiracion: Date
init(token: String, fechaExpiracion: Date) {
self.token = token
self.fechaExpiracion = fechaExpiracion
}
var estaExpirado: Bool {
Date() > fechaExpiracion
}
}
// Los enums con valores asociados Sendable son Sendable
enum ResultadoSincronizacion: Sendable {
case exito(cantidadArticulos: Int)
case parcial(sincronizados: Int, fallidos: Int)
case fallo(mensaje: String)
}
@Sendable closures
Los closures que cruzan fronteras de concurrencia deben marcarse como @Sendable:
actor GestorTareas {
func ejecutarEnSegundoPlano(
_ operacion: @Sendable () async throws -> Void
) async throws {
try await operacion()
}
}
// Uso
let gestor = GestorTareas()
let configuracion = ConfiguracionApp(
apiKey: "abc123",
baseURL: URL(string: "https://api.ejemplo.com")!,
timeoutSegundos: 30,
maxReintentos: 3
)
try await gestor.ejecutarEnSegundoPlano {
// configuracion es Sendable, así que se puede capturar
print("Conectando a \(configuracion.baseURL)")
}
Ejemplo completo: Agregador de noticias con concurrencia
Vamos a unir todo lo que hemos aprendido en un ejemplo más completo. Esta es la arquitectura de NoticiasApp usando concurrencia moderna de Swift 6.2:
// MARK: - Modelo
struct FuenteNoticias: Sendable {
let nombre: String
let urlBase: URL
let categorias: [String]
}
// MARK: - Servicio con Actor
actor AgregadorNoticias {
private var cachePorFuente: [String: [Articulo]] = [:]
private let fuentes: [FuenteNoticias]
private let sesion: URLSession
init(fuentes: [FuenteNoticias], sesion: URLSession = .shared) {
self.fuentes = fuentes
self.sesion = sesion
}
/// Obtiene artículos de todas las fuentes en paralelo
func obtenerTodosLosArticulos() async -> [Articulo] {
await withTaskGroup(of: (String, [Articulo]).self) { grupo in
for fuente in fuentes {
grupo.addTask {
let articulos = await self.obtenerArticulosDeFuente(fuente)
return (fuente.nombre, articulos)
}
}
var todos: [Articulo] = []
for await (nombre, articulos) in grupo {
cachePorFuente[nombre] = articulos
todos.append(contentsOf: articulos)
}
// Ordenar por fecha, más recientes primero
return todos.sorted { $0.fechaPublicacion > $1.fechaPublicacion }
}
}
private func obtenerArticulosDeFuente(
_ fuente: FuenteNoticias
) async -> [Articulo] {
let url = fuente.urlBase.appendingPathComponent("articulos")
do {
let (datos, _) = try await sesion.data(from: url)
return try JSONDecoder().decode([Articulo].self, from: datos)
} catch {
return [] // Devolver vacío si una fuente falla
}
}
func articulosCacheados(fuente: String) -> [Articulo] {
cachePorFuente[fuente] ?? []
}
}
// MARK: - ViewModel
@Observable
class NoticiasAgregadasViewModel {
var articulos: [Articulo] = []
var estaCargando = false
var ultimaActualizacion: Date?
private let agregador: AgregadorNoticias
init(agregador: AgregadorNoticias) {
self.agregador = agregador
}
func cargar() async {
estaCargando = true
articulos = await agregador.obtenerTodosLosArticulos()
ultimaActualizacion = Date()
estaCargando = false
}
}
// MARK: - Vista
struct NoticiasAgregadasView: View {
@State private var viewModel: NoticiasAgregadasViewModel
init(agregador: AgregadorNoticias) {
_viewModel = State(
initialValue: NoticiasAgregadasViewModel(agregador: agregador)
)
}
var body: some View {
NavigationStack {
List(viewModel.articulos) { articulo in
NavigationLink(value: articulo) {
FilaArticuloView(articulo: articulo)
}
}
.navigationTitle("Noticias")
.refreshable {
await viewModel.cargar()
}
.overlay {
if viewModel.estaCargando && viewModel.articulos.isEmpty {
ProgressView("Agregando noticias...")
}
}
.toolbar {
if let fecha = viewModel.ultimaActualizacion {
ToolbarItem(placement: .status) {
Text("Actualizado: \(fecha.formatted(.relative(presentation: .named)))")
.font(.caption)
}
}
}
.task {
if viewModel.articulos.isEmpty {
await viewModel.cargar()
}
}
}
}
}
struct FilaArticuloView: View {
let articulo: Articulo
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(articulo.titulo)
.font(.headline)
.lineLimit(2)
HStack {
Text(articulo.autor)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Text(articulo.fechaPublicacion.formatted(date: .abbreviated, time: .omitted))
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.padding(.vertical, 4)
}
}
Errores comunes y cómo evitarlos
Después de trabajar bastante con Swift Concurrency, estos son los errores más frecuentes que me he encontrado. Seguramente tú también reconocerás alguno.
1. Olvidar verificar la cancelación
// MAL: No verifica cancelación
func procesarMuchosDatos(items: [Dato]) async -> [Resultado] {
var resultados: [Resultado] = []
for item in items {
resultados.append(await procesar(item))
}
return resultados
}
// BIEN: Verifica cancelación en cada iteración
func procesarMuchosDatos(items: [Dato]) async throws -> [Resultado] {
var resultados: [Resultado] = []
for item in items {
try Task.checkCancellation()
resultados.append(await procesar(item))
}
return resultados
}
2. Crear Tasks no estructuradas innecesariamente
// MAL: Task no estructurada — pierde cancelación automática
func cargar() {
Task {
let datos = try await servicio.obtener()
self.datos = datos
}
}
// BIEN: Usar .task en SwiftUI para cancelación automática
var body: some View {
Text("Hola")
.task {
let datos = try? await servicio.obtener()
self.datos = datos ?? []
}
}
3. Bloquear el MainActor con trabajo pesado
// MAL: Trabajo pesado en el MainActor
func procesarImagen() async {
// Si estamos en @MainActor, esto bloquea la UI
let resultado = await aplicarFiltrosComplejos(imagen)
self.imagenFiltrada = resultado
}
// BIEN: Mover trabajo pesado fuera del MainActor con @concurrent
@concurrent
func aplicarFiltrosComplejos(_ imagen: UIImage) async -> UIImage {
// Corre en hilo de fondo gracias a @concurrent
// ... procesamiento pesado ...
return imagenProcesada
}
4. Actor reentrancy
Este es sutil y puede pillarte desprevenido. Los actores en Swift son reentrantes: cuando un método de actor suspende (en un await), otro llamador puede ejecutar métodos del mismo actor. Esto puede causar inconsistencias si no se maneja correctamente:
actor Contador {
var valor = 0
// PELIGRO: El valor puede cambiar entre el await y la asignación
func incrementarConPeticion() async throws {
let valorActual = valor // valor = 0
let nuevoValor = try await servicio.calcular(valorActual) // Suspensión aquí
// Otro llamador pudo haber modificado valor durante la suspensión
valor = nuevoValor // ¿Estamos sobrescribiendo cambios?
}
// MEJOR: Verificar el estado después de la suspensión
func incrementarSeguro() async throws {
let valorAntes = valor
let nuevoValor = try await servicio.calcular(valorAntes)
// Verificar que nada cambió durante la suspensión
guard valor == valorAntes else {
throw ErrorConcurrencia.estadoModificado
}
valor = nuevoValor
}
}
Migración desde código heredado
Si tienes código existente con completion handlers (y seamos realistas, la mayoría de proyectos todavía tienen algo), puedes usar continuaciones para adaptarlo al mundo async:
// API antigua con completion handler
func obtenerDatosLegacy(completion: @escaping (Result) -> Void) {
// ... código legacy ...
}
// Adaptación a async/await con continuación
func obtenerDatos() async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
obtenerDatosLegacy { resultado in
switch resultado {
case .success(let datos):
continuation.resume(returning: datos)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
Regla de oro con continuaciones: el resume debe llamarse exactamente una vez. Ni más, ni menos. Llamarlo cero veces provoca que la tarea se cuelgue para siempre. Llamarlo más de una vez causa un crash. Usa withCheckedThrowingContinuation (en vez de la variante unsafe) durante el desarrollo — te avisará con un error claro si metes la pata.
Configuración de Approachable Concurrency en Xcode 26
Para activar las nuevas características de Swift 6.2 en tu proyecto existente, sigue estos pasos:
- Abre tu proyecto en Xcode 26.
- Ve a Build Settings de tu target.
- Busca «Approachable Concurrency» en la sección Swift Compiler.
- Activa la opción SWIFT_APPROACHABLE_CONCURRENCY = YES.
- Esto habilita dos flags internos:
InferIsolatedConformancesyNonisolatedNonsendingByDefault.
En proyectos nuevos creados con Xcode 26, esta configuración viene activada por defecto. Para proyectos existentes, la migración es gradual: puedes activar estos flags individualmente para adoptar los cambios de forma incremental. No hay prisa — ve a tu ritmo.
Conclusión
Swift 6.2 representa un punto de inflexión para la concurrencia en Swift. Con Approachable Concurrency, el equipo de Swift ha reconocido que la seguridad no debería venir a costa de la accesibilidad. Los cambios clave — MainActor por defecto, nonisolated(nonsending) como comportamiento estándar, y @concurrent para paralelismo explícito — crean un modelo mental mucho más claro.
La receta para el éxito es bastante simple:
- Empieza con código secuencial normal. Con Default Actor Isolation, todo corre en el MainActor y es seguro.
- Añade
async/awaitcuando necesites operaciones asíncronas. Gracias anonisolated(nonsending), tu código seguirá corriendo en el mismo contexto. - Usa
@concurrentyTaskGroupsolo cuando necesites paralelismo real para mejorar el rendimiento. - Protege el estado compartido con actores.
- Siempre verifica la cancelación en operaciones largas.
La concurrencia no tiene que ser intimidante. Con las herramientas que Swift 6.2 nos ofrece, puedes escribir código concurrente que es seguro, legible y eficiente. Empieza simple, escala la complejidad solo cuando la necesites, y disfruta el proceso.