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

Futures и синтаксис async

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

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

Ключевое слово async можно применять к блокам и функциям, чтобы указать, что их выполнение может быть прервано и затем возобновлено. Внутри async-блока или async-функции можно использовать ключевое слово await, чтобы ожидать future (то есть ждать, пока он станет готовым). Любое место, где вы ожидаете future внутри async-блока или функции, является потенциальной точкой, в которой этот блок или функция может приостановиться и затем возобновиться. Процесс проверки future, чтобы узнать, доступно ли уже его значение, называется опросом (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 сегодня является самой широко используемой async-средой выполнения в Rust, особенно для веб-приложений. Существуют и другие отличные среды выполнения, и они могут лучше подходить для ваших целей. Мы используем крейт tokio внутри trpl, потому что он хорошо протестирован и широко применяется.

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

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

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

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

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

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

Имя файла: 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| title.inner_html())
}
Listing 17-1: Определение async-функции для получения элемента title из HTML-страницы

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

Мы должны явно ожидать оба этих future, потому что futures в Rust ленивые: они ничего не делают, пока вы не попросите их об этом с помощью ключевого слова await. (На самом деле Rust покажет предупреждение компилятора, если вы не используете future.) Это может напомнить обсуждение итераторов в разделе «Обработка серии элементов с помощью итераторов» главы 13. Итераторы ничего не делают, пока вы не вызовете их метод next — напрямую, через циклы for или через методы вроде map, которые используют next внутри. Точно так же futures ничего не делают, пока вы явно не попросите их об этом. Эта ленивость позволяет 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 у title, чтобы получить его содержимое, которое является String. Когда все сказано и сделано, у нас получается Option<String>.

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

Имя файла: 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| title.inner_html())
}
Listing 17-2: Цепочка вызовов с ключевым словом await

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

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

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

#![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>, то есть тот же тип, что и исходный возвращаемый тип из версии page_title с async fn.
  • Весь код, вызываемый в теле исходной функции, обернут в блок async move. Помните, что блоки являются выражениями. Весь этот блок является выражением, возвращаемым из функции.
  • Этот async-блок производит значение с типом Option<String>, как только что описано. Это значение соответствует типу Output в возвращаемом типе. Это похоже на другие блоки, которые вы уже видели.
  • Новое тело функции является блоком async move из-за того, как оно использует параметр url. (Позже в этой главе мы гораздо подробнее поговорим о async и async move.)

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

Выполнение async-функции с помощью среды выполнения

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

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

Мы следуем тому же шаблону, который использовали для получения аргументов командной строки в разделе «Прием аргументов командной строки» главы 12. Затем мы передаем URL-аргумент в page_title и ожидаем результат. Поскольку значение, произведенное future, является Option<String>, мы используем выражение match, чтобы печатать разные сообщения с учетом того, был ли у страницы <title>.

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

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

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

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

Имя файла: src/main.rs
extern crate trpl; // required for mdbook test

use trpl::Html;

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"),
        }
    })
}

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| title.inner_html())
}
Listing 17-4: Ожидание async-блока с помощью 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

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

Каждая точка await, то есть каждое место, где код использует ключевое слово await, представляет место, где управление передается обратно среде выполнения. Чтобы это работало, Rust должен отслеживать состояние, задействованное в async-блоке, так чтобы среда выполнения могла запустить какую-то другую работу, а затем вернуться, когда будет готова снова попытаться продвинуть первую задачу. Это невидимый конечный автомат, как если бы вы написали такое перечисление, чтобы сохранять текущее состояние в каждой точке 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 },
}
}

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

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

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

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

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

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

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

Имя файла: 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::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)
}
Listing 17-5: Вызов page_title для двух URL, чтобы увидеть, какой вернется первым

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

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

Любой из futures может законно «выиграть», поэтому возвращать 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 с результатом этого future, если выигрывает первый аргумент, и Right с результатом второго аргумента-future, если выигрывает он. Это соответствует порядку, в котором аргументы появляются при вызове функции: первый аргумент находится слева от второго.

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

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