Futures und die asynchrone Syntax
Die Schlüsselelemente der asynchronen Programmierung in Rust sind Futures und
die Rust-Schlüsselwörter async
und await
.
Ein Future ist ein Wert, der vielleicht noch nicht verfügbar ist, aber
irgendwann in der Zukunft verfügbar sein wird. (Das gleiche Konzept taucht in
vielen Programmiersprachen auf, manchmal unter anderen Namen wie „task“ oder
„promise“.) Rust enthält ein Merkmal Future
als Baustein, sodass verschiedene
asynchrone Operationen mit verschiedenen Datenstrukturen, aber mit einer
gemeinsamen Schnittstelle implementiert werden können. In Rust sagen wir, dass
Typen, die das Merkmal Future
implementieren, Futures sind. Jeder Typ, der
Future
implementiert, hält seine eigene Information über den Fortschritt, der
gemacht wurde und was „fertig“ bedeutet.
Das Schlüsselwort async
kann auf Blöcke und Funktionen angewendet werden, um
anzugeben, dass sie unterbrochen und fortgesetzt werden können. Innerhalb eines
asynchronen Blocks oder einer asynchronen Funktion kannst du mit dem
Schlüsselwort await
warten, bis ein Future fertig ist, was als Warten auf
ein Future (awaiting a future) bezeichnet wird. Jede Stelle, an der du
innerhalb eines asynchronen Blocks oder einer asynchronen Funktion auf ein
Future wartest, ist eine Stelle, an der der asynchrone Block oder die
asynchrone Funktion unterbrochen und fortgesetzt werden kann. Der Vorgang, bei
dem bei einem Future geprüft wird, ob sein Wert bereits verfügbar ist, wird
polling genannt.
Andere Sprachen verwenden ebenfalls die Schlüsselwörter async
und await
für
die asynchrone Programmierung. Wenn du mit diesen Sprachen vertraut bist, wirst
du vielleicht einige signifikante Unterschiede in der Arbeitsweise von Rust
bemerken, einschließlich der Art und Weise, wie es die Syntax handhabt. Und das
aus gutem Grund, wie wir sehen werden!
Die meiste Zeit, wenn wir asynchrones Rust schreiben, verwenden wir die
Schlüsselwörter async
und await
. Rust kompiliert sie in äquivalenten Code
unter Verwendung des Merkmals Future
, genauso wie es for
-Schleifen in
äquivalenten Code unter Verwendung des Merkmals Iterator
kompiliert. Da Rust
das Merkmal Future
bereitstellt, kannst du es bei Bedarf auch für deine
eigenen Datentypen implementieren. Viele der Funktionen, die wir in diesem
Kapitel sehen werden, geben Typen mit ihren eigenen Implementierungen von
Future
zurück. Wir werden am Ende des Kapitels noch einmal auf die Definition
des Merkmals zurückkommen und mehr darüber erfahren, wie es funktioniert. Aber
das sind vorerst genug Details, die wir brauchen.
Das mag sich alles ein wenig abstrakt anfühlen. Lass uns unser erstes asynchrones Programm schreiben: Einen kleinen Web Scraper. Wir geben zwei URLs über die Befehlszeile ein, rufen beide gleichzeitig ab und geben das Ergebnis desjenigen zurück, der zuerst fertig wird. Dieses Beispiel wird eine neue Syntax verwenden, aber keine Sorge. Wir erklären dir alles, was du zum jeweiligen Zeitpunkt wissen musst.
Unser erstes asynchrones Programm
Um dieses Kapitel auf das Erlernen von async zu beschränken, anstatt mit Teilen
des Ökosystems zu jonglieren, haben wir die Kiste trpl
erstellt (trpl
ist
die Abkürzung für „The Rust Programming Language“). Sie re-exportiert alle
Typen, Merkmale und Funktionen, die du benötigst, hauptsächlich aus den Kisten
futures
und tokio
.
-
Die Kiste
futures
ist ein offizielles Zuhause für Rust-Experimente für asynchronen Code und ist eigentlich der Ort, an dem der TypFuture
ursprünglich entworfen wurde. -
Tokio ist heute die am häufigsten verwendete asynchrone Laufzeitumgebung in Rust, insbesondere (aber nicht nur!) für Webanwendungen. Es gibt noch andere großartige Laufzeitumgebungen, die für deine Zwecke evtl. besser geeignet sind. Wir verwenden Tokio unter der Haube für
trpl
, weil es gut getestet und weit verbreitet ist.
In einigen Fällen nennt trpl
die ursprünglichen APIs um oder umschließt sie,
damit wir uns auf die für dieses Kapitel relevanten Details konzentrieren
können. Wenn du verstehen willst, was die Kiste tut, empfehlen wir dir, sich
den Quellcode anzusehen. Du wirst sehen können, aus welcher
Kiste jeder Re-Export stammt, und wir haben ausführliche Kommentare angegeben,
die erklären, was die Kiste tut.
Erstelle ein neues Binärprojekt mit dem Namen hello-async
und füge die Kiste
trpl
als Abhängigkeit hinzu:
$ cargo new hello-async
$ cd hello-async
$ cargo add trpl
Jetzt können wir die verschiedenen von trpl
bereitgestellten Teile verwenden,
um unser erstes asynchrones Programm zu schreiben. Wir werden ein kleines
Kommandozeilen-Werkzeug erstellen, das zwei Webseiten abruft, das
jeweilige <title>
-Element ausliest und den Titel derjenigen Seite ausgibt,
die den Vorgang zuerst beendet hat.
Beginnen wir mit dem Schreiben einer Funktion, die eine Seiten-URL als Parameter entgegennimmt, eine Anfrage an diese stellt und den Text des Titelelements zurückgibt:
Dateiname: src/main.rs
extern crate trpl; fn main() { // TODO: Wir fügen dies als nächstes hinzu! } 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_element| title_element.inner_html()) }
In Codeblock 17-1 definieren wir eine Funktion page_title
und versehen sie
mit dem Schlüsselwort async
. Dann verwenden wir die Funktion trpl::get
, um
die übergebene URL abzurufen, und warten auf die Antwort mit dem Schlüsselwort
await
. Dann holen wir mit der Methode text
den Text aus der Antwort und
warten wieder mit dem Schlüsselwort await
. Diese beiden Schritte sind
asynchron. Bei get
müssen wir darauf warten, dass der Server den ersten Teil
seiner Antwort sendet, der den HTTP-Header, Cookies und so weiter enthält.
Dieser Teil der Antwort kann getrennt vom Hauptteil der Anfrage übermittelt
werden. Vor allem, wenn der Textteil sehr umfangreich ist, kann es einige Zeit
dauern, bis er vollständig angekommen ist. Daher müssen wir warten, bis die
gesamte Antwort eingetroffen ist, weshalb die Methode text
ebenfalls
asynchron ist.
Wir müssen beide Futures explizit abwarten, weil Futures in Rust faul (lazy)
sind: Sie tun nichts, bis man sie mit await
dazu auffordert. (Tatsächlich
zeigt Rust eine Compiler-Warnung an, wenn du keine Futures verwendest.) Das
sollte dich an unsere Diskussion über Iteratoren in Kapitel
13 erinnern. Iteratoren tun nichts, es sei denn, du rufst ihre
Methode next
auf – entweder direkt oder mit Hilfe von for
-Schleifen
oder Methoden wie map
, die next
unter der Haube verwenden. Für Futures gilt
die gleiche Grundidee: Sie tun nichts, es sei denn, man bittet sie ausdrücklich
darum. Diese Faulheit erlaubt es Rust, die Ausführung von asynchronem Code zu
vermeiden, bis er tatsächlich benötigt wird.
Anmerkung: Dies unterscheidet sich von dem Verhalten, das wir bei der Verwendung von
thread::spawn
im vorherigen Kapitel gesehen haben, wo der Funktionsabschluss, den wir an einen anderen Strang übergeben haben, sofort zu laufen begann. Es unterscheidet sich auch davon, wie viele andere Sprachen die asynchrone Programmierung umsetzen! Aber es ist wichtig für Rust. Warum das so ist, werden wir später sehen.
Sobald wir response_text
haben, können wir ihn mit Html::parse
in eine
Instanz des Typs Html
einlesen. Anstelle einer rohen Zeichenkette haben wir
nun einen Datentyp, den wir verwenden können, um mit HTML als eine
reichhaltigere Datenstruktur zu arbeiten. Insbesondere können wir die Methode
select_first
verwenden, um die erste Instanz eines bestimmten CSS-Selektors
zu finden. Durch Übergeben der Zeichenkette "title"
erhalten wir das erste
<title>
-Element im Dokument, wenn es eines gibt. Da es möglicherweise kein
passendes Element gibt, gibt select_first
eine Option<ElementRef>
zurück.
Schließlich verwenden wir die Methode Option::map
, die uns mit dem Element in
der Option
arbeiten lässt, wenn es vorhanden ist, und nichts tut, wenn es
nicht vorhanden ist. (Wir könnten hier auch einen match
-Ausdruck verwenden,
aber map
ist idiomatischer.) Im Rumpf der Funktion, die wir an map
übergeben, rufen wir inner_html
auf dem title_element
auf, um dessen Inhalt
als String
zu erhalten. Wenn alles erledigt ist, haben wir eine
Option<String>
.
Beachte, dass das Rust-Schlüsselwort await
hinter dem Ausdruck steht, auf den
du wartest, nicht vor ihm. Das heißt, es ist ein Postfix-Schlüsselwort. Dies
mag sich von dem unterscheiden, was du vielleicht gewohnt bist, wenn du async
in anderen Sprachen verwendet hast. Rust hat sich dafür entschieden, weil es
die Arbeit mit Methodenketten viel angenehmer macht. Als Ergebnis können wir
den Rumpf von page_url_for
ändern, um die Funktionsaufrufe trpl::get
und
text
mit await
dazwischen zu verketten, wie in Codeblock 17-2 gezeigt:
Dateiname: src/main.rs
extern crate trpl; use trpl::Html; fn main() { // TODO: Wir fügen dies als nächstes hinzu! } 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_element| title_element.inner_html()) }
Damit haben wir erfolgreich unsere erste asynchrone Funktion geschrieben! Bevor
wir etwas Code in main
hinzufügen, um sie aufzurufen, wollen wir uns ansehen,
was wir geschrieben haben und was es bedeutet.
Wenn Rust einen mit dem Schlüsselwort async
markierten Block sieht,
kompiliert es ihn in einen eindeutigen, anonymen Datentyp, der das Merkmal
Future
implementiert. Wenn Rust eine Funktion sieht, die mit dem
Schlüsselwort async
gekennzeichnet ist, kompiliert es sie zu einer nicht
asynchronen Funktion, deren Rumpf ein asynchroner Block ist. Der Rückgabetyp
einer asynchronen Funktion ist der Typ des anonymen Datentyps, den der Compiler
für diesen asynchronen Block erstellt.
Die Angabe von async fn
ist also gleichbedeutend mit dem Schreiben einer
Funktion, die ein Future des Rückgabetyps zurückgibt. Wenn der Compiler eine
Funktionsdefinition wie async fn page_title
in Codeblock 17-1 sieht, ist sie
äquivalent zu einer nicht-asynchronen Funktion, die wie folgt definiert ist:
#![allow(unused)] fn main() { extern crate trpl; 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()) } } }
Gehen wir die einzelnen Teile der umgewandelten Version durch:
- Sie verwendet die Syntax
impl Trait
, die wir bereits im Abschnitt „Merkmale als Parameter“ in Kapitel 10 besprochen haben. - Das zurückgegebene Merkmal ist ein
Future
mit dem assoziierten TypOutput
. Beachte, dass derOutput
-TypOption<String>
ist, was dem ursprünglichen Rückgabetyp derasync fn
-Version vonpage_title
entspricht. - Der gesamte im Rumpf der ursprünglichen Funktion aufgerufene Code wird in
einen
async move
-Block eingepackt. Denke daran, dass Blöcke Ausdrücke sind. Dieser ganze Block ist der Ausdruck, der von der Funktion zurückgegeben wird. - Dieser asynchrone Block erzeugt einen Wert vom Typ
Option<String>
, wie oben beschrieben. Dieser Wert entspricht dem TypOutput
im Rückgabetyp. Dies ist genau wie andere Blöcke, die du gesehen hast. - Der neue Funktionsrumpf ist ein
async move
-Block, da er den Parameterurl
verwendet. (Wir werden später in diesem Kapitel mehr überasync
vs.async move
sprechen.) - Die neue Version der Funktion hat eine Lebensdauerangabe, die wir bisher im
Ausgabetyp nicht gesehen haben:
'_
. Da die Funktion einFuture
zurückgibt, das sich auf eine Referenz bezieht – in diesem Fall die Referenz aus dem Parameterurl
– müssen wir Rust mitteilen, dass wir diese Referenz mit einbeziehen wollen. Wir müssen die Lebensdauer hier nicht angeben, da Rust intelligent genug ist, um zu wissen, dass es nur eine Referenz gibt, die involviert sein könnte, aber wir müssen explizit sagen, dass das resultierendeFuture
an diese Lebensdauer gebunden ist.
Jetzt können wir page_title
in main
aufrufen. Für den Anfang werden wir nur
den Titel einer einzelnen Seite abrufen. In Codeblock 17-3 folgen wir dem
gleichen Muster, das wir in Kapitel 12 für das Abrufen von
Kommandozeilenargumenten verwendet haben. Dann übergeben wir die erste URL an
page_title
und warten das Ergebnis ab. Da der vom Future erzeugte Wert ein
Option<String>
ist, verwenden wir einen match
-Ausdruck, um verschiedene
Meldungen auszugeben, je nachdem ob die Seite einen <Titel>
hatte oder nicht.
Dateiname: src/main.rs
extern crate trpl;
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!("Der Titel von {url} ist {title}"),
None => println!("{url} hat keinen Titel"),
}
}
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_element| title_element.inner_html())
}
Leider lässt sich das nicht kompilieren. Der einzige Ort, an dem wir das
Schlüsselwort await
verwenden können, ist in asynchronen Funktionen oder
Blöcken, und Rust lässt uns die spezielle Funktion main
nicht als async
markieren.
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`
Der Grund, warum main
nicht mit async
markiert werden kann, ist, dass
asynchroner Code eine Laufzeitumgebung benötigt: Eine Rust-Kiste, die die
Details der Ausführung von asynchronem Code verwaltet. Die Funktion main
eines Programms kann eine Laufzeitumgebung initialisieren, aber sie ist
nicht selbst eine Laufzeitumgebung. (Warum das so ist, werden wir später
sehen.) Jedes Rust-Programm, das asynchronen Code ausführt, hat mindestens eine
Stelle, an der es eine Laufzeitumgebung einrichtet und die Futures ausführt.
Die meisten Sprachen, die asynchrone Programmierung unterstützen, bündeln eine Laufzeitumgebung mit der Sprache. Rust tut dies nicht. Stattdessen gibt es viele verschiedene asynchrone Laufzeitumgebungen, von denen jede für den jeweiligen Anwendungsfall unterschiedliche Kompromisse eingeht. Ein Webserver mit hohem Durchsatz, vielen CPU-Kernen und einer großen Menge an RAM hat zum Beispiel ganz andere Anforderungen als einen Mikrocontroller mit einem einzigen Kern, einer kleinen Menge an RAM und keiner Möglichkeit, Haldenspeicher-Allokationen (heap allocations) durchzuführen. Die Kisten, die diese Laufzeitumgebungen bereitstellen, bieten oft auch asynchrone Versionen gängiger Funktionen wie Datei- oder Netzwerkkommunikation.
Hier und im Rest dieses Kapitels werden wir die Funktion run
aus der Kiste
trpl
verwenden, die ein Future als Argument annimmt und ihn bis zum Ende
ausführt. Hinter den Kulissen wird durch den Aufruf von run
eine
Laufzeitumgebung eingerichtet, die das übergebene Future ausführt. Sobald das
Future abgeschlossen ist, gibt run
den Wert zurück, den das Future erzeugt
hat.
Wir könnten das von page_title
zurückgegebene Future direkt an run
übergeben. Sobald es abgeschlossen ist, wären wir in der Lage, die
resultierende Option<String>
abzugleichen, so wie wir es in Codeblock 17-3
versucht haben. Für die meisten Beispiele in diesem Kapitel (und den meisten
asynchronen Code in der realen Welt!) werden wir jedoch mehr als nur einen
asynchronen Funktionsaufruf durchführen, also übergeben wir stattdessen einen
async
-Block und warten explizit auf das Ergebnis des Aufrufs von
page_title
, wie in Codeblock 17-4.
Dateiname: src/main.rs
extern crate trpl;
use trpl::Html;
fn main() {
let args: Vec<String> = std::env::args().collect();
trpl::run(async {
let url = &args[1];
match page_title(url).await {
Some(title) => println!("Der Titel von {url} ist {title}"),
None => println!("{url} hat keinen Titel"),
}
})
}
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_element| title_element.inner_html())
}
Wenn wir dies ausführen, erhalten wir das Verhalten, das wir anfangs erwartet haben:
$ 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'`
Der Titel von https://www.rust-lang.org ist
Rust Programming Language
Puh: Wir haben endlich funktionierenden asynchronen Code! Er lässt sich jetzt kompilieren und wir können ihn ausführen. Bevor wir Code hinzufügen, um zwei Webseiten gegeneinander antreten zu lassen, wollen wir uns noch einmal kurz der Funktionsweise von Futures zuwenden.
Jede Codestelle mit dem Schlüsselwort await
stellt eine Stelle dar, an der
die Kontrolle an die Laufzeitumgebung abgegeben wird. Damit das funktioniert,
muss Rust den Zustand des asynchronen Blocks verwalten, sodass die Laufzeitumgebung eine andere Arbeit starten und dann zurückkommen kann,
wenn sie bereit ist, diese Arbeit wieder fortzusetzen. Dies ist eine
unsichtbare Zustandsmaschine, so als ob man eine Aufzählung auf diese Weise
schreiben würde, um den aktuellen Zustand an jedem await
-Punkt zu speichern:
#![allow(unused)] fn main() { extern crate trpl; enum PageTitleFuture<'a> { Initial { url: &'a str }, GetAwaitPoint { url: &'a str }, TextAwaitPoint { response: trpl::Response }, } }
Den Code für den Übergang zwischen den einzelnen Zuständen von Hand zu schreiben, wäre mühsam und fehleranfällig, vor allem, wenn dem Code später mehr Funktionalität und mehr Zustände hinzugefügt werden. Stattdessen erstellt und verwaltet der Rust-Compiler die Zustandsmaschinen-Datenstrukturen für asynchronen Code automatisch. Falls du dich wunderst: Ja, die normalen Regeln für das Ausleihen und die Eigentümerschaft von Datenstrukturen gelten auch hier. Erfreulicherweise übernimmt der Compiler auch die Überprüfung dieser Regeln für uns und gibt gute Fehlermeldungen aus. Ein paar davon werden wir später im Kapitel durcharbeiten!
Letztendlich muss etwas diese Zustandsmaschine ausführen. Dieses Etwas ist eine Laufzeitumgebung. (Aus diesem Grund wird manchmal auf Executors verwiesen, wenn man sich mit Laufzeitumgebungen befasst: Ein Executor ist der Teil einer Laufzeitumgebung, der für die Ausführung des asynchronen Codes verantwortlich ist.)
Jetzt können wir verstehen, warum der Compiler uns in Codeblock 17-3 davon
abgehalten hat, main
selbst zu einer asynchronen Funktion zu machen. Wäre
main
eine asynchrone Funktion, müsste etwas anderes den Zustandsautomaten für
das Future verwalten, das main
zurückgibt, aber main
ist der Startpunkt des
Programms! Stattdessen rufen wir die Funktion trpl::run
in main
auf, die
eine Laufzeitumgebung einrichtet und das vom async
-Block zurückgegebene
Future ausführt, bis es Ready
zurückgibt.
Hinweis: Einige Laufzeitumgebungen stellen Makros zur Verfügung, mit denen du eine asynchrone
main
-Funktion schreiben kannst. Diese Makros schreibenasync fn main() { ... }
in ein normalesfn main
um, das dasselbe tut, was wir in Codeblock 17-5 von Hand gemacht haben: Eine Funktion aufrufen, die ein Future zu Ende ausführt, so wie estrpl::run
tut.
Fügen wir die Teile zusammen und sehen wir uns an, wie wir nebenläufigen Code
schreiben können, indem wir page_title
mit zwei verschiedenen URLs aufrufen,
die von der Befehlszeile übergeben werden, und diese miteinander vergleichen.
Dateiname: src/main.rs
extern crate trpl;
use trpl::{Either, Html};
fn main() {
let args: Vec<String> = std::env::args().collect();
trpl::run(async {
let title_fut_1 = page_title(&args[1]);
let title_fut_2 = page_title(&args[2]);
let (url, maybe_title) =
match trpl::race(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!("Der Titel ist: '{title}'"),
None => println!("Der Titel konnte nicht eingelesen werden."),
}
})
}
async fn page_title(url: &str) -> (&str, Option<String>) {
let text = trpl::get(url).await.text().await;
let title = Html::parse(&text)
.select_first("title")
.map(|title| title.inner_html());
(url, title)
}
In Codeblock 17-5 beginnen wir mit dem Aufruf von page_title
für jede der vom
Benutzer angegebenen URLs. Wir speichern die durch den Aufruf von page_title
erzeugten Futures als title_fut_1
und title_fut_2
. Denke daran, dass diese
noch nichts tun, denn Futures sind faul und wir haben noch nicht auf sie
gewartet. Dann übergeben wir die Futures an trpl::race
, das einen Wert
zurückgibt, der anzeigt, welches der übergebenen Futures zuerst fertig wurde.
Anmerkung: Unter der Haube ist
race
auf der allgemeineren Funktionselect
aufgebaut, der du in der realen Welt des Rust-Codes häufiger begegnen wirst. Eine Funktionselect
kann eine Menge Dinge tun, die die Funktiontrpl::race
nicht kann, aber sie bringt auch zusätzliche Komplexität mit sich, die wir für den Moment überspringen können.
Jedes Future kann legitimerweise „gewinnen“, also macht es keinen Sinn, ein
Result
zurückzugeben. Stattdessen gibt race
einen Typ zurück, den wir noch
nicht gesehen haben: trpl::Either
. Der Typ Either
ist einem Result
insofern ähnlich, als dass er zwei Fälle hat. Im Gegensatz zu Result
ist in
Either
jedoch kein Begriff von Erfolg oder Misserfolg eingebaut. Stattdessen
werden Left
und Right
verwendet, um „das eine oder das andere“ anzuzeigen.
#![allow(unused)] fn main() { enum Either<A, B> { Left(A), Right(B), } }
Die Funktion race
gibt Left
mit der Ausgabe des ersten Futures zurück, wenn
das erste Argument zuerst endet, und Right
mit der Ausgabe des zweiten
Futures, wenn dieses zuerst endet. Dies entspricht der Reihenfolge, in der die
Argumente beim Aufruf der Funktion erscheinen: Das erste Argument steht links
vom zweiten Argument.
Wir aktualisieren auch page_title
, um die gleiche URL zurückzugeben, die wir
übergeben haben. Auf diese Weise können wir eine aussagekräftige Meldung
ausgeben, wenn die Seite, die zuerst zurückkommt, keinen <title>
hat, den wir
auflösen können. Mit diesen Informationen aktualisieren wir die Ausgabe von
println!
, um anzugeben, welche URL als erste beendet wurde und was der
<title>
der Webseite hinter dieser URL war, sofern vorhanden.
Du hast jetzt einen kleinen funktionierenden Web Scraper erstellt! Wähle ein paar URLs aus und führe das Befehlszeilenwerkzeug aus. Du wirst möglicherweise feststellen, dass einige Webseiten stets schneller sind als andere, während in anderen Fällen die schnellere Webseite von Ausführung zu Ausführung unterschiedlich ist. Noch wichtiger ist, dass du die Grundlagen der Arbeit mit Futures gelernt hast, sodass wir uns jetzt noch mehr mit den Dingen befassen können, die wir mit asynchroner Programmierung tun können.