Futures та синтаксис Async
Ключові елементи асинхронного програмування в Rust — це future та ключові слова Rust
async і await.
Future — це значення, яке зараз може бути не готове, але стане готовим у
якийсь момент у майбутньому. (Ця сама концепція трапляється в багатьох мовах, іноді
під іншими назвами, такими як task або promise. ) Rust надає трейт Future
як будівельний блок, щоб різні async-операції можна було реалізовувати з
різними структурами даних, але з єдиним інтерфейсом. У Rust future — це
типи, які реалізують трейт Future. Кожен future містить власну інформацію
про прогрес, який було досягнуто, і про те, що означає «готовий».
Ви можете застосувати ключове слово async до блоків і функцій, щоб вказати, що вони
можуть бути перервані та відновлені. Усередині async-блоку або async-функції ви
можете використовувати ключове слово await, щоб await a future (тобто чекати, поки він стане
готовим). Будь-яка точка, де ви очікуєте future всередині async-блоку або функції, є
потенційним місцем, де цей блок або функція може призупинитися й відновитися. Процес
перевірки future, щоб побачити, чи доступне вже його значення, називається polling.
Деякі інші мови, такі як C# і JavaScript, також використовують ключові слова async і await
для async-програмування. Якщо ви знайомі з цими мовами, ви
можете помітити деякі суттєві відмінності в тому, як Rust обробляє синтаксис. І це недарма, як
ми побачимо!
Коли ми пишемо async Rust, ми здебільшого використовуємо ключові слова async і await.
Rust компілює їх в еквівалентний код, використовуючи трейт Future, так само як
він компілює цикли for в еквівалентний код, використовуючи трейт Iterator.
Оскільки Rust надає трейт Future, ви також можете реалізувати його для
власних типів даних, коли це потрібно. Багато функцій, які ми побачимо
протягом цієї глави, повертають типи з власними реалізаціями
Future. Ми повернемося до визначення трейту наприкінці глави
і глибше розглянемо, як це працює, але цього рівня деталізації достатньо, щоб рухатися
далі.
Усе це може здаватися трохи абстрактним, тож давайте напишемо нашу першу async-програму: невеликий web scraper. Ми передамо два URL з командного рядка, отримаємо обидва конкурентно, і повернемо результат того, який завершиться першим. У цьому прикладі буде чимало нового синтаксису, але не хвилюйтеся — ми пояснимо все, що вам потрібно знати, у процесі.
Наша перша async-програма
Щоб зосередити цю главу на вивченні async, а не на керуванні частинами
екосистеми, ми створили крейт trpl (trpl — скорочення від “The Rust
Programming Language”). Він перевизначає всі типи, трейти й функції, які
вам знадобляться, переважно з крейтів futures і
tokio. Крейт futures — це офіційне місце для експериментів Rust з async-кодом, і
саме там спочатку було розроблено трейт Future. Tokio — це найпоширеніший async runtime в
Rust сьогодні, особливо для web-застосунків. Є й інші чудові runtime
там, і вони можуть бути більш придатними для ваших цілей. Ми використовуємо крейт tokio
під капотом для trpl, тому що він добре протестований і широко використовується.
У деяких випадках trpl також перейменовує або обгортає оригінальні API, щоб ви
зосередилися на деталях, релевантних для цієї глави. Якщо ви хочете зрозуміти, що
робить крейт, ми радимо вам ознайомитися з його вихідним кодом.
Ви зможете побачити, з якого крейта походить кожен re-export, і ми залишили
розгорнуті коментарі, що пояснюють, що робить крейт.
Створіть новий бінарний пакет із назвою hello-async і додайте крейт trpl як
залежність:
$ cargo new hello-async
$ cd hello-async
$ cargo add trpl
Тепер ми можемо використовувати різні частини, надані trpl, щоб написати нашу першу async
програму. Ми побудуємо невеликий інструмент командного рядка, який отримує дві web-сторінки,
витягує елемент <title> з кожної та друкує заголовок тієї сторінки, яка завершить увесь цей процес першою.
Визначення функції page_title
Почнімо з написання функції, яка приймає один URL сторінки як параметр, надсилає
до нього запит і повертає текст елемента <title> (див. Listing
17-1).
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())
}
Спочатку ми визначаємо функцію з назвою page_title і позначаємо її
ключовим словом async. Потім ми використовуємо функцію trpl::get, щоб отримати будь-який URL, який передано
всередину, і додаємо ключове слово await, щоб очікувати відповідь. Щоб отримати текст
response, ми викликаємо її метод text і знову очікуємо його за допомогою
ключового слова await. Обидва ці кроки є асинхронними. Для функції get нам
потрібно дочекатися, поки сервер надішле назад першу частину своєї відповіді, яка міститиме HTTP-заголовки, cookie тощо
і може бути доставлена окремо від тіла відповіді. Особливо якщо тіло дуже велике,
може знадобитися деякий час, щоб усе дійшло. Оскільки нам потрібно дочекатися
всієї відповіді, метод text також є async.
Ми повинні явно очікувати обидва ці future, тому що future в Rust — lazy: вони
нічого не роблять, доки ви не попросите їх про це за допомогою ключового слова await.
(Насправді Rust покаже попередження компілятора, якщо ви не використовуєте future.) Це
може нагадати вам обговорення ітераторів у розділі “Processing a Series of
Items with Iterators” у Главі 13.
Ітератори нічого не роблять, якщо ви не викликаєте їхній метод next — прямо або
через використання циклів for чи методів на кшталт map, які використовують next
під капотом. Так само future нічого не роблять, якщо ви явно не попросите їх про це. Така
лінощі дозволяє Rust не запускати async-код, доки він справді не потрібен.
Примітка: Це відрізняється від поведінки, яку ми бачили під час використання
thread::spawnу розділі “Creating a New Thread with 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>.
Зверніть увагу, що ключове слово await у Rust іде після виразу, який ви очікуєте,
а не перед ним. Тобто це постфіксне ключове слово. Це може відрізнятися від того, до чого ви
звикли, якщо використовували async в інших мовах, але в Rust це робить
ланцюжки методів значно зручнішими для роботи. У результаті ми могли б змінити
тіло page_title, щоб об’єднати виклики функцій trpl::get і text
разом із await між ними, як показано в Listing 17-2.
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())
}
З цим ми успішно написали нашу першу async-функцію! Перш ніж ми додамо
деякий код у main, щоб викликати її, давайте трохи більше поговоримо про те, що ми
написали і що це означає.
Коли Rust бачить блок, позначений ключовим словом async, він компілює його в
унікальний, анонімний тип даних, який реалізує трейт Future. Коли Rust бачить
функцію, позначену async, він компілює її в не-async функцію,
тіло якої є async-блоком. Тип повернення async-функції — це тип
анонімного типу даних, який компілятор створює для цього async-блоку.
Отже, написання async fn еквівалентне написанню функції, яка повертає
future типу повернення. Для компілятора визначення функції на кшталт
async fn page_title у Listing 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 у розділі “Traits as Parameters”. - Значення, що повертається, реалізує трейт
Futureіз асоційованим типомOutput. Зверніть увагу, що типOutput— цеOption<String>, який є тим самим, що й оригінальний тип повернення з версіїasync fnдляpage_title. - Увесь код, викликаний у тілі оригінальної функції, обгорнуто в
блок
async move. Пам’ятайте, що блоки — це вирази. Увесь цей блок є виразом, який повертається з функції. - Цей async-блок створює значення типу
Option<String>, як щойно описано. Це значення відповідає типуOutputу типі повернення. Це так само, як і інші блоки, які ви вже бачили. - Нове тіло функції є блоком
async moveчерез те, як воно використовує параметрurl. (Ми ще багато говоритимемо проasyncпротиasync moveпізніше в цій главі.)
Тепер ми можемо викликати page_title у main.
Виконання Async-функції за допомогою runtime
Для початку ми отримаємо заголовок для однієї сторінки, показаної в Listing 17-3. На жаль, цей код ще не компілюється.
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())
}
Ми дотримуємося того самого шаблону, який використовували для отримання аргументів командного рядка
у розділі “Accepting Command Line Arguments” у
Главі 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, полягає в тому, що async-коду потрібен runtime:
крейт Rust, який керує деталями виконання асинхронного коду. Функція main
програми може ініціалізувати runtime, але вона не є runtime
сама по собі. (Невдовзі ми побачимо більше про те, чому це так.) Кожна програма Rust,
яка виконує async-код, має принаймні одне місце, де вона налаштовує runtime, що виконує future.
Більшість мов, які підтримують async, постачають runtime, але Rust — ні. Замість цього, доступно багато різних async runtime, кожен із яких робить різні компроміси, придатні для того варіанта використання, на який він націлений. Наприклад, web-сервер із високою пропускною здатністю, багатьма CPU-ядрами й великою кількістю RAM має зовсім інші потреби, ніж мікроконтролер з одним ядром, невеликою кількістю RAM і без можливості виділення купі. Крейт-и, які надають ці runtime, також часто постачають async-версії поширеної функціональності, такої як файловий або мережевий I/O.
Тут і протягом решти цієї глави ми використовуватимемо функцію block_on з
крейта trpl, яка приймає future як аргумент і блокує
поточний потік, доки цей future не завершить виконання. За лаштунками,
виклик block_on налаштовує runtime за допомогою крейта tokio, який використовується для виконання
переданого future (поведінка block_on у крейті trpl подібна до
функцій block_on в інших крейтах runtime). Коли future завершується,
block_on повертає те значення, яке створив future.
Ми могли б передати future, що повертається page_title, безпосередньо до block_on і,
коли він завершиться, могли б виконати match над отриманим Option<String>, як
ми намагалися зробити в Listing 17-3. Однак для більшості прикладів у главі (і
більшості async-коду в реальному світі) ми робитимемо більше, ніж один виклик async-
функції, тому замість цього ми передамо async-блок і явно очікуємо
результат виклику page_title, як у Listing 17-4.
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())
}
Коли ми запускаємо цей код, ми отримуємо поведінку, яку спочатку й очікували:
$ 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
Фух — нарешті в нас є трохи робочого async-коду! Але перш ніж ми додамо код, щоб змагати два сайти один з одним, давайте ненадовго повернемо нашу увагу до того, як працюють future.
Кожна await point — тобто кожне місце, де код використовує ключове слово await, — представляє місце, де керування передається
назад runtime. Щоб це працювало, Rust має відстежувати стан, пов’язаний з async-блоком, щоб
runtime міг запустити якусь іншу роботу, а потім повернутися, коли буде готовий знову спробувати просунути першу. Це невидимий автомат станів,
ніби ви написали б такий перелік, щоб зберігати поточний стан на кожній
точці 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 автоматично створює і керує структурами даних автомата станів для async-коду. Звичайні правила запозичення та власності, що стосуються структур даних, усе ще застосовуються, і на щастя, компілятор також перевіряє їх за нас і надає корисні повідомлення про помилки. Декілька з них ми розглянемо пізніше в цій главі.
Зрештою, щось має виконувати цей автомат станів, і цим чимось є runtime. (Ось чому ви можете натрапити на згадки про executors, коли досліджуєте runtime: executor — це частина runtime, відповідальна за виконання async-коду.)
Тепер ви бачите, чому компілятор зупинив нас від того, щоб зробити main саму по собі async-функцією
ще в Listing 17-3. Якби main була async-функцією, щось інше
мало б керувати автоматом станів для будь-якого future, який повернула б main, але
main — це точка запуску програми! Замість цього ми викликали
функцію trpl::block_on у main, щоб налаштувати runtime і виконувати future,
який повертає async-блок, доти, доки він не завершиться.
Примітка: Деякі runtime надають макроси, щоб ви могли написати async
mainfunction. Ці макроси переписуютьasync fn main() { ... }у звичайнуfn main, що робить те саме, що ми зробили вручну в Listing 17-4: викликає функцію, яка виконує future до завершення так, як це робитьtrpl::block_on.
Тепер давайте з’єднаємо ці частини разом і подивимося, як ми можемо написати конкурентний код.
Змагання двох URL один з одним конкурентно
У Listing 17-5 ми викликаємо page_title для двох різних URL, переданих із
командного рядка, і змагаємо їх, вибираючи той future, який завершиться першим.
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)
}
Ми починаємо з виклику page_title для кожного з наданих користувачем URL. Ми зберігаємо
отримані future як title_fut_1 і title_fut_2. Пам’ятайте, що вони
ще нічого не роблять, тому що future ліниві, і ми ще не очікували їх. Потім
ми передаємо future до trpl::select, яка повертає значення, щоб показати, який
із переданих їй future завершується першим.
Примітка: Під капотом
trpl::selectпобудовано на більш загальній функціїselect, визначеній у крейтіfutures. Функціяselectкрейтаfuturesможе робити багато речей, яких не можеtrpl::select, але вона також має деяку додаткову складність, яку ми поки що можемо пропустити.
Будь-який future може законно “виграти”, тож не має сенсу повертати
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> має web-сторінка за цим URL.
Тепер ви створили невеликий робочий web scraper! Виберіть кілька URL і запустіть інструмент командного рядка. Ви можете виявити, що деякі сайти стабільно швидші за інші, тоді як в інших випадках швидший сайт змінюється від запуску до запуску. Що ще важливіше, ви вивчили основи роботи з future, тож тепер ми можемо глибше зануритися в те, що ми можемо робити з async.