Футуры и синтаксис async
Ключевые элементы асинхронного программирования в Rust — футуры (futures)
и ключевые слова async и await.
Футура — это значение, которое, может быть, ещё не готово сейчас, но будет
готово когда-нибудь в будущем. (Та же идея есть во многих языках, иногда под
другими названиями — например, task или promise.) В Rust есть трейт
Future — строительный блок, благодаря которому разные асинхронные операции
можно реализовывать разными структурами данных, но с общим интерфейсом. В Rust
футуры — это типы, реализующие трейт Future. Каждая футура сама хранит
сведения о том, насколько она продвинулась и что для неё значит «готово».
Ключевое слово async можно применять к блокам и функциям, чтобы указать, что
их выполнение может прерываться и возобновляться. Внутри асинхронного блока или
асинхронной функции можно использовать ключевое слово await, чтобы
дождаться футуры (await a future) — то есть подождать, пока она станет
готовой. Каждое место, где асинхронный блок или функция ждёт футуру, — это
потенциальная точка, где их выполнение может приостановиться и потом
возобновиться. Проверка, не готово ли уже значение футуры, называется
опросом (polling).
В некоторых других языках, например в C# и JavaScript, тоже есть ключевые слова
async и await для асинхронного программирования. Если вы с ними знакомы,
то увидите серьёзные отличия в том, как этот синтаксис устроен в Rust. И на то
есть веские причины, как мы увидим!
Когда мы пишем асинхронный код на Rust, то чаще всего используем async и
await. Rust компилирует их в равнозначный код с трейтом Future — примерно
так же, как циклы for компилируются в равнозначный код с трейтом Iterator.
Но раз трейт Future есть в языке, при необходимости его можно реализовать и
для своих типов данных. Многие функции, которые встретятся нам в этой главе,
возвращают типы с собственными реализациями Future. В конце главы мы
вернёмся к определению этого трейта и разберём подробнее, как он работает, а
пока этого достаточно, чтобы двигаться дальше.
Всё это может показаться абстрактным, поэтому напишем первую асинхронную программу — маленький веб-скрейпер. Мы передадим в командной строке два URL, конкурентно загрузим обе страницы и вернём результат той, что закончит первой. В этом примере будет немало нового синтаксиса, но не волнуйтесь — всё, что нужно, объясним по ходу дела.
Первая асинхронная программа
Чтобы в этой главе сосредоточиться на изучении async, а не жонглировать
частями экосистемы, мы сделали крейт trpl (сокращение от «The Rust
Programming Language»). Он реэкспортирует все нужные типы, трейты и функции —
в основном из крейтов futures и
tokio. Крейт futures — официальная площадка для
экспериментов с асинхронным кодом в Rust; именно там изначально и
проектировался трейт Future. Tokio — самая популярная сегодня асинхронная
среда выполнения в Rust, особенно для веб-приложений. Есть и другие отличные
среды выполнения, и, возможно, для ваших задач они подойдут лучше. Под
капотом trpl мы используем tokio, потому что он хорошо протестирован и
широко распространён.
Иногда trpl ещё и переименовывает или оборачивает исходные API, чтобы вы
могли сосредоточиться на деталях, важных для этой главы. Если хотите понять,
что делает этот крейт, загляните в его исходный код. Там видно,
из какого крейта взят каждый реэкспорт, а подробные комментарии объясняют, что
делает крейт.
Создайте новый бинарный проект hello-async и добавьте в зависимости крейт
trpl:
$ cargo new hello-async
$ cd hello-async
$ cargo add trpl
Теперь с помощью того, что даёт trpl, можно написать первую асинхронную
программу. Сделаем небольшую утилиту командной строки, которая загружает две
веб-страницы, достаёт из каждой элемент <title> и выводит заголовок той
страницы, для которой весь этот процесс закончится первым.
Функция page_title
Начнём с функции, которая принимает параметром URL страницы, отправляет на
него запрос и возвращает текст элемента <title> (листинг 17-1).
extern crate trpl; // нужно для mdbook test
fn main() {
// TODO: допишем дальше!
}
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())
}
Сначала мы определяем функцию page_title и помечаем её ключевым словом
async. Затем с помощью функции trpl::get загружаем переданный URL и
добавляем ключевое слово await, чтобы дождаться ответа. Чтобы получить текст
ответа response, вызываем его метод text и снова ждём результат через
await. Оба шага асинхронные. В случае get нужно дождаться, пока сервер
пришлёт первую часть ответа: HTTP-заголовки, cookie и прочее — она может
прийти отдельно от тела ответа. А тело, особенно большое, может приходить
долго. Нам нужно дождаться ответа целиком, поэтому метод text тоже
асинхронный.
Обе футуры нужно явно ждать через await, потому что футуры в Rust ленивые
(lazy): они ничего не делают, пока вы не попросите их об этом ключевым словом
await. (Более того, если футуру не использовать, Rust выдаст предупреждение.)
Это может напомнить вам разговор об итераторах в разделе «Обработка
последовательностей итераторами» главы 13.
Итераторы ничего не делают, пока не вызван их метод next — напрямую или через
циклы for и методы вроде map, которые используют next под капотом. Так и
футуры ничего не делают, пока вы явно их об этом не попросите. Благодаря этой
ленивости Rust не выполняет асинхронный код, пока он действительно не нужен.
Примечание: это отличается от поведения thread::spawn в разделе
«Создание потока через spawn» главы 16, где
замыкание, переданное в другой поток, начинало выполняться сразу. И от того,
как устроена асинхронность во многих других языках, — тоже. Но для Rust это
важно, чтобы он мог давать свои гарантии производительности, — как и в случае
итераторов.
Получив response_text, мы разбираем его в экземпляр типа Html с помощью
Html::parse. Вместо «сырой» строки у нас теперь тип данных, позволяющий
работать с HTML как с более богатой структурой. В частности, методом
select_first можно найти первый элемент, подходящий под CSS-селектор.
Передав строку "title", получим первый элемент <title> в документе, если он
есть. Подходящего элемента может и не быть, поэтому select_first возвращает
Option<ElementRef>. Наконец, мы вызываем метод Option::map, который
позволяет поработать с элементом внутри Option, если он есть, и ничего не
делать, если нет. (Можно было использовать и выражение match, но map
идиоматичнее.) В теле функции, которую мы передаём в map, вызываем у title
метод inner_html, чтобы получить его содержимое типа String. В итоге у нас
получается Option<String>.
Обратите внимание: в Rust ключевое слово await пишется после выражения,
которое мы ждём, а не перед ним. То есть это постфиксное ключевое слово. Если
вы пользовались async в других языках, это может быть непривычно, но в Rust
так гораздо удобнее строить цепочки методов. Поэтому тело page_title можно
переписать, соединив вызовы trpl::get и text в цепочку с await между ними,
как в листинге 17-2.
extern crate trpl; // нужно для mdbook test
use trpl::Html;
fn main() {
// TODO: допишем дальше!
}
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())
}
awaitВот мы и написали первую асинхронную функцию! Прежде чем добавить в main код,
который её вызывает, поговорим ещё немного о том, что мы написали и что это
значит.
Когда Rust видит блок, помеченный ключевым словом async, он компилирует его
в уникальный анонимный тип данных, реализующий трейт Future. А когда видит
функцию, помеченную 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>, то есть тот же, что и возвращаемый тип исходной версииpage_titleсasync fn. - Весь код из тела исходной функции обёрнут в блок
async move. Помните, что блоки — это выражения. Весь этот блок и есть выражение, которое возвращает функция. - Этот асинхронный блок выдаёт значение типа
Option<String>, как только что было сказано. Оно совпадает с типомOutputв возвращаемом типе. Всё как с другими блоками, которые вы уже видели. - Новое тело функции — блок
async moveиз-за того, как в нём используется параметрurl. (Обasyncиasync moveмы подробно поговорим дальше в главе.)
Теперь можно вызвать page_title в main.
Запуск асинхронной функции в среде выполнения
Для начала получим заголовок одной страницы — листинг 17-3. К сожалению, этот код пока не компилируется.
extern crate trpl; // нужно для 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())
}
page_title из main с аргументом, который передал пользовательАргументы командной строки мы получаем так же, как в разделе «Аргументы
командной строки» главы 12. Затем передаём аргумент с
URL в page_title и ждём результат. Футура выдаёт Option<String>, поэтому
выражением match выводим разные сообщения в зависимости от того, был ли у
страницы <title>.
Но ключевое слово await можно использовать только в асинхронных функциях или
блоках, а помечать особую функцию main как async Rust не позволяет.
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, выполняющей асинхронный код, есть хотя бы одно место,
где создаётся среда выполнения, которая и выполняет футуры.
Большинство языков с поддержкой асинхронности поставляют среду выполнения вместе с языком, а Rust — нет. Вместо этого есть множество разных асинхронных сред выполнения, и в каждой свои компромиссы, подходящие под свою задачу. Например, у высоконагруженного веб-сервера с множеством ядер и большим объёмом памяти совсем другие потребности, чем у микроконтроллера с одним ядром, небольшим объёмом памяти и без возможности выделять память в куче. Крейты, предоставляющие такие среды выполнения, часто дают и асинхронные версии распространённой функциональности, например файлового и сетевого ввода-вывода.
Здесь и до конца главы мы будем использовать функцию block_on из крейта
trpl. Она принимает аргументом футуру и блокирует текущий поток, пока эта
футура не выполнится до конца. За кулисами вызов block_on создаёт с помощью
крейта tokio среду выполнения, в которой и выполняется переданная футура
(функция block_on из trpl ведёт себя так же, как функции block_on из
других крейтов сред выполнения). Когда футура завершается, block_on
возвращает выданное ею значение.
Можно было бы передать в block_on футуру, которую вернула page_title,
напрямую, а когда она завершится — сопоставить полученный Option<String>, как
мы пытались в листинге 17-3. Но в большинстве примеров главы (и в
большинстве реального асинхронного кода) вызовов асинхронных функций будет
больше одного. Поэтому мы передадим блок async и внутри явно дождёмся
результата page_title, как в листинге 17-4.
extern crate trpl; // нужно для 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())
}
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 должен отслеживать состояние асинхронного блока: тогда среда
выполнения может запустить другую работу, а потом вернуться и снова попытаться
продвинуть первую. Получается невидимый конечный автомат — как если бы вы
написали перечисление вроде такого, чтобы сохранять текущее состояние в каждой
точке ожидания:
#![allow(unused)]
fn main() {
extern crate trpl; // нужно для 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 — это точка входа в программу! Поэтому мы вызвали в main функцию
trpl::block_on, чтобы создать среду выполнения и выполнить до конца футуру,
которую возвращает блок async.
Примечание: некоторые среды выполнения предоставляют макросы, с которыми
асинхронную main написать можно. Эти макросы переписывают
async fn main() { ... } в обычную fn main, которая делает то же, что мы
сделали вручную в листинге 17-4: вызывает функцию, выполняющую футуру до
конца, как trpl::block_on.
Теперь соберём всё вместе и посмотрим, как писать конкурентный код.
Гонка двух URL
В листинге 17-5 мы вызываем page_title для двух разных URL, переданных в
командной строке, и устраиваем между ними гонку: берём ту футуру, которая
закончит первой.
extern crate trpl; // нужно для 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)
}
page_title для двух URL и смотрим, какой вернётся первымСначала вызываем page_title для каждого из переданных пользователем URL и
сохраняем получившиеся футуры в title_fut_1 и title_fut_2. Помните: пока
они ничего не делают — футуры ленивые, а мы их ещё не ждали. Затем передаём
футуры в 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.