Futures y la sintaxis async

Los elementos clave de la programación asíncrona en Rust son los futures y las palabras clave async y await.

Un future es un valor que puede no estar listo ahora, pero que estará listo en algún momento en el futuro. (Este mismo concepto aparece en muchos lenguajes, a veces bajo otros nombres como “tarea” o “promesa”.) Rust proporciona un trait Future como un bloque de construcción para que diferentes operaciones asíncronas puedan implementarse con diferentes estructuras de datos, pero con una interfaz común. En Rust, decimos que los tipos que implementan el trait Future son futuros. Cada tipo que implementa Future contiene su propia información sobre el progreso que se ha hecho y lo que significa estar "listo".

La palabra clave async se puede aplicar a bloques y funciones para especificar que pueden ser interrumpidos y reanudados. Dentro de un bloque asíncrono o una función asíncrona, puedes usar la palabra clave await para esperar a que un futuro esté listo, pudiendo esperar un futuro. Cada lugar donde esperas un futuro dentro de un bloque o función asíncrona es un lugar donde ese bloque o función asíncrona puede ser pausado y reanudado. El proceso de comprobar con un futuro para ver si su valor está disponible se llama polling.

Algunos otros lenguajes también utilizan las palabras clave async y await para la programación asíncrona. Si estás familiarizado con esos lenguajes, puede que notes algunas diferencias significativas en cómo Rust hace las cosas, incluyendo cómo maneja la sintaxis. ¡Por una buena razón, como veremos!

La mayor parte del tiempo al escribir Rust asíncrono, usamos las palabras clave async y await. Rust las compila en código equivalente utilizando el trait Future, al igual que compila los bucles for en código equivalente utilizando el trait Iterator. Sin embargo, como Rust proporciona el trait Future, puedes implementarlo para tus propios tipos de datos cuando sea necesario. Muchas de las funciones que veremos a lo largo de este capítulo devuelven tipos con sus propias implementaciones de Future. Volveremos a la definición del trait al final del capítulo y profundizaremos más en cómo funciona, pero este es suficiente detalle para seguir avanzando.

Todo esto puede parecer un poco abstracto. Escribamos nuestro primer programa asíncrono: un pequeño web scraper. Pasaremos dos URLs desde la línea de comandos, obtendremos ambos de forma concurrente y devolveremos el resultado de aquel que termine primero. Este ejemplo tendrá un poco de nueva sintaxis, pero no te preocupes. Explicaremos todo lo que necesitas saber a medida que avanzamos.

Nuestro primer programa asíncrono

Para mantener este capítulo centrado en aprender lo asíncrono, en lugar de manejar partes del ecosistema, hemos creado el crate trpl (trpl es la abreviatura de “The Rust Programming Language”). Re-exporta todos los tipos, traits y funciones que necesitarás, principalmente de los crates futures y tokio.

  • El crate futures es un hogar oficial para la experimentación de Rust para el código asíncrono, y es en realidad donde el tipo Future fue diseñado originalmente.

  • Tokio es el runtime asíncrono más utilizado en Rust hoy en día, especialmente (¡pero no solo!) para aplicaciones web. Hay otros runtimes geniales por ahí, y pueden ser más adecuados para tus propósitos. Usamos Tokio bajo el capó para trpl porque está bien probado y ampliamente utilizado.

En algunos casos, trpl también renombra o envuelve las APIs originales para permitirnos mantenernos enfocados en los detalles relevantes para este capítulo. Si quieres entender qué hace el crate, te animamos a que eches un vistazo a su código fuente. Podrás ver de qué crate proviene cada re-exportación, y hemos dejado extensos comentarios explicando qué hace el crate.

Crea un nuevo proyecto binario llamado hello-async y añade el crate trpl como dependencia:

$ cargo new hello-async
$ cd hello-async
$ cargo add trpl

Ahora podemos usar las diversas piezas proporcionadas por trpl para escribir nuestro primer programa asíncrono. Construiremos una pequeña herramienta de línea de comandos que obtiene dos páginas web, extrae el elemento <title> de cada una e imprime el título de aquella que termine todo el proceso primero.

Empecemos escribiendo una función que toma una URL de página como parámetro, hace una petición a ella y devuelve el texto del elemento título:

extern crate trpl; // required for mdbook test

fn main() {
    // TODO: we'll add this next!
}

use trpl::Html;

async fn page_title(url: &str) -> Option<String> {
    let response = trpl::get(url).await;
    let response_text = response.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title_element| title_element.inner_html())
}

En el Listado 17-1, definimos una función llamada page_title, y la marcamos con la palabra clave async. Luego usamos la función trpl::get para obtener cualquier URL que se pase, y esperamos la respuesta usando la palabra clave await. Luego obtenemos el texto de la respuesta llamando a su método text, y una vez más lo esperamos con la palabra clave await. Ambos pasos son asíncronos. Para get, necesitamos esperar a que el servidor envíe la primera parte de su respuesta, que incluirá cabeceras HTTP, cookies, etc. Esa parte de la respuesta puede entregarse por separado del cuerpo de la petición. Especialmente si el cuerpo es muy grande, puede llevar algo de tiempo que todo llegue. Por lo tanto, tenemos que esperar a que toda la respuesta llegue, por lo que el método text también es asíncrono.

Tenemos que esperar explícitamente ambos de estos futuros, porque los futuros en Rust son perezosos: no hacen nada hasta que les pides con await. (De hecho, Rust mostrará una advertencia del compilador si no usas un futuro.) Esto debería recordarte nuestra discusión de los iteradores en el Capítulo 13. Los iteradores no hacen nada a menos que llames a su método next—ya sea directamente, o usando bucles for o métodos como map que usan next bajo el capó. Con los futuros, se aplica la misma idea básica: no hacen nada a menos que les pidas explícitamente. Esta estrategia de ejecución perezosa permite a Rust evitar ejecutar código asíncrono hasta que realmente sea necesario.

Nota: Esto es diferente del comportamiento que vimos al usar thread::spawn en el capítulo anterior, donde la closure que pasamos a otro hilo comenzó a ejecutarse inmediatamente. ¡También es diferente de cómo muchos otros lenguajes abordan lo asíncrono! Pero es importante para Rust. Veremos por qué es así más adelante.

Una vez que tenemos response_text, podemos analizarlo en una instancia del tipo Html usando Html::parse. En lugar de una cadena en bruto, ahora tenemos un tipo de datos con el que podemos trabajar con el HTML como una estructura de datos más rica. En particular, podemos usar el método select_first para encontrar la primera instancia de un selector CSS dado. Pasando la cadena "title", obtendremos el primer elemento <title> en el documento, si lo hay. Dado que puede que no haya ningún elemento coincidente, select_first devuelve un Option<ElementRef>. Finalmente, usamos el método Option::map, que nos permite trabajar con el elemento en el Option si está presente, y no hacer nada si no lo está. (También podríamos usar una expresión match aquí, pero map es más idiomático.) En el cuerpo de la función que proporcionamos a map, llamamos a inner_html en el title_element para obtener su contenido, que es un String. Cuando todo está dicho y hecho, tenemos un Option<String>.

Observa que la palabra clave await de Rust va después de la expresión que estás esperando, no antes. Es decir, es una palabra clave posfija. Esto puede ser diferente de lo que estás acostumbrado si has usado asíncrono en otros lenguajes. Rust eligió esto porque hace que las cadenas de métodos sean mucho más agradables de trabajar. Como resultado, podemos cambiar el cuerpo de page_url_for para encadenar las llamadas a las funciones trpl::get y text juntas con await entre ellas, como se muestra en el Listado 17-2:

extern crate trpl; // required for mdbook test

use trpl::Html;

fn main() {
    // TODO: we'll add this next!
}

async fn page_title(url: &str) -> Option<String> {
    let response_text = trpl::get(url).await.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title_element| title_element.inner_html())
}

¡Con eso, hemos escrito con éxito nuestra primera función asíncrona! Antes de añadir algo de código en main para llamarla, hablemos un poco más sobre lo que hemos escrito y lo que significa.

Cuando Rust ve un bloque marcado con la palabra clave async, lo compila en un tipo de datos único y anónimo que implementa el trait Future. Cuando Rust ve una función marcada con async, la compila en una función no asíncrona cuyo cuerpo es un bloque asíncrono. El tipo de retorno de una función asíncrona es el tipo del tipo de datos anónimo que el compilador crea para ese bloque asíncrono.

Por lo tanto, escribir async fn es equivalente a escribir una función que devuelve un futuro del tipo de retorno. Cuando el compilador ve una definición de función como la async fn page_title en el Listado 17-1, es equivalente a una función no asíncrona definida de la siguiente manera:

#![allow(unused)]
fn main() {
extern crate trpl; // requerido para mdbook test
use std::future::Future;
use trpl::Html;

fn page_title(url: &str) -> impl Future<Output = Option<String>> + '_ {
    async move {
        let text = trpl::get(url).await.text().await;
        Html::parse(&text)
            .select_first("title")
            .map(|title| title.inner_html())
    }
}
}

Veamos cada parte de la versión transformada:

  • Utiliza la sintaxis impl Trait que discutimos en la sección “Traits como parámetros” en el Capítulo 10.
  • El trait devuelto es un Future, con un tipo asociado de Output. Observa que el tipo Output es Option<String>, que es el mismo que el tipo de retorno original de la versión async fn de page_title.
  • Todo el código llamado en el cuerpo de la función original está envuelto en un bloque async move. Recuerda que los bloques son expresiones. Todo este bloque es la expresión devuelta por la función.
  • Este bloque asíncrono produce un valor con el tipo Option<String>, como se describió anteriormente. Ese valor coincide con el tipo Output en el tipo de retorno. Esto es igual que otros bloques que has visto.
  • El nuevo cuerpo de la función es un bloque async move debido a cómo usa el parámetro url. (Hablaremos mucho más sobre async vs. async move más adelante en el capítulo.)
  • La nueva versión de la función tiene un tipo de duración que no hemos visto antes en el tipo de salida: '_. Debido a que la función devuelve un Future que se refiere a una referencia —en este caso, la referencia del parámetro url— necesitamos decirle a Rust que queremos que esa referencia esté incluida. No tenemos que nombrar la duración aquí, porque Rust es lo suficientemente inteligente como para saber que solo hay una referencia que podría estar involucrada, pero tenemos que ser explícitos en que el Future resultante está vinculado por esa duración.

Ahora podemos llamar a page_title en main. Para empezar, solo obtendremos el título de una sola página. En el Listado 17-3, seguimos el mismo patrón que usamos para obtener los argumentos de la línea de comandos en el Capítulo 12. Luego pasamos la primera URL a page_title, y esperamos el resultado. Dado que el valor producido por el futuro es un Option<String>, usamos una expresión match para imprimir diferentes mensajes para tener en cuenta si la página tenía un <title>.

extern crate trpl; // required for mdbook test

use trpl::Html;

async fn main() {
    let args: Vec<String> = std::env::args().collect();
    let url = &args[1];
    match page_title(url).await {
        Some(title) => println!("The title for {url} was {title}"),
        None => println!("{url} had no title"),
    }
}

async fn page_title(url: &str) -> Option<String> {
    let response_text = trpl::get(url).await.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title_element| title_element.inner_html())
}

Desafortunadamente, esto no compila. El único lugar donde podemos usar la palabra clave await es en funciones o bloques asíncronos, y Rust no nos permitirá marcar la función especial main como async.

error[E0752]: `main` function is not allowed to be `async`
 --> src/main.rs:6:1
  |
6 | async fn main() {
  | ^^^^^^^^^^^^^^^ `main` function is not allowed to be `async`

La razón por la que main no puede ser marcada como async es que el código asíncrono necesita un runtime: un crate de Rust que gestiona los detalles de la ejecución de código asíncrono. La función main de un programa puede inicializar un runtime, pero no es un runtime en sí mismo. (Veremos más sobre por qué esto es un poco más adelante.) Cada programa de Rust que ejecuta código asíncrono tiene al menos un lugar donde configura un runtime y ejecuta los futuros.

La mayoría de los lenguajes que admiten asíncrono incluyen un runtime con el lenguaje. Rust no lo hace. En su lugar, hay muchos runtimes asíncronos disponibles, cada uno de los cuales hace diferentes compensaciones adecuadas para el caso de uso al que se dirigen. Por ejemplo, un servidor web de alto rendimiento con muchos núcleos de CPU y una gran cantidad de RAM tiene necesidades muy diferentes a las de un microcontrolador con un solo núcleo, una pequeña cantidad de RAM y sin capacidad para hacer asignaciones en el montón. Los crates que proporcionan esos runtimes también suelen suministrar versiones asíncronas de funcionalidades comunes como la E/S de archivos o de red.

Aquí, y a lo largo del resto de este capítulo, usaremos la función run del crate trpl, que toma un futuro como argumento y lo ejecuta hasta su finalización. Detrás de escena, llamar a run configura un runtime para usarlo para ejecutar el futuro pasado. Una vez que el futuro se completa, run devuelve cualquier valor que el futuro haya producido.

Podríamos pasar el futuro devuelto por page_title directamente a run. Una vez completado, podríamos hacer una coincidencia en el Option<String> resultante, de la misma manera que intentamos hacer en el Listado 17-3. Sin embargo, para la mayoría de los ejemplos en el capítulo (¡y la mayoría del código asíncrono en el mundo real!), haremos más que una sola llamada a función asíncrona, por lo que en su lugar pasaremos un bloque async y esperaremos explícitamente el resultado de llamar a page_title, como en el Listado 17-4.

extern crate trpl; // required for mdbook test

use trpl::Html;

fn main() {
    let args: Vec<String> = std::env::args().collect();

    trpl::run(async {
        let url = &args[1];
        match page_title(url).await {
            Some(title) => println!("The title for {url} was {title}"),
            None => println!("{url} had no title"),
        }
    })
}

async fn page_title(url: &str) -> Option<String> {
    let response_text = trpl::get(url).await.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title_element| title_element.inner_html())
}

Cuando ejecutamos esto, obtenemos el comportamiento que podríamos haber esperado inicialmente:

$ cargo run -- https://www.rust-lang.org
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.05s
     Running `target/debug/async_await 'https://www.rust-lang.org'`
The title for https://www.rust-lang.org was
            Rust Programming Language

¡Uf! ¡Finalmente tenemos algo de código asíncrono funcional! Ahora compila, y podemos ejecutarlo. Antes de añadir código para competir dos sitios entre sí, volvamos brevemente nuestra atención a cómo funcionan los futuros.

Cada punto de espera — es decir, cada lugar donde el código usa la palabra clave await — representa un lugar donde el control se devuelve al runtime. Para que esto funcione, Rust necesita hacer un seguimiento del estado involucrado en el bloque asíncrono, para que el runtime pueda iniciar otro trabajo y luego volver cuando esté listo para intentar avanzar en este de nuevo. Esta es una máquina de estados invisible, como si escribieras un enum de esta manera para guardar el estado actual en cada punto de await:

#![allow(unused)]
fn main() {
extern crate trpl; // required for mdbook test

enum PageTitleFuture<'a> {
    Initial { url: &'a str },
    GetAwaitPoint { url: &'a str },
    TextAwaitPoint { response: trpl::Response },
}
}

Escribir el código para la transición entre cada estado a mano sería tedioso y propenso a errores, especialmente al añadir más funcionalidad y más estados al código más adelante. En su lugar, el compilador de Rust crea y gestiona las estructuras de datos de la máquina de estados para el código asíncrono automáticamente. Si te lo estás preguntando: sí, las reglas normales de préstamo y propiedad en torno a las estructuras de datos se aplican. Afortunadamente, el compilador también se encarga de comprobarlas por nosotros, y tiene buenos mensajes de error. ¡Trabajaremos a través de algunos de esos más tarde en el capítulo!

En última instancia, algo tiene que ejecutar esa máquina de estados. Eso algo es un runtime. (Es por eso que a veces puedes encontrarte con referencias a ejecutores al investigar runtimes: un ejecutor es la parte de un runtime responsable de ejecutar el código asíncrono.)

Ahora podemos entender por qué el compilador nos impidió hacer que main en sí fuera una función asíncrona en el Listado 17-3. Si main fuera una función asíncrona, algo más tendría que gestionar la máquina de estados para cualquier futuro que main devolviera, ¡pero main es el punto de inicio del programa! En su lugar, llamamos a la función trpl::run en main, que configura un runtime y ejecuta el futuro devuelto por el bloque async hasta que devuelva Ready.

Nota: algunos runtimes proporcionan macros para que puedas escribir una función main asíncrona. Esos macros reescriben async fn main() { ... } para ser un fn main normal que hace lo mismo que hicimos a mano en el Listado 17-5: llamar a una función que ejecuta un futuro hasta su finalización de la misma manera que trpl::run hace.

Pongamos estas piezas juntas y veamos cómo podemos escribir código concurrente, llamando a page_title con dos URLs diferentes pasadas desde la línea de comandos y compitiéndolas.

extern crate trpl; // required for mdbook test

use trpl::{Either, Html};

fn main() {
    let args: Vec<String> = std::env::args().collect();

    trpl::run(async {
        let title_fut_1 = page_title(&args[1]);
        let title_fut_2 = page_title(&args[2]);

        let (url, maybe_title) =
            match trpl::race(title_fut_1, title_fut_2).await {
                Either::Left(left) => left,
                Either::Right(right) => right,
            };

        println!("{url} returned first");
        match maybe_title {
            Some(title) => println!("Its page title is: '{title}'"),
            None => println!("Its title could not be parsed."),
        }
    })
}

async fn page_title(url: &str) -> (&str, Option<String>) {
    let text = trpl::get(url).await.text().await;
    let title = Html::parse(&text)
        .select_first("title")
        .map(|title| title.inner_html());
    (url, title)
}

En el Listado 17-5, comenzamos llamando a page_title para cada una de las URLs proporcionadas por el usuario. Guardamos los futuros producidos al llamar a page_title como title_fut_1 y title_fut_2. Recuerda, estos todavía no hacen nada, porque los futuros son perezosos, y aún no los hemos esperado. Luego pasamos los futuros a trpl::race, que devuelve un valor para indicar cuál de los futuros pasados a él termina primero.

Nota: Bajo el capó, race está construido sobre una función más general, select, que encontrarás más a menudo en el código de Rust del mundo real. Una función select puede hacer muchas cosas que la función trpl::race no puede, pero también tiene cierta complejidad adicional que podemos omitir por ahora.

Cualquiera de los futuros puede “ganar” legítimamente, por lo que no tiene sentido devolver un Result. En su lugar, race devuelve un tipo que no hemos visto antes, trpl::Either. El tipo Either es algo similar a un Result, en que tiene dos casos. A diferencia de Result, sin embargo, no hay noción de éxito o fracaso integrada en Either. En su lugar, usa Left y Right para indicar “uno u otro”.

#![allow(unused)]
fn main() {
enum Either<A, B> {
    Left(A),
    Right(B),
}
}

La función race devuelve Left si el primer argumento termina primero, con la salida de ese futuro, y Right con la salida del segundo argumento futuro si ese termina primero. Esto coincide con el orden en que aparecen los argumentos cuando se llama a la función: el primer argumento está a la izquierda del segundo argumento.

También actualizamos page_title para devolver la misma URL pasada. De esa manera, si la página que se devuelve primero no tiene un <title> que podamos resolver, aún podemos imprimir un mensaje significativo. Con esa información disponible, terminamos actualizando nuestra salida de println! para indicar tanto qué URL terminó primero como cuál fue el <title> de la página web en esa URL, si lo hay.

¡Has construido un pequeño scrapper web funcional ahora! Elige un par de URLs y ejecuta la herramienta de línea de comandos. Puedes descubrir que algunos sitios son confiablemente más rápidos que otros, mientras que en otros casos qué sitio “gana” varía de una ejecución a otra. Más importante aún, has aprendido los conceptos básicos de trabajar con futuros, por lo que ahora podemos profundizar en aún más de las cosas que podemos hacer con asíncrono.