Saltar al contenido
K
Swift: memoria y concurrenciaPaso a paso37 min de lectura · 5 min de máquina Intermedio

async/await y Task

Al terminar: Escribir funciones async, lanzar Task y cancelar.

Al terminar tendrás jugarPartido(_:) async throws, una función que juega un partido minuto a minuto sin bloquear el hilo. También tendrás el Package.swift con la línea platforms, y sabrás leer el error que sale cuando falta.

Importa porque casi todo lo que tarda en una app (la red, un temporizador, un partido en directo) se escribe con async/await. Es la versión de Swift de las funciones suspend de Kotlin, con una diferencia visible: cada punto donde la función se pausa lleva la palabra await. La cancelación, la otra mitad de este tema, está en la lección siguiente: Cancelar una tarea.

Temas: async/await, Task, Task.sleep y la plataforma mínima de un paquete.

async y await: una función que puede pausarse

Una función marcada async puede pausarse a mitad: le dice al sistema «espero algo, usa mi hilo para otra cosa y avísame cuando pueda seguir». Un hilo (thread) es una línea de ejecución del programa: el sistema reparte el trabajo entre varios. Cada punto donde puede pausarse lleva await. Una función async solo se llama desde otra async o desde una Task.

async no significa «corre en otro hilo». Significa «puede pausarse». Cuál es el hilo lo decide el sistema.

Ejemplo mínimo: pausar y lanzar una Task

Task { … } arranca trabajo asíncrono desde código normal y devuelve un manejador con el que puedes esperar el resultado (tarea.value). Task.sleep(for:) es la pausa: no bloquea el hilo. .milliseconds(100) es una duración (Duration).

ejemplo mínimo: una pausa y una Task (con comentarios)
// async: esta función puede pausarse.
func cafe() async -> String {
    // La pausa: el hilo queda libre 100 ms. `try?` se explica debajo.
    try? await Task.sleep(for: .milliseconds(100))   // se pausa aquí
    return "café"
}
 
// Task { … } arranca el trabajo sin esperarlo y guarda su manejador.
let tarea = Task { await cafe() }
// .value espera a que termine y entrega lo que devolvió; `await` marca la espera.
print(await tarea.value)

Salida, unos 100 ms después de ejecutarlo:

café

Si la tarea puede lanzar un error, esperar su valor lleva también try: try await tarea.value, como en el ejercicio. Y un try que falla en el nivel superior, sin do/catch (en el Playground o en main.swift), hace que el programa termine con ese error: por eso el ejercicio y el paso atrapan los errores con do/catch cuando pueden fallar de verdad.

Pruébalo en tu Playground. try? convierte un error en nil en vez de propagarlo (lo viste en Errores: throws, try y do/catch); aquí se usa porque Task.sleep puede lanzar un error cuando cancelan la tarea, algo que verás en Cancelar una tarea.

Kotlin y Swift, lado a lado

KotlinSwift
suspend fun jugarPartido()func jugarPartido() async throws
La llamada no lleva marca (el IDE dibuja una flecha)Cada llamada lleva await, y try si puede lanzar
delay(10)try await Task.sleep(for: .milliseconds(10))
launch { … } y Job (o async { … } y await() si esperas el resultado)Task { … } (y tarea.value para el resultado)
runBlocking { … } en un main normalNo hace falta: main.swift ya admite await

Reto · Ejercicio: el horno

En tu Playground, escribe un error del horno con un caso para «demasiado tiempo» que guarde los minutos, y una función que pueda pausarse y fallar: recibe un plato y unos minutos; si son más de 10, lanza ese error; si no, espera 10 milisegundos por minuto y devuelve el plato seguido de «listo». Después:

  1. Hornea pan 5 minutos, esperando el resultado ahí mismo, e imprímelo.
  2. Mete una pizza de 8 minutos en una tarea aparte, imprime «la pizza ya está en el horno» sin esperarla, y después espera el valor de la tarea e imprímelo.
  3. Intenta hornear un asado de 30 minutos dentro de un bloque que atrape errores; si falla, imprime «No se pudo:» y el error.

Lo que debe salir:

pan listo
la pizza ya está en el horno
pizza listo
No se pudo: demasiadoTiempo(30)
Ver solución
solución: el horno
enum ErrorDelHorno: Error {
    case demasiadoTiempo(Int)
}
 
func hornear(_ plato: String, minutos: Int) async throws -> String {
    if minutos > 10 { throw ErrorDelHorno.demasiadoTiempo(minutos) }
    try await Task.sleep(for: .milliseconds(10 * minutos))
    return "\(plato) listo"
}
 
print(try await hornear("pan", minutos: 5))
 
let pizza = Task { try await hornear("pizza", minutos: 8) }
print("la pizza ya está en el horno")
print(try await pizza.value)
 
do {
    print(try await hornear("asado", minutos: 30))
} catch {
    print("No se pudo: \(error)")
}

El pan se espera ahí mismo (try await). La pizza se lanza con Task y el programa sigue: por eso «la pizza ya está en el horno» sale antes de «pizza listo», que aparece cuando pides .value. El asado lanza el error antes de esperar nada, y catch lo imprime.

Paso · jugarPartido() async

Objetivo: una función jugarPartido que reproduce el guion de un partido minuto a minuto, sin bloquear el hilo. Al terminar, torneo-cli juega un partido e imprime Final: 2-1 con 5 eventos y cuánto duró.

Trabajas en el paquete Torneo (la carpeta Marcador-Swift/Torneo de tu carpeta personal, abierta en Xcode). Para la Terminal: cd ~/Marcador-Swift/Torneo, y todos los xcrun swift … de esta lección salen de ahí. El título de cada bloque es la ruta del fichero dentro de ~/Marcador-Swift, la misma que ves en el Project navigator (el panel de la izquierda de Xcode, Cmd+1).

Torneo
Package.swiftse modifica (paso 2)
Sources
Torneo
Simulacion.swiftse crea
Formato.swiftse modifica
torneo-cli
main.swiftse modifica
Un fichero nuevo y tres que cambian. Así los ves en el Project navigator.

Paso 1 · Escribe Simulacion.swift y milisegundos

Crea Simulacion.swift: en el Project navigator, clic derecho sobre la carpeta Torneo que está dentro de Sources → New File from Template… → Swift File → Next, escribe Simulacion y pulsa Create. Si el menú de tu versión de Xcode se llama distinto, crea el fichero desde la Terminal con touch Sources/Torneo/Simulacion.swift.

Piezas nuevas de este paso:

  • func jugarPartido(…) async throws -> Partido: async para poder pausarse y throws porque Task.sleep puede lanzar un error cuando cancelan la tarea (lo verás en Cancelar una tarea). Se llama con try await.
  • for evento in guion.eventos where evento.minuto == 0 { … }: un for con where, que solo recorre los que cumplen la condición.
  • max(a, b): el mayor de los dos números. Con guion.eventos.map(\.minuto).max() ?? 0: \.minuto es un atajo para «el campo minuto de cada elemento» (lo viste en Colecciones y key paths); max() devuelve un opcional y ?? 0 pone 0 si está vacío.
  • ContinuousClock(): un reloj; reloj.now es el instante actual y inicio.duration(to: reloj.now) es lo que pasó desde inicio (una Duration). Lo usa main.swift en el paso 3.
  • El rango cerrado 1...ultimoMinuto ya lo viste en Tu primer test.
  • Nombres exactos que exigen los tests de la lección siguiente: la constante pública minutosDelPartido; jugarPartido, que recibe el guion sin etiqueta y, con la etiqueta msPorMinuto, los milisegundos de cada minuto; y milisegundos, que te doy hecha más abajo.

Las piezas juntas, con una ruta de autobús en vez de un partido. Pruébalo en tu Playground:

ejemplo mínimo: una función async throws con for … where y max (con comentarios)
struct Parada {
    let minuto: Int
    let nombre: String
}
 
// async: puede pausarse. throws: Task.sleep lanza CancellationError si cancelan la tarea.
func recorrerRuta(_ paradas: [Parada], msPorMinuto: Int = 5) async throws -> [String] {
    var visitadas: [String] = []
    // for … where: solo recorre las paradas que cumplen la condición.
    for parada in paradas where parada.minuto == 0 {
        visitadas.append(parada.nombre)
    }
    // map(\.minuto) saca los minutos; max() da el mayor, o nil si la lista está vacía.
    let ultimo = max(10, paradas.map(\.minuto).max() ?? 0)   // max(a, b): el mayor de los dos
    for minuto in 1...ultimo {                               // del 1 al último, ambos incluidos
        try await Task.sleep(for: .milliseconds(msPorMinuto))   // aquí se pausa, sin bloquear el hilo
        for parada in paradas where parada.minuto == minuto {
            visitadas.append(parada.nombre)
        }
    }
    return visitadas
}
 
let ruta = [Parada(minuto: 0, nombre: "Salida"), Parada(minuto: 4, nombre: "Plaza"), Parada(minuto: 12, nombre: "Meta")]
print(try await recorrerRuta(ruta))
["Salida", "Plaza", "Meta"]

Intenta escribirlo tú, en Simulacion.swift:

  1. Una constante pública con los 90 minutos del partido.
  2. jugarPartido, una función que puede pausarse y fallar si la cancelan: recibe el guion y los milisegundos por minuto (10 si no se dicen) y lo juega minuto a minuto, empezando con un partido vacío entre los mismos equipos. Primero registra lo que pasa en el minuto 0; después, de minuto en minuto hasta el 90 (o hasta el último evento, si es posterior), espera ese tiempo y registra los eventos de ese minuto. Devuelve el partido jugado.

Cuando termines, abre el bloque y compara con la solución.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
Torneo/Sources/Torneo/Simulacion.swift
public let minutosDelPartido = 90
 
// `async` NO significa «corre en otro hilo». Significa «esta función puede
// PAUSARSE»: dentro puede llamar a otras `async` con `await` (como
// `Task.sleep`) y, mientras espera, el hilo queda libre para hacer otra cosa.
// Es exactamente el `suspend` de Kotlin. `await` marca cada punto donde puede
// pausarse (en Kotlin la marca no se ve en el código, solo en el IDE).
//
// `throws` porque `Task.sleep` lanza `CancellationError` si alguien cancela la
// tarea: la cancelación es COOPERATIVA (igual que en Kotlin), y el error que
// avisa es un error normal que sube por la función.
//
// `guion` es todo lo que va a pasar. La función lo «juega» minuto a minuto y
// devuelve el partido tal como quedó. `msPorMinuto` es cuánto dura un minuto de
// juego en tiempo real (10 ms hace un partido de algo más de un segundo).
public func jugarPartido(_ guion: Partido, msPorMinuto: Int = 10) async throws -> Partido {
    var jugado = Partido(local: guion.local, visitante: guion.visitante)
    // Lo que pasa en el minuto 0 (antes de empezar a contar).
    for evento in guion.eventos where evento.minuto == 0 {
        jugado.registrar(evento)
    }
    let ultimoMinuto = max(minutosDelPartido, guion.eventos.map(\.minuto).max() ?? 0)
    for minuto in 1...ultimoMinuto {
        // Aquí se pausa: el hilo queda libre y, cuando pase el tiempo, la
        // función sigue por la línea siguiente (a lo mejor en otro hilo).
        try await Task.sleep(for: .milliseconds(msPorMinuto))
        for evento in guion.eventos where evento.minuto == minuto {
            jugado.registrar(evento)
        }
    }
    return jugado
}

milisegundos no se escribe: convierte una Duration en un número de milisegundos para imprimirlo («1099 ms»), y los tests y main.swift la usan. Una Duration guarda segundos y fracciones de segundo, y la cuenta para juntarlos es un detalle que no enseña nada de async. Abre Formato.swift (en Sources → Torneo, ya existe) y pega esta función al final, debajo de lo que ya tienes:

Formato.swift: añade al final (se pega, no se escribe)
// Convierte una `Duration` en milisegundos enteros, para escribir «1099 ms».
public func milisegundos(_ duracion: Duration) -> Int {
    let (segundos, fraccion) = duracion.components
    return Int(segundos) * 1_000 + Int(fraccion / 1_000_000_000_000_000)
}

Todavía no pasa nada que ver: compila desde ~/Marcador-Swift/Torneo.

xcrun swift build

Falla, y no por tu código. Es lo que explica el paso siguiente.

Paso 2 · Instala el ajuste platforms

Ya conoces la receta de tocar el manifiesto Package.swift de Closures; aquí va en corto. Esta vez el ajuste no es una librería: no añade código de fuera, así que no hay nada que descargar, no aparece nada nuevo en Package Dependencies y no se crea ningún Package.resolved.

1 · Qué es y por qué ahora. Un paquete de Swift sin la línea platforms se compila para una versión de macOS muy antigua (SwiftPM, el gestor de paquetes de Swift, asume la 10.13), y en esa versión la concurrencia no existía. Esa cifra la pones tú en Package.swift. Sin ella, Task «no existe» aunque tu código esté bien.

Estos son los errores que acabas de ver. En tu pantalla cada línea empieza con la ruta completa del fichero (aquí se muestra recortada a partir de Sources/), y los números de línea y columna pueden cambiar si tu código no es idéntico a la solución (en Formato.swift, según las líneas en blanco que tengas). En Xcode los ves en el Issue navigator (el triángulo del panel de la izquierda, Cmd+5) cuando compilas con Product → Build (Cmd+B):

Sources/Torneo/Simulacion.swift:26:19: error: 'Task' is only available in macOS 10.15 or newer
Sources/Torneo/Simulacion.swift:26:24: error: 'sleep(for:tolerance:clock:)' is only available in macOS 13.0 or newer
Sources/Torneo/Simulacion.swift:26:36: error: 'milliseconds' is only available in macOS 13.0 or newer
Sources/Torneo/Formato.swift:46:38: error: 'Duration' is only available in macOS 13.0 or newer
Sources/Torneo/Formato.swift:47:41: error: 'components' is only available in macOS 13.0 or newer

2 · Dónde y cuál es el cambio. Abre Package.swift (el primer fichero dentro de Torneo en el Project navigator). El principio del Package(…) tiene esta forma (tus comentarios pueden variar):

Package.swift: el principio, antes
let package = Package(
    name: "Torneo",
    products: [
Package.swift: el principio, después
let package = Package(
    name: "Torneo",
    platforms: [.macOS(.v15), .iOS(.v18)],
    products: [

Intenta añadir tú la línea de las plataformas, justo después del nombre del paquete y antes de los productos: macOS 15 e iOS 18 como mínimo. Lo que debes ver al compilar después: Build complete!. El bloque de abajo es la línea, con sus comentarios:

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
Package.swift: la línea platforms
    // Sistemas mínimos. Sin esta línea, SwiftPM compila para macOS 10.13 y las
    // APIs de concurrencia (`Task`, `Task.sleep(for:)`) «no existen» todavía.
    platforms: [.macOS(.v15), .iOS(.v18)],
PiezaQué hace
platforms:Lista de sistemas mínimos donde funciona el paquete. Tiene que ir después de name y antes de products
.macOS(.v15)macOS 15 como mínimo. Para este código basta macOS 13, pero se elige el 15 porque en AsyncSequence y AsyncStream escribirás un tipo que lo necesita, y más adelante Mutex, en Sendable y el modo Swift 6
.iOS(.v18)iOS 18 como mínimo: el de la app de iOS que crearás más adelante
La coma finalCada argumento de Package(...) termina en coma, menos el último

3 · Guarda (Cmd+S) y deja que Xcode vuelva a leer el manifiesto: lo hace solo al guardar y muestra un indicador de progreso arriba mientras resuelve el paquete.

4 · Si algo sale mal, dos errores típicos y su arreglo. Antes de ellos sale una línea larga, error: 'torneo': Invalid manifest (compiled with: …), como en Closures:

  • Package.swift:8:42: error: expected ',' separator (o la línea donde pusiste la tuya): te falta la coma al final de la línea platforms.
  • error: argument 'platforms' must precede argument 'products': la línea está en un sitio equivocado (por ejemplo, después de products). Muévela justo debajo de name.

5 · Compruébalo. Desde ~/Marcador-Swift/Torneo:

xcrun swift build

Ya no salen los errores de Task y la compilación termina con Build complete!. (Todavía no hay nada que ejecutar: falta el bloque nuevo de main.swift, el del paso siguiente.)

Paso 3 · Juega un partido desde main.swift

Antes, el reloj, con una función que solo espera:

ejemplo mínimo: medir con ContinuousClock (con comentarios)
func esperar(_ ms: Int) async throws -> String {
    try await Task.sleep(for: .milliseconds(ms))
    return "esperé \(ms) ms"
}
 
let reloj = ContinuousClock()                // un reloj que no se ve afectado por cambiar la hora
let inicio = reloj.now                       // el instante actual
print(try await esperar(50))
let pasado = inicio.duration(to: reloj.now)  // lo que pasó desde inicio: una Duration
print(pasado > .milliseconds(40))            // true
esperé 50 ms
true

Abre main.swift (dentro de Sources → torneo-cli). Intenta escribir, al final del fichero, después de lo que ya tienes, una parte con su título: «== un partido suspendido ==». Juega entero Rayo contra Toros, imprime el resultado final y cuántos eventos tuvo (o, si falla, que no terminó), y cuánto duró en milisegundos.

Llama reloj al reloj: las lecciones siguientes lo vuelven a usar con ese nombre. Lo que debes ver al ejecutarlo, sin contar el título (Duró es unos 1100 ms y varía según la máquina: cada pausa tarda un poco más de lo pedido, y por eso 90 pausas de 10 ms no suman 900 ms sino bastante más):

Final: 2-1 con 5 eventos
Duró 1100 ms
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
main.swift: añade al final
// Un título para separar esta parte de la salida anterior.
print("== un partido suspendido ==")
// Un reloj para medir cuánto dura el partido.
let reloj = ContinuousClock()
// El instante en que empieza.
let inicioDelPartido = reloj.now
// `main.swift` permite `await` al nivel superior, sin una función alrededor.
do {
    // Juega el partido entero: `try` por si falla, `await` porque se pausa.
    let jugado = try await jugarPartido(Ejemplo.rayoContraToros)
    print("Final: \(jugado.golesLocal)-\(jugado.golesVisitante) con \(jugado.eventos.count) eventos")
} catch {
    // Solo llega aquí si `jugarPartido` lanza un error.
    print("El partido no terminó: \(error)")
}
// Cuánto pasó desde el inicio, en milisegundos.
print("Duró \(milisegundos(inicioDelPartido.duration(to: reloj.now))) ms")

Ejecuta desde ~/Marcador-Swift/Torneo:

xcrun swift run torneo-cli

Sin contar las líneas de título que empiezan por ==, la salida de esta parte termina con algo como esto (Duró es unos 1100 ms y varía según la máquina):

Final: 2-1 con 5 eventos
Duró 1100 ms

Paso 4 · Provoca los tres errores típicos

Llamar a una async sin la marca que toca es el fallo más común, y conviene verlo una vez con sus mensajes. Ya tienes jugarPartido, así que puedes provocarlos. Al final de main.swift añade estas tres funciones con el fallo puesto (el _ = descarta el resultado para que el compilador no avise de que no se usa):

main.swift: añade al final (tres funciones con el bug)
// Falta `await`: la llamada puede pausarse y no lo dice.
func sinAwait() async throws {
    _ = try jugarPartido(Ejemplo.rayoContraToros)
}
 
// Falta `try`: la llamada puede lanzar un error y no se maneja.
func sinTry() async {
    _ = await jugarPartido(Ejemplo.rayoContraToros)
}
 
// Una función normal no sabe pausarse: no puede usar `await`.
func desdeUnaFuncionNormal() {
    _ = try? await jugarPartido(Ejemplo.rayoContraToros)
}

Compila con xcrun swift build. Los mensajes, en el mismo orden (en Xcode los ves en el Issue navigator, Cmd+5, tras Cmd+B):

error: expression is 'async' but is not marked with 'await'
error: call can throw, but it is not marked with 'try' and the error is not handled
error: 'async' call in a function that does not support concurrency

Ahora corrígelas: sustituye las tres funciones por estas dos (la tercera se arregla abriendo una Task):

main.swift: corregido, await y try, y una Task en el código normal
// Con `try await` la llamada queda marcada: puede fallar y puede pausarse.
func conAwaitYTry() async throws {
    let resultado = try await jugarPartido(Ejemplo.rayoContraToros)
    print(resultado.golesLocal)
}
 
// Desde código normal se entra al mundo `async` abriendo una `Task`.
func desdeUnaFuncionNormal() {
    Task {
        // `try?` convierte un error en `nil`; `?? -1` pone -1 en ese caso.
        let resultado = try? await jugarPartido(Ejemplo.rayoContraToros)
        print(resultado?.golesLocal ?? -1)
    }
}

xcrun swift build vuelve a terminar con Build complete!. Borra después las dos funciones: no se llaman y no hacen falta.

Fallan porque await y try son marcas obligatorias: el compilador exige ver dónde la función puede pausarse y dónde puede fallar. Y una función normal no sabe pausarse, así que la única forma de entrar en el mundo async desde ella es abrir una Task.

Esta lección no añade tests: los de async llegan en Cancelar una tarea, junto con la cancelación. Comprueba que los que ya tienes siguen pasando, desde ~/Marcador-Swift/Torneo:

xcrun swift test

(En Xcode, Product → Test, Cmd+U.)

Punto de control

  • Desde ~/Marcador-Swift/Torneo, xcrun swift test termina con una línea como Test run with 95 tests in 12 suites passed.
  • xcrun swift run torneo-cli imprime Final: 2-1 con 5 eventos y Duró … ms.
  • Viste los errores de platforms al compilar antes de añadir la línea, y desaparecieron después.
  • Provocaste los tres errores de await y try y los corregiste.
  • Sabes decir por qué async no significa «otro hilo» y qué hace falta para entrar en código async desde una función normal.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.¿Qué significa que una función Swift sea async?

  2. 2.Tienes una función normal (no async) y quieres llamar a jugarPartido. ¿Qué haces?

  3. 3.Compilas un paquete con Task.sleep(for:) y sale 'Task' is only available in macOS 10.15 or newer. ¿Cuál es la causa y el arreglo?

  4. 4.¿Qué marca exactamente la palabra await en el código?

  5. 5.Llamas a una función async sin escribir await delante. ¿Qué pasa?

Faltan 5 de 5