wandres.dev
ASYNCSEQUENCE · streams asíncronos

AsyncSequence: la secuencia que ocurre en el tiempo

Un protocolo casi calcado de `Sequence` al que una sola palabra —`async` sobre `next`— le cambia la naturaleza: los elementos dejan de estar y pasan a llegar. Desazucarado de `for await`, el papel del iterador, los tipos asociados de Swift 6 y las garantías que no puedes trasladar desde el mundo síncrono.

⏱ 18 min

Una Sequence describe una colección de valores que ya existen o que pueden calcularse sin esperar a nadie: el bucle avanza tan rápido como el procesador se lo permita y termina cuando el iterador devuelve nil. Una AsyncSequence describe algo distinto en su raíz: valores que irán llegando, cada uno a su hora, procedentes de un socket, de un fichero, de un temporizador o de un usuario que todavía no ha pulsado nada. La firma de ambos protocolos se diferencia en una palabra, y sin embargo esa palabra reordena el significado entero del bucle. Donde antes el for era un recorrido, ahora es una espera repetida; donde antes el coste dominante era el cálculo, ahora es la latencia; y donde antes el final llegaba por agotamiento, ahora puede llegar por agotamiento, por error o por cancelación.

🎯 Al terminar esta lección sabrás
  • Leer la definición de AsyncSequence y AsyncIteratorProtocol y justificar cada una de sus diferencias con Sequence.
  • Desazucarar un for await en su bucle equivalente y localizar los puntos de suspensión.
  • Escribir una secuencia asíncrona propia con su iterador y razonar sobre su aislamiento.
  • Identificar qué garantías del mundo síncrono desaparecen: multipaso, conteo, algoritmos gratuitos.

Dos protocolos separados por una palabra

Conviene poner las dos definiciones una junto a otra, porque el parecido es casi literal y la diferencia es exactamente la que importa.

protocol Sequence {
    associatedtype Element
    associatedtype Iterator: IteratorProtocol where Iterator.Element == Element
    func makeIterator() -> Iterator
}

protocol IteratorProtocol {
    mutating func next() -> Element?
}
protocol AsyncSequence {
    associatedtype Element
    associatedtype AsyncIterator: AsyncIteratorProtocol where AsyncIterator.Element == Element
    associatedtype Failure: Error                 // Swift 6, SE-0421
    func makeAsyncIterator() -> AsyncIterator
}

protocol AsyncIteratorProtocol {
    mutating func next() async throws -> Element?
}

El async sobre next es la bisagra. Significa que producir el siguiente elemento es una operación que puede suspender la tarea que la pide: el hilo queda libre, la función se guarda en su continuación y el bucle se reanuda cuando el valor esté disponible. De ahí se deducen las demás diferencias sin necesidad de memorizarlas. next puede lanzar, porque una operación que habla con el mundo puede fallar a mitad del recorrido y no solo antes de empezarlo. Y el resultado sigue siendo un opcional, porque el fin de la secuencia continúa señalándose con nil: un valor, un error o el final son los tres únicos desenlaces posibles de cada iteración.

Swift 6 añadió el tipo asociado Failure con tipado de errores preciso, de forma que una secuencia que no puede fallar declara Failure igual a Never y el compilador te ahorra el try. Añadió también una variante next(isolation:) que permite al iterador heredar el aislamiento del llamante en vez de saltar siempre a un contexto no aislado, lo que elimina copias y saltos de actor en el camino caliente. Ambas son mejoras de precisión sobre un diseño que no cambió de forma.

ℹ️
Por qué AsyncSequence no puede refinar a Sequence

La tentación de escribir que AsyncSequence hereda de Sequence choca con la varianza de los efectos: un requisito síncrono no puede satisfacerse con una implementación asíncrona, porque quien llama a next de forma síncrona no tiene dónde suspender. La relación entre ambos protocolos no es de herencia sino de analogía estructural deliberada: mismos nombres, mismas formas, misma intuición, jerarquías disjuntas.

for await, por dentro

El bucle for await es azúcar sintáctico, y verlo desazucarado despeja casi todas las dudas sobre su comportamiento.

for try await linea in fichero.lines {
    print(linea)
}
// Equivalente conceptual
var it = fichero.lines.makeAsyncIterator()
while let linea = try await it.next() {     // <- punto de suspension en cada vuelta
    print(linea)
}

Tres consecuencias caen de ahí. La primera: hay un punto de suspensión por iteración, no uno por bucle. Entre dos elementos la tarea puede quedar dormida un microsegundo o una hora, y durante ese tiempo el hilo atiende otro trabajo. La segunda: el try es obligatorio si la secuencia puede fallar, y un error interrumpe el bucle propagándose como cualquier otro; el iterador queda abandonado y por contrato no debe volver a consultarse. La tercera: un break o un return terminan la iteración destruyendo el iterador, y esa destrucción es justamente la señal que muchas implementaciones usan para liberar recursos.

Falta el cuarto desenlace, el más característico del mundo asíncrono: la cancelación. Cada await es un punto donde la cancelación de la tarea puede observarse, de modo que un bucle asíncrono es cancelable por construcción sin que el programador escriba nada. Las secuencias bien educadas responden a la cancelación devolviendo nil —terminando limpiamente— o lanzando CancellationError; ambas son legítimas, y conviene saber cuál elige la que estás consumiendo.

sequenceDiagram
participant B as Bucle for await
participant I as AsyncIterator
participant F as Fuente externa
B->>I: next
I->>F: solicita dato
Note over B,I: la tarea suspende y libera el hilo
F-->>I: llega el valor
I-->>B: Optional con elemento
B->>I: next
F-->>I: fin de datos
I-->>B: nil
Note over B: el bucle termina y el iterador se destruye

Escribir la tuya

Nada impide implementar el protocolo a mano, y hacerlo una vez aclara el modelo mejor que cualquier explicación.

struct Latidos: AsyncSequence {
    typealias Element = Int
    let intervalo: Duration
    let total: Int

    struct AsyncIterator: AsyncIteratorProtocol {
        let intervalo: Duration
        let total: Int
        var n = 0

        mutating func next() async -> Int? {
            guard n < total else { return nil }
            try? await Task.sleep(for: intervalo)   // suspension real
            n += 1
            return n
        }
    }

    func makeAsyncIterator() -> AsyncIterator {
        AsyncIterator(intervalo: intervalo, total: total)
    }
}

Obsérvese que el iterador es un struct con estado mutable y que next es mutating: el estado del recorrido vive en el iterador, no en la secuencia, exactamente igual que en el mundo síncrono. La secuencia es una receta; el iterador, una ejecución concreta de esa receta. Por eso crear dos iteradores de Latidos produce dos recorridos independientes que empiezan de cero, y por eso esta secuencia se llama fría: no ocurre nada hasta que alguien la consume.

El aislamiento merece atención. Un AsyncIteratorProtocol no exige Sendable en su iterador, y hace bien: el iterador suele quedarse confinado en la tarea que ejecuta el bucle. La que sí conviene que sea Sendable es la secuencia, porque suele cruzar fronteras para ser consumida en otro sitio. Cuando el iterador captura una referencia compartida —un socket, un actor, un búfer— es ahí donde hay que pensar el aislamiento, no en la firma del protocolo.

🌊

Fría contra caliente

Una secuencia fría produce al ser consumida y desde el principio. Una caliente emite pase lo que pase y quien llega tarde se pierde lo anterior. La distinción no está en el protocolo: está en la implementación.

🎣

El consumidor tira

El bucle pide el siguiente elemento; la fuente no empuja. Ese modelo de tracción es lo que hace que la contrapresión sea expresable, a diferencia de las tuberías basadas en notificaciones.

🚪

Un solo pase

Nada garantiza que puedas iterar dos veces. Muchas implementaciones consumen la fuente al recorrerla y un segundo bucle termina de inmediato o falla.

Lo que no viaja desde Sequence

La analogía es tan buena que induce a confianzas indebidas. Conviene tener presentes cuatro pérdidas.

No hay multipaso. Sequence ya advertía de que iterar podía ser destructivo, pero en la práctica casi todas las secuencias del sistema eran colecciones reiterables. En el mundo asíncrono lo destructivo es la norma: los bytes leídos de un socket no vuelven. No hay count, ni isEmpty, ni índices, porque responder a esas preguntas exigiría consumir la secuencia entera y esperar a que terminara, algo que podría no ocurrir jamás.

No hay algoritmos gratuitos. La biblioteca estándar ofrece map, filter, prefix, dropFirst, compactMap y poco más; todo lo demás —combinar dos secuencias, agrupar por tiempo, eliminar repeticiones consecutivas— vive en swift-async-algorithms o lo escribes tú.

No hay difusión. Una AsyncSequence es, por defecto, una relación entre una fuente y un consumidor. Repartir los mismos elementos entre varios lectores no es una propiedad del protocolo sino un tipo que hay que construir.

Y no hay orden temporal garantizado más allá del propio recorrido: los elementos llegan uno detrás de otro dentro del bucle, pero el trabajo que dispares dentro del cuerpo del bucle sí puede desordenarse si lo lanzas en tareas hijas.

La iteración como negociación entre dos ritmos

Lo que de verdad introduce AsyncSequence no es una forma de escribir bucles sobre datos que tardan, sino un cambio de quién manda en el tiempo. En un for síncrono existe un único ritmo, el del programa: el iterador entrega en cuanto se le pide y el consumidor consume en cuanto recibe, así que hablar de velocidades relativas no tiene sentido porque solo hay una. En cuanto next puede suspender aparecen dos relojes independientes —el de quien produce y el de quien consume— y el bucle deja de ser un recorrido para convertirse en el protocolo de negociación entre ambos. Ahí está la razón profunda de que el diseño sea de tracción y no de empuje: en un modelo de empuje, como el de una tubería de notificaciones o el de Combine antes de la demanda explícita, el productor impone su ritmo y el consumidor solo puede encajarlo, así que cuando el consumidor es más lento la única salida es acumular indefinidamente o descartar en silencio. En un modelo de tracción el consumidor decide cuándo pide el siguiente elemento, y esa decisión se propaga hacia atrás por toda la cadena de transformaciones hasta la fuente: un map asíncrono no ejecuta su transformación hasta que alguien la solicita, y esa cadena de solicitudes es lo que da sentido operativo a la palabra contrapresión. La segunda consecuencia, menos evidente pero igual de central, es que la suspensión convierte el bucle en un objeto cancelable: como cada vuelta pasa por un await, el sistema tiene un punto natural donde comprobar si la tarea sigue siendo necesaria, y por eso terminar un bucle asíncrono desde fuera no requiere banderas compartidas ni comprobaciones manuales sembradas por el código. Un bucle síncrono es, en este sentido, un compromiso irrevocable con el trabajo pendiente; uno asíncrono es una intención revisable en cada elemento. Cuando entiendas for await como una conversación de ritmos revisable en cada vuelta —y no como un for que a veces espera—, el resto del nivel se lee solo: AsyncStream es cómo se enchufa una fuente ajena a esa conversación, las políticas de búfer son qué se hace cuando los ritmos no cuadran, los operadores son cómo se transforma la conversación sin romperla, y onTermination es cómo se cierra con educación.

📝
Lo esencial

AsyncSequence replica la forma de Sequence con un next que puede suspender, fallar y ser cancelado. for await desazucara a un bucle con un punto de suspensión por elemento. El estado del recorrido vive en el iterador; la secuencia es la receta. A cambio de la dimensión temporal pierdes multipaso, conteo, algoritmos gratuitos y difusión.

⚔️ Mide la forma del bucle
  1. Implementa Latidos y comprueba, con marcas de tiempo, que la suspensión ocurre una vez por elemento y no una vez por bucle.
  2. Reescribe un for await como bucle while explícito y localiza a mano los puntos de suspensión y de cancelación.
  3. Crea dos iteradores de la misma secuencia y consúmelos en paralelo: describe si el estado se comparte o se duplica y por qué.
  4. Añade a tu iterador una comprobación explícita de cancelación y compara el comportamiento al cancelar la tarea consumidora con y sin ella.
  5. Declara Failure igual a Never en una secuencia propia y observa qué desaparece en el punto de consumo.