Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Фьючерсы и синтаксис async

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Filename: src/main.rs
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())
}
Listing 17-1: Определение async-функции для получения элемента заголовка из HTML-страницы

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

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

Примечание: Это отличается от поведения, которое мы видели в предыдущей главе при использовании thread::spawn в разделе Создание нового потока с помощью spawn, где замыкание, переданное в другой поток, начинало выполняться немедленно. Это также отличается от подхода многих других языков к 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 для title_element, чтобы получить его содержимое, которое является String. В итоге у нас есть Option<String>.

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

Filename: src/main.rs
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())
}
Listing 17-2: Объединение с ключевым словом await

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

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

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

#![allow(unused)]
fn main() {
extern crate trpl; // required for 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())
    }
}
}

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

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

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

Определение заголовка одной страницы

Для начала мы просто получим заголовок для одной страницы. В Листинге 17-3 мы следуем тому же шаблону, который использовали в Главе 12 для получения аргументов командной строки в разделе Принятие аргументов командной строки. Затем мы передаём первый URL в page_title и ожидаем результат. Поскольку значение, производимое фьючерсом, является Option<String>, мы используем выражение match, чтобы выводить разные сообщения с учётом того, была ли на странице <title>.

Filename: src/main.rs
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())
}
Listing 17-3: Вызов функции page_title из main с аргументом, предоставленным пользователем

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

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

Здесь и на протяжении всей оставшейся части этой главы мы будем использовать функцию run из крейта trpl, которая принимает фьючерс в качестве аргумента и выполняет его до завершения. За кулисами вызов run настраивает рантайм, который используется для выполнения переданного фьючерса. Как только фьючерс завершается, run возвращает любое значение, которое произвёл фьючерс.

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

Filename: src/main.rs
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())
}
Listing 17-4: Ожидание async-блока с помощью trpl::run

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

$ 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 должен отслеживать состояние, связанное с async-блоком, чтобы рантайм мог запустить другую работу, а затем вернуться, когда будет готов попробовать продвинуть первый снова. Это невидимая машина состояний, как если бы вы написали перечисление (enum) вроде этого для сохранения текущего состояния в каждой точке ожидания:

#![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 },
}
}

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

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

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

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

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

Гонка наших двух URL друг против друга

В Листинге 17-5 мы вызываем page_title с двумя разными URL, переданными из командной строки, и устраиваем между ними гонку.

Filename: src/main.rs
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)
}
Listing 17-5:

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

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

Любой фьючерс может законно “выиграть”, поэтому не имеет смысла возвращать Result. Вместо этого race возвращает тип, который мы раньше не видели, trpl::Either. Тип Either несколько похож на Result в том смысле, что у него два случая. В отличие от Result, однако, в Either нет встроенного понятия успеха или неудачи. Вместо этого он использует Left и Right, чтобы указать “один или другой”:

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

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

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

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