Saltar al contenido
K
Swift: memoria y concurrenciaPaso a paso36 min de lectura · 5 min de máquina Básico

async let: varias tareas a la vez

Al terminar: Lanzar varias tareas a la vez con async let y TaskGroup.

Al terminar tendrás una jornada con dos partidos que se juegan a la vez y unas estadísticas que lanzan tres consultas a la vez. Verás con números que dos partidos a la vez duran lo que uno solo, no el doble, y sabrás escribir el mismo truco con async let.

Importa porque casi toda pantalla real pide varias cosas al mismo tiempo (datos, imágenes, estadísticas), y esperarlas una detrás de otra multiplica el tiempo. Swift ofrece dos herramientas para eso. En esta lección ves la primera, async let, que sirve cuando sabes cuántas tareas hay. La segunda, TaskGroup, para cuando no lo sabes, es la lección siguiente, TaskGroup y errores en tareas hijas.

Temas: async let, tareas hijas y concurrencia estructurada, esperar varias a la vez.

async let: lanzar ya, esperar después

async let x = trabajo() arranca trabajo() ahora, como tarea hija, y sigue con la línea siguiente sin esperar. El await llega más tarde, cuando necesitas el resultado. Si lanzas tres async let seguidos y esperas los tres, el total dura lo que dura el más largo.

Se llaman tareas hijas porque viven dentro de la función que las lanza: la función no termina hasta que todas terminan. Eso se llama concurrencia estructurada: no quedan tareas sueltas por ahí. Si lanzas un async let y nunca lo esperas, Swift lo cancela al salir de la función y espera a que termine antes de devolver el control.

Cuidado con la palabra «a la vez». Concurrente quiere decir que las tareas avanzan solapadas en el tiempo; paralelo, que se ejecutan en el mismo instante en núcleos distintos. Dos esperas (Task.sleep) son concurrentes por definición. Dos cálculos pesados pueden además ser paralelos, porque Swift reparte las tareas entre los hilos del sistema. Aquí los partidos solo esperan, así que lo que ves es concurrencia.

ejemplo mínimo: async let (con comentarios)
// Una función que «trabaja» esperando unos milisegundos y devuelve cuántos esperó.
func espera(_ ms: Int) async -> Int {
    try? await Task.sleep(for: .milliseconds(ms))   // try?: si la cancelan, no pasa nada
    return ms
}
 
let reloj = ContinuousClock()          // un reloj para medir
let inicio = reloj.now                 // el instante de salida
async let x = espera(200)              // arranca ya y NO se espera
async let y = espera(200)              // arranca ya, a la vez que x
async let z = espera(200)              // arranca ya, a la vez que x e y
let total = await x + y + z            // aquí se esperan las tres; un await cubre las tres lecturas
print(total, inicio.duration(to: reloj.now))

Imprime 600 y una duración de unos 0,2 segundos, que Swift escribe como 0.2045… seconds (los decimales varían según la máquina). Si escribes las tres llamadas seguidas con let a = await espera(200), tardan unos 600 ms. Pruébalo en tu Playground, no en main.swift: ahí ya existe un reloj.

Dos resultados a la vez: la tupla

Una tupla (la viste en Colecciones, diccionarios y propiedades) junta varios valores en uno: (String, String) es «un texto y otro texto». Es la forma natural de devolver los resultados de dos async let: return try await (a, b) los espera a la vez y los entrega juntos. El try va porque la espera puede fallar, y un solo try await delante cubre las dos lecturas.

ejemplo mínimo: dos async let devueltos en una tupla (con comentarios)
// «Descarga» algo esperando unos milisegundos; puede fallar si cancelan la tarea.
func descargar(_ nombre: String, ms: Int) async throws -> String {
    try await Task.sleep(for: .milliseconds(ms))
    return "\(nombre) listo"
}
 
// Devuelve una tupla con los dos resultados.
func descargarDos(_ uno: String, _ otro: String) async throws -> (String, String) {
    async let a = descargar(uno, ms: 100)   // arranca ya
    async let b = descargar(otro, ms: 100)  // arranca ya, a la vez que a
    return try await (a, b)                 // espera a las dos; try porque pueden fallar
}
 
let (foto, video) = try await descargarDos("foto", "vídeo")   // la tupla se separa en dos nombres
print(foto, "|", video)
foto listo | vídeo listo

Y lo hace en unos 100 ms, no en 200.

Varias líneas en un async let

Un async let admite una sola expresión. Si cada tarea necesita varias líneas (esperar y después calcular algo), se escribe una closure entre llaves y se llama enseguida con (): async let nombre: Tipo = { … }(). Una closure es un trozo de código sin nombre que puedes guardar o pasar (Closures); aquí lo escribes y lo ejecutas en el mismo sitio, para que el async let tenga varias líneas dentro. Al ser una closure, hay que decir el tipo del resultado después de los dos puntos.

ejemplo mínimo: async let con una closure de varias líneas (con comentarios)
let palabras = ["sol", "luna", "mar"]
 
// Una closure entre llaves y llamado al momento con (): permite varias líneas en un async let.
async let largas: Int = {
    try? await Task.sleep(for: .milliseconds(50))   // la «consulta» tarda 50 ms
    return palabras.count { $0.count > 3 }           // cuántas tienen más de 3 letras
}()
async let cortas: Int = {
    try? await Task.sleep(for: .milliseconds(50))
    return palabras.count { $0.count <= 3 }
}()
// Leer un async let es lo que lleva await. El await va delante de toda la expresión que
// lee los dos (aquí, el texto con \(largas) y \(cortas)), y por eso cubre las dos lecturas.
print(await "largas: \(largas), cortas: \(cortas)")
largas: 1, cortas: 2

count { … } con una closure cuenta los elementos para los que devuelve true; lo viste en Enums: el partido de eventos.

Kotlin y Swift, lado a lado

KotlinSwift
async { … } + await()async let x = … + await x
Dos async seguidos corren a la vezDos async let seguidos corren a la vez
coroutineScope espera a sus hijos antes de terminarLa función espera a sus tareas hijas antes de devolver

Reto · Ejercicio: el desayuno

En tu Playground, escribe una función que pueda pausarse, reciba el nombre de algo y unos milisegundos, espere ese tiempo (ignorando cualquier error de la espera) y devuelva el nombre seguido de «listo». Mide con un reloj:

  1. Café, tostada y zumo de 300 ms cada uno, uno detrás de otro, esperando cada uno antes de pedir el siguiente. Imprime «seguidos:», la lista de los tres resultados y si tardó más de 800 ms.
  2. Los tres a la vez, cada uno en su propia constante, y esperándolos juntos al final. Aquí la tostada son dos pasos seguidos dentro de su propia tarea: tostar el pan (200 ms) y después untarla (100 ms), y su resultado es el de untar. Imprime «a la vez:», la lista y si tardó menos de 500 ms.

Lo que debe salir:

seguidos: ["café listo", "tostada listo", "zumo listo"] true
a la vez: ["café listo", "tostada listo", "zumo listo"] true
Ver solución
solución: el desayuno
// Espera unos milisegundos y devuelve el nombre con «listo».
func preparar(_ cosa: String, ms: Int) async -> String {
    try? await Task.sleep(for: .milliseconds(ms))
    return "\(cosa) listo"
}
 
let reloj = ContinuousClock()
 
// 1. Uno detrás de otro: los tiempos se suman (~900 ms).
let inicioSeguidos = reloj.now
let a = await preparar("café", ms: 300)
let b = await preparar("tostada", ms: 300)
let c = await preparar("zumo", ms: 300)
print("seguidos:", [a, b, c], inicioSeguidos.duration(to: reloj.now) > .milliseconds(800))
 
// 2. A la vez: manda la más lenta (~300 ms).
let inicioALaVez = reloj.now
async let x = preparar("café", ms: 300)
async let y: String = {
    _ = await preparar("pan", ms: 200)            // primero se tuesta el pan…
    return await preparar("tostada", ms: 100)     // …y después se unta
}()
async let z = preparar("zumo", ms: 300)
let juntos = await [x, y, z]
print("a la vez:", juntos, inicioALaVez.duration(to: reloj.now) < .milliseconds(500))

La tostada tarda 200 + 100 = 300 ms por dentro, y como corre a la vez que las otras dos, el total sigue siendo ~300 ms. El _ = descarta el resultado de tostar el pan, que aquí no hace falta. Si alguna de las dos líneas sale false, tu Mac iba muy ocupado: vuelve a ejecutarlo.

Paso · dos partidos a la vez

Objetivo: jugarJornada con dos async let y estadisticasDe con tres. Al terminar, torneo-cli muestra que los dos partidos duran lo que uno solo.

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 … salen de ahí. En esta lección no hay librerías ni ajustes de Package.swift nuevos: el platforms que añadiste en async/await y Task ya cubre todo lo que se usa.

Torneo
Sources
Torneo
Simulacion.swiftse modifica: todo va al final
torneo-cli
main.swiftse modifica: todo va al final
Dos ficheros cambian. Así los ves en el Project navigator.

No hay ficheros nuevos: todo se añade al final de Simulacion.swift (en Sources → Torneo), después de probabilidadDeVictoria, y de main.swift (en Sources → torneo-cli), después de lo que ya tienes.

Paso 1 · Dos partidos a la vez con async let

Piezas nuevas de este paso:

  • jugarJornada: el nombre exacto que exige main.swift. Recibe los dos partidos sin etiqueta y, con la etiqueta msPorMinuto, los milisegundos de cada minuto (10 si no se dicen); devuelve una tupla con los dos partidos jugados. En la lección siguiente escribirás otra jugarJornada que recibe una lista: tener varias funciones con el mismo nombre y distintos parámetros se llama sobrecarga (overloading), y Swift elige una según lo que le pasas.

Intenta escribirla tú: jugarJornada juega los dos partidos a la vez, con el mismo truco que descargarDos, y devuelve los dos cuando terminan ambos. Después, el bloque de main.swift que la prueba: con el título «== dos partidos a la vez con async let ==», juega a la vez Rayo contra Toros y Lobos contra Águilas, imprime un pitido final por partido con los equipos y los goles, y cuánto duró todo (con el reloj que ya tienes, el ContinuousClock de async/await y Task); si algo falla, «Falló la jornada:» y el error. Lo que debes ver (tu número de milisegundos varía):

Pitido final: Rayo FC 2-1 Toros
Pitido final: Lobos 1-2 Águilas
Los dos partidos duraron 1100 ms
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
Torneo/Sources/Torneo/Simulacion.swift
// `async let` lanza una tarea hija que corre a la vez que el resto de la función.
// Las dos líneas `async let` arrancan antes del primer `await`, así que los dos
// partidos se juegan a la vez; el `await` de la última línea espera a los dos.
// El total dura lo que el más largo, no la suma.
public func jugarJornada(
    _ primero: Partido,
    _ segundo: Partido,
    msPorMinuto: Int = 10
) async throws -> (Partido, Partido) {
    async let a = jugarPartido(primero, msPorMinuto: msPorMinuto)
    async let b = jugarPartido(segundo, msPorMinuto: msPorMinuto)
    return try await (a, b)
}
Torneo/Sources/torneo-cli/main.swift
// Un título para separar esta parte de la salida anterior.
print("== dos partidos a la vez con async let ==")
// El instante de salida, con el `reloj` que ya tienes.
let inicioJornada = reloj.now
do {
    // Juega los dos partidos a la vez y recoge los dos resultados en una tupla.
    let (uno, otro) = try await jugarJornada(Ejemplo.rayoContraToros, Ejemplo.lobosContraAguilas)
    // Un «pitido final» por partido: equipos y goles.
    print("Pitido final: \(uno.local.nombre) \(uno.golesLocal)-\(uno.golesVisitante) \(uno.visitante.nombre)")
    print("Pitido final: \(otro.local.nombre) \(otro.golesLocal)-\(otro.golesVisitante) \(otro.visitante.nombre)")
    // Lo que tardó todo.
    print("Los dos partidos duraron \(milisegundos(inicioJornada.duration(to: reloj.now))) ms")
} catch {
    // Solo llega aquí si un partido lanza un error.
    print("Falló la jornada: \(error)")
}

Ejecuta xcrun swift run torneo-cli. Al final de la salida (la parte de esta lección empieza en la línea de título == dos partidos a la vez con async let ==) verás los dos «Pitido final» y la línea de la duración. Compárala con la línea Duró … ms de más arriba, la del partido solo: tiene que parecerse, no ser el doble. Eso es lo que demuestra el async let.

Paso 2 · Tres consultas a la vez

Tres consultas independientes (aquí simuladas con una espera de 50 ms cada una, como si fueran a una base de datos). Cada async let necesita ejecutar varias líneas, así que usas la closure llamada al momento que viste arriba.

Piezas nuevas de este paso:

  • Los nombres que exige main.swift: Estadisticas, un struct que se puede comparar con == (Equatable), con goles, tarjetas y cambios (tres enteros), y estadisticasDe, que recibe el partido sin etiqueta y, con la etiqueta msPorConsulta, lo que tarda cada consulta (50 ms si no se dice).
  • return try await Estadisticas(goles: goles, tarjetas: tarjetas, cambios: cambios): goles, tarjetas y cambios son async let, y leer un async let es lo que lleva await (y try, porque la espera puede fallar). Un solo try await delante cubre las tres lecturas, que se esperan a la vez.

Para contar los goles, las tarjetas y los cambios se usa partido.eventos.count { if case .gol = $0 { true } else { false } }, que es el count con closure y el if case que ya usaste en Partido de eventos.

Intenta escribirlo tú: Estadisticas y estadisticasDe, que hace a la vez tres consultas (cada una espera lo que tarda y cuenta los goles, las tarjetas o los cambios del partido) y las junta. Y el bloque de main.swift que las mide: con el título «== tres consultas a la vez ==», imprime las estadísticas de Rayo contra Toros y cuánto tardaron (o «Falló la consulta:» y el error). Lo que debes ver (unos 50 ms, según la máquina, no 150):

Estadisticas(goles: 3, tarjetas: 1, cambios: 1)
Estadísticas en 53 ms (tres consultas de 50 ms a la vez)
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
Torneo/Sources/Torneo/Simulacion.swift
public struct Estadisticas: Equatable {
    public let goles: Int
    public let tarjetas: Int
    public let cambios: Int
}
 
// Tres consultas independientes (aquí simuladas con una espera de `msPorConsulta`,
// como si fueran a una base de datos). Con `async let` corren a la vez y el total
// es ~1 consulta; una detrás de otra serían ~3.
public func estadisticasDe(_ partido: Partido, msPorConsulta: Int = 50) async throws -> Estadisticas {
    // Consulta 1: espera lo que tarda «la base de datos» y cuenta los goles.
    async let goles: Int = {
        try await Task.sleep(for: .milliseconds(msPorConsulta))
        return partido.eventos.count { if case .gol = $0 { true } else { false } }
    }()
    // Consulta 2: lo mismo con las tarjetas, a la vez que la anterior.
    async let tarjetas: Int = {
        try await Task.sleep(for: .milliseconds(msPorConsulta))
        return partido.eventos.count { if case .tarjeta = $0 { true } else { false } }
    }()
    // Consulta 3: y con los cambios.
    async let cambios: Int = {
        try await Task.sleep(for: .milliseconds(msPorConsulta))
        return partido.eventos.count { if case .cambio = $0 { true } else { false } }
    }()
    // Aquí se esperan las tres a la vez y se juntan en una `Estadisticas`.
    return try await Estadisticas(goles: goles, tarjetas: tarjetas, cambios: cambios)
}
Torneo/Sources/torneo-cli/main.swift
// Un título para separar esta parte.
print("== tres consultas a la vez ==")
do {
    // El instante de salida.
    let inicioEstadisticas = reloj.now
    // Pide las tres consultas a la vez e imprime las estadísticas.
    print(try await estadisticasDe(Ejemplo.rayoContraToros))
    // Tiene que salir cerca de 50 ms, no de 150.
    print("Estadísticas en \(milisegundos(inicioEstadisticas.duration(to: reloj.now))) ms (tres consultas de 50 ms a la vez)")
} catch {
    print("Falló la consulta: \(error)")
}

Al ejecutar verás:

Estadisticas(goles: 3, tarjetas: 1, cambios: 1)
Estadísticas en 53 ms (tres consultas de 50 ms a la vez)

Unos 50 ms (más o menos, según la máquina), no 150.

Punto de control

  • xcrun swift build termina con Build complete!, sin avisos nuevos.
  • Desde ~/Marcador-Swift/Torneo, xcrun swift test sigue terminando con una línea como Test run with 102 tests in 13 suites passed: en esta lección no hay tests nuevos.
  • xcrun swift run torneo-cli muestra, tras sus títulos anteriores, los dos «Pitido final» con una duración parecida a la de un partido solo, y las estadísticas en unos 50 ms.
  • Sabes decir qué hace async let, por qué los dos partidos duran lo que uno solo y para qué sirven las llaves y el () en un async let.

Falta una pieza: jugar una lista de partidos de cualquier tamaño y que uno que falle no tumbe a los demás. Es TaskGroup y errores en tareas hijas.

Comprueba lo que entendiste

4 preguntas · necesitas 3 para completar la lección

  1. 1.Lanzas dos async let, cada uno de una espera de 1 segundo, y luego esperas los dos. ¿Cuánto tarda, aproximadamente, todo?

  2. 2.Dentro de una función lanzas un async let y nunca llegas a esperarlo. ¿Qué hace Swift al salir de la función?

  3. 3.¿Para qué sirven las llaves y el () en async let largas: Int = { … }()?

  4. 4.Tienes async let goles = … y async let tarjetas = …. ¿Dónde va el await para leer los dos a la vez?

Faltan 4 de 4