Swift y Objective-C

[Swift avanzado #1] Cómo funciona async/await: no se detiene el hilo

En await se detiene la función, no el hilo. Explicamos el significado exacto de la suspensión: la función guarda su estado en el heap y devuelve el hilo al pool cooperativo, y por qué un await secuencial no es ejecución paralela.

6 min de lectura
Imagen de portada de [Swift avanzado #1] Cómo funciona async/await: no se detiene el hilo

En los artículos sobre manejo de errores y cierres añadí varias veces la salvedad «desde async/await». Este es el artículo principal.

Este es el primer artículo de la serie Swift Concurrency que abre la serie avanzada. Empezamos por qué resuelve exactamente async/await y cómo funciona.

La sintaxis se aprende en un día: añadir async a la función y await a la llamada.

Lo difícil viene después. Preguntas como «¿se detiene el hilo en await?» o «¿en qué hilo se ejecuta una función async?»

Si estas preguntas no tienen una respuesta clara, el código de concurrencia sigue siendo una cuestión de memorizar conjuros.

El objetivo de este artículo es responder con precisión a ambas preguntas.

El verdadero problema de los callbacks: no la sangría, sino la ceguera del compilador

Antes de async/await, el código asíncrono usaba completion handlers, como vimos en el artículo sobre cierres.

A menudo se culpa a la sangría del «infierno de callbacks», pero solo es el síntoma. La raíz es que el compilador no puede ver el flujo.

func loadProfile(completion: @escaping (Result<Profile, Error>) -> Void) {
    fetchUser { result in
        switch result {
        case .success(let user):
            fetchAvatar(user.avatarURL) { avatarResult in
                // completion ¿Qué pasa si olvidas llamarlo? El compilador no lo sabe
            }
        case .failure(let error):
            completion(.failure(error))
        }
    }
}

Aunque omitas completion en una ruta, lo llames dos veces o lo invoques desde el hilo equivocado, el compilador no dice nada.

El resultado de sustituir el concepto básico de retorno del lenguaje por la convención de llamar a un cierre. Las garantías del lenguaje—todas las rutas devuelven un valor y los errores se propagan—pasaron a depender de la disciplina del desarrollador.

async/await devuelve el código asíncrono a la jurisdicción del lenguaje.

func loadProfile() async throws -> Profile {
    let user = try await fetchUser()
    let avatar = try await fetchAvatar(user.avatarURL)
    return Profile(user: user, avatar: avatar)
}

El retorno es return y los errores son throws. El compilador vuelve a comprobar que toda ruta produzca un valor o lance un error.

Las reglas de do-catch y propagación explicadas en el artículo sobre errores funcionan igual en código asíncrono. No es azúcar sintáctico: recupera la visión perdida del compilador.

El significado exacto de la suspensión: se detiene la función, no el hilo

Primera pregunta: ¿se detiene el hilo en await?

No. Se detiene la función y el hilo queda libre para hacer otro trabajo.

Esta distinción es la frase más importante de Swift Concurrency.

En await, la función guarda su estado de progreso—variables locales y punto de ejecución—en el heap, no en la pila, y devuelve el hilo que ocupaba.

Esto se llama suspensión. Cuando termina el trabajo esperado, la ejecución se reanuda con el estado guardado.

No hay garantía de que la reanudación use el mismo hilo. Las líneas anterior y posterior de una misma función pueden ejecutarse en hilos distintos.

Gracias a este diseño, el runtime de concurrencia de Swift usa un pool cooperativo de hilos. Crea aproximadamente tantos hilos como núcleos de CPU, y las funciones se los ceden en cada await.

Esto contrasta con la época de GCD (Grand Central Dispatch), cuando se creaban cientos de hilos y se bloqueaban. Como los hilos no duermen, se reducen estructuralmente las explosiones de hilos y los cambios de contexto innecesarios.

De aquí se deduce una regla práctica: no bloquees en los hilos del pool cooperativo.

Esperar con un semáforo o llamar a sleep duerme por completo un hilo del pool, diseñado para cederlo. Con 4 núcleos y unos 4 hilos, dormir uno equivale a detener una cuarta parte del sistema.

«await cede; bloquear está prohibido» es la primera regla del pool cooperativo.

Ilustración de una función que guarda su estado en una bolsa en la parada await y devuelve el hilo
En await, la función recoge sus cosas y el hilo se devuelve para otro trabajo

En qué hilo se ejecuta: pregunta por el aislamiento, no por los hilos

Segunda pregunta: ¿en qué hilo se ejecuta una función async?

La respuesta de Swift Concurrency es «descarta esa pregunta». Los hilos son recursos gestionados por el runtime; el desarrollador especifica el aislamiento, es decir, «¿a qué contexto de ejecución serial pertenece este código?»

El aislamiento representativo es MainActor. Las funciones y tipos marcados con @MainActor tienen garantizada la ejecución en el hilo principal.

Es distinto de esparcir DispatchQueue.main.async por el código de actualización de UI a ojo. El requisito «solo en el principal» se declara en el sistema de tipos y el compilador lo comprueba.

UIViewController y SwiftUI View ya están declarados con @MainActor. El código de las vistas recibe automáticamente aislamiento principal.

En cambio, una función async sin indicador de aislamiento no pertenece a ningún actor concreto (nonisolated) y se ejecuta en algún punto del pool cooperativo.

Para sacar un cálculo pesado del principal no hay que escribir código que lo «envíe a un hilo en segundo plano». En Swift, se declara que ese trabajo no pertenece al aislamiento de MainActor.

Cuando hace falta, abre un contexto asíncrono nuevo con Task. (El uso estructurado de Task se trata en el artículo 4 de esta serie.)

El lenguaje también proporciona un puente hacia las API de callbacks existentes. withCheckedThrowingContinuation permite envolver una API de callback como función async.

Llamar exactamente una vez a resume de continuation es el contrato, y la versión «Checked» detecta sus incumplimientos en runtime. Es la primera herramienta de bridging que conviene dominar al trabajar con SDK heredados.

La trampa del await secuencial: la concurrencia no es gratis

El código loadProfile anterior contiene una trampa de rendimiento. ¿Qué ocurre si la otra solicitud no está relacionada con fetchUser?

// Secuencial: el avatar empieza cuando termina el banner (total:  2s)
let avatar = try await fetchAvatar()   // 1s
let banner = try await fetchBanner()   // 1s

await significa «esperar aquí», así que escrito de esta forma las dos solicitudes se ejecutan en serie. Si las tareas no dependen entre sí, hay que iniciarlas simultáneamente con async let.

// Concurrente: las dos solicitudes se ejecutan juntas (total:  1s)
async let avatar = fetchAvatar()
async let banner = fetchBanner()
let profile = try await Profile(avatar: avatar, banner: banner)

async/await no paraleliza automáticamente. Elegir entre ejecución secuencial y concurrente sigue siendo responsabilidad del diseñador.

La mejora es que esa elección se convirtió en una diferencia sintáctica de una línea, en lugar de composición de callbacks. El uso sistemático de async let y TaskGroup se trata a fondo en el artículo sobre concurrencia estructurada.

Diagrama temporal que contrasta 2 segundos con await secuencial y 1 segundo con async let
await no paraleliza automáticamente; inicia las tareas independientes simultáneamente con async let

Resumen

  • El verdadero problema de los callbacks no era la sangría, sino la ceguera del compilador. async/await devuelve los retornos y la propagación de errores a la jurisdicción del lenguaje, restaurando las comprobaciones del compilador.
  • En await se detiene la función, no el hilo. El estado se guarda en el heap, el hilo se devuelve y no se garantiza que la reanudación use el mismo hilo.
  • El runtime es un pool cooperativo de hilos. Por eso está prohibido bloquear dentro de él (semáforos, sleep).
  • No se pregunta «¿en qué hilo?», sino «¿en qué aislamiento?». La UI se garantiza con declaraciones @MainActor y las API de callbacks se envuelven con continuations.
  • await no paraleliza automáticamente. Inicia las tareas independientes simultáneamente con async let.

El próximo artículo trata el núcleo de esta serie: actor. Explica qué es exactamente una carrera de datos, cómo actor la convierte en un concepto de tiempo de compilación y la famosa trampa de reentrancy.

Seguir leyendo