Futures i składnia async
Kluczowymi elementami programowania asynchronicznego w Rust są futures oraz
słowa kluczowe async i await Rust.
Future to wartość, która może nie być gotowa teraz, ale stanie się gotowa w
pewnym momencie w przyszłości. (Ta sama koncepcja pojawia się w wielu językach,
czasami pod innymi nazwami, takimi jak task lub promise.) Rust zapewnia
cechę Future jako element konstrukcyjny, dzięki czemu różne operacje
asynchroniczne mogą być implementowane z różnymi strukturami danych, ale z
jednolitym interfejsem. W Rust futures to typy, które implementują cechę
Future. Każda przyszłość przechowuje własne informacje o postępie, który
został osiągnięty, i co oznacza „gotowy”.
Możesz zastosować słowo kluczowe async do bloków i funkcji, aby określić,
że mogą być one przerywane i wznawiane. Wewnątrz bloku async lub funkcji async,
możesz użyć słowa kluczowego await, aby oczekiwać na przyszłość (czyli
poczekać, aż stanie się gotowa). Każdy punkt, w którym oczekujesz na przyszłość
w bloku async lub funkcji async, jest potencjalnym miejscem, w którym ten blok
lub funkcja może się zatrzymać i wznowić. Proces sprawdzania z przyszłością, czy
jej wartość jest już dostępna, nazywa się polling (odpytywaniem).
Niektóre inne języki, takie jak C# i JavaScript, również używają słów
kluczowych async i await do programowania asynchronicznego. Jeśli znasz te
języki, możesz zauważyć pewne znaczące różnice w sposobie obsługi składni przez
Rust. Ma to dobry powód, jak zobaczymy!
Pisząc kod async w Rust, używamy słów kluczowych async i await przez większość
czasu. Rust kompiluje je do równoważnego kodu używającego cechy Future,
podobnie jak kompiluje pętle for do równoważnego kodu używającego cechy
Iterator. Ponieważ Rust zapewnia cechę Future, możesz ją również
zaimplementować dla własnych typów danych, gdy tego potrzebujesz. Wiele funkcji,
które zobaczymy w tym rozdziale, zwraca typy z własnymi implementacjami
Future. Wróćmy do definicji cechy na końcu rozdziału i zagłębmy się w to, jak
działa, ale to wystarczy, abyśmy mogli kontynuować.
Wszystko to może wydawać się nieco abstrakcyjne, więc napiszmy nasz pierwszy program asynchroniczny: mały scraper internetowy. Przekażemy dwa adresy URL z wiersza poleceń, pobierzemy je oba współbieżnie i zwrócimy wynik tego, który zakończy się jako pierwszy. Ten przykład będzie zawierał sporo nowej składni, ale nie martw się – wyjaśnimy wszystko, co musisz wiedzieć, w trakcie.
Nasz pierwszy program asynchroniczny
Aby skupić się w tym rozdziale na nauce async, a nie na żonglowaniu częściami
ekosystemu, stworzyliśmy crate trpl (trpl to skrót od „The Rust Programming
Language”). Reeksportuje on wszystkie typy, cechy i funkcje, których będziesz
potrzebować, głównie z crate’ów futures i
tokio. Crate futures jest oficjalnym miejscem
eksperymentów Rust dla kodu async i to właśnie tam pierwotnie zaprojektowano
cechę Future. Tokio jest obecnie najczęściej używanym środowiskiem
asynchronicznym w Rust, zwłaszcza w aplikacjach internetowych. Istnieją inne
dobre środowiska uruchomieniowe, które mogą być bardziej odpowiednie dla Twoich
potrzeb. Używamy crate’a tokio pod maską dla trpl, ponieważ jest dobrze
przetestowany i szeroko stosowany.
W niektórych przypadkach trpl również zmienia nazwy lub opakowuje oryginalne
API, abyś skupił się na szczegółach istotnych dla tego rozdziału. Jeśli chcesz
zrozumieć, co robi ten crate, zachęcamy do zapoznania się z jego kodem
źródłowym. Będziesz mógł zobaczyć, z którego crate’a pochodzi
każdy reeksport, a my zostawiliśmy obszerne komentarze wyjaśniające, co robi
crate.
Stwórzmy nowy projekt binarny o nazwie hello-async i dodajmy crate trpl
jako zależność:
$ cargo new hello-async
$ cd hello-async
$ cargo add trpl
Teraz możemy użyć różnych elementów dostarczonych przez trpl do napisania
naszego pierwszego programu asynchronicznego. Zbudujemy małe narzędzie
wiersza poleceń, które pobierze dwie strony internetowe, wyciągnie z każdej z
nich element <title> i wypisze tytuł tej strony, która zakończy cały proces
pierwsza.
Definiowanie funkcji page_title
Zacznijmy od napisania funkcji, która przyjmuje jeden URL strony jako parametr,
wykonuje do niego żądanie i zwraca tekst elementu <title> (patrz 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())
}
Najpierw definiujemy funkcję o nazwie page_title i oznaczamy ją słowem
kluczowym async. Następnie używamy funkcji trpl::get, aby pobrać dowolny
przekazany URL i dodajemy słowo kluczowe await, aby oczekiwać na
odpowiedź. Aby uzyskać tekst response, wywołujemy jego metodę text i
ponownie oczekujemy na nią za pomocą słowa kluczowego await. Oba te kroki
są asynchroniczne. Dla funkcji get musimy poczekać, aż serwer odeśle
pierwszą część swojej odpowiedzi, która będzie zawierała nagłówki HTTP,
ciasteczka itd. i może być dostarczana osobno od treści odpowiedzi. Zwłaszcza
jeśli treść jest bardzo duża, jej dotarcie może zająć trochę czasu. Ponieważ
musimy poczekać na całość odpowiedzi, metoda text również jest asynchroniczna.
Musimy jawnie oczekiwać na obie te futures, ponieważ futures w Rust są
leniwymi: nie robią nic, dopóki nie poprosisz ich o to słowem kluczowym
await. (W rzeczywistości Rust wyświetli ostrzeżenie kompilatora, jeśli nie
użyjesz future.) To może przypominać dyskusję o iteratorach w sekcji
„Przetwarzanie sekwencji elementów za pomocą iteratorów” w Rozdziale 13. Iteratory nic nie robią, dopóki nie wywołasz ich
metody next – bezpośrednio lub za pomocą pętli for lub metod takich jak
map, które używają next pod maską. Podobnie, futures nic nie robią, dopóki
nie poprosisz ich o to jawnie. Ta leniwość pozwala Rustowi uniknąć
uruchamiania kodu async, dopóki nie jest on faktycznie potrzebny.
Uwaga: Różni się to od zachowania, które widzieliśmy podczas używania
thread::spawnw sekcji „Tworzenie nowego wątku za pomocą spawn” w Rozdziale 16, gdzie domknięcie, które przekazaliśmy do innego wątku, zaczęło działać natychmiast. Różni się to również od podejścia wielu innych języków do async. Ale jest to ważne, aby Rust mógł zapewnić swoje gwarancje wydajności, tak jak w przypadku iteratorów.
Gdy mamy response_text, możemy ją przetworzyć na instancję typu Html za
pomocą Html::parse. Zamiast surowego ciągu znaków, mamy teraz typ danych,
którego możemy użyć do pracy z HTML-em jako bogatszą strukturą danych. W
szczególności, możemy użyć metody select_first do znalezienia pierwszej
instancji danego selektora CSS. Przekazując ciąg "title", otrzymamy pierwszy
element <title> w dokumencie, jeśli taki istnieje. Ponieważ może nie być
żadnego pasującego elementu, select_first zwraca Option<ElementRef>.
Na koniec używamy metody Option::map, która pozwala nam pracować z elementem
w Option, jeśli jest obecny, i nic nie robić, jeśli go nie ma. (Moglibyśmy
też użyć tutaj wyrażenia match, ale map jest bardziej idiomatyczne.) W treści
funkcji, którą przekazujemy do map, wywołujemy inner_html na title, aby
pobrać jego zawartość, która jest String. Po wszystkim mamy
Option<String>.
Zauważ, że słowo kluczowe await w Rust znajduje się po wyrażeniu, na które
oczekujesz, a nie przed nim. To znaczy, jest to słowo kluczowe postfixowe.
Może się to różnić od tego, do czego jesteś przyzwyczajony, jeśli używałeś
async w innych językach, ale w Rust sprawia, że łańcuchy metod są znacznie
przyjemniejsze w pracy. W rezultacie, mogliśmy zmienić ciało page_title, aby
połączyć wywołania funkcji trpl::get i text za pomocą await między nimi,
jak pokazano w Listingu 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())
}
I w ten sposób pomyślnie napisaliśmy naszą pierwszą funkcję asynchroniczną!
Zanim dodamy kod w main, aby ją wywołać, porozmawiajmy trochę więcej o tym,
co napisaliśmy i co to oznacza.
Kiedy Rust widzi blok oznaczony słowem kluczowym async, kompiluje go do
unikalnego, anonimowego typu danych, który implementuje cechę Future. Kiedy
Rust widzi funkcję oznaczoną async, kompiluje ją do funkcji nieasync,
której ciało jest blokiem async. Typ zwracany przez funkcję async jest typem
anonimowego typu danych, który kompilator tworzy dla tego bloku async.
W związku z tym, pisanie async fn jest równoważne pisaniu funkcji,
która zwraca future typu zwracanego. Dla kompilatora, definicja funkcji,
taka jak async fn page_title w Listingu 17-1, jest z grubsza równoważna
funkcji nieasync zdefiniowanej w ten sposób:
#![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())
}
}
}
Przejdźmy przez każdą część przekształconej wersji:
- Używa składni
impl Trait, którą omówiliśmy w Rozdziale 10 w sekcji „Cechy jako parametry”. - Zwrócona wartość implementuje cechę
Futureze skojarzonym typemOutput. Zauważ, że typOutputtoOption<String>, czyli ten sam typ, co oryginalny typ zwracany przez wersjęasync fnpage_title. - Cały kod wywołany w treści oryginalnej funkcji jest opakowany w blok
async move. Pamiętaj, że bloki są wyrażeniami. Cały ten blok jest wyrażeniem zwracanym z funkcji. - Ten blok async produkuje wartość typu
Option<String>, jak właśnie opisano. Ta wartość odpowiada typowiOutputw typie zwracanym. Jest to tak samo jak inne bloki, które widziałeś. - Nowe ciało funkcji to blok
async moveze względu na sposób użycia parametruurl. (Oasynckontraasync movebędziemy rozmawiać znacznie więcej później w rozdziale.)
Teraz możemy wywołać page_title w main.
Wykonanie funkcji async w środowisku uruchomieniowym
Aby zacząć, pobierzemy tytuł dla pojedynczej strony, pokazany w Listingu 17-3. Niestety, ten kod jeszcze się nie skompiluje.
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())
}
Postępujemy zgodnie z tym samym wzorcem, którego użyliśmy do pobierania
argumentów wiersza poleceń w sekcji „Akceptowanie argumentów wiersza
poleceń” w Rozdziale 12. Następnie przekazujemy
argument URL do page_title i czekamy na wynik. Ponieważ wartość
produkowana przez future jest Option<String>, używamy wyrażenia match, aby
wypisać różne komunikaty w zależności od tego, czy strona miała element
<title>.
Jedynym miejscem, w którym możemy użyć słowa kluczowego await, są funkcje
lub bloki async, a Rust nie pozwoli nam oznaczyć specjalnej funkcji main jako
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`
Powodem, dla którego main nie może być oznaczona jako async, jest to, że
kod asynchroniczny potrzebuje środowiska uruchomieniowego (runtime): crate’a
Rust, który zarządza szczegółami wykonywania kodu asynchronicznego. Funkcja
main programu może zainicjalizować środowisko uruchomieniowe, ale sama nie
jest środowiskiem uruchomieniowym. (Więcej o tym, dlaczego tak jest, dowiemy
się za chwilę.) Każdy program Rust, który wykonuje kod asynchroniczny, ma
przynajmniej jedno miejsce, w którym konfiguruje środowisko uruchomieniowe,
które wykonuje futures.
Większość języków obsługujących async dostarcza środowisko uruchomieniowe, ale Rust tego nie robi. Zamiast tego dostępnych jest wiele różnych środowisk uruchomieniowych async, z których każde dokonuje różnych kompromisów, odpowiednich dla docelowego przypadku użycia. Na przykład, wydajny serwer webowy z wieloma rdzeniami CPU i dużą ilością pamięci RAM ma bardzo różne potrzeby niż mikrokontroler z pojedynczym rdzeniem, małą ilością pamięci RAM i brakiem możliwości alokacji na stercie. Crates, które dostarczają te środowiska uruchomieniowe, często dostarczają również asynchroniczne wersje typowych funkcji, takich jak wejście/wyjście plików lub sieci.
Tutaj i przez resztę tego rozdziału będziemy używać funkcji block_on z crate’a
trpl, która przyjmuje przyszłość jako argument i blokuje bieżący wątek,
dopóki ta przyszłość nie zostanie ukończona. Pod maską, wywołanie block_on
konfiguruje środowisko uruchomieniowe za pomocą crate’a tokio, które jest
używane do uruchamiania przekazanej przyszłości (zachowanie trpl::block_on
jest podobne do funkcji block_on innych crate’ów środowiskowych).
Gdy przyszłość zostanie ukończona, block_on zwraca wartość, którą przyszłość
wyprodukowała.
Moglibyśmy przekazać przyszłość zwróconą przez page_title bezpośrednio do
block_on i, po jej zakończeniu, dopasować wynikowy Option<String>, jak to
próbowaliśmy zrobić w Listingu 17-3. Jednak w większości przykładów w rozdziale
(i w większości kodu async w prawdziwym świecie) będziemy wykonywać więcej niż
jedno wywołanie funkcji async, więc zamiast tego przekażemy blok async i
jawnie poczekamy na wynik wywołania page_title, jak w Listingu 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())
}
Po uruchomieniu tego kodu otrzymujemy zachowanie, którego początkowo się spodziewaliśmy:
$ 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
Uff – w końcu mamy działający kod async! Ale zanim dodamy kod do wyścigów dwóch witryn ze sobą, poświęćmy chwilę, aby powrócić do tego, jak działają futures.
Każdy punkt oczekiwania – to znaczy każde miejsce, w którym kod używa słowa
kluczowego await – reprezentuje miejsce, w którym kontrola zostaje
przekazana z powrotem do środowiska uruchomieniowego. Aby to zadziałało, Rust
musi śledzić stan zaangażowany w blok async, tak aby środowisko uruchomieniowe
mogło rozpocząć inną pracę, a następnie powrócić, gdy będzie gotowe, aby
ponownie spróbować posunąć pierwszą pracę. Jest to niewidoczna maszyna stanów,
tak jakbyś napisał enum w ten sposób, aby zapisać bieżący stan w każdym punkcie
oczekiwania:
#![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 },
}
}
Ręczne pisanie kodu do przechodzenia między poszczególnymi stanami byłoby udręką i podatne na błędy, zwłaszcza gdy trzeba później dodać więcej funkcjonalności i stanów do kodu. Na szczęście kompilator Rust automatycznie tworzy i zarządza strukturami danych maszyny stanów dla kodu async. Normalne zasady pożyczania i własności dotyczące struktur danych nadal obowiązują, a co najważniejsze, kompilator zajmuje się również ich sprawdzaniem i dostarcza przydatne komunikaty o błędach. Kilka z nich omówimy później w tym rozdziale.
Ostatecznie, coś musi wykonać tę maszynę stanów, a tym czymś jest środowisko uruchomieniowe. (Dlatego możesz natknąć się na wzmianki o executorach, szukając informacji o środowiskach uruchomieniowych: executor to część środowiska uruchomieniowego odpowiedzialna za wykonywanie kodu async.)
Teraz widzisz, dlaczego kompilator powstrzymał nas przed uczynieniem main
samego w sobie funkcją asynchroniczną w Listingu 17-3. Gdyby main było
funkcją asynchroniczną, coś innego musiałoby zarządzać maszyną stanów dla
dowolnej przyszłości, którą main zwróciło, ale main jest punktem
początkowym programu! Zamiast tego wywołaliśmy funkcję trpl::block_on w
main, aby skonfigurować środowisko uruchomieniowe i uruchomić przyszłość
zwracaną przez blok async, dopóki nie zostanie ona ukończona.
Uwaga: Niektóre środowiska uruchomieniowe zapewniają makra, dzięki czemu możesz pisać funkcję
mainasync. Te makra przepisująasync fn main() { ... }na zwykłą funkcjęfn main, która robi to samo, co zrobiliśmy ręcznie w Listingu 17-4: wywołuje funkcję, która uruchamia przyszłość do końca w sposób, w jaki to robitrpl::block_on.
Teraz połączmy te elementy i zobaczmy, jak możemy pisać kod współbieżny.
Wyścig dwóch adresów URL ze sobą współbieżnie
W Listingu 17-5 wywołujemy page_title z dwoma różnymi adresami URL
przekazanymi z wiersza poleceń i ścigamy je, wybierając tę future, która
zakończy się jako pierwsza.
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)
}
Zaczynamy od wywołania page_title dla każdego z adresów URL podanych przez
użytkownika. Wynikowe futures zapisujemy jako title_fut_1 i title_fut_2.
Pamiętaj, że te futures jeszcze nic nie robią, ponieważ są leniwe i jeszcze
na nie nie czekaliśmy. Następnie przekazujemy futures do trpl::select, który
zwraca wartość wskazującą, która z przekazanych do niego futures zakończyła
działanie jako pierwsza.
Uwaga: Pod maską,
trpl::selectjest zbudowany na bardziej ogólnej funkcjiselectzdefiniowanej w crate’ciefutures. Funkcjaselectz crate’afuturespotrafi wiele rzeczy, których funkcjatrpl::selectnie potrafi, ale ma też pewne dodatkowe złożoności, które na razie możemy pominąć.
Każda przyszłość może legalnie „wygrać”, więc zwracanie Result nie ma sensu.
Zamiast tego, trpl::select zwraca typ, którego wcześniej nie widzieliśmy,
trpl::Either. Typ Either jest nieco podobny do Result w tym, że ma dwa
przypadki. Jednak w przeciwieństwie do Result, w Either nie ma pojęcia
czynnika sukcesu ani porażki. Zamiast tego, używa Left i Right, aby wskazać
„jedno lub drugie”:
#![allow(unused)]
fn main() {
enum Either<A, B> {
Left(A),
Right(B),
}
}
Funkcja select zwraca Left z wynikiem tej future, jeśli pierwszy argument
wygra, i Right z wynikiem future drugiego argumentu, jeśli ta wygra.
Odpowiada to kolejności, w jakiej argumenty pojawiają się podczas wywoływania
funkcji: pierwszy argument jest na lewo od drugiego argumentu.
Aktualizujemy również page_title, aby zwracała ten sam URL, który został
przekazany. W ten sposób, jeśli strona, która zwróci się jako pierwsza, nie ma
<title>, którą możemy rozwiązać, nadal możemy wypisać sensowny komunikat.
Po uzyskaniu tych informacji, kończymy aktualizację naszego wyjścia println!,
aby wskazać zarówno to, który URL zakończył się jako pierwszy, jak i jaki,
jeśli w ogóle, jest <title> dla strony internetowej pod tym URL-em.
Zbudowałeś teraz mały, działający scraper internetowy! Wybierz kilka adresów URL
i uruchom narzędzie wiersza poleceń. Możesz odkryć, że niektóre witryny są
konsekwentnie szybsze od innych, podczas gdy w innych przypadkach szybsza
witryna zmienia się z uruchomienia na uruchomienie. Co ważniejsze, nauczyłeś
się podstaw pracy z futures, więc teraz możemy zagłębić się w to, co możemy
zrobić z async.