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 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::spawn w 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ę Future ze skojarzonym typem Output. Zauważ, że typ Output to Option<String>, czyli ten sam typ, co oryginalny typ zwracany przez wersję async fn page_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 typowi Output w typie zwracanym. Jest to tak samo jak inne bloki, które widziałeś.
  • Nowe ciało funkcji to blok async move ze względu na sposób użycia parametru url. (O async kontra async move bę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ę main async. 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 robi trpl::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::select jest zbudowany na bardziej ogólnej funkcji select zdefiniowanej w crate’cie futures. Funkcja select z crate’a futures potrafi wiele rzeczy, których funkcja trpl::select nie 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.