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) и ключевые слова 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).

Файл: src/main.rs
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())
}
Листинг 17-1: Определяем асинхронную функцию, которая получает элемент title из 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.

Файл: src/main.rs
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())
}
Листинг 17-2: Цепочка вызовов с ключевым словом 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. К сожалению, этот код пока не компилируется.

Файл: src/main.rs
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())
}
Листинг 17-3: Вызываем функцию 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.

Файл: src/main.rs
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())
}
Листинг 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 должен отслеживать состояние асинхронного блока: тогда среда выполнения может запустить другую работу, а потом вернуться и снова попытаться продвинуть первую. Получается невидимый конечный автомат — как если бы вы написали перечисление вроде такого, чтобы сохранять текущее состояние в каждой точке ожидания:

#![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, переданных в командной строке, и устраиваем между ними гонку: берём ту футуру, которая закончит первой.

Файл: src/main.rs
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)
}
Листинг 17-5: Вызываем 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.