Основы асинхронного программирования: Async, Await, Futures и Streams

Futures и синтаксис Async

Ключевыми элементами асинхронного программирования в Rust являются futures, а также ключевые слова Rust async и await.

Future (футур) — это значение, которое может быть не готово сейчас, но станет готовым в какой-то момент в будущем. (Этот же концепт встречается во многих языках, иногда под другими названиями, такими как задача (task) или промис (promise).) Rust предозывает типаж Future в качестве строительного блока, чтобы различные асинхронные операции могли быть реализованы с помощью разных структур данных, но с общим интерфейсом. В Rust фьючерсы — это типы, которые реализуют типаж Future. Каждый фьючерс хранит свою собственную информацию о том, какой прогресс был достигнут и что означает "готово".

Вы можете применить ключевое слово async к блокам и функциям, чтобы указать, что их можно приостанавливать и возобновлять. Внутри async-блока или async-функции вы можете использовать ключевое слово .await для ожидания фьючерса (то есть, чтобы дождаться его готовности). Любая точка, в которой вы ожидаете (await) фьючерс внутри async-блока или функции, является потенциальным местом для приостановки и возобновления этого блока или функции. Процесс проверки фьючерса на доступность его значения называется опросом (polling).

В некоторых других языках, таких как C# и JavaScript, также используются ключевые слова async и await для асинхронного программирования. Если вы знакомы с этими языками, вы можете заметить существенные различия в том, как Rust обрабатывает синтаксис. На это есть веские причины, как мы увидим!

При написании асинхронного кода на Rust мы большую часть времени используем ключевые слова async и .await. Rust компилирует их в эквивалентный код, использующий типаж Future, подобно тому, как он компилирует циклы for в эквивалентный код, использующий типаж Iterator. Однако, поскольку Rust предоставляет типаж Future, вы также можете реализовать его для своих собственных типов данных, когда это необходимо. Многие функции, которые мы увидим в этой главе, возвращают типы с их собственными реализациями Future. Мы вернемся к определению этого типажа в конце главы и углубимся в детали его работы, но пока этого достаточно, чтобы двигаться дальше.

Всё это может показаться немного абстрактным, поэтому давайте напишем нашу первую асинхронную программу: небольшой веб-скрапер. Мы передадим два URL-адреса из командной строки, загрузим их оба конкурентно и вернем результат того, который завершится первым. В этом примере будет довольно много нового синтаксиса, но не волнуйтесь — мы объясним всё, что нужно знать, по мере его появления.

Наша первая асинхронная программа

Чтобы сосредоточить внимание этой главы на изучении async, а не на работе с различными частями экосистемы, мы создали крейт trpl (сокращение от "The Rust Programming Language"). Он реэкспортирует все типы, типажи и функции, которые вам понадобятся, в основном из крейтов futures и tokio. Крейт futures — это официальная площадка для экспериментов с асинхронным кодом в Rust, и именно там изначально был разработан типаж Future. Tokio — это наиболее широко используемый асинхронный рантайм в Rust на сегодняшний день, особенно для веб-приложений. Существуют и другие отличные рантаймы, которые могут лучше подходить для ваших целей. Мы используем крейт tokio внутри trpl, потому что он хорошо протестирован и широко используется.

В некоторых случаях trpl также переименовывает или оборачивает оригинальные API, чтобы помочь вам сосредоточиться на деталях, relevantных для этой главы. Если вы хотите понять, что делает этот крейт, мы рекомендуем ознакомиться с его исходным кодом. Вы сможете увидеть, из какого крейта происходит каждый реэкспорт, и мы оставили подробные комментарии, объясняющие, что делает крейт.

Создайте новый бинарный проект с именем hello-async и добавьте крейт trpl в качестве зависимости:

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

Теперь мы можем использовать различные компоненты, предоставляемые trpl, чтобы написать нашу первую асинхронную программу. Мы создадим небольшой инструмент командной строки, который загружает две веб-страницы, извлекает элемент <title> из каждой и печатает заголовок той страницы, которая первой завершит весь этот процесс.

Определение функции page_title

Давайте начнем с написания функции, которая принимает один URL-адрес страницы в качестве параметра, делает к нему запрос и возвращает текст элемента <title> (см. Листинг 17-1).

Файл: src/main.rs

#![allow(unused)]
fn main() {
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| title.inner_html())
}
}

Листинг 17-1: Определение асинхронной функции для получения элемента title из HTML-страницы

Сначала мы определяем функцию с именем page_title и помечаем ее ключевым словом async. Затем мы используем функцию trpl::get для загрузки любого переданного URL-адреса и добавляем ключевое слово .await для ожидания ответа. Чтобы получить текст ответа, мы вызываем его метод text и снова ожидаем его с помощью ключевого слова .await. Оба этих шага являются асинхронными. Для функции get нам приходится ждать, пока сервер отправит обратно первую часть своего ответа, которая будет включать HTTP-заголовки, куки и так далее и может доставляться отдельно от тела ответа. Особенно если тело очень большое, его полная передача может занять некоторое время. Поскольку нам нужно дождаться полного поступления ответа, метод text также является асинхронным.

Нам нужно явно ожидать оба этих фьючерса, потому что фьючерсы в Rust ленивы (lazy): они ничего не делают, пока вы не попросите их об этом с помощью ключевого слова .await. (На самом деле, Rust покажет предупреждение компилятора, если вы не используете фьючерс.) Это может напомнить вам обсуждение итераторов в разделе "Обработка последовательности элементов с помощью итераторов" Главы 13. Итераторы ничего не делают, если вы не вызываете их метод next — напрямую или с помощью циклов for, или методов, таких как map, которые используют next внутри. Точно так же фьючерсы ничего не делают, если вы явно не попросите их об этом. Эта ленивость позволяет Rust избежать выполнения асинхронного кода до тех пор, пока он действительно не понадобится.

Примечание: Это поведение отличается от того, что мы видели при использовании thread::spawn в разделе "Создание нового потока с помощью spawn" Главы 16, где переданное в другой поток замыкание начинало выполняться немедленно. Это также отличается от подхода ко async во многих других языках. Но это важно для возможности Rust предоставлять свои гарантии производительности, так же, как и в случае с итераторами.

Как только у нас есть response_text, мы можем разобрать его в экземпляр типа Html с помощью Html::parse. Теперь у нас есть тип данных, который мы можем использовать для работы с HTML как с более богатой структурой данных. В частности, мы можем использовать метод select_first, чтобы найти первый экземпляр заданного CSS-селектора. Передав строку "title", мы получим первый элемент <title> в документе, если он есть. Поскольку может не быть ни одного подходящего элемента, select_first возвращает Option<ElementRef>. Наконец, мы используем метод Option::map, который позволяет нам работать с элементом внутри Option, если он присутствует, и ничего не делать, если его нет. (Мы могли бы также использовать здесь выражение match, но map является более идиоматичным.) В теле функции, которую мы передаем в map, мы вызываем inner_html для заголовка, чтобы получить его содержимое, которое имеет тип String. В конечном итоге у нас получается Option<String>.

Обратите внимание, что ключевое слово .await в Rust ставится после выражения, которое вы ожидаете, а не перед ним. То есть, это постфиксное ключевое слово. Это может отличаться от того, к чему вы привыкли, если использовали async в других языках, но в Rust это делает цепочки методов гораздо удобнее. В результате мы могли бы изменить тело page_title, чтобы объединить вызовы функций trpl::get и text вместе с .await между ними, как показано в Листинге 17-2.

Файл: src/main.rs

#![allow(unused)]
fn main() {
    let response_text = trpl::get(url).await.text().await;
}

Листинг 17-2: Цепочка вызовов с ключевым словом await

Таким образом, мы успешно написали нашу первую асинхронную функцию! Прежде чем мы добавим код в main для ее вызова, давайте немного поговорим о том, что мы написали и что это означает.

Когда Rust видит блок, помеченный ключевым словом async, он компилирует его в уникальный, анонимный тип данных, который реализует типаж Future. Когда Rust видит функцию, помеченную async, он компилирует ее в не-асинхронную функцию, тело которой представляет собой async-блок. Тип возвращаемого значения асинхронной функции — это тип анонимного типа данных, который компилятор создает для этого async-блока.

Таким образом, написание async fn эквивалентно написанию функции, которая возвращает фьючерс типа возвращаемого значения. Для компилятора определение функции, такое как async fn page_title в Листинге 17-1, примерно эквивалентно не-асинхронной функции, определенной следующим образом:

#![allow(unused)]
fn main() {
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())
    }
}
}

Давайте разберем каждую часть преобразованной версии:

  • Она использует синтаксис impl Trait, который мы обсуждали в Главе 10 в разделе "Traits as Parameters".
  • Возвращаемое значение реализует типаж Future с ассоциированным типом Output. Обратите внимание, что тип Output — это Option<String>, который совпадает с исходным типом возвращаемого значения из асинхронной версии page_title.
  • Весь код, вызываемый в теле исходной функции, обернут в блок async move. Помните, что блоки являются выражениями. Весь этот блок является выражением, возвращаемым из функции.
  • Этот async-блок производит значение с типом Option<String>, как только что было описано. Это значение соответствует типу Output в возвращаемом типе. Это похоже на другие блоки, которые вы видели.
  • Новое тело функции представляет собой блок async move из-за того, как оно использует параметр url. (Мы поговорим гораздо больше о async против async move позже в этой главе.)

Теперь мы можем вызвать page_title в main.

Выполнение асинхронной функции с помощью рантайма

Для начала мы получим заголовок для одной страницы, как показано в Листинге 17-3. К сожалению, этот код пока не компилируется.

Файл: src/main.rs

// [Этот код не компилируется!]
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"),
    }
}

Листинг 17-3: Вызов функции page_title из main с аргументом, предоставленным пользователем

Мы следуем тому же шаблону, который использовали для получения аргументов командной строки в разделе ["Принятие аргументов командной строки"] Главы 12. Затем мы передаем аргумент URL в page_title и ожидаем (await) результат. Поскольку значение, производимое фьючерсом, — это Option<String>, мы используем выражение match для вывода разных сообщений в зависимости от того, была ли у страницы <title>.

Единственное место, где мы можем использовать ключевое слово .await, — это асинхронные функции или блоки, и Rust не позволяет нам пометить специальную функцию main как 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`

Причина, по которой main не может быть помечена как async, заключается в том, что асинхронному коду нужен рантайм (runtime): крейт Rust, который управляет деталями выполнения асинхронного кода. Функция main программы может инициализировать рантайм, но сама по себе рантаймом не является. (Мы вскоре увидим подробнее, почему это так.) Каждая программа Rust, выполняющая асинхронный код, имеет как минимум одно место, где она настраивает рантайм для выполнения фьючерсов.

В большинстве языков, поддерживающих async, рантайм поставляется в комплекте, но в Rust — нет. Вместо этого доступно множество различных асинхронных рантаймов, каждый из которых предлагает разные компромиссы, подходящие для целевых сценариев использования. Например, высокопроизводительный веб-сервер с множеством ядер CPU и большим объемом оперативной памяти имеет совершенно другие потребности, чем микроконтроллер с одним ядром, небольшим объемом RAM и без возможности выделения памяти в куче. Крейты, предоставляющие эти рантаймы, также часто поставляют асинхронные версии распространенной функциональности, такой как файловый или сетевой ввод-вывод.

Здесь и на протяжении оставшейся части главы мы будем использовать функцию block_on из крейта trpl, которая принимает фьючерс в качестве аргумента и блокирует (blocks) текущий поток до тех пор, пока этот фьючерс не выполнится до конца. За кулисами вызов block_on настраивает рантайм с использованием крейта tokio, который используется для запуска переданного фьючерса (поведение block_on в крейте trpl похоже на функции block_on в других крейтах-рантаймах). Как только фьючерс завершается, block_on возвращает то значение, которое произвел фьючерс.

Мы могли бы передать фьючерс, возвращенный page_title, напрямую в block_on, и, как только он завершится, мы могли бы сопоставить результат Option<String>, как мы пытались сделать в Листинге 17-3. Однако для большинства примеров в этой главе (и для большей части асинхронного кода в реальном мире) мы будем делать больше, чем просто один вызов асинхронной функции, поэтому вместо этого мы передадим асинхронный блок и явно будем ожидать результат вызова page_title, как в Листинге 17-4.

Файл: src/main.rs

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

    trpl::block_on(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"),
        }
    })
}

Листинг 17-4: Ожидание асинхронного блока с помощью trpl::block_on

Когда мы запускаем этот код, мы получаем поведение, которое изначально ожидали:

$ 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

Фух — у нас наконец-то есть работающий асинхронный код! Но прежде чем мы добавим код для "гонки" двух сайтов друг с другом, давайте ненадолго вернем наше внимание к тому, как работают фьючерсы.

Каждая точка ожидания (await point) — то есть каждое место, где код использует ключевое слово .await — представляет собой место, где управление возвращается рантайму. Чтобы это работало, Rust должен отслеживать состояние, задействованное в асинхронном блоке, чтобы рантайм мог запустить другую работу, а затем вернуться, когда будет готов попытаться продолжить выполнение первой. Это невидимая state machine (state machine), как если бы вы написали перечисление (enum) примерно такого вида, чтобы сохранять текущее состояние в каждой точке ожидания:

#![allow(unused)]
fn main() {
enum PageTitleFuture<'a> {
    Initial { url: &'a str },
    GetAwaitPoint { url: &'a str },
    TextAwaitPoint { response: trpl::Response },
}
}

Написание кода для перехода между каждым состоянием вручную было бы утомительным и подверженным ошибкам, особенно когда позже вам потребуется добавить больше функциональности и больше состояний в код. К счастью, компилятор Rust автоматически создает структуры данных для state machine и управляет ими для асинхронного кода. Обычные правила заимствования и владения для структур данных все так же применяются, и, к счастью, компилятор также проверяет их для нас и предоставляет полезные сообщения об ошибках. Мы рассмотрим некоторые из них позже в этой главе.

В конечном счете, что-то должно выполнять эту state machine, и это что-то — рантайм. (Вот почему вы можете встретить упоминания об исполнителях (executors), когда будете изучать рантаймы: исполнитель — это часть рантайма, ответственная за выполнение асинхронного кода.)

Теперь вы понимаете, почему компилятор остановил нас, когда мы попытались сделать саму функцию main асинхронной в Листинге 17-3. Если бы main была асинхронной функцией, что-то другое должно было бы управлять state machine для того фьючерса, который возвращает main, но main — это отправная точка программы! Вместо этого мы вызвали функцию trpl::block_on в main, чтобы настроить рантайм и запустить фьючерс, возвращенный асинхронным блоком, до его завершения.

Примечание: Некоторые рантаймы предоставляют макросы, позволяющие писать асинхронную функцию main. Эти макросы переписывают async fn main() { ... } в обычную fn main, которая делает то же самое, что мы сделали вручную в Листинге 17-4: вызывает функцию, которая запускает фьючерс до завершения, подобно тому, как это делает trpl::block_on.

Теперь давайте соберем эти части вместе и посмотрим, как мы можем писать конкурентный код.

Соревнование двух URL-адресов друг с другом конкурентно

В Листинге 17-5 мы вызываем page_title с двумя разными URL-адресами, переданными из командной строки, и организуем между ними "гонку" (race), выбирая тот фьючерс, который завершится первым.

Файл: src/main.rs

use trpl::{Either, Html};

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

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

        let (url, maybe_title) =
            match trpl::select(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 was: '{title}'"),
            None => println!("It had no title."),
        }
    })
}

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

Листинг 17-5: Вызов page_title для двух URL-адресов, чтобы узнать, какой вернется первым

Мы начинаем с вызова page_title для каждого из URL-адресов, предоставленных пользователем. Мы сохраняем результирующие фьючерсы как title_fut_1 и title_fut_2. Помните, они пока ничего не делают, потому что фьючерсы ленивы, и мы еще не начали их ожидать (await). Затем мы передаем эти фьючерсы в trpl::select, которая возвращает значение, указывающее, какой из переданных фьючерсов завершился первым.

Примечание: Внутри trpl::select построена на основе более общей функции select, определенной в крейте futures. Функция select из крейта futures может делать многое из того, что не может trpl::select, но она также имеет некоторую дополнительную сложность, которую мы можем пока опустить.

Любой из фьючерсов может законно "победить", поэтому возвращать Result не имеет смысла. Вместо этого trpl::select возвращает тип, который мы раньше не видели, — trpl::Either. Тип Either чем-то похож на Result тем, что имеет два варианта. Однако, в отличие от Result, в Either не заложено понятие успеха или неудачи. Вместо этого он использует Left (Левый) и Right (Правый) для обозначения "тот или другой":

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

Функция select возвращает Left с выходным значением этого фьючерса, если побеждает первый аргумент, и Right с выходным значением второго фьючерса, если побеждает он. Это соответствует порядку, в котором аргументы появляются при вызове функции: первый аргумент находится слева от второго.

Мы также обновили page_title, чтобы она возвращала тот же переданный URL. Таким образом, если страница, которая вернулась первой, не имеет <title>, которое мы можем получить, мы все равно можем вывести содержательное сообщение. Имея эту информацию, мы завершаем, обновляя наш вывод println!, чтобы указать и какой URL завершился первым, и какой (если есть) <title> у веб-страницы по этому URL.

Теперь вы создали небольшой работающий веб-скрапер! Выберите пару URL-адресов и запустите инструмент командной строки. Вы можете обнаружить, что некоторые сайты стабильно быстрее других, в то время как в других случаях более быстрый сайт меняется от запуска к запуску. Что более важно, вы изучили основы работы с фьючерсами, так что теперь мы можем глубже погрузиться в то, что мы можем делать с помощью async.