From c5ba2eccdf437368ae8bbdce2ce9750e462b2a38 Mon Sep 17 00:00:00 2001 From: Steven Hernandez Date: Sat, 24 Oct 2026 06:53:52 +0200 Subject: [PATCH] Rust-Kurs: Ein-/Ausgabe, Performance messen und mehr Praxis in Async, CLI, Tests und API-Design (LRN-9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neue Module: ein-ausgabe (Dateien, atomares Ersetzen, Command ohne Shell, TCP-Zeilenprotokoll; nur std, unterwegs), performance-messen (criterion, divan, hyperfine, Flamegraphs, Allokationen, Diagnose in vier Schritten; unterwegs) und profiling-werkstatt (am PC ein langsames Programm messen, profilieren und schneller machen). Neuer Track "Performance". Ausgebaut: async-rust (select!, Cancellation Safety, Semaphore, CancellationToken, Kanäle, tokio::net/fs, Streams, "future cannot be sent", #[tokio::test]), rust-praxis (clap, stdin, Env, BufWriter, Broken Pipe, Strg+C, TOML), testen-qualitaet (CARGO_BIN_EXE, assert_cmd, tempfile, proptest, insta), traits-generics (API-Design). Bestehende Übungen sind in ID und geprüften Teilen unverändert. Dazu Level-3-Projekt healthchecker, 12 Interview-Karten, 12 Glossarbegriffe, Spickzettel-Abschnitte. Rust: 14 → 17 Module, 161 → 215 Übungen. Co-Authored-By: Claude Opus 5.5 --- content/rust/area.yaml | 12 +- content/rust/cheatsheet.md | 107 +- content/rust/glossary.yaml | 24 + content/rust/interview.yaml | 122 ++ content/rust/modules/async-rust.yaml | 608 +++++- content/rust/modules/ein-ausgabe.yaml | 1079 +++++++++++ content/rust/modules/performance-messen.yaml | 1063 ++++++++++ content/rust/modules/profiling-werkstatt.yaml | 1708 +++++++++++++++++ content/rust/modules/rust-praxis.yaml | 620 +++++- content/rust/modules/testen-qualitaet.yaml | 518 ++++- content/rust/modules/traits-generics.yaml | 381 +++- content/rust/projects.yaml | 107 ++ 12 files changed, 6133 insertions(+), 216 deletions(-) create mode 100644 content/rust/modules/ein-ausgabe.yaml create mode 100644 content/rust/modules/performance-messen.yaml create mode 100644 content/rust/modules/profiling-werkstatt.yaml diff --git a/content/rust/area.yaml b/content/rust/area.yaml index f9dc01a5..2ca5a875 100644 --- a/content/rust/area.yaml +++ b/content/rust/area.yaml @@ -4,8 +4,10 @@ short: Rust tagline: Systemnah, sicher, schnell – ohne Garbage Collector. description: > Ownership und Borrowing, Traits und Generics, Fehlerbehandlung, Iteratoren, - Nebenläufigkeit ohne Data Races, async mit Tokio und professionelles Arbeiten - mit Cargo. Vom ersten cargo new bis zum eigenen CLI-Werkzeug. + Nebenläufigkeit ohne Data Races, Dateien, Prozesse und Netzwerk, async mit Tokio, + Performance messen und professionelles Arbeiten mit Cargo. Vom ersten cargo new + bis zum eigenen CLI-Werkzeug. Module mit der Marke „Unterwegs“ gehen auf dem Handy, + „Am PC“ braucht einen Rechner mit Cargo. color: "#FF6A3D" order: 2 outcomes: @@ -13,6 +15,7 @@ outcomes: - Du modellierst Daten mit Structs, Enums und Pattern Matching und behandelst Fehler mit `Result` und `?`. - Du schreibst generischen Code mit Traits und nutzt Iteratoren und Closures idiomatisch. - Du schreibst nebenläufigen und asynchronen Code mit Threads, Channels, `Arc>` und Tokio. + - Du arbeitest mit **Dateien, Prozessen und TCP-Verbindungen** aus der Standardbibliothek und **misst Performance, bevor du optimierst** – mit Release-Build, Benchmark, Flamegraph und Allokationszählung. - Du organisierst Projekte mit Cargo, Modulen, Tests, Clippy und rustfmt. - Du baust ein vollständiges CLI-Werkzeug und kannst Rust-Interviewfragen beantworten. tracks: @@ -24,4 +27,7 @@ tracks: modules: [collections-strings, traits-generics, lifetimes, closures-iteratoren] - title: Systeme & Praxis description: Projekte strukturieren, Speicher verwalten, nebenläufig und asynchron arbeiten, testen und ausliefern. - modules: [module-cargo, smart-pointer, nebenlaeufigkeit, async-rust, testen-qualitaet, rust-praxis] + modules: [module-cargo, smart-pointer, nebenlaeufigkeit, ein-ausgabe, async-rust, testen-qualitaet, rust-praxis] + - title: Performance + description: Erst unterwegs Messwerte, Benchmark-Ausgaben und Flamegraphs lesen, dann am PC selbst messen, profilieren und optimieren. + modules: [performance-messen, profiling-werkstatt] diff --git a/content/rust/cheatsheet.md b/content/rust/cheatsheet.md index b2cf300c..798ad69a 100644 --- a/content/rust/cheatsheet.md +++ b/content/rust/cheatsheet.md @@ -118,6 +118,22 @@ fn erster<'a>(werte: &'a [String]) -> Option<&'a str> { | `type Item = T` | Assoziierter Typ einer Implementierung | | `T: 'static` | Keine zu kurzen geliehenen Abhängigkeiten; nicht ewige Lebensdauer | +## API-Design + +| Ziel | Werkzeug | Merksatz | +| --- | --- | --- | +| Prüfend konvertieren | `impl TryFrom for Port` | `TryInto` gibt es dazu | +| Aus Text lesen | `impl FromStr for Stufe` | dann geht `"warn".parse::()` | +| Viele Optionen | Builder mit `fn x(mut self, …) -> Self` | `build()` prüft und liefert `Result` | +| Abläufe erzwingen | Typestate `Verbindung` + `PhantomData` | falsche Reihenfolge = E0599 | +| Fremde nicht implementieren lassen | sealed trait (privater Supertrait) | Trait bleibt erweiterbar | +| Methoden für fremde Typen | extension trait `…Ext` | wirkt nur, wo importiert | +| Enum darf wachsen | `#[non_exhaustive]` ab dem ersten Release | nachträglich selbst ein Major-Change | +| Ergebnis nicht verwerfen | `#[must_use = "…"]` | nicht doppelt an `Result`-Funktionen | +| Felder ändern dürfen | private Felder + Konstruktor | Literal-Syntax bricht sonst | + +[Rust API Guidelines · Checkliste](https://rust-lang.github.io/api-guidelines/checklist.html) + ## Iteratoren und Closures | API | Zweck | @@ -156,6 +172,95 @@ fn erster<'a>(werte: &'a [String]) -> Option<&'a str> { | `spawn_blocking` | Synchrone Arbeit aus Runtime-Workern auslagern; Begrenzung beachten | | `timeout` | Warten begrenzen; keine Rückabwicklung | | `mpsc::channel(n)` | Begrenzter Puffer mit Backpressure | +| `tokio::select!` | Erster fertiger Zweig gewinnt, die anderen werden gedroppt; `biased;` für feste Reihenfolge | +| `tokio::pin!(f)` + `&mut f` | Dieselbe Future über mehrere `select!`-Runden, etwa eine Frist | +| `Semaphore::acquire_owned` | Permit vor `spawn` holen, in die Task verschieben; begrenzt laufende Arbeit | +| `CancellationToken` | `cancel()` auslösen, in jeder Task `cancelled()` im `select!` abwarten | +| `TaskTracker` | `close()`, dann `wait()` bis alle Tasks fertig sind | +| `#[tokio::test(start_paused = true)]` | Timer ohne echte Wartezeit testen (Feature `test-util`) | + +| Kanal | Sender → Leser | Was ankommt | +| --- | --- | --- | +| `oneshot` | einer → einer | genau ein Wert (Antwort) | +| `mpsc` | viele → einer | jede Nachricht, begrenzter Puffer | +| `broadcast` | viele → viele | jede Nachricht an jeden; `Lagged(n)` bei Rückstand | +| `watch` | viele → viele | nur der letzte Wert (Konfiguration, Shutdown) | + +## Dateien, Prozesse und Netzwerk (std) + +| Aufgabe | API | Falle | +| --- | --- | --- | +| Anhängen | `OpenOptions::new().append(true).create(true)` | – | +| Nur neu anlegen | `.write(true).create_new(true)` | `AlreadyExists`, wenn es die Datei gibt | +| Gepuffert schreiben | `BufWriter::new(datei)` | Drop schluckt Fehler: `flush()?` aufrufen | +| Verzeichnis lesen | `fs::read_dir(pfad)?` | Reihenfolge unbestimmt, Einträge sind `Result` | +| Atomar ersetzen | temp im Zielverzeichnis, `sync_all`, `fs::rename` | anderes Dateisystem: `rename` scheitert | +| Programm starten | `Command::new(p).args(a).output()?` | keine Shell; `status.success()` prüfen | +| Exit-Code | `status.code()` | `None` bei Signal (Unix) | +| Kindprozess | `spawn()` und `wait()` | kein Kill bei Drop; ohne `wait` Zombie | +| Zwei Pipes lesen | `wait_with_output()` | nacheinander bis EOF lesen kann hängen | +| TCP-Server | `TcpListener::bind("127.0.0.1:0")?` | Port 0: das System wählt, `local_addr()` | +| Zeilen lesen | `reader.read_line(&mut zeile)?` | `0` heißt EOF; Framing ist deine Aufgabe | +| Schreiben | `write_all` + `flush` | `write` darf weniger schreiben | +| Hängende Gegenstelle | `set_read_timeout(Some(d))` | ohne Timeout blockiert der Thread | + +```rust +use std::{fs, io::Write}; + +fn ersetzen(ziel: &str, inhalt: &[u8]) -> std::io::Result<()> { + let tmp = format!("{ziel}.tmp"); + let mut f = fs::File::create(&tmp)?; + f.write_all(inhalt)?; + f.sync_all()?; + fs::rename(&tmp, ziel) +} +``` + +## CLI und Tests mit Crates + +| Aufgabe | Werkzeug | Merksatz | +| --- | --- | --- | +| Argumente, Subcommands | `#[derive(Parser)]`, `#[derive(Subcommand)]` (clap) | Nutzungsfehler enden mit Status 2 | +| Wert aus Umgebung | `#[arg(env = "LOGWERK_STUFE")]` | Vorrang: CLI > Umgebung > Datei > Standard | +| stdin als Datei | Pfad `-` → `io::stdin().lock()` als `BufRead` | eine Funktion für beides: `Box` | +| Schnelle Ausgabe | `BufWriter::new(io::stdout().lock())` | `flush()` am Ende selbst aufrufen | +| Ausgabe an `head` | `ErrorKind::BrokenPipe` als normales Ende | `println!` panikt bei Schreibfehlern | +| Strg+C | `ctrlc::set_handler` + `AtomicBool` | ohne Handler gehen Puffer verloren | +| TOML-Konfiguration | `#[derive(Deserialize)]`, `toml::from_str` | `#[serde(deny_unknown_fields)]` fängt Tippfehler | +| Binary im Test | `env!("CARGO_BIN_EXE_logwerk")` | nur in Integrationstests und Benchmarks gesetzt | +| CLI-Asserts | `assert_cmd` + `predicates` | Status, stdout und stderr getrennt prüfen | +| Wegwerf-Verzeichnis | `tempfile::tempdir()` | wird beim Drop gelöscht; `keep()` zum Untersuchen | +| Property-Test | `proptest! { … }` | verkleinert ein Gegenbeispiel, solange der Test scheitert | +| Snapshot | `insta::assert_snapshot!` | Änderungen mit `cargo insta review` bewusst annehmen | + +## Messen und Profiling + +```toml +[profile.profiling] +inherits = "release" +debug = true +``` + +```bash +cargo build --profile profiling +hyperfine --warmup 3 ./logauswertung-vorher target/profiling/logauswertung +samply record ./target/profiling/logauswertung +cargo bench --bench auswerten -- --save-baseline vorher +cargo bench --bench auswerten -- --baseline vorher +``` + +| Werkzeug / Ausgabe | Lesart | +| --- | --- | +| `Instant::now()` / `elapsed()` | Monotone Uhr; eine Einzelmessung ist Rauschen | +| `std::hint::black_box(x)` | Eingabe und Ergebnis vor dem Optimierer verstecken; „best effort“ | +| criterion `time: [a b c]` | Konfidenzintervall: untere Grenze, Schätzung, obere Grenze | +| criterion `change:` mit `p < 0.05` | Signifikant; „within noise threshold“ heißt trotzdem: nicht relevant | +| `iter_batched` | Frische Eingabe pro Aufruf, Herstellen wird nicht gemessen | +| hyperfine | Ganzes Programm mit Start und I/O; erstes Kommando ist die Referenz | +| Flamegraph-Breite | Anteil der Samples inklusive Kinder; x-Achse ist keine Zeit | +| Oberkante eines Balkens | Selbstzeit der Funktion | +| Viel `malloc`/`free` im Profil | Allokationen zählen (`GlobalAlloc`-Zähler), `&str` statt `String`, `entry`, `with_capacity`, Puffer wiederverwenden | +| Amdahl | 5 % Anteil an der Laufzeit spart höchstens 5 % | ## Häufige Compilerfehler @@ -171,4 +276,4 @@ fn erster<'a>(werte: &'a [String]) -> Option<&'a str> { ## Offizielle Referenzen -[The Book](https://doc.rust-lang.org/book/) · [Standardbibliothek](https://doc.rust-lang.org/std/) · [Cargo](https://doc.rust-lang.org/cargo/) · [Compilerfehler](https://doc.rust-lang.org/error_codes/error-index.html) · [Clippy](https://doc.rust-lang.org/clippy/) · [Tokio](https://tokio.rs/tokio/tutorial) +[The Book](https://doc.rust-lang.org/book/) · [Standardbibliothek](https://doc.rust-lang.org/std/) · [Cargo](https://doc.rust-lang.org/cargo/) · [Compilerfehler](https://doc.rust-lang.org/error_codes/error-index.html) · [Clippy](https://doc.rust-lang.org/clippy/) · [Tokio](https://tokio.rs/tokio/tutorial) · [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/) · [Rust Performance Book](https://nnethercote.github.io/perf-book/) diff --git a/content/rust/glossary.yaml b/content/rust/glossary.yaml index 9f329c05..a67228ca 100644 --- a/content/rust/glossary.yaml +++ b/content/rust/glossary.yaml @@ -9,6 +9,8 @@ terms: definition: Typ, den eine konkrete Trait-Implementierung festlegt, etwa `Iterator::Item`. - term: Async definition: Programmierung mit Futures und kooperativen Wartepunkten. Sie bedeutet nicht automatisch CPU-Parallelität. +- term: Atomares Ersetzen + definition: Datei vollständig in eine temporäre Datei im selben Verzeichnis schreiben, mit `sync_all` sichern und per `fs::rename` über das Ziel legen. Leser sehen die alte oder die neue Fassung, nie eine halbe. - term: Backpressure definition: Rückmeldung begrenzter Verarbeitungskapazität an den Produzenten, etwa durch einen vollen bounded Channel. @@ -18,6 +20,12 @@ terms: definition: Zeitweises Nutzen eines Werts über Referenzen, ohne seinen Besitz zu übernehmen. - term: Box definition: Eigener Besitz an einem Wert über eine Heap-Indirektion. Nützlich unter anderem für rekursive Typen. +- term: Broken Pipe + definition: Schreibfehler, wenn der Leser einer Pipe schon beendet ist, etwa `programm | head -1`. Rust ignoriert SIGPIPE, das Schreiben liefert `ErrorKind::BrokenPipe`, und `println!` panikt. +- term: Builder + definition: Hilfstyp, der viele optionale Angaben schrittweise über verkettete Methoden sammelt und in `build` prüft. Ersatz für fehlende benannte und optionale Parameter. +- term: Cancellation Safety + definition: Eigenschaft einer async-Methode, beim Drop an einem `.await` keine Daten oder Fortschritte zu verlieren. Wichtig für `tokio::select!` in Schleifen; Tokio nennt sie je Methode. - term: Cargo definition: Rusts Werkzeug für Pakete, Builds, Abhängigkeiten, Tests und weitere Entwicklungsaufgaben. - term: Channel @@ -48,9 +56,13 @@ terms: machen. - term: Feature definition: Benannte Cargo-Option zur Aktivierung optionaler Teile einer Crate oder ihrer Abhängigkeiten. +- term: Flamegraph + definition: Darstellung von Stack-Samples eines Profilers. Die Breite eines Balkens ist der Anteil der Samples inklusive Kinder, die Höhe die Aufruftiefe; die x-Achse ist keine Zeit. - term: FnOnce definition: Closure-Trait für einen Aufruf, der eingefangene Werte konsumieren darf. Jede Closure implementiert zumindest FnOnce. +- term: Framing + definition: Regel, wie ein Protokoll Nachrichten in einem Bytestrom wie TCP abgrenzt, etwa durch ein Zeilenende oder eine vorangestellte Länge. - term: Future definition: Wert, der eine noch nicht abgeschlossene Berechnung beschreibt und durch Polling vorangetrieben wird. - term: Generic @@ -98,6 +110,8 @@ terms: - term: Pin definition: API zur Einschränkung des Bewegens bestimmter Werte nach dem Pinnen. Relevant unter anderem für selbstreferenzielle Future-Zustände. +- term: Property-Test + definition: Test, der eine Eigenschaft für viele erzeugte Eingaben prüft (z. B. Roundtrip). Werkzeuge wie proptest verkleinern ein gefundenes Gegenbeispiel, solange der Test weiter scheitert. - term: RAII definition: Ressourcenverwaltung über Objektlebensdauer. Freigabe wird an das Ende des Besitzes gekoppelt. - term: Rc @@ -106,15 +120,25 @@ terms: definition: Typ für innere Veränderbarkeit mit zur Laufzeit geprüften Leihregeln. Regelverletzung kann paniken. - term: Result definition: Enum aus `Ok(T)` und `Err(E)` für Erfolg oder konkrete Fehlerursache. +- term: Sampling-Profiler + definition: Werkzeug wie samply oder perf, das in festen Abständen den Stack eines Programms aufzeichnet. Häufig gesehene Funktionen kosten viel Zeit; braucht Release-Build mit Debug-Symbolen. +- term: Sealed Trait + definition: Öffentlicher Trait mit einem Supertrait aus einem privaten Modul. Fremde Crates können ihn benutzen, aber nicht implementieren, deshalb darf er später neue Methoden bekommen. +- term: Semaphore + definition: Zähler für Erlaubnisse (Permits). Wer arbeiten will, holt ein Permit und gibt es beim Drop zurück; so laufen höchstens N Aufgaben gleichzeitig. - term: Send definition: Marker-Trait, der sicheren Besitztransfer eines Typs zwischen Threads ausdrückt. - term: Slice definition: Geliehene Sicht auf eine zusammenhängende Folge von Elementen. Ein Slice besitzt nicht den zugrunde liegenden Puffer. +- term: Snapshot-Test + definition: Test, der eine Ausgabe mit einer gespeicherten Referenzdatei vergleicht (insta). Änderungen werden bewusst geprüft und angenommen. - term: Sync definition: Marker-Trait, der sicheren threadübergreifenden Gebrauch geteilter Referenzen auf einen Typ ausdrückt. - term: Trait definition: Vertrag für Verhalten mit Methoden und gegebenenfalls assoziierten Typen oder Konstanten. +- term: Typestate + definition: Muster, das den Zustand eines Objekts als Typparameter trägt, etwa `Verbindung`. Methoden gibt es nur im passenden Zustand; Verstöße sind Compile-Fehler. - term: Unsafe definition: Bereich für bestimmte zusätzliche Operationen mit manuell einzuhaltenden Sicherheitsinvarianten. Normale Typregeln gelten weiterhin. diff --git a/content/rust/interview.yaml b/content/rust/interview.yaml index f3bac49c..cce6610f 100644 --- a/content/rust/interview.yaml +++ b/content/rust/interview.yaml @@ -428,6 +428,128 @@ cards: Die Abstraktion soll keinen unvermeidbaren Zusatzaufwand gegenüber einer entsprechenden direkten Implementierung erzeugen. Das ist keine Garantie, dass jede generische Pipeline optimal ist. Allokationen, Datenlayout und tatsächliche Messungen bleiben relevant. [Offizielle Dokumentation](https://doc.rust-lang.org/book/ch10-00-generics.html). +- id: atomar-ersetzen + module: ein-ausgabe + level: 2 + tags: + - io + q: Wie ersetzt du eine Konfigurationsdatei so, dass ein Absturz keine halbe Datei hinterlässt? + a: | + Ich schreibe den neuen Inhalt in eine temporäre Datei im selben Verzeichnis, rufe `flush` und `sync_all` auf und ersetze das Ziel dann mit `fs::rename`. Auf demselben Dateisystem ist `rename` unter POSIX atomar: Ein Leser sieht die alte oder die neue Fassung, nie eine Mischung. Liegt die temporäre Datei auf einem anderen Dateisystem, etwa unter `/tmp`, scheitert `rename` mit `EXDEV`. Wer auch nach einem Stromausfall sicher sein will, synchronisiert zusätzlich das Verzeichnis. + + [std::fs::rename](https://doc.rust-lang.org/std/fs/fn.rename.html) · [rename(2)](https://man7.org/linux/man-pages/man2/rename.2.html) +- id: command-ohne-shell + module: ein-ausgabe + level: 2 + tags: + - io + - sicherheit + q: Warum `Command::new("git").args(["log", pfad])` statt `sh -c` mit zusammengesetztem String? + a: | + `Command` übergibt jedes Argument als eigenen Eintrag an das Programm, ohne Shell dazwischen. Ein Dateiname mit Leerzeichen, `;` oder `$(…)` bleibt ein Dateiname, es gibt keine Injection, kein Globbing und keine Variablenexpansion. Brauche ich doch Shell-Funktionen, schreibe ich ein festes Skript für `sh -c` und reiche die Daten als Positionsargumente durch. Danach prüfe ich den Exit-Status, denn ein gestartetes Programm ist noch kein erfolgreiches. + + [std::process::Command](https://doc.rust-lang.org/std/process/struct.Command.html) +- id: tcp-bytestrom + module: ein-ausgabe + level: 2 + tags: + - io + - netzwerk + q: Ein TCP-Client bekommt manchmal nur halbe Nachrichten. Warum, und was tust du? + a: | + TCP liefert einen Bytestrom ohne Nachrichtengrenzen. Ein `read` gibt zurück, was gerade angekommen ist, also auch eine halbe oder anderthalb Nachrichten. Das Protokoll braucht deshalb Framing: ein Trennzeichen wie `\n`, das ich mit `BufReader::read_line` lese, oder eine Längenangabe vor jeder Nachricht, die ich mit `read_exact` lese. Dazu ein Lese-Timeout, damit eine stumme Gegenstelle den Thread nicht ewig blockiert. + + [std::net::TcpStream](https://doc.rust-lang.org/std/net/struct.TcpStream.html) · [BufRead::read_line](https://doc.rust-lang.org/std/io/trait.BufRead.html#method.read_line) +- id: langsamer-release + module: performance-messen + level: 3 + tags: + - performance + q: Ein Dienst ist nach dem Release spürbar langsamer. Wie gehst du vor? + a: | + Erst reproduzieren: Release-Build, dieselben Daten, alte und neue Version unter gleichen Bedingungen gemessen, mit hyperfine für den ganzen Lauf oder criterion mit Baseline für die verdächtige Funktion. Liegt der Unterschied außerhalb der Streuung, profiliere ich mit einem Sampling-Profiler und lese den Flamegraph: Wo ist der neue breite Balken? Daraus wird eine Hypothese, ich ändere genau eine Sache und messe erneut. Ohne Messung vorher und nachher gibt es keine Optimierung, nur eine Vermutung. + + [The Rust Performance Book](https://nnethercote.github.io/perf-book/) +- id: flamegraph-breite + module: performance-messen + level: 2 + tags: + - performance + q: Was zeigt die Breite eines Balkens im Flamegraph – und was nicht? + a: | + Die Breite ist der Anteil der Samples, in denen diese Funktion auf dem Stack lag, ihre Kinder eingeschlossen. Die freie Oberkante über einem Balken ist Selbstzeit, also Arbeit in der Funktion selbst. Die x-Achse ist keine Zeitachse: Stacks werden zusammengefasst und sortiert, die Farben tragen keine Bedeutung. Ein On-CPU-Profiler zeigt außerdem keine Wartezeit auf I/O oder Locks. + + [The Rust Performance Book · Profiling](https://nnethercote.github.io/perf-book/profiling.html) +- id: black-box + module: performance-messen + level: 3 + tags: + - performance + q: Dein Benchmark meldet 0,3 ns für eine Funktion mit einer Schleife. Was ist passiert? + a: | + Der Optimierer hat die Arbeit entfernt oder zur Compile-Zeit ausgerechnet, weil das Ergebnis nie benutzt wird oder die Eingabe konstant ist. Gemessen wird dann ein leerer Aufruf. `std::hint::black_box` um Eingabe und Ergebnis verhindert das nach bestem Bemühen – die Doku verspricht ausdrücklich keine Garantie. Ich prüfe solche Werte gegen eine Größenordnungsschätzung, bevor ich ihnen glaube. + + [std::hint::black_box](https://doc.rust-lang.org/std/hint/fn.black_box.html) +- id: allokationen-reduzieren + module: profiling-werkstatt + level: 3 + tags: + - performance + q: Ein Profil zeigt viel Zeit in `malloc` und `free`. Wie gehst du vor? + a: | + Ich suche im Flamegraph die Aufrufer der Allokationen und zähle sie, etwa mit einem zählenden `GlobalAlloc` oder dhat. Typische Quellen in heißen Schleifen sind `to_string`, `format!`, `collect` in Zwischen-Vecs und `String`-Schlüssel. Abhilfe: `&str` statt `String`, Puffer mit `clear` wiederverwenden, `with_capacity`, die `entry`-API. Danach messe ich Zeit und Zahl der Allokationen erneut. + + [The Rust Performance Book · Heap Allocations](https://nnethercote.github.io/perf-book/heap-allocations.html) +- id: select-verlierer + module: async-rust + level: 3 + tags: + - async + q: Was passiert in `tokio::select!` mit den Zweigen, die nicht gewinnen? + a: | + Sobald ein Zweig fertig ist, droppt `select!` die übrigen Futures. Arbeit, die sie bis zum letzten `.await` erledigt hatten, kann dabei verloren gehen, etwa schon gelesene Bytes in einem internen Puffer. Deshalb prüfe ich die Cancellation Safety jeder Methode, die ich in einer `select!`-Schleife verwende; die Tokio-Doku nennt sie je Methode. Ohne `biased;` wählt `select!` unter bereiten Zweigen zufällig. + + [tokio::select!](https://docs.rs/tokio/latest/tokio/macro.select.html) +- id: semaphore-grenze + module: async-rust + level: 3 + tags: + - async + q: Du musst 1.000 Endpunkte prüfen, aber höchstens 20 gleichzeitig. Wie? + a: | + Eine `Arc` mit 20 Permits. Vor jeder Prüfung hole ich mit `acquire_owned().await` ein Permit und verschiebe es in die Task; es wird beim Drop am Ende der Task zurückgegeben, auch bei Fehler oder Panic. Die Tasks sammle ich in einem `JoinSet`. Ein begrenzter Channel allein reicht nicht, wenn der Consumer für jede Nachricht beliebig viele Tasks startet. + + [tokio::sync::Semaphore](https://docs.rs/tokio/latest/tokio/sync/struct.Semaphore.html) +- id: broken-pipe + module: rust-praxis + level: 2 + tags: + - cli + q: Warum panikt `mein-cli | head -1`, und wie behebst du es? + a: | + `head` beendet sich nach der ersten Zeile und schließt die Pipe. Rust-Programme ignorieren SIGPIPE standardmäßig, der nächste Schreibversuch liefert deshalb einen `io::Error` mit `ErrorKind::BrokenPipe`, und `println!` panikt bei jedem Schreibfehler. Ich schreibe mit `writeln!` auf einen gepufferten, gesperrten stdout, reiche Fehler mit `?` nach oben und behandle `BrokenPipe` in `main` als normales Ende. + + [std::io::ErrorKind::BrokenPipe](https://doc.rust-lang.org/std/io/enum.ErrorKind.html#variant.BrokenPipe) +- id: property-snapshot + module: testen-qualitaet + level: 3 + tags: + - tests + q: Wann nimmst du einen Property-Test, wann einen Snapshot-Test? + a: | + Einen Property-Test, wenn sich eine Regel für alle Eingaben formulieren lässt: Roundtrip `parse(format(x)) == x`, Sortierung bleibt sortiert, kein Panic bei beliebigem Text. proptest erzeugt Eingaben und verkleinert ein gefundenes Gegenbeispiel, solange der Test weiter scheitert. Einen Snapshot-Test für große, stabile Ausgaben wie Hilfetext oder Bericht, die ich nicht Feld für Feld prüfen will; jede Änderung schaue ich dann bewusst an, statt sie blind zu akzeptieren. Beide ergänzen Beispieltests, sie ersetzen sie nicht. + + [proptest](https://docs.rs/proptest/latest/proptest/) · [insta](https://docs.rs/insta/latest/insta/) +- id: non-exhaustive + module: traits-generics + level: 3 + tags: + - api + q: Wie machst du ein öffentliches Fehler-Enum erweiterbar? + a: | + Mit `#[non_exhaustive]`, und zwar beim ersten Release oder beim nächsten Major-Release – nachträglich angebracht bricht das Attribut selbst jedes vorhandene `match`. Danach brauchen Crates, die meine Bibliothek nutzen, in jedem `match` einen `_`-Arm, und eine neue Variante ist kein Breaking Change mehr. Im eigenen Crate wirkt das Attribut nicht. Dazu gehören private Felder in öffentlichen Structs und versiegelte Traits, wenn nur meine Bibliothek sie implementieren soll. + + [Reference · non_exhaustive](https://doc.rust-lang.org/reference/attributes/type_system.html#the-non_exhaustive-attribute) - id: language-choice level: 2 tags: diff --git a/content/rust/modules/async-rust.yaml b/content/rust/modules/async-rust.yaml index 85e1958f..e165eb7b 100644 --- a/content/rust/modules/async-rust.yaml +++ b/content/rust/modules/async-rust.yaml @@ -1,13 +1,16 @@ id: async-rust title: Async Rust mit Tokio level: 3 -minutes: 180 +minutes: 240 summary: Futures, Tasks und Warteoperationen mit klaren Besitzern, Grenzen und Abbruchwegen organisieren. +place: pc goals: -- Du erklärst Future, Runtime, await und spawn getrennt. -- Du sammelst Task-Ergebnisse und behandelst JoinError. -- Du begrenzt Arbeit mit Channels und planst Cancellation. -- Du erkennst blockierende Aufrufe und gefährliche Lock-Lebensdauern. +- Du erklärst Future, Runtime, `.await` und `spawn` getrennt und liest den Fehler „future cannot be sent between threads safely“. +- Du sammelst Task-Ergebnisse mit `JoinHandle` und `JoinSet` und trennst `JoinError` vom fachlichen Fehler. +- Du setzt `select!` mit gepinntem Timer und `biased;` ein und prüfst die Cancellation Safety jedes Zweigs. +- Du beendest Tasks kooperativ mit `CancellationToken` und `TaskTracker` statt mit `abort()`. +- Du wählst den passenden Kanal und begrenzt Arbeit mit Backpressure und `Semaphore`. +- Du testest Timer-Logik mit `#[tokio::test(start_paused = true)]` ohne echte Wartezeit. resources: - &id001 title: Tokio · Async in depth @@ -21,13 +24,13 @@ resources: - title: Tokio · Graceful shutdown url: https://tokio.rs/tokio/topics/shutdown lesson: | - Ein Werkzeug fragt viele Dienste ab und verbringt den größten Teil seiner Zeit mit Warten. Ein Betriebssystem-Thread pro Anfrage ist möglich, kostet aber Ressourcen. Asynchroner Code ermöglicht, viele wartende Operationen auf einer kleineren Zahl von Threads zu verwalten. CPU-intensive Arbeit wird dadurch nicht automatisch schneller, und ein blockierender Aufruf bleibt blockierend, auch wenn er in einer `async fn` steht. + Ein Werkzeug fragt 200 Dienste nach ihrem Health-Status und wartet fast die ganze Zeit auf das Netz. Ein Thread pro Anfrage kostet je einen Stack und Kontextwechsel im Kernel; asynchroner Code verwaltet viele wartende Operationen auf wenigen Threads. CPU-lastige Arbeit wird dadurch nicht schneller, und ein blockierender Aufruf bleibt blockierend, auch in einer `async fn`. - Du kennst Threads, Channels und Ownership bereits. Jetzt kommen Futures und eine Runtime dazu. Das zentrale Lernziel ist nicht „überall await schreiben“, sondern die Lebensdauer nebenläufiger Arbeit bewusst zu kontrollieren: Wer startet sie, wer wartet auf das Ergebnis, wann wird sie abgebrochen und wie wird Überlast begrenzt? + Threads, Channels und `Send`/`Sync` kennst du aus [Nebenläufigkeit](/rust/nebenlaeufigkeit), Dateien und TCP mit der Standardbibliothek aus [Ein- und Ausgabe](/rust/ein-ausgabe). Hier geht es um die Lebensdauer nebenläufiger Arbeit: Wer startet sie, wer wartet auf das Ergebnis, wann endet sie, und wie begrenzt du Überlast? ## Futures beschreiben noch nicht abgeschlossene Arbeit - Der Aufruf einer `async fn` erzeugt eine Future. Ihr Körper läuft nicht allein deshalb schon im Hintergrund. Eine Runtime muss die Future vorantreiben. Sie fragt mit `poll`, ob ein Ergebnis bereit ist; andernfalls liefert die Future `Pending` und organisiert eine spätere Benachrichtigung über einen Waker. Die [Tokio-Erklärung zu Futures](https://tokio.rs/tokio/tutorial/async) beschreibt diese Zustandsmaschine. + Der Aufruf einer `async fn` erzeugt eine Future. Ihr Körper läuft dadurch noch nicht. Eine Runtime ruft `poll` auf, und die Future liefert ihr Ergebnis oder `Pending`. Im zweiten Fall hat sie einen Waker hinterlegt, über den sie meldet, wann sich ein neuer Versuch lohnt ([Tokio: Async in depth](https://tokio.rs/tokio/tutorial/async)). ```rust async fn meldung() { @@ -41,20 +44,22 @@ lesson: | } ``` - Hier erscheint nur `erzeugt`. Der Future-Wert wurde nie gepollt. `.await` bedeutet innerhalb einer async-Funktion, die innere Future zum Abschluss zu treiben und bei Bedarf die Ausführung freizugeben. Es bedeutet weder „neuer Thread“ noch „garantierte Unterbrechung“. Eine bereits fertige Future kann unmittelbar ein Ergebnis liefern. + Hier erscheint nur `erzeugt`, denn niemand hat die Future gepollt. `.await` heißt: die innere Future bis zum Ergebnis treiben und, solange sie `Pending` liefert, den Thread für andere Tasks freigeben. Es heißt weder „neuer Thread“ noch „garantierte Unterbrechung“; eine fertige Future liefert sofort. - Die Sprache stellt Future und async-Syntax bereit. Tokio ergänzt Executor, Timer, Netzwerkoperationen und Synchronisationswerkzeuge. Für die Beispiele verwendest du Tokio 1 mit den benötigten Features: + Die Sprache stellt `Future` und `async` bereit, Tokio Executor, Timer, Netzwerk und Synchronisation: ```toml [dependencies] - tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync"] } + tokio = { version = "1.54", features = ["full"] } + tokio-util = { version = "0.7", features = ["rt"] } + tokio-stream = { version = "0.1", features = ["io-util"] } ``` - Die Plattform prüft Standardbibliotheks-Beispiele automatisch. Tokio-Beispiele benötigen diese Cargo-Abhängigkeit und sind deshalb hier dokumentationsgeprüfte Beispiele für dein lokales Cargo-Projekt, keine im Browser ausgeführten Aufgaben. + `full` schaltet alle stabilen Features außer `test-util` ein; Bibliotheken wählen gezielt aus der [Feature-Liste](https://docs.rs/tokio/latest/tokio/#feature-flags). Die Plattform kompiliert Tokio-Code nicht, jedes Beispiel hier ist lokal mit Tokio 1.54 gelaufen. ## Await-Reihenfolge und gemeinsame Ausführung - Zwei direkt aufeinanderfolgende `.await`-Aufrufe beginnen im üblichen Fall nacheinander. Erzeuge und bearbeite beide Futures gemeinsam, wenn die Operationen voneinander unabhängig sind. `tokio::join!` führt sie innerhalb derselben Task nebenläufig fort; es verspricht keine parallele CPU-Ausführung auf zwei Kernen. + Zwei aufeinanderfolgende `.await` laufen nacheinander. Sind die Operationen unabhängig, treibe beide Futures gemeinsam voran. `tokio::join!` tut das innerhalb derselben Task; es verspricht keine parallele Ausführung auf zwei Kernen. ```rust use tokio::time::{sleep, Duration}; @@ -71,11 +76,11 @@ lesson: | } ``` - Beide Zweige dürfen während ihrer Wartephase Fortschritt ermöglichen. Das Tupel folgt der Argumentreihenfolge, nicht der Abschlussreihenfolge. Bei `Result`-liefernden Operationen kann `try_join!` beim ersten Fehler zurückkehren. Prüfe dann, was das Fallenlassen der anderen Future bedeutet; bereits versendete Anfragen werden dadurch nicht rückgängig gemacht. Die [join!-Dokumentation](https://docs.rs/tokio/latest/tokio/macro.join.html) erläutert auch, warum blockierende Zweige alle anderen Zweige derselben Task anhalten. + Beide Zweige warten gleichzeitig. Das Tupel folgt der Argumentreihenfolge, nicht der Abschlussreihenfolge. `try_join!` kehrt beim ersten Fehler zurück und droppt die übrigen Futures; schon versendete Anfragen macht das nicht rückgängig. Ein blockierender Zweig hält alle anderen Zweige derselben Task an ([join!](https://docs.rs/tokio/latest/tokio/macro.join.html)). - ## Spawn braucht einen Besitzer und ein Ergebnis + ## Spawn: Tasks brauchen Besitzer, Ergebnis und `Send` - `tokio::spawn` startet eine eigenständig geplante Task. Die Future muss dafür üblicherweise `Send + 'static` sein. `Send` erlaubt das Verschieben zwischen Runtime-Threads. `'static` bedeutet hier, dass sie keine kurzlebigen geliehenen Daten des Aufrufers benötigt; es bedeutet nicht, dass sie für immer laufen muss. `async move` übernimmt benötigte Werte. + `tokio::spawn` startet eine eigenständig geplante Task. Die Future muss `Send + 'static` sein: `Send`, weil die Runtime die Task zwischen ihren Worker-Threads verschieben darf, `'static`, weil sie keine kurzlebigen Leihgaben des Aufrufers halten darf. `async move` übernimmt die benötigten Werte. ```rust #[tokio::main] @@ -87,9 +92,7 @@ lesson: | } ``` - Der [`JoinHandle`](https://tokio.rs/tokio/tutorial/spawning) liefert beim Await zusätzlich einen `JoinError`, beispielsweise wenn die Task panikt oder abgebrochen wird. Gibt die Task selbst `Result` zurück, entstehen zwei getrennte Fehlerebenen: Konnte die Task beendet werden, und war ihre fachliche Arbeit erfolgreich? Handle beide statt sie in ein irreführendes „Request fehlgeschlagen“ zusammenzuwerfen. - - Das Fallenlassen eines JoinHandle bricht eine Tokio-Task nicht ab, sondern trennt den Handle von ihr. Eine Anwendung sollte wichtige Tasks deshalb sammeln. Ein [`JoinSet`](https://docs.rs/tokio/latest/tokio/task/struct.JoinSet.html) verwaltet mehrere Tasks und liefert mit `join_next` jeweils einen Abschluss. Seine Reihenfolge ist nicht die Startreihenfolge. Beim Fallenlassen des Sets werden verbleibende Tasks abgebrochen. + Der [`JoinHandle`](https://tokio.rs/tokio/tutorial/spawning) liefert beim Await einen `JoinError`, wenn die Task panikt oder abgebrochen wird. Gibt die Task selbst `Result` zurück, gibt es zwei Fehlerebenen: Lief die Task zu Ende, und war ihre Arbeit erfolgreich? Behandle beide getrennt. Einen JoinHandle fallen zu lassen bricht die Task nicht ab. Ein [`JoinSet`](https://docs.rs/tokio/latest/tokio/task/struct.JoinSet.html) sammelt mehrere Tasks, liefert mit `join_next` den jeweils nächsten Abschluss und bricht beim Drop die übrigen ab. ```rust use tokio::task::JoinSet; @@ -112,80 +115,336 @@ lesson: | > [!PRAXIS] > Ein Task-Start ohne klaren Ergebnis- und Abbruchpfad ist im Review ein Warnsignal. Notiere den Owner, die Fehlerbehandlung und das Verhalten beim Herunterfahren, bevor du weitere Arbeit auslagerst. - ## Timeout und Cancellation sind keine Transaktion + ### „future cannot be sent between threads safely“ lesen - Ein [`timeout`](https://docs.rs/tokio/latest/tokio/time/fn.timeout.html) begrenzt das Warten auf eine Future. Er unterbricht keinen beliebigen CPU-Code präemptiv. Wenn eine Future nicht kooperativ Fortschritt freigibt, kann die Zeitgrenze überschritten werden. + Was über ein `.await` hinweg lebt, speichert der Compiler in der Future. Geht sie an `spawn`, muss jeder dieser Werte `Send` sein. Der häufigste Verstoß ist ein `std::sync::MutexGuard` oder ein `Rc` über einem `.await`: - ```rust - use tokio::time::{timeout, Duration}; + ```rust nocheck + use std::sync::{Arc, Mutex}; + use tokio::time::{sleep, Duration}; + + async fn zaehlen(zaehler: Arc>) { + let mut stand = zaehler.lock().unwrap(); + *stand += 1; + sleep(Duration::from_millis(10)).await; + println!("Stand: {}", *stand); + } #[tokio::main] async fn main() { - let ergebnis = timeout(Duration::from_secs(1), async { 42 }).await; - match ergebnis { - Ok(wert) => println!("{wert}"), - Err(_) => eprintln!("Zeitlimit überschritten"), - } + let zaehler = Arc::new(Mutex::new(0)); + tokio::spawn(zaehlen(zaehler)).await.unwrap(); } ``` - `tokio::select!` wartet auf mehrere Alternativen und bearbeitet einen fertigen Zweig. Nicht ausgewählte, vom Ausdruck besessene Futures werden fallengelassen. Die [Select-Dokumentation](https://tokio.rs/tokio/tutorial/select) erklärt deshalb Cancellation Safety: Ist es korrekt, eine Operation mittendrin zu verwerfen und später erneut zu beginnen? Ein teilweise geschriebener Datensatz oder eine schon bestätigte Remote-Aktion verschwindet nicht durch Drop. + `cargo build` mit Rust 1.99.1 (gekürzt, Pfad zur Tokio-Quelle abgekürzt): + + ```text + error: future cannot be sent between threads safely + --> src/main.rs:14:18 + | + 14 | tokio::spawn(zaehlen(zaehler)).await.unwrap(); + | ^^^^^^^^^^^^^^^^ future returned by `zaehlen` is not `Send` + | + = help: within `impl Future`, the trait `Send` is not implemented for `std::sync::MutexGuard<'_, u64>` + note: future is not `Send` as this value is used across an await + --> src/main.rs:7:38 + | + 5 | let mut stand = zaehler.lock().unwrap(); + | --------- has type `std::sync::MutexGuard<'_, u64>` which is not `Send` + 6 | *stand += 1; + 7 | sleep(Duration::from_millis(10)).await; + | ^^^^^ await occurs here, with `mut stand` maybe used later + note: required by a bound in `tokio::spawn` + --> …/tokio-1.54.1/src/task/spawn.rs:176:21 + | + 176 | F: Future + Send + 'static, + | ^^^^ required by this bound in `spawn` + ``` + + Lies sie in drei Schritten: Zeile 14 und die letzte Notiz sagen, wo und warum `Send` verlangt wird. Die `help`-Zeile nennt den Typ, dem `Send` fehlt. Die mittlere Notiz zeigt den Mechanismus: `stand` aus Zeile 5 lebt über das `.await` in Zeile 7, an dem die Runtime die Task auf einen anderen Thread verschieben darf. + + Die meist richtige Lösung: Der Guard endet vor dem `.await`. Ein `drop(stand)` davor reicht dem Compiler nicht – die Meldung bleibt in Rust 1.99.1, weil er laut [Tokio-Tutorial](https://tokio.rs/tokio/tutorial/shared-state#holding-a-mutexguard-across-an-await) nur Gültigkeitsbereiche auswertet. Ein Block reicht: - Auch `timeout(handle)` auf einem JoinHandle bedeutet nicht automatisch, dass die gestartete Task beendet wird. Ein verworfener Handle kann sie weiterlaufen lassen. Behalte den Handle, verwende bei Bedarf `abort()` und warte auf dessen Abschluss. Für fachlich notwendiges Aufräumen brauchst du einen kooperativen Shutdown-Pfad. Tokios [Shutdown-Anleitung](https://tokio.rs/tokio/topics/shutdown) trennt Auslöser, Benachrichtigung und Warten auf den Abschluss. + ```rust + async fn zaehlen(zaehler: Arc>) { + let neu = { + let mut stand = zaehler.lock().unwrap(); + *stand += 1; + *stand + }; // Guard endet hier, vor dem .await + sleep(Duration::from_millis(10)).await; + println!("Stand: {neu}"); + } + ``` - ## Backpressure und blockierende Grenzen + `tokio::sync::Mutex` ist die zweite Lösung, aber nur, wenn die Sperre wirklich über ein `.await` reichen muss, etwa weil Tasks dieselbe Verbindung nacheinander beschreiben. Für kurze Änderungen an Zählern oder Maps empfiehlt Tokio den std-Mutex ([std- oder Tokio-Mutex](https://tokio.rs/tokio/tutorial/shared-state#on-using-stdsyncmutex-and-tokiosyncmutex)). Eine Runtime ohne `Send`-Pflicht verschiebt das Problem nur: Will eine zweite Task auf demselben Thread sperren, blockiert sie ihn, und die erste kommt nie zum Freigeben. - Ein unbegrenzter Produzent kann mehr Arbeit erzeugen, als ein Consumer verarbeiten kann. Ein begrenzter [`mpsc`-Channel](https://docs.rs/tokio/latest/tokio/sync/mpsc/index.html) macht Kapazität explizit. Ist der Puffer voll, wartet `send().await` auf Platz. Das ist Backpressure: Überlast wird an die Quelle weitergereicht statt als wachsender Speicherverbrauch versteckt. + ## select!: das erste Ergebnis gewinnt, der Rest wird gedroppt + + `tokio::select!` wartet in derselben Task auf mehrere Futures. Sobald eine fertig ist und ihr Muster passt, läuft deren Zweig, und alle anderen Futures dieses Durchlaufs werden gedroppt ([select!](https://docs.rs/tokio/latest/tokio/macro.select.html)). Eine gedroppte Operation endet an ihrem letzten `.await`, ohne Ergebnis und ohne Fehler. Was sie bis dahin getan hat, bleibt getan. ```rust use tokio::sync::mpsc; + use tokio::time::{sleep, Duration}; #[tokio::main] async fn main() { - let (tx, mut rx) = mpsc::channel(2); - let producer = tokio::spawn(async move { + let (tx, mut rx) = mpsc::channel::(16); + tokio::spawn(async move { for n in 1..=3 { - if tx.send(n).await.is_err() { break; } + let _ = tx.send(format!("event {n}")).await; + sleep(Duration::from_millis(40)).await; } }); - while let Some(n) = rx.recv().await { - println!("{n}"); + + let frist = sleep(Duration::from_millis(100)); + tokio::pin!(frist); + loop { + tokio::select! { + biased; + _ = &mut frist => { + println!("Frist erreicht"); + break; + } + nachricht = rx.recv() => match nachricht { + Some(text) => println!("{text}"), + None => break, + }, + } } - producer.await.expect("Demo-Produzent"); } ``` - Sobald alle Sender verschwunden und gepufferte Werte gelesen sind, liefert `recv` None. Ein versehentlich behaltenes Sender-Klon kann diese Beendigung verhindern. Begrenze bei einem Workerpool außerdem die aktiven Tasks, nicht nur einen vorgelagerten Puffer; ein Consumer, der beliebig viele neue Tasks startet, umgeht die Begrenzung wieder. + Ausgabe: `event 1` bis `event 3`, dann `Frist erreicht`. Zwei Details tragen das Beispiel: + + - **Der Timer entsteht einmal.** `sleep(…)` steht vor der Schleife, `tokio::pin!` heftet die Future fest, und `&mut frist` übergibt in jeder Runde dieselbe. Stünde `sleep(…)` im Zweig, begänne in jeder Runde ein neuer Timer, und bei einem Event alle 40 ms liefe die Frist nie ab. `&mut` auf eine Future verlangt `Unpin`, daher das Pinnen. + - **`biased;` legt die Reihenfolge fest.** Ohne prüft `select!` die Zweige in zufälliger Reihenfolge, damit kein ständig bereiter Zweig die anderen aushungert ([Fairness](https://docs.rs/tokio/latest/tokio/macro.select.html#fairness)). Mit `biased;` gilt oben vor unten; Frist und Shutdown gehören dann nach oben. + + ### Cancellation Safety: Was geht beim Drop verloren? - Blockierende Bibliotheken gehören an eine explizite Grenze. [`spawn_blocking`](https://docs.rs/tokio/latest/tokio/task/fn.spawn_blocking.html) führt synchrone Arbeit auf dafür vorgesehenen Threads aus: + In einer Schleife wird jeder Verlierer-Zweig verworfen und in der nächsten Runde neu erzeugt. Das ist nur korrekt, wenn ein Neubeginn nichts verliert. Tokio nennt das Cancellation Safety, vermerkt sie bei den einzelnen Methoden und sammelt die häufigsten im Abschnitt [Cancellation safety](https://docs.rs/tokio/latest/tokio/macro.select.html#cancellation-safety): + + | Methode | cancel-safe | Grund | + | --- | --- | --- | + | `mpsc::Receiver::recv` | ja | Eine Nachricht verlässt den Kanal erst mit dem Ergebnis. | + | `TcpListener::accept` | ja | Die Verbindung wartet weiter in der Queue des Kernels. | + | `AsyncReadExt::read_exact` | nein | Schon entnommene Bytes sind weg, ihre Zahl kennst du nicht. | + | `AsyncWriteExt::write_all` | nein | Ein Teil ist vielleicht schon gesendet. | + | `Mutex::lock`, `Semaphore::acquire` | nein | Der Platz in der fairen Warteschlange geht verloren. | + + Beim Herunterfahren ist ein halb gelesener Datensatz oft egal, in einer `select!`-Schleife verlierst du damit Daten. Lege solche Operationen in eine eigene Task, erzeuge sie einmal vor der Schleife und selecte auf `&mut`, oder nimm eine cancel-safe Variante wie `Lines::next_line`. + + > [!MERKE] + > `select!` droppt jeden Zweig, der nicht gewinnt. In eine `select!`-Schleife gehört nur, was einen Neubeginn ohne Verlust übersteht – das steht in der Doku jeder Tokio-Methode unter „Cancel safety“. + + [`timeout`](https://docs.rs/tokio/latest/tokio/time/fn.timeout.html) ist ein `select!` zwischen deiner Future und einem Timer. Er begrenzt das Warten, unterbricht aber keinen CPU-Code: Gibt eine Future nie Kontrolle ab, wird die Grenze überschritten. Und `timeout(handle)` auf einem JoinHandle droppt nach Ablauf nur den Handle; die Task arbeitet weiter. + + ## Kooperativ beenden: CancellationToken und TaskTracker + + [`JoinHandle::abort`](https://docs.rs/tokio/latest/tokio/task/struct.JoinHandle.html#method.abort) bricht eine Task am nächsten `.await` ab. Danach laufen nur noch die `Drop`-Implementierungen ihrer Werte, das Await auf den Handle liefert meist einen „cancelled“-`JoinError`. Für Arbeit, die aufräumen muss – Datei abschließen, Abmeldung senden –, ist das zu grob. Ein [`CancellationToken`](https://docs.rs/tokio-util/latest/tokio_util/sync/struct.CancellationToken.html) aus `tokio-util` macht den Abbruch kooperativ: Der Auslöser ruft `cancel()`, jede Task wartet in ihrem `select!` zusätzlich auf `cancelled()` und entscheidet selbst, wie sie endet. ```rust + use std::time::Duration; + use tokio_util::sync::CancellationToken; + use tokio_util::task::TaskTracker; + + async fn worker(name: &'static str, token: CancellationToken) { + let mut runden = 0; + loop { + tokio::select! { + _ = token.cancelled() => { + println!("{name}: räume auf nach {runden} Runden"); + return; + } + _ = tokio::time::sleep(Duration::from_millis(200)) => runden += 1, + } + } + } + #[tokio::main] async fn main() { - let bytes = vec![1_u8, 2, 3]; - let summe = tokio::task::spawn_blocking(move || { - bytes.iter().map(|&b| u64::from(b)).sum::() - }).await.expect("Demo-Berechnung"); - println!("{summe}"); + let token = CancellationToken::new(); + let tracker = TaskTracker::new(); + for name in ["import", "export"] { + tracker.spawn(worker(name, token.child_token())); + } + tracker.close(); + + tokio::signal::ctrl_c().await.expect("Signal-Handler"); + token.cancel(); + tracker.wait().await; + println!("sauber beendet"); } ``` - Das winzige Beispiel dient nur der API. Für echte CPU-Last braucht es zusätzlich eine Begrenzung oder einen passenden CPU-Pool. Bereits gestartete `spawn_blocking`-Arbeit lässt sich durch Task-Abbruch nicht zuverlässig stoppen. Ebenso gehört `std::thread::sleep` nicht in eine wartende async-Schleife; verwende `tokio::time::sleep`. + Mit SIGINT nach 0,7 Sekunden (per `kill -INT`, wirkt wie Strg+C) meldeten beide Worker `räume auf nach 3 Runden`, dann kam `sauber beendet`. - Halte Locks kurz. Ein gewöhnlicher Mutex kann für kurze, nicht wartende Änderungen ausreichen, aber sein Guard sollte nicht über `.await` leben. Ein async-Mutex erlaubt wartendes Locken, beseitigt jedoch keine Deadlocks und keine unnötig lange Exklusivität. Kopiere die benötigten kleinen Werte unter dem Lock und führe I/O nach Freigabe aus. + - [`child_token()`](https://docs.rs/tokio-util/latest/tokio_util/sync/struct.CancellationToken.html#method.child_token) bricht mit dem Elterntoken ab, lässt sich aber auch allein abbrechen. So beendest du einen Teilbaum, etwa alle Tasks einer Verbindung. + - [`TaskTracker`](https://docs.rs/tokio-util/latest/tokio_util/task/task_tracker/struct.TaskTracker.html) wartet mit `wait()`, bis er geschlossen und leer ist. Anders als ein `JoinSet` speichert er keine Rückgabewerte und bricht beim Drop nichts ab. + - `tokio::signal::ctrl_c()` ist der async-Signal-Handler; synchrone CLIs lösen dasselbe in [Rust in der Praxis](/rust/rust-praxis) mit einem Flag. - > [!INTERVIEW] - > Async organisiert Wartezeiten. Threads stellen Ausführungskapazität bereit. Eine Future ist eine Zustandsmaschine, `.await` eine mögliche kooperative Unterbrechung und `spawn` eine neue Task. Keine dieser Aussagen verspricht automatische Parallelisierung oder Rückabwicklung. + Die [Shutdown-Anleitung](https://tokio.rs/tokio/topics/shutdown) beschreibt dieselben drei Schritte: Auslöser erkennen, alle Teile benachrichtigen, auf ihr Ende warten. + + ## Kanäle, Backpressure und Semaphore + + Tokio hat vier Kanalarten ([Übersicht](https://docs.rs/tokio/latest/tokio/sync/index.html#message-passing)). Die Wahl hängt an drei Fragen: Wie viele senden, wie viele lesen, und muss jede Nachricht ankommen? + + | Kanal | Sender → Leser | Was ankommt | Einsatz | + | --- | --- | --- | --- | + | `oneshot` | einer → einer | genau ein Wert | Antwort auf eine Anfrage | + | `mpsc` | viele → einer | jede Nachricht, in Reihenfolge | Aufträge an einen Worker | + | `broadcast` | viele → viele | jede Nachricht an jeden Leser | Ereignisse für alle | + | `watch` | viele → viele | nur der letzte Wert | Konfiguration, Shutdown-Flag | + + Ein `watch`-Leser überspringt Zwischenstände, gewollt: Von einer Konfiguration zählt der aktuelle Stand. `broadcast` hält jede Nachricht, bis alle Leser sie haben, aber höchstens so viele, wie die Kapazität erlaubt. Wer zurückfällt, bekommt `RecvError::Lagged(n)` und liest ab der ältesten vorhandenen Nachricht weiter ([Lagging](https://docs.rs/tokio/latest/tokio/sync/broadcast/index.html#lagging)); ausprobiert mit Kapazität 2 und fünf Werten vor dem ersten Lesen: `Lagged(3)`, dann 4 und 5. Behandle `Lagged`, statt es per `unwrap` zum Absturz zu machen. + + Ein begrenzter [`mpsc`-Kanal](https://docs.rs/tokio/latest/tokio/sync/mpsc/index.html) (`mpsc::channel(2)`) macht Kapazität sichtbar: Ist der Puffer voll, wartet `send().await` auf Platz. Das ist Backpressure – Überlast wandert zurück zur Quelle, statt sich als wachsender Speicherverbrauch zu verstecken. Sind alle Sender weg und der Puffer leer, liefert `recv` `None`; ein vergessener Sender-Klon verhindert dieses Ende. - ## Häufige Fehler und Zusammenfassung + Ein begrenzter Kanal begrenzt aber die Warteschlange, nicht die Zahl laufender Tasks. Wer für jede Nachricht eine Task startet, umgeht die Grenze. Ein [`Semaphore`](https://docs.rs/tokio/latest/tokio/sync/struct.Semaphore.html) zählt Erlaubnisse (Permits): `acquire` wartet, bis eine frei ist, und der Drop des Permits gibt sie zurück. - - Ungepollte Futures erledigen keine Arbeit. - - Zwei aufeinanderfolgende Awaits sind nicht automatisch nebenläufig. - - Bewahre Handles und behandle Task- sowie Fachfehler getrennt. - - Timeout und Drop machen externe Nebenwirkungen nicht rückgängig. - - Begrenze Warteschlangen und die Zahl aktiver Tasks. - - Blockierende Arbeit benötigt eine kontrollierte Grenze. - - Plane Shutdown und prüfe Cancellation Safety jeder wartenden Operation. + ```rust + use std::sync::Arc; + use tokio::sync::Semaphore; + use tokio::task::JoinSet; + use tokio::time::{sleep, Duration}; + + async fn health_check(host: &str) -> String { + sleep(Duration::from_millis(20)).await; // steht für einen HTTP-Aufruf + format!("{host} ok") + } + + #[tokio::main] + async fn main() { + let limit = Arc::new(Semaphore::new(2)); + let mut jobs = JoinSet::new(); + for host in ["web-1", "web-2", "web-3", "web-4", "web-5"] { + // wartet, solange schon zwei Prüfungen laufen + let permit = Arc::clone(&limit).acquire_owned().await.unwrap(); + jobs.spawn(async move { + let antwort = health_check(host).await; + drop(permit); // Platz für die nächste Prüfung + antwort + }); + } + while let Some(antwort) = jobs.join_next().await { + println!("{}", antwort.unwrap()); + } + } + ``` + + Ein Zähler für aktive Prüfungen ergab nie mehr als zwei gleichzeitig. [`acquire_owned`](https://docs.rs/tokio/latest/tokio/sync/struct.Semaphore.html#method.acquire_owned) verbraucht einen `Arc` und liefert ein Permit ohne Lifetime, das in die `'static`-Task wandern darf; `acquire` würde den Semaphore nur leihen. Das Permit kommt vor `spawn`, also wartet schon die Schleife und nicht erst die fünfte Task – dasselbe Muster zeigt die Doku für eingehende Verbindungen ([Beispiel](https://docs.rs/tokio/latest/tokio/sync/struct.Semaphore.html#limit-the-number-of-incoming-requests-being-handled-at-the-same-time)). Das Permit kehrt auch bei Panik oder Abbruch zurück; `drop(permit)` markiert nur die Stelle. + + Blockierende Arbeit gehört hinter [`spawn_blocking`](https://docs.rs/tokio/latest/tokio/task/fn.spawn_blocking.html): Sie läuft auf einem eigenen Thread-Pool, und die Worker-Threads pollen weiter. Gestartete `spawn_blocking`-Arbeit lässt sich nicht abbrechen. `std::thread::sleep`, `std::fs` in einer Schleife oder eine Regex über 50 MB kompilieren in einer `async fn` ohne Warnung; das Symptom zeigt sich erst unter Last, wenn Timer zu spät feuern und andere Verbindungen hängen. Miss in diesem Fall zuerst ([Performance messen](/rust/performance-messen)). + + ## Netzwerk, Dateien und Streams + + Der Zeilenserver aus [Ein- und Ausgabe](/rust/ein-ausgabe) bediente seine Verbindung in einem eigenen Thread; bei vielen Clients wäre das ein Thread pro Verbindung. Mit `tokio::net` wird daraus eine Task pro Verbindung, und tausend wartende Clients kosten tausend kleine Futures statt tausend Stacks. + + ```rust + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + use tokio::net::{TcpListener, TcpStream}; + + #[tokio::main] + async fn main() -> std::io::Result<()> { + let listener = TcpListener::bind("127.0.0.1:7878").await?; + loop { + let (verbindung, adresse) = listener.accept().await?; + tokio::spawn(async move { + if let Err(fehler) = bedienen(verbindung).await { + eprintln!("{adresse}: {fehler}"); + } + }); + } + } + + async fn bedienen(verbindung: TcpStream) -> std::io::Result<()> { + let (lesen, mut schreiben) = verbindung.into_split(); + let mut zeilen = BufReader::new(lesen).lines(); + while let Some(zeile) = zeilen.next_line().await? { + let antwort = format!("{} Zeichen\n", zeile.chars().count()); + schreiben.write_all(antwort.as_bytes()).await?; + } + Ok(()) + } + ``` + + Mit drei Zeilen über Bashs `/dev/tcp` getestet, antwortete der Server `11 Zeichen`, `10 Zeichen`, `5 Zeichen`. [`into_split`](https://docs.rs/tokio/latest/tokio/net/struct.TcpStream.html#method.into_split) teilt die Verbindung in zwei eigene Hälften, die in verschiedene Tasks wandern dürfen, etwa ein Leser für Befehle und ein Schreiber für Antworten aus einem `mpsc`-Kanal. [`lines()`](https://docs.rs/tokio/latest/tokio/io/trait.AsyncBufReadExt.html#method.lines) liefert mit `next_line().await` ein `io::Result>`; `Ok(None)` heißt, die Gegenseite hat geschlossen. `next_line` ist cancel-safe und passt in eine `select!`-Schleife mit Shutdown-Zweig. + + Dateien sind anders. Die meisten Betriebssysteme haben keine asynchrone Datei-API, deshalb führt [`tokio::fs`](https://docs.rs/tokio/latest/tokio/fs/index.html) jede Operation per `spawn_blocking` aus. Viele kleine Operationen kosten je einen Wechsel in den Pool und sind nicht schneller als `std::fs`; die Doku rät, Zugriffe zu bündeln ([Tuning](https://docs.rs/tokio/latest/tokio/fs/index.html#tuning-your-file-io)). Ein abgebrochenes `write` läuft im Pool weiter, und `tokio::fs::File` braucht nach dem Schreiben `flush().await`. + + Ein Stream ist ein asynchroner Iterator. `for` geht dafür nicht; du schreibst `while let Some(x) = stream.next().await`. [`StreamExt`](https://docs.rs/tokio-stream/latest/tokio_stream/trait.StreamExt.html) aus `tokio-stream` bringt `next`, `map`, `filter` und `timeout`, die Wrapper in `tokio_stream::wrappers` machen aus Tokio-Typen Streams. `LinesStream` gibt es nur mit dem Feature `io-util` von `tokio-stream`, deshalb steht es oben in der `Cargo.toml`: + + ```rust + use tokio::io::{AsyncBufReadExt, BufReader}; + use tokio_stream::{wrappers::LinesStream, StreamExt}; + + #[tokio::main] + async fn main() -> std::io::Result<()> { + let eingabe: &[u8] = b"INFO start\nERROR db\nINFO ok\nERROR netz\n"; + let mut fehler = LinesStream::new(BufReader::new(eingabe).lines()) + .filter(|zeile| match zeile { + Ok(text) => text.starts_with("ERROR"), + Err(_) => true, // Lesefehler nicht verschlucken + }); + while let Some(zeile) = fehler.next().await { + println!("{}", zeile?); + } + Ok(()) + } + ``` + + Ausgabe: `ERROR db`, `ERROR netz`. Ein stabiles Gegenstück in der Standardbibliothek fehlt: `std::async_iter::AsyncIterator` ist auch in Rust 1.99 nightly-only ([AsyncIterator](https://doc.rust-lang.org/std/async_iter/trait.AsyncIterator.html)). Bibliotheken nutzen deshalb den Trait `Stream` aus `futures-core`, den `tokio-stream` re-exportiert. + + ## Async-Code testen mit kontrollierter Zeit + + [`#[tokio::test]`](https://docs.rs/tokio/latest/tokio/attr.test.html) startet für jeden Test eine eigene Runtime, standardmäßig Current-Thread; `flavor = "multi_thread"` gibt echte Parallelität. Für Timer-Logik gibt es `start_paused = true`: Die Tokio-Uhr steht, und sobald die Runtime nichts zu tun hat, springt sie zum nächsten Timer ([Auto-advance](https://docs.rs/tokio/latest/tokio/time/fn.pause.html#auto-advance)). Das braucht `tokio = { version = "1.54", features = ["test-util"] }` in den `[dev-dependencies]`. + + ```rust + use std::time::Duration; + use tokio::time::{self, Instant}; + + async fn mit_wiederholung(versuche: u32) -> u32 { + let mut n = 0; + while n < versuche { + n += 1; + time::sleep(Duration::from_secs(30)).await; // Backoff + } + n + } + + #[tokio::test(start_paused = true)] + async fn wartet_zwischen_versuchen() { + let start = Instant::now(); + assert_eq!(mit_wiederholung(3).await, 3); + assert_eq!(start.elapsed(), Duration::from_secs(90)); + } + ``` + + Der Test prüft 90 Sekunden Backoff auf die Sekunde genau; `cargo test` meldete `finished in 0.00s`. `tokio::time::pause()` schaltet dasselbe mitten im Test ein; `std::time::Instant` läuft in beiden Fällen weiter. Mehr zu Teststrategien steht in [Tests & Code-Qualität](/rust/testen-qualitaet). + + > [!INTERVIEW] + > Async organisiert Wartezeiten, Threads stellen Rechenkapazität bereit. Eine Future ist eine Zustandsmaschine, `.await` eine mögliche kooperative Unterbrechung, `spawn` eine neue Task. Fragt jemand nach den anderen Zweigen von `select!`, antwortest du: Sie werden gedroppt, ihre Arbeit endet am letzten `.await`, deshalb prüfe ich die Cancellation Safety jedes Zweigs. + + ## Häufige Fehler + + - **Future nie gepollt.** Es passiert nichts. Für `f();` ohne `.await` warnt der Compiler „unused implementer of `Future` that must be used“. + - **std-Guard über `.await`.** „future cannot be sent between threads safely“. Block um die Sperre; `drop()` reicht nicht. + - **Timer im `select!`-Zweig.** Die Frist läuft nie ab, solange ein anderer Zweig regelmäßig gewinnt. + - **`read_exact` oder `write_all` in einer `select!`-Schleife.** Daten gehen verloren, sobald ein anderer Zweig gewinnt. + - **Timeout oder fallengelassener Handle als Abbruch verstanden.** Die Task läuft weiter. + - **Unbegrenzt Tasks starten.** Ein Semaphore vor `spawn` begrenzt laufende Arbeit. + + ## Zusammenfassung + + - Eine Future ist eine Zustandsmaschine; ohne Polling passiert nichts. + - `join!` und `JoinSet` lassen unabhängige Operationen gleichzeitig warten; Task-Fehler und Fachfehler sind zwei Ebenen. + - Was über ein `.await` lebt, muss für `spawn` `Send` sein. Sperren endet per Block davor. + - `select!` droppt die Verlierer: Timer einmal erzeugen und pinnen, `biased;` für feste Reihenfolge, Cancellation Safety nachschlagen. + - `CancellationToken` und `TaskTracker` beenden Tasks kooperativ; nach `abort()` laufen nur noch die `Drop`-Implementierungen. + - Kanal nach Zweck: `oneshot`, `mpsc`, `broadcast`, `watch`. Begrenzte Kanäle und Permits vor `spawn` begrenzen Arbeit. + - Blockierendes gehört hinter `spawn_blocking`; `tokio::fs` läuft selbst darüber. + - `#[tokio::test(start_paused = true)]` testet Zeitlogik ohne Wartezeit. exercises: - id: future-start type: choice @@ -219,7 +478,8 @@ exercises: explanation: Nach dem Signal werden keine neuen Aufträge aufgenommen. Erst nach Abschluss oder begründetem Abbruch endet der Prozess. resources: - - *id001 + - title: Tokio · Graceful shutdown + url: https://tokio.rs/tokio/topics/shutdown - id: lazy-future type: output title: Erzeugen ist nicht ausführen @@ -255,7 +515,51 @@ exercises: und wird vom Plattform-Verifier nicht ausgeführt.' verify: false resources: - - *id001 + - title: tokio::join! + url: https://docs.rs/tokio/latest/tokio/macro.join.html +- id: select-biased + type: output + title: Wer zuerst geprüft wird + prompt: | + Vor der Schleife liegen zwei Aufträge im Kanal, und das Stopp-Signal ist schon gesetzt. Beide Zweige von `select!` sind also in der ersten Runde bereit. Was gibt das Programm aus? Gib jede Ausgabezeile an. + lang: rust + code: |- + use tokio::sync::{mpsc, watch}; + + #[tokio::main] + async fn main() { + let (tx, mut rx) = mpsc::channel(4); + let (stopp_tx, mut stopp_rx) = watch::channel(false); + tx.send("deploy").await.unwrap(); + tx.send("backup").await.unwrap(); + stopp_tx.send(true).unwrap(); + loop { + tokio::select! { + biased; + _ = stopp_rx.changed() => { + println!("stopp"); + break; + } + Some(job) = rx.recv() => println!("job {job}"), + } + } + println!("offen: {}", rx.len()); + } + expected: |- + stopp + offen: 2 + verify: false + hints: + - Was bedeutet `biased;` für die Reihenfolge, in der `select!` die Zweige prüft? + - "`changed()` kehrt sofort zurück, wenn der aktuelle Wert noch nicht als gesehen markiert ist." + - Was passiert mit der `recv`-Future, wenn der erste Zweig gewinnt – und mit den Nachrichten im Kanal? + explanation: | + Mit `biased;` prüft `select!` die Zweige von oben nach unten. In der ersten Runde ist `stopp_rx.changed()` schon bereit, also gewinnt dieser Zweig, und die Schleife endet. Die `recv`-Future wird gedroppt, ohne etwas aus dem Kanal genommen zu haben – `recv` ist cancel-safe –, deshalb liegen beide Aufträge noch darin und `rx.len()` ist 2. Ohne `biased;` wählt `select!` die Startreihenfolge zufällig: In 40 Läufen ohne `biased;` kamen drei verschiedene Ausgaben heraus, mit null, einem oder zwei Aufträgen vor `stopp`. Für einen Shutdown-Zweig ist die feste Reihenfolge meist gewollt; sie entscheidet aber auch, dass offene Arbeit liegen bleibt. + resources: + - title: tokio::select! · Fairness + url: https://docs.rs/tokio/latest/tokio/macro.select.html#fairness + - title: watch::Receiver::changed + url: https://docs.rs/tokio/latest/tokio/sync/watch/struct.Receiver.html#method.changed - id: await-future type: gap title: Auf ein Ergebnis warten @@ -297,7 +601,8 @@ exercises: explanation: move übernimmt den String, sodass keine kurzlebige Leihe nach main besteht. await beobachtet das Task-Ergebnis inklusive möglichem JoinError. resources: - - *id001 + - title: Tokio · Spawning + url: https://tokio.rs/tokio/tutorial/spawning - id: bounded-channel type: gap title: Aufträge begrenzen @@ -317,7 +622,52 @@ exercises: explanation: Ein begrenzter Channel wartet bei ausgeschöpfter Kapazität. Die Begrenzung betrifft gepufferte Nachrichten, nicht automatisch beliebig neu gestartete Tasks. resources: - - *id001 + - title: tokio::sync::mpsc + url: https://docs.rs/tokio/latest/tokio/sync/mpsc/index.html +- id: semaphore-permit + type: gap + title: Höchstens zwei Prüfungen gleichzeitig + prompt: | + Höchstens zwei Health-Checks sollen gleichzeitig laufen. Ergänze die Methode, die ein Permit liefert, das in die Task wandern darf, und die Zeile, die das Permit nach getaner Arbeit ausdrücklich zurückgibt. + lang: rust + code: |- + use std::sync::Arc; + use tokio::sync::Semaphore; + use tokio::task::JoinSet; + + #[tokio::main] + async fn main() { + let limit = Arc::new(Semaphore::new(2)); + let mut jobs = JoinSet::new(); + for host in ["web-1", "web-2", "web-3"] { + let permit = Arc::clone(&limit).⟦acquire_owned⟧().await.unwrap(); + jobs.spawn(async move { + let antwort = format!("{host} ok"); + ⟦drop(permit)⟧; + antwort + }); + } + let mut antworten = Vec::new(); + while let Some(ergebnis) = jobs.join_next().await { + antworten.push(ergebnis.unwrap()); + } + antworten.sort(); + println!("{antworten:?}"); + } + gaps: + - label: Permit, das die Task besitzen kann + hint: "`acquire` liefert ein geliehenes Permit mit Lifetime. Die Variante für `Arc` trägt einen Zusatz im Namen." + - label: Permit zurückgeben + hint: Ein Permit gibt seine Erlaubnis im Drop zurück. Welche Funktion beendet einen Wert sofort? + accept: + - std::mem::drop(permit) + explanation: | + `acquire_owned` verbraucht einen `Arc` und liefert ein `OwnedSemaphorePermit` ohne Lifetime; nur so darf es in die `'static`-Task von `spawn` wandern. Setzt du `acquire` ein, leiht das Permit den Semaphore, und die `'static`-Task darf keine Leihe halten: Bei `Arc::clone(&limit).acquire()` meldet der Compiler E0716 („temporary value dropped while borrowed“), bei `limit.acquire()` E0597 („`limit` does not live long enough“). Weil das Permit vor `spawn` geholt wird, wartet die Schleife selbst, sobald zwei Prüfungen laufen – die Grenze wirkt bis zur Quelle. `drop(permit)` gibt die Erlaubnis zurück; ohne die Zeile geschähe das am Ende der Task, auch bei Panik oder Abbruch. Ausgabe im lokalen Lauf: `["web-1 ok", "web-2 ok", "web-3 ok"]`. + resources: + - title: Semaphore::acquire_owned + url: https://docs.rs/tokio/latest/tokio/sync/struct.Semaphore.html#method.acquire_owned + - title: Semaphore · eingehende Anfragen begrenzen + url: https://docs.rs/tokio/latest/tokio/sync/struct.Semaphore.html#limit-the-number-of-incoming-requests-being-handled-at-the-same-time - id: sleep-blockiert type: bug title: Der Runtime-Thread wartet mit @@ -337,7 +687,73 @@ exercises: explanation: Eine async-Markierung ändert keinen synchronen sleep-Aufruf. Der Tokio-Timer gibt die Task während der Wartezeit frei. resources: - - *id001 + - title: tokio::time::sleep + url: https://docs.rs/tokio/latest/tokio/time/fn.sleep.html +- id: guard-block + type: bug + title: Der Block steht, und trotzdem kein Send + prompt: | + `melden` soll den Zähler unter der Sperre erhöhen und den neuen Stand danach über den Kanal melden. Der Block soll die Sperre vor dem `.await` beenden. `cargo build` meldet trotzdem (gekürzt): + + ```text + error: future cannot be sent between threads safely + --> src/main.rs:17:18 + | + 17 | tokio::spawn(melden(zaehler, tx)); + | ^^^^^^^^^^^^^^^^^^^ future returned by `melden` is not `Send` + note: future is not `Send` as this value is used across an await + --> src/main.rs:10:19 + | + 5 | let mut stand = zaehler.lock().unwrap(); + | --------- has type `std::sync::MutexGuard<'_, u32>` which is not `Send` + ... + 10 | log.send(neu).await.unwrap(); + | ^^^^^ await occurs here, with `mut stand` maybe used later + ``` + + Finde die Zeilen, die die Sperre zu lange leben lassen, und korrigiere sie. + lang: rust + code: |- + use std::sync::{Arc, Mutex}; + use tokio::sync::mpsc; + + async fn melden(zaehler: Arc>, log: mpsc::Sender) { + let mut stand = zaehler.lock().unwrap(); + let neu = { + *stand += 1; + *stand + }; + log.send(neu).await.unwrap(); + } + + #[tokio::main] + async fn main() { + let zaehler = Arc::new(Mutex::new(0)); + let (tx, mut rx) = mpsc::channel(1); + tokio::spawn(melden(zaehler, tx)); + println!("{:?}", rx.recv().await); + } + lines: + - 5 + - 6 + fix: + - line: 5 + answers: + - let neu = { + - line: 6 + answers: + - let mut stand = zaehler.lock().unwrap(); + hints: + - Die Notiz nennt Zeile 5 und Zeile 10. Wo beginnt und wo endet der Gültigkeitsbereich von `stand`? + - Ein Guard endet am Ende des Blocks, in dem er gebunden wurde. In welchem Block steht `let mut stand` gerade? + - Die beiden Zeilen 5 und 6 stehen in der falschen Reihenfolge. + explanation: | + `stand` wird in Zeile 5 im Funktionskörper gebunden, nicht im Block. Der Block liest den Guard nur, er besitzt ihn nicht, und so lebt der `MutexGuard` bis zum Ende der Funktion – über das `.await` in Zeile 10 hinweg. Der std-Guard ist nicht `Send`, also ist die ganze Future nicht `Send`, und `tokio::spawn` lehnt sie ab. Gehört `let mut stand = …` in den Block, endet die Sperre an dessen `}`; nach der Korrektur gibt das Programm `Some(1)` aus. Ein `drop(stand)` vor dem `.await` würde der Compiler nicht anerkennen, er entscheidet über Gültigkeitsbereiche. Selbst ohne `spawn` wäre der lange Guard falsch: Eine zweite Task auf demselben Thread, die sperren will, blockiert ihn. + resources: + - title: Tokio · Holding a MutexGuard across an .await + url: https://tokio.rs/tokio/tutorial/shared-state#holding-a-mutexguard-across-an-await + - title: Tokio · Spawning, Send bound + url: https://tokio.rs/tokio/tutorial/spawning#send-bound - id: timeout-handle type: explain title: Was läuft nach dem Zeitlimit? @@ -354,7 +770,10 @@ exercises: explanation: Der Handle ist die Beobachtung einer gestarteten Task. Sein Verlust ist kein Rückruf ihrer Arbeit. Bewahre ihn, wenn du die Task nach dem Zeitlimit beenden und ihren Abschluss beobachten musst. resources: - - *id001 + - title: JoinHandle::abort + url: https://docs.rs/tokio/latest/tokio/task/struct.JoinHandle.html#method.abort + - title: tokio::time::timeout + url: https://docs.rs/tokio/latest/tokio/time/fn.timeout.html - id: channel-worker type: practice title: Einen begrenzten Verarbeitungspfad bauen @@ -389,3 +808,66 @@ exercises: leert den Puffer und erhält None. Der JoinHandle macht das Ende beobachtbar. resources: - *id001 +- id: heartbeat-abbrechen + type: practice + title: Ein Heartbeat, der auf Abbruch hört + prompt: | + Ein Dienst sendet alle 10 Sekunden einen Heartbeat an das Monitoring. Schreibe `async fn heartbeat(token: CancellationToken) -> u32`, die mit `tokio::time::interval` taktet, bei jedem Takt einen Heartbeat zählt (das Senden selbst darfst du weglassen) und bei Abbruch des Tokens die Zahl gesendeter Heartbeats zurückgibt. Der erste, sofortige Tick des Intervalls zählt nicht. + + Schreibe dazu einen Test mit `#[tokio::test(start_paused = true)]`: Task starten, 35 Sekunden warten, Token abbrechen, Ergebnis 3 erwarten. Der Test darf keine echten 35 Sekunden dauern. Du brauchst `tokio` mit `test-util` in den `[dev-dependencies]` und `tokio-util`. + lang: rust + starter: |- + use std::time::Duration; + use tokio_util::sync::CancellationToken; + + pub async fn heartbeat(token: CancellationToken) -> u32 { + todo!() + } + + #[tokio::test(start_paused = true)] + async fn zaehlt_bis_zum_abbruch() { + todo!() + } + solution: |- + use std::time::Duration; + use tokio_util::sync::CancellationToken; + + pub async fn heartbeat(token: CancellationToken) -> u32 { + let mut gesendet = 0; + let mut takt = tokio::time::interval(Duration::from_secs(10)); + takt.tick().await; // der erste Tick kommt sofort + loop { + tokio::select! { + _ = token.cancelled() => return gesendet, + _ = takt.tick() => gesendet += 1, + } + } + } + + #[tokio::test(start_paused = true)] + async fn zaehlt_bis_zum_abbruch() { + let token = CancellationToken::new(); + let task = tokio::spawn(heartbeat(token.clone())); + tokio::time::sleep(Duration::from_secs(35)).await; + token.cancel(); + assert_eq!(task.await.unwrap(), 3); + } + checklist: + - Der Takt entsteht einmal vor der Schleife (`interval`), nicht als neuer `sleep` in jeder Runde. + - "`select!` wartet auf `token.cancelled()` und auf `takt.tick()`." + - Der sofortige erste Tick wird vor der Schleife verbraucht und nicht gezählt. + - Der Test nutzt `start_paused = true` und läuft in Millisekunden statt 35 Sekunden. + - Der Test bricht das Token ab und prüft das Ergebnis der Task über ihren JoinHandle. + verify: false + hints: + - "`tokio::time::interval` liefert beim ersten `tick()` sofort; danach im eingestellten Abstand." + - Bei angehaltener Zeit springt die Uhr zum nächsten Timer, sobald alle Tasks warten. Nach 35 Sekunden sind die Ticks bei 10, 20 und 30 Sekunden durch. + explanation: | + `interval` behält seinen Takt über alle Runden; ein `sleep` im Zweig würde in jeder Runde neu beginnen und bei häufigen anderen Ereignissen nie feuern. `token.cancelled()` ist cancel-safe, darf also in jeder Runde neu entstehen. Im Test steht die Uhr: Wartet die Test-Task auf `sleep(35 s)` und die Heartbeat-Task auf den nächsten Tick, springt Tokio von Timer zu Timer, bis 35 Sekunden vergangen sind. Ticks bei 10, 20 und 30 Sekunden ergeben 3. Lokal lief der Test in 0,00 s; ohne `start_paused` dauert er 35 echte Sekunden. + resources: + - title: CancellationToken + url: https://docs.rs/tokio-util/latest/tokio_util/sync/struct.CancellationToken.html + - title: tokio::test · start_paused + url: https://docs.rs/tokio/latest/tokio/attr.test.html#configure-the-runtime-to-start-with-time-paused + - title: tokio::time::interval + url: https://docs.rs/tokio/latest/tokio/time/fn.interval.html diff --git a/content/rust/modules/ein-ausgabe.yaml b/content/rust/modules/ein-ausgabe.yaml new file mode 100644 index 00000000..40b743ae --- /dev/null +++ b/content/rust/modules/ein-ausgabe.yaml @@ -0,0 +1,1079 @@ +id: ein-ausgabe +title: "Ein- und Ausgabe: Dateien, Prozesse, Netzwerk" +level: 2 +minutes: 170 +summary: Dateien, Kindprozesse und TCP-Verbindungen mit der Standardbibliothek – was dabei im Kernel passiert, wo Daten verloren gehen und warum Programme hängen bleiben. +place: unterwegs +goals: + - Du wählst passende `OpenOptions`, setzt `BufWriter` gezielt ein und machst Schreibfehler mit `flush`, `into_inner` und `sync_all` sichtbar. + - Du ersetzt eine Konfigurationsdatei atomar über eine temporäre Datei, `sync_all` und `rename` und begründest jeden Schritt. + - Du startest Prozesse mit `Command` ohne Shell, wählst zwischen `status`, `output` und `spawn` und wertest Exit-Code, Signal, stdout und stderr aus. + - Du erkennst die typischen Hänger mit Pipes – fehlendes EOF und volle Pipe-Puffer – und vermeidest sie. + - Du baust Server und Client für ein Zeilenprotokoll über TCP mit Timeout und sauberem Verbindungsende und erklärst, warum TCP Framing braucht. +resources: + - title: std::io + url: https://doc.rust-lang.org/std/io/index.html + - title: std::fs + url: https://doc.rust-lang.org/std/fs/index.html + - title: std::process + url: https://doc.rust-lang.org/std/process/index.html + - title: std::net + url: https://doc.rust-lang.org/std/net/index.html + - title: Linux man-pages · rename(2) + url: https://man7.org/linux/man-pages/man2/rename.2.html +lesson: | + Ein Deploy-Werkzeug schreibt die neue `app.toml`, startet `git` und `systemctl` und fragt zum Schluss den Health-Port des Dienstes ab. Datei, Prozess, Netzwerk – drei Welten mit demselben Kern: Dein Programm bittet den Kernel um die Arbeit, über **Systemaufrufe** (syscalls) wie `open`, `read`, `write`, `rename` oder `connect`. Jeder kostet einen Wechsel in den Kernel, jeder kann blockieren, jeder kann scheitern. Deshalb liefert in `std::io` fast alles ein `io::Result`. + + `File`, die Pipes eines Kindprozesses und `TcpStream` sind dünne Hüllen um Handles des Betriebssystems und sprechen dieselben Traits `Read` und `Write` ([std::io](https://doc.rust-lang.org/std/io/index.html#read-and-write)). Dieses Modul zeigt, wo die teuren Fehler entstehen: halbe Konfigurationsdateien, hängende Kindprozesse, Server, die auf ein Ende warten, das nie kommt. Alles hier ist synchron und blockierend; [Async Rust](/rust/async-rust) baut danach mit Tokio darauf auf. + + ## Dateien: Handles, Systemaufrufe und Puffer + + `File::open` fragt den Kernel nach einer Datei und bekommt einen **Dateideskriptor** zurück, unter Linux eine kleine Zahl wie `3`. Das `File` besitzt diesen Deskriptor. Jeder `read` und jeder `write` darauf wird zu einem Systemaufruf `read(2)` oder `write(2)`, denn `File` puffert nichts ([File](https://doc.rust-lang.org/std/fs/struct.File.html)). Fällt das `File` aus dem Scope, schließt `Drop` den Deskriptor. + + ```rust + use std::fs::File; + use std::io::{self, Read, Write}; + + fn main() -> io::Result<()> { + let p = "deploy.log"; + let mut f = File::create(p)?; + f.write_all(b"v1.4 live\n")?; + drop(f); // Deskriptor zu + + let mut text = String::new(); + let mut f = File::open(p)?; + f.read_to_string(&mut text)?; + print!("{text}"); + Ok(()) + } + ``` + + `File::create` legt an oder kürzt eine vorhandene Datei auf null Bytes. `File::open` öffnet nur zum Lesen. Für alles dazwischen gibt es `OpenOptions`. Drei Schalter brauchst du im Betrieb: + + - `append(true)` schreibt jeden `write` ans aktuelle Dateiende, auch wenn andere Prozesse gleichzeitig anhängen. Richtig für Logdateien. + - `create_new(true)` legt die Datei nur an, wenn es sie noch nicht gibt. Prüfen und Anlegen passieren in einem Systemaufruf, kein anderer Prozess kommt dazwischen. Die Doku nennt das ausdrücklich atomar ([create_new](https://doc.rust-lang.org/std/fs/struct.OpenOptions.html#method.create_new)). Gut für Sperrdateien. + - `truncate(true)` kürzt beim Öffnen auf null. Wer mit `write(true)` ohne `truncate` öffnet, überschreibt nur den Anfang, und ein längerer alter Inhalt bleibt als Rest stehen. + + ```rust + use std::fs::OpenOptions; + use std::io::{self, Write}; + + fn main() -> io::Result<()> { + let mut log = + OpenOptions::new() + .append(true) + .create(true) + .open("deploy.log")?; + writeln!(log, "v1.4 live")?; + + for _ in 0..2 { + let r = OpenOptions::new() + .write(true) + .create_new(true) + .open("deploy.lock"); + if let Err(e) = r { + println!("{e}"); + } else { + println!("geholt"); + } + } + Ok(()) + } + ``` + + Der erste Durchlauf meldet `geholt`, der zweite `File exists (os error 17)`. Genau das willst du für „nur ein Deploy gleichzeitig“. Stürzt der erste Job ab, bleibt die Sperrdatei allerdings liegen. Robuster ist [`File::try_lock`](https://doc.rust-lang.org/std/fs/struct.File.html#method.try_lock) (seit Rust 1.89): Das Betriebssystem gibt die Sperre frei, sobald die Datei geschlossen wird, auch beim Absturz. + + ### BufReader und BufWriter gegen viele kleine Systemaufrufe + + Tausend kleine `write`-Aufrufe auf einem `File` sind tausend Wechsel in den Kernel. `writeln!(datei, "req {i}")` sind sogar drei pro Zeile: Rust reicht festen Text, Zahl und Zeilenende einzeln weiter. [`BufWriter`](https://doc.rust-lang.org/std/io/struct.BufWriter.html) sammelt die Bytes in einem Puffer, derzeit 8 KiB, und schreibt erst, wenn er voll ist. `BufReader` macht dasselbe beim Lesen. Beide lohnen sich nur bei vielen kleinen Zugriffen. + + ```rust + use std::fs::File; + use std::io::{self, Write}; + use std::io::BufWriter; + + fn main() -> io::Result<()> { + let p = "req.log"; + let f = File::create(p)?; + let mut w = BufWriter::new(f); + for i in 0..1000 { + writeln!(w, "req {i}")?; + } + // leert Puffer, meldet Fehler + let f = w.into_inner()?; + // bis auf den Datenträger + f.sync_all()?; + Ok(()) + } + ``` + + > [!WARNING] BufWriter verschluckt Fehler beim Drop + > Fällt ein `BufWriter` aus dem Scope, versucht sein `Drop`, den Rest zu schreiben. Scheitert das – Platte voll, Netzlaufwerk weg –, ignoriert `Drop` den Fehler. Die std-Doku verlangt deshalb ausdrücklich, vorher `flush` aufzurufen. Eine Ebene tiefer gilt dasselbe: `File` ignoriert Fehler beim Schließen; wer sie sehen will, ruft [`sync_all`](https://doc.rust-lang.org/std/fs/struct.File.html#method.sync_all) auf. `into_inner()` leert den Puffer, meldet Fehler als `Err` und gibt dir das `File` für `sync_all` zurück. + + `flush` und `sync_all` sind zwei Stufen. `flush` schiebt die Bytes aus dem Puffer deines Programms in den Kernel; andere Prozesse sehen sie, aber sie liegen noch im Page Cache im RAM. Erst `sync_all` wartet, bis sie auf dem Datenträger sind. Einen Stromausfall überlebt nur, was synchronisiert war. + + ## Verzeichnisse lesen: read_dir und stabile Reihenfolge + + `fs::read_dir` liefert einen Iterator über die Einträge eines Verzeichnisses. Das Beispiel legt zwei Dateien und einen Symlink an und listet sie: + + ```rust + use std::fs; + use std::io; + use std::os::unix::fs::symlink; + + fn main() -> io::Result<()> { + let d = "etc"; + fs::create_dir_all(d)?; + fs::write("etc/b.toml", "")?; + fs::write("etc/a.toml", "")?; + symlink("b.toml", "etc/neu")?; + + let mut namen = Vec::new(); + for e in fs::read_dir(d)? { + let e = e?; // io::Result + let link = e.file_type()? + .is_symlink(); + let name = e.file_name(); + namen.push((name, link)); + } + namen.sort(); + for (n, link) in namen { + println!("{n:?} {link}"); + } + Ok(()) + } + ``` + + Jeder Eintrag ist ein eigenes `io::Result`, weil das Lesen mitten im Verzeichnis scheitern kann. `let e = e?;` reicht den Fehler weiter, statt ihn mit `filter_map(Result::ok)` zu verstecken. Die Reihenfolge ist plattform- und dateisystemabhängig und kann sich sogar zwischen zwei Aufrufen ändern ([read_dir](https://doc.rust-lang.org/std/fs/fn.read_dir.html#platform-specific-behavior)). Auf dem Laptop sieht sie vielleicht sortiert aus, auf dem Server nicht. Muss die Ausgabe stabil sein – in Tests, Logs oder beim Laden von `conf.d`-Dateien –, sortierst du selbst. + + `file_type()` folgt Symlinks nicht: Für `neu` meldet es einen Link, keine Datei. `fs::metadata` folgt dem Link. + + ## Konfiguration atomar ersetzen + + Dein Dienst schreibt seine Konfiguration mit `fs::write("app.toml", neu)`. Laut Doku ist `fs::write` nur `File::create` plus `write_all`: Es kürzt die Datei auf null Bytes und schreibt danach. Stürzt der Prozess dazwischen ab, ist die Platte voll oder fällt der Strom aus, liegt eine leere oder halbe `app.toml` da, und der nächste Start scheitert. + + Das Gegenmittel nutzt `rename(2)`: Existiert das Ziel schon, ersetzt der Kernel es atomar. Jeder andere Prozess sieht die alte oder die neue Datei, nie einen Zwischenstand ([rename(2)](https://man7.org/linux/man-pages/man2/rename.2.html#DESCRIPTION)). + + ```rust + use std::fs::{self, File}; + use std::io::{self, Write}; + use std::path::Path; + + fn ersetze( + ziel: &Path, + inhalt: &[u8], + ) -> io::Result<()> { + // gleicher Ordner wie ziel + let mut tmp = ziel.to_owned(); + tmp.set_extension("tmp"); + let mut f = + File::create(&tmp)?; + f.write_all(inhalt)?; + f.sync_all()?; + drop(f); + fs::rename(&tmp, ziel) + } + + fn main() -> io::Result<()> { + let p = Path::new("app.toml"); + fs::write(p, "port = 80\n")?; + ersetze(p, b"port = 8080\n")?; + let neu = + fs::read_to_string(p)?; + print!("{neu}"); + Ok(()) + } + ``` + + Jeder Schritt hat einen Grund: + + 1. **Temporäre Datei im selben Verzeichnis.** `rename` hängt nur einen Verzeichniseintrag um, die Daten bleiben, wo sie sind. Das geht nur innerhalb eines Dateisystems. Liegt die temporäre Datei in `/tmp` und ist das ein eigenes Dateisystem, etwa ein tmpfs, scheitert `rename` mit `EXDEV`, „Invalid cross-device link“ ([rename(2), Errors](https://man7.org/linux/man-pages/man2/rename.2.html#ERRORS)). `fs::rename` kopiert dann nicht heimlich, es meldet den Fehler. + 2. **Vollständig schreiben** mit `write_all`, bei einem `BufWriter` danach `flush` oder `into_inner`. + 3. **`sync_all` vor dem Umbenennen.** Sonst liegen die Daten womöglich noch im Page Cache, und nach einem Stromausfall zeigt der neue Name auf eine leere oder unvollständige Datei. + 4. **`fs::rename`** auf den Zielnamen. + + Unter Unix entspricht `fs::rename` dem Systemaufruf `rename`, unter Windows `MoveFileExW`; die Regeln für vorhandene Ziele unterscheiden sich im Detail ([fs::rename](https://doc.rust-lang.org/std/fs/fn.rename.html#platform-specific-behavior)). + + > [!DEEP] Auch das Verzeichnis synchronisieren + > `sync_all` bringt Inhalt und Metadaten der Datei auf den Datenträger, aber nicht unbedingt den Verzeichniseintrag, den `rename` ändert. Laut [fsync(2)](https://man7.org/linux/man-pages/man2/fsync.2.html#DESCRIPTION) braucht es dafür ein eigenes `fsync` auf das Verzeichnis, in Rust unter Unix `File::open(verzeichnis)?.sync_all()?` nach dem `rename`. Bei einer Konfiguration, die sich nach einem Absturz neu erzeugen lässt, kannst du darauf verzichten, bei Zustandsdateien und Datenbanken nicht. + + ## Prozesse starten ohne Shell + + `Command::new("git")` startet keine Shell. Rust sucht `git` im `PATH` und übergibt die Argumente als Liste (`argv`) an das neue Programm. Niemand trennt an Leerzeichen, expandiert `*`, liest `;` als Befehlsende oder ersetzt `$HOME`; laut [`arg`](https://doc.rust-lang.org/std/process/struct.Command.html#method.arg) haben Quotes, Escapes, Word Splitting, Glob-Muster und Variablen keine Wirkung. Ohne Shell gibt es keine Shell-Injection. + + ```rust + use std::process::Command; + + fn text(b: &[u8]) -> &str { + str::from_utf8(b).unwrap() + } + + fn main() -> std::io::Result<()> { + let name = "*.log; rm -rf ~"; + // ohne Shell: ein Argument + let o = Command::new("echo") + .arg(name) + .output()?; + print!("{}", text(&o.stdout)); + + // mit Shell: Skript fest, + // Daten nur als Parameter + let s = Command::new("sh") + .arg("-c") + .arg(r#"test -e "$1""#) + .arg("sh") // wird $0 + .arg(name) // wird $1 + .status()?; + println!("{}", s.success()); + Ok(()) + } + ``` + + `echo` bekommt `*.log; rm -rf ~` als ein einziges Argument und gibt es unverändert aus. Brauchst du doch eine Shell, etwa für eine Pipe, rufst du `sh -c` mit einem **festen** Skript auf und reichst Daten als Positionsparameter hinein. Wer das Skript mit `format!` aus Eingaben zusammensetzt, hat die Injection zurück. + + ### status, output und spawn + + Drei Methoden starten den Prozess. Sie unterscheiden sich darin, ob sie warten und was mit stdin, stdout und stderr passiert ([Command::stdout](https://doc.rust-lang.org/std/process/struct.Command.html#method.stdout)): + + | Methode | stdout, stderr | wartet | + | --- | --- | --- | + | `status()` | erbt | ja | + | `output()` | fängt ab | ja | + | `spawn()` | erbt | nein | + + „Erbt“ heißt: Das Kind schreibt direkt in dieselben Ausgaben wie dein Programm. `output()` fängt stdout und stderr in zwei `Vec` ab, das stdin des Kindes ist dabei sofort geschlossen. `spawn()` kehrt sofort zurück und gibt dir ein `Child`. Jede Voreinstellung lässt sich mit `Stdio::piped()`, `Stdio::null()` oder `Stdio::inherit()` überschreiben. + + Wie der Prozess endete, steht im [`ExitStatus`](https://doc.rust-lang.org/std/process/struct.ExitStatus.html#method.code). `success()` heißt Exit-Code 0. `code()` ist unter Unix `None`, wenn ein Signal den Prozess beendet hat, etwa `kill` oder der OOM-Killer; das Signal liefert `ExitStatusExt::signal`. + + ```rust + use std::os::unix::process::{ + ExitStatusExt, + }; + use std::process::Command; + + fn main() -> std::io::Result<()> { + let s = Command::new("sh") + .arg("-c") + .arg("kill -TERM $$") + .status()?; + println!("{}", s.success()); + println!("{:?}", s.code()); + println!("{:?}", s.signal()); + // false, None, Some(15) + Ok(()) + } + ``` + + > [!MERKE] Start geglückt heißt nicht Erfolg + > `Command::output()?` ist `Ok`, sobald der Prozess **gestartet** werden konnte – auch wenn er mit Code 1 endet. Das `?` fängt nur Startfehler wie „Programm nicht gefunden“. Ob das Werkzeug seine Arbeit geschafft hat, sagt erst `status.success()`. + + ### stdin füttern, EOF senden, auf das Ende warten + + Für einen Dialog mit dem Kind setzt du die Pipes selbst und startest mit `spawn`: + + ```rust + use std::io::{self, Write}; + use std::process::Command; + use std::process::Stdio; + + fn text(b: &[u8]) -> &str { + str::from_utf8(b).unwrap() + } + + fn main() -> io::Result<()> { + let mut cmd = + Command::new("sort"); + cmd.stdin(Stdio::piped()); + cmd.stdout(Stdio::piped()); + let mut kind = cmd.spawn()?; + + let mut ein = kind.stdin + .take() + .unwrap(); + ein.write_all(b"web\napi\n")?; + drop(ein); // sort sieht EOF + + let o = + kind.wait_with_output()?; + print!("{}", text(&o.stdout)); + Ok(()) + } + ``` + + `sort` gibt erst etwas aus, wenn seine Eingabe zu Ende ist, und das Ende sieht es erst, wenn alle schreibenden Enden der Pipe geschlossen sind. `drop(ein)` schließt deins. Ohne diese Zeile wartet `sort` auf Eingabe und `wait_with_output` auf `sort`, beide für immer. `wait` schließt zwar selbst das stdin, das noch im `Child` steckt ([Child::wait](https://doc.rust-lang.org/std/process/struct.Child.html#method.wait)), aber nach `take()` ist es deine Aufgabe. + + Ein `Child` hat kein `Drop`, das den Prozess beendet ([Child](https://doc.rust-lang.org/std/process/struct.Child.html#warning)). Ohne `wait` läuft das Kind weiter, auch wenn das Handle aus dem Scope fällt. Ist es fertig und niemand hat gewartet, bleibt es als **Zombie** in der Prozesstabelle. Ein langlebiger Dienst, der nie wartet, sammelt Zombies, bis die Prozess-IDs ausgehen. + + > [!WARNING] Deadlock mit zwei Pipes + > Eine Pipe hat einen festen Puffer im Kernel, unter Linux standardmäßig 64 KiB ([pipe(7)](https://man7.org/linux/man-pages/man7/pipe.7.html#DESCRIPTION)). Ist er voll, blockiert der `write` des Kindes. Setzt du stdout **und** stderr auf `piped` und liest erst stdout bis zum Ende, dann stderr, hängt alles, sobald das Kind mehr als 64 KiB auf stderr schreibt: Es wartet auf einen Leser, dein Programm auf das Ende von stdout. Lies beide Ströme gleichzeitig – `output()` und `wait_with_output()` tun das intern, sonst liest ein zweiter Thread stderr. + + ## TCP: ein Bytestrom ohne Nachrichtengrenzen + + Ein `TcpListener` ist ein Socket, an dem der Kernel Verbindungen annimmt. `accept()` gibt dir für jede Verbindung einen eigenen `TcpStream`, und der verhält sich wie eine Datei: ein Deskriptor, `Read` und `Write`, blockierende Aufrufe. Mit Port 0 wählt das Betriebssystem einen freien Port, `local_addr()` verrät welchen ([bind](https://doc.rust-lang.org/std/net/struct.TcpListener.html#method.bind)). So kollidieren Tests nie mit einem belegten Port. + + Das wichtigste Denkmodell: TCP überträgt einen geordneten **Strom von Bytes**, keine Nachrichten. Zwei `write_all` beim Sender werden beim Empfänger nicht zu zwei `read`-Ergebnissen. Ein `read` kann eine halbe Nachricht liefern, eine oder anderthalb; laut [`Read::read`](https://doc.rust-lang.org/std/io/trait.Read.html#method.read) ist ein kurzes Ergebnis kein Fehler, auch mitten im Strom. Wo eine Nachricht aufhört, legt dein Protokoll fest. Das heißt **Framing**, üblich sind ein Trennzeichen wie `\n` oder eine Längenangabe vor jeder Nachricht. + + Das folgende Zeilenprotokoll beantwortet `PING` mit `PONG`, `ECHO text` mit `text` und alles andere mit `ERR unbekannt`. Server und Client laufen im selben Programm. + + ```rust + use std::io::{self, Read, Write}; + use std::io::{BufRead, BufReader}; + use std::io::BufWriter; + use std::net::Shutdown; + use std::net::TcpListener; + use std::net::TcpStream; + use std::thread; + + fn antwort(z: &str) -> &str { + if z == "PING" { + return "PONG"; + } + match z.split_once(' ') { + Some(("ECHO", t)) => t, + _ => "ERR unbekannt", + } + } + + fn bediene(s: &TcpStream) + -> io::Result<()> + { + let mut r = BufReader::new(s); + let mut w = BufWriter::new(s); + let mut z = String::new(); + loop { + z.clear(); + let n = + r.read_line(&mut z)?; + if n == 0 { + return Ok(()); // EOF + } + let a = antwort(z.trim()); + writeln!(w, "{a}")?; + w.flush()?; + } + } + + const LOKAL: &str = "127.0.0.1:0"; + + fn main() -> io::Result<()> { + let l = + TcpListener::bind(LOKAL)?; + let addr = l.local_addr()?; + let mut c = + TcpStream::connect(addr)?; + let (s, _) = l.accept()?; + let t = thread::spawn( + move || bediene(&s), + ); + + c.write_all(b"PING\n")?; + c.write_all(b"ECHO hallo\n")?; + c.shutdown(Shutdown::Write)?; + let mut alles = String::new(); + c.read_to_string(&mut alles)?; + print!("{alles}"); + t.join().unwrap() + } + ``` + + Das Programm gibt `PONG` und `hallo` aus. Was dabei passiert: + + - `connect` gelingt vor `accept`: Der Kernel baut die Verbindung schon auf und stellt sie in eine Warteschlange, `accept` holt sie nur ab. + - `read_line` liest bis einschließlich `\n` und **hängt an** den String an, deshalb `z.clear()`. `Ok(0)` heißt: Die Gegenseite hat ihre Schreibrichtung geschlossen ([read_line](https://doc.rust-lang.org/std/io/trait.BufRead.html#method.read_line)). + - Nach jeder Antwort ruft der Server `flush` auf. Sonst läge sie im `BufWriter`, und ein Client, der auf die Antwort wartet, bevor er weiterschreibt, wartet für immer. + - `shutdown(Shutdown::Write)` schließt nur die Schreibrichtung des Clients. Der Server sieht EOF, sein Thread endet und schließt den Stream, erst dann endet `read_to_string` beim Client. Schließt keine Seite, wartet [`read_to_string`](https://doc.rust-lang.org/std/io/trait.Read.html#method.read_to_string) für immer. + + ### Timeouts gegen schweigende Gegenstellen + + Ohne Timeout blockiert `read`, bis Daten kommen. Eine Gegenstelle, die die Verbindung offen hält und schweigt – ein hängender Dienst, ein Angreifer –, hält deinen Thread unbegrenzt fest. `set_read_timeout` begrenzt jeden einzelnen `read`: + + ```rust + use std::io::{self, Read}; + use std::net::TcpListener; + use std::net::TcpStream; + use std::time::Duration; + + const LOKAL: &str = "127.0.0.1:0"; + + fn main() -> io::Result<()> { + // nimmt an, antwortet nie + let l = + TcpListener::bind(LOKAL)?; + let addr = l.local_addr()?; + let mut c = + TcpStream::connect(addr)?; + + let t = + Duration::from_secs(2); + c.set_read_timeout(Some(t))?; + let mut puffer = [0; 512]; + let e = c.read(&mut puffer) + .unwrap_err(); + println!("{:?}", e.kind()); + Ok(()) + } + ``` + + Unter Linux meldet ein abgelaufener Timeout `WouldBlock` („Resource temporarily unavailable“), unter Windows `TimedOut` ([set_read_timeout](https://doc.rust-lang.org/std/net/struct.TcpStream.html#method.set_read_timeout)). Er gilt pro Aufruf: Ein Client, der alle vier Sekunden ein Byte schickt, unterläuft einen Timeout von fünf Sekunden. Gegen endlose Zeilen begrenzt du zusätzlich die Länge, etwa mit `Read::take` vor `read_line`. + + ### Ein Thread pro Verbindung und seine Grenze + + Der einfachste Server startet in einer Schleife über `listener.incoming()` für jede Verbindung einen Thread. Für interne Werkzeuge reicht das. Jeder Thread reserviert aber einen eigenen Stack, auf Tier-1-Plattformen derzeit 2 MiB ([std::thread](https://doc.rust-lang.org/std/thread/index.html#stack-size)). Zehntausend langsame Clients sind zehntausend Threads, die fast nur warten. Deshalb gehört eine Obergrenze dazu: ein `AtomicUsize`, der aktive Verbindungen zählt und über dem Limit mit `ERR busy` ablehnt, oder ein fester Pool von Worker-Threads an einem Channel. In [Async Rust](/rust/async-rust) teilen sich viele wartende Verbindungen wenige Threads. + + Randnotiz: Kleine Schreibvorgänge fasst der Kernel unter Umständen zu größeren Paketen zusammen (Nagle-Algorithmus). Bei vielen kleinen Frage-Antwort-Runden schaltet [`set_nodelay(true)`](https://doc.rust-lang.org/std/net/struct.TcpStream.html#method.set_nodelay) das ab. + + > [!PRAXIS] So sieht es in echten Diensten aus + > Konfigurationen und Zustandsdateien schreibt ein sorgfältiger Dienst über „temporäre Datei, sync, rename“, in Rust oft mit der Crate [tempfile](https://docs.rs/tempfile/latest/tempfile/struct.NamedTempFile.html#method.persist): `NamedTempFile::new_in(verzeichnis)` legt eine Datei mit eindeutigem Namen im Zielverzeichnis an, `persist(ziel)` benennt sie um. Laut Doku synchronisiert `persist` weder Inhalt noch Verzeichnis, `sync_all` bleibt deine Aufgabe. Externe Werkzeuge laufen mit fester Argumentliste und Zeitlimit: `spawn`, in einer Schleife `try_wait`, nach Ablauf `kill` und `wait`. Im Code-Review fallen auf: `sh -c` mit `format!`, ignorierte Exit-Codes, Server ohne Read-Timeout, `read_to_string` auf Sockets. + + > [!INTERVIEW] „Warum braucht ein Protokoll über TCP Framing?“ + > TCP garantiert Reihenfolge und Vollständigkeit der Bytes, aber keine Grenzen zwischen Nachrichten. Ein `write` beim Sender kann beim Empfänger in mehreren `read`s ankommen, zwei `write`s in einem. Deshalb legt das Protokoll fest, wo eine Nachricht endet: mit einem Trennzeichen wie `\n` oder einer Längenangabe davor. Dazu nennst du die Grenzen – maximale Nachrichtengröße, Timeout, wer die Verbindung wann schließt. Wer „ein `read`, eine Nachricht“ annimmt, baut einen Fehler, der auf Loopback selten auftritt und unter Last im echten Netz regelmäßig. + + ## Häufige Fehler + + - **Kein `flush` vor `rename`.** Mit `BufWriter` liegen die letzten Bytes noch im Puffer, die umbenannte Datei ist unvollständig. Erst `into_inner()?`, dann `sync_all`, dann `rename`. + - **Temporäre Datei in `/tmp`.** Läuft auf dem Laptop, scheitert auf dem Server mit `Invalid cross-device link (os error 18)`. Die temporäre Datei gehört neben das Ziel. + - **`read_to_string` auf einem Socket.** Wartet auf EOF. Hält der Client die Verbindung offen, weil er auf die Antwort wartet, hängen beide. Lies mit Framing, etwa `read_line`. + - **`Command::new("ls -la")`.** Sucht ein Programm, das wörtlich `ls -la` heißt, und scheitert mit `No such file or directory (os error 2)`. Programm und Argumente gehören getrennt: `Command::new("ls").arg("-la")`. + - **Exit-Code nicht geprüft.** `output()?` ist `Ok`, auch bei Code 1. `status.success()` prüfen, stderr in die eigene Fehlermeldung übernehmen. + - **`read_dir` unsortiert ausgegeben.** Die Reihenfolge hängt vom Dateisystem ab, Tests werden auf einem anderen Rechner rot. Erst sammeln, dann sortieren. + - **`write` statt `write_all`.** `write` darf weniger Bytes schreiben als übergeben, der Rest geht still verloren. `write_all` wiederholt, bis alles geschrieben ist oder ein Fehler kommt. + + ## Zusammenfassung + + - `File`, Pipes und `TcpStream` sind Handles auf Kernel-Objekte. Jeder `read` und `write` ist ein Systemaufruf, der blockieren und scheitern kann. + - `OpenOptions`: `append` für Logs, `create_new` als atomares „nur wenn noch nicht da“, `truncate` bewusst setzen. `BufWriter` spart Systemaufrufe, verschluckt aber Fehler beim Drop – `flush` oder `into_inner` aufrufen. + - `read_dir` liefert pro Eintrag ein `io::Result` in unbestimmter Reihenfolge. Für stabile Ausgabe sortieren. + - Atomar ersetzen: temporäre Datei im selben Verzeichnis, `write_all`, `flush`, `sync_all`, `rename`. Über Dateisystemgrenzen scheitert `rename` mit `EXDEV`. + - `Command` startet ohne Shell, Argumente kommen wörtlich an. `output()` fängt ab, `status()` und `spawn()` erben. Exit-Status immer prüfen, `code()` ist `None` bei einem Signal. + - Kindprozesse brauchen `wait`. stdin schließen, damit das Kind EOF sieht. stdout und stderr gleichzeitig lesen, sonst droht ein Deadlock am vollen Pipe-Puffer. + - TCP ist ein Bytestrom: Framing festlegen, `read_line` liefert `Ok(0)` bei EOF, `write_all` und `flush` zum Senden, `shutdown(Shutdown::Write)` meldet das Ende, Read-Timeouts gegen schweigende Gegenstellen. + - Ein Thread pro Verbindung ist einfach, braucht aber eine Obergrenze. Viele wartende Verbindungen sind der Fall für async. +exercises: + - id: create-new-garantie + type: choice + title: Zwei Deploys, eine Sperrdatei + prompt: Zwei Deploy-Jobs starten auf demselben Server in derselben Millisekunde. Beide führen diesen Code aus, um eine Sperre zu holen. Welche Aussage stimmt? + lang: rust + code: | + let lock = OpenOptions::new() + .write(true) + .create_new(true) + .open("/run/deploy.lock"); + options: + - text: Höchstens ein Job bekommt `Ok`. Der andere bekommt sofort einen Fehler der Art `AlreadyExists`, weil Prüfen und Anlegen ein einziger Systemaufruf sind. + correct: true + why: Unter Linux wird `create_new(true)` zu `open` mit `O_CREAT | O_EXCL`. Der Kernel prüft und legt in einem Schritt an, kein anderer Prozess kommt dazwischen. + - text: Beide können `Ok` bekommen, wenn sie exakt gleichzeitig laufen. Dagegen hilft nur ein zusätzlicher `Mutex`. + correct: false + why: Ein `Mutex` wirkt nur innerhalb eines Prozesses. Zwei Deploy-Jobs sind zwei Prozesse; die Atomarität muss der Kernel liefern, und mit `create_new` tut er das. + - text: Der zweite Aufruf blockiert, bis der erste Job die Sperrdatei wieder löscht. + correct: false + why: "`open` mit `create_new` wartet nicht. Es scheitert sofort. Ob du wartest, es später erneut versuchst oder abbrichst, entscheidet dein Code." + - text: "`if !Path::new(p).exists() { File::create(p)?; }` erreicht dasselbe und ist lesbarer." + correct: false + why: Zwischen `exists()` und `File::create` liegt ein Zeitfenster. Beide Jobs können „existiert nicht“ sehen und beide anlegen. Diese Lücke heißt TOCTOU (time of check to time of use). + explanation: | + Sobald mehrere Prozesse beteiligt sind, muss die Atomarität vom Kernel kommen. `create_new` gehört dazu: Die Doku nennt es atomar und erklärt, dass sonst zwischen Prüfen und Anlegen ein anderer Prozess die Datei erzeugen könnte. Eine Grenze hat das Muster: Stürzt der erste Job ab, bleibt die Sperrdatei liegen, und kein Deploy läuft mehr, bis jemand sie löscht. `File::try_lock` (seit Rust 1.89) hat dieses Problem nicht, weil das Betriebssystem die Sperre beim Schließen der Datei freigibt, auch beim Absturz. + resources: + - title: OpenOptions::create_new + url: https://doc.rust-lang.org/std/fs/struct.OpenOptions.html#method.create_new + - title: File::try_lock + url: https://doc.rust-lang.org/std/fs/struct.File.html#method.try_lock + - id: tcp-read-grenzen + type: choice + title: Was liefert ein einziges read? + prompt: Der Client schreibt zwei Zeilen, zusammen 16 Bytes. Der Server ruft danach genau einmal `read` mit einem 1024-Byte-Puffer auf. Welche Aussagen stimmen? + lang: rust + code: | + // Client + c.write_all(b"PING\n")?; + c.write_all(b"ECHO hallo\n")?; + + // Server, ein einziges read: + let mut puffer = [0u8; 1024]; + let n = s.read(&mut puffer)?; + options: + - text: "`n` kann 5 sein: nur die erste Zeile." + correct: true + why: Die zweite Zeile kann noch unterwegs sein, wenn der Server liest. `read` liefert, was gerade im Empfangspuffer liegt. + - text: "`n` kann 16 sein: beide Zeilen auf einmal." + correct: true + why: Liegen beide Schreibvorgänge schon im Empfangspuffer des Kernels, kommen sie in einem `read` an. TCP kennt keine Grenze zwischen ihnen. + - text: "`n` kann 3 sein: mitten in `PING`." + correct: true + why: Auch das erlaubt der Vertrag von `Read::read`. Auf Loopback siehst du es selten, im echten Netz mit Paketgrößen, Verlusten und Wiederholungen schon. + - text: "`n` ist immer 5, weil jeder `write_all` als eigenes Paket ankommt." + correct: false + why: TCP überträgt einen Bytestrom. Wie der Kernel ihn in Segmente teilt und wie viel ein `read` davon abholt, hat mit den Aufrufen beim Sender nichts zu tun. + - text: "`read` wartet, bis der Puffer voll ist oder der Client die Verbindung schließt." + correct: false + why: So verhält sich `read_exact`, und das scheitert bei vorzeitigem EOF. `read` kehrt zurück, sobald mindestens ein Byte da ist. + explanation: | + `read` gibt zurück, was beim Aufruf im Empfangspuffer liegt: mindestens ein Byte, höchstens so viel, wie in deinen Puffer passt. Die Doku zu `Read::read` sagt ausdrücklich, dass ein kleineres `n` kein Fehler ist, auch wenn der Strom noch nicht zu Ende ist. Deshalb braucht jedes Protokoll über TCP Framing: Der Empfänger sammelt Bytes, bis eine Nachricht vollständig ist, zum Beispiel bis zum `\n`. `BufReader::read_line` erledigt genau das. + resources: + - title: Read::read + url: https://doc.rust-lang.org/std/io/trait.Read.html#method.read + - title: BufRead::read_line + url: https://doc.rust-lang.org/std/io/trait.BufRead.html#method.read_line + - id: atomar-ersetzen-schritte + type: order + title: Konfiguration ohne halbe Datei + prompt: Dein Dienst soll `app.toml` über einen `BufWriter` so ersetzen, dass nach einem Absturz oder Stromausfall entweder die alte oder die neue Fassung vollständig da ist. Bring die Schritte in die richtige Reihenfolge. + lang: prose + lines: | + Eine temporäre Datei im Verzeichnis von app.toml anlegen, etwa app.toml.tmp + Den neuen Inhalt vollständig in den BufWriter schreiben + Den BufWriter mit into_inner leeren und das File zurückbekommen + sync_all auf der temporären Datei aufrufen + Mit fs::rename die temporäre Datei auf app.toml umbenennen + Unter Unix das Verzeichnis öffnen und darauf sync_all aufrufen + explanation: | + Die temporäre Datei muss im selben Verzeichnis liegen, weil `rename` nur innerhalb eines Dateisystems funktioniert und nur dort atomar ersetzt. `into_inner` schiebt den Pufferinhalt in den Kernel und meldet Fehler, statt sie wie der Drop zu verschlucken. `sync_all` wartet, bis die Daten auf dem Datenträger sind; erst danach darf der Name umgehängt werden, sonst kann `app.toml` nach einem Stromausfall auf eine leere Datei zeigen. Der letzte Schritt macht die Umbenennung selbst dauerhaft, denn laut fsync(2) synchronisiert ein `fsync` auf die Datei nicht den Verzeichniseintrag. + resources: + - title: std::fs::rename + url: https://doc.rust-lang.org/std/fs/fn.rename.html + - title: Linux man-pages · fsync(2) + url: https://man7.org/linux/man-pages/man2/fsync.2.html#DESCRIPTION + - id: ueberschreiben-ohne-truncate + type: output + title: Geöffnet, geschrieben, verdorben + prompt: Was gibt dieses Programm aus? Es läuft in einem leeren Verzeichnis. + lang: rust + code: | + use std::fs::{self, OpenOptions}; + use std::io::{self, Write}; + + fn main() -> io::Result<()> { + let p = "app.conf"; + fs::write(p, "port=8080\n")?; + + let mut f = OpenOptions::new() + .append(true) + .open(p)?; + f.write_all(b"debug=1\n")?; + + let mut f = OpenOptions::new() + .write(true) + .open(p)?; + f.write_all(b"port=90\n")?; + + let inhalt = + fs::read_to_string(p)?; + print!("{inhalt}"); + Ok(()) + } + expected: | + port=90 + 0 + debug=1 + hints: + - Nach dem ersten `write_all` steht `port=8080\ndebug=1\n` in der Datei. + - "`write(true)` ohne `truncate` und ohne `append` beginnt bei Byte 0 und überschreibt nur so viele Bytes, wie du schreibst." + explanation: | + `fs::write` legt die Datei mit `port=8080\n` an, 10 Bytes. `append(true)` hängt `debug=1\n` ans Ende. Das zweite `OpenOptions` öffnet mit `write(true)`, aber ohne `truncate`: Der Schreibzeiger steht auf Byte 0, und `port=90\n` überschreibt genau die ersten 8 Bytes. Übrig bleiben die alten Bytes 8 und 9, also `0` und `\n` – eine Zeile `0`, die niemand geschrieben hat. Wer eine Datei komplett ersetzen will, nimmt `File::create`, das kürzt, oder besser das atomare Ersetzen mit `rename`. + resources: + - title: OpenOptions::write + url: https://doc.rust-lang.org/std/fs/struct.OpenOptions.html#method.write + - title: OpenOptions::truncate + url: https://doc.rust-lang.org/std/fs/struct.OpenOptions.html#method.truncate + - id: puffer-zaehlt-aufrufe + type: output + title: Wie oft klopft das Programm beim Kernel an? + prompt: | + `Fd` tut so, als wäre er ein Dateideskriptor: Jeder Aufruf von `write` steht für einen Systemaufruf und wird gezählt. `writeln!(w, "req {i}")` reicht die Zeile in drei Stücken weiter – `req `, die Zahl, `\n` – und ruft für jedes Stück `write_all` auf. Was gibt das Programm aus? + lang: rust + code: | + use std::io::{self, Write}; + use std::io::BufWriter; + + // tut so, als wäre jeder write + // ein Systemaufruf, und zählt mit + struct Fd(usize); + + impl Write for Fd { + fn write(&mut self, b: &[u8]) + -> io::Result + { + self.0 += 1; + Ok(b.len()) + } + fn flush(&mut self) + -> io::Result<()> + { + Ok(()) + } + } + + fn main() -> io::Result<()> { + let mut roh = Fd(0); + let mut puf = + BufWriter::new(Fd(0)); + for i in 0..100 { + writeln!(roh, "req {i}")?; + writeln!(puf, "req {i}")?; + } + println!("roh: {}", roh.0); + let vorher = puf.get_ref().0; + println!("vorher: {vorher}"); + let fd = puf.into_inner()?; + println!("danach: {}", fd.0); + Ok(()) + } + expected: | + roh: 300 + vorher: 0 + danach: 1 + hints: + - Ohne Puffer wird jedes der drei Stücke zu einem eigenen `write`. + - Der `BufWriter` hat derzeit 8 KiB Platz. Die 100 Zeilen sind zusammen 690 Bytes lang. + - "`get_ref` schaut auf den inneren Writer, ohne den Puffer zu leeren. `into_inner` leert ihn." + explanation: | + Ohne Puffer ergeben 100 Zeilen mit je drei Stücken 300 Aufrufe von `write`. Bei einem echten `File` wären das 300 Systemaufrufe `write(2)`. Der `BufWriter` kopiert jedes Stück nur in seinen Puffer im Speicher. 690 Bytes passen in 8 KiB, deshalb hat er bis zum `into_inner` noch nichts geschrieben (`vorher: 0`). `into_inner` leert den Puffer mit einem einzigen `write` und gibt den inneren Writer zurück. Darin liegt der Gewinn bei Logdateien – und das Risiko: Wird ein `BufWriter` ohne `flush` gedroppt, liegt bis dahin womöglich alles nur im Puffer. + resources: + - title: std::io::BufWriter + url: https://doc.rust-lang.org/std/io/struct.BufWriter.html + - title: Write::write_fmt + url: https://doc.rust-lang.org/std/io/trait.Write.html#method.write_fmt + - id: exit-code-und-stderr + type: output + title: Zweimal dasselbe Skript + prompt: Das Programm startet dasselbe Shell-Skript zweimal, erst mit `output()`, dann mit `status()`. Was erscheint auf der **Standardausgabe** des Programms? Was auf stderr landet, zählt nicht. + lang: rust + code: | + use std::process::Command; + + const SKRIPT: &str = "echo gebaut + echo 'Test rot' >&2 + exit 3"; + + fn text(b: &[u8]) -> &str { + str::from_utf8(b).unwrap() + } + + fn main() -> std::io::Result<()> { + let mut sh = + Command::new("sh"); + sh.arg("-c").arg(SKRIPT); + + let o = sh.output()?; + let code = o.status.code(); + println!("{code:?}"); + print!("{}", text(&o.stderr)); + + let s = sh.status()?; + println!("{}", s.success()); + Ok(()) + } + expected: | + Some(3) + Test rot + gebaut + false + hints: + - "`output()` fängt stdout und stderr des Kindes ab. Welchen der beiden Ströme gibt das Programm danach selbst aus?" + - "`status()` lässt das Kind direkt in die geerbten Ausgaben schreiben." + explanation: | + Ein `Command` lässt sich mehrfach ausführen. Beim ersten Lauf fängt `output()` beide Ströme in `Vec` ab. Das Programm gibt den Exit-Code `Some(3)` und den abgefangenen stderr-Text aus; das abgefangene `gebaut` gibt es nie aus. Beim zweiten Lauf erbt das Kind stdout und stderr: `gebaut` landet direkt in der Standardausgabe, `Test rot` auf stderr und zählt hier nicht. `status()` kehrt erst zurück, wenn das Skript beendet ist, deshalb steht `gebaut` vor `false`. `success()` ist `false`, weil der Exit-Code nicht 0 ist. + resources: + - title: Command::output + url: https://doc.rust-lang.org/std/process/struct.Command.html#method.output + - title: Command::status + url: https://doc.rust-lang.org/std/process/struct.Command.html#method.status + - id: zeilenprotokoll-loopback + type: output + title: Drei Stücke über Loopback + prompt: Server und Client laufen im selben Programm. Der Client schreibt seine Bytes in drei Stücken, das letzte endet ohne `\n`. Was gibt das Programm aus? + lang: rust + code: | + use std::io::{self, Read, Write}; + use std::io::{BufRead, BufReader}; + use std::net::Shutdown; + use std::net::TcpListener; + use std::net::TcpStream; + use std::thread; + + fn antwort(z: &str) -> &str { + if z == "PING" { + return "PONG"; + } + match z.split_once(' ') { + Some(("ECHO", t)) => t, + _ => "ERR unbekannt", + } + } + + fn bediene(s: &TcpStream) + -> io::Result<()> + { + let mut r = BufReader::new(s); + let mut w = s; + let mut z = String::new(); + loop { + z.clear(); + let n = + r.read_line(&mut z)?; + if n == 0 { + return Ok(()); + } + let a = antwort(z.trim()); + writeln!(w, "{a}")?; + } + } + + const LOKAL: &str = "127.0.0.1:0"; + + fn main() -> io::Result<()> { + let l = + TcpListener::bind(LOKAL)?; + let addr = l.local_addr()?; + let mut c = + TcpStream::connect(addr)?; + let (s, _) = l.accept()?; + let t = thread::spawn( + move || bediene(&s), + ); + + c.write_all(b"PI")?; + c.write_all(b"NG\nping\n")?; + c.write_all(b"ECHO bye")?; + c.shutdown(Shutdown::Write)?; + let mut alles = String::new(); + c.read_to_string(&mut alles)?; + print!("{alles}"); + t.join().unwrap() + } + expected: | + PONG + ERR unbekannt + bye + hints: + - "`read_line` liest bis zum nächsten `\\n`, egal in wie vielen Stücken die Bytes ankamen." + - Groß- und Kleinschreibung zählt bei `z == "PING"`. + - Nach `shutdown(Shutdown::Write)` liefert `read_line` die letzte, unvollständige Zeile und beim nächsten Aufruf `0`. + explanation: | + TCP überträgt Bytes, keine Schreibaufrufe. Aus `PI` und `NG\nping\n` macht `read_line` die Zeilen `PING` und `ping`, egal wie die Stücke ankamen. `PING` wird zu `PONG`. `ping` besteht den Vergleich nicht, weil Groß- und Kleinschreibung zählt, und hat kein Leerzeichen für `split_once`: `ERR unbekannt`. `ECHO bye` hat kein Zeilenende. Weil der Client danach seine Schreibrichtung schließt, liefert `read_line` die Bytes bis zum EOF als letzte Zeile, also `bye`. Der nächste Aufruf gibt `0` zurück, `bediene` endet, der Thread schließt den Stream, und erst dann endet `read_to_string` beim Client. + resources: + - title: BufRead::read_line + url: https://doc.rust-lang.org/std/io/trait.BufRead.html#method.read_line + - title: TcpStream::shutdown + url: https://doc.rust-lang.org/std/net/struct.TcpStream.html#method.shutdown + - id: log-sync-sperre + type: gap + title: Log, Sync, Sperre + prompt: Ergänze drei Stellen. Die Logzeile soll ans Ende der bestehenden `deploy.log` angehängt werden und einen Stromausfall überleben, bevor das Programm weitermacht. `deploy.lock` darf nur angelegt werden, wenn es noch nicht existiert. + lang: rust + code: | + use std::fs::OpenOptions; + use std::io::{self, Write}; + + fn main() -> io::Result<()> { + let mut f = OpenOptions::new() + .⟦append(true)⟧ + .create(true) + .open("deploy.log")?; + writeln!(f, "v1.4 live")?; + f.⟦sync_all()⟧?; + + let lock = OpenOptions::new() + .write(true) + .⟦create_new(true)⟧ + .open("deploy.lock"); + println!("{}", lock.is_ok()); + Ok(()) + } + gaps: + - label: Ans Ende schreiben + hint: Der Modus, in dem jeder `write` am aktuellen Dateiende landet. + - label: Auf den Datenträger bringen + hint: "`flush` reicht bei einem `File` nicht, es puffert ohnehin nichts. Gesucht ist die Methode, die auf den Datenträger wartet." + accept: ["sync_data()"] + - label: Nur wenn neu + hint: Prüfen und Anlegen in einem einzigen Systemaufruf. + explanation: | + `append(true)` setzt jeden Schreibvorgang ans aktuelle Ende, auch wenn andere Prozesse gleichzeitig anhängen; mit `create(true)` entsteht die Datei beim ersten Mal. `sync_all` wartet, bis Inhalt und Metadaten auf dem Datenträger sind. `sync_data` darf Metadaten wie die Änderungszeit auslassen; die Dateigröße, die beim Anhängen wächst, synchronisiert `fdatasync` unter Linux laut fsync(2) mit. Hier genügt es also auch. `flush` wäre wirkungslos: `File` hat keinen eigenen Puffer, die Bytes liegen nach `writeln!` schon im Kernel, aber noch nicht auf der Platte. `create_new(true)` scheitert mit `AlreadyExists`, wenn die Sperrdatei schon da ist – atomar, ohne Zeitfenster zwischen Prüfen und Anlegen. + resources: + - title: OpenOptions::append + url: https://doc.rust-lang.org/std/fs/struct.OpenOptions.html#method.append + - title: File::sync_data + url: https://doc.rust-lang.org/std/fs/struct.File.html#method.sync_data + - id: grep-ueber-stdin + type: gap + title: Logzeilen an grep verfüttern + prompt: Das Programm schickt Logzeilen über stdin an `grep -c ERROR` und gibt die Anzahl aus (`2`). Ergänze die Pipe für stdin, das Signal „Eingabe zu Ende“ und das Einsammeln von Ausgabe und Exit-Status. + lang: rust + code: | + use std::io::{self, Write}; + use std::process::Command; + use std::process::Stdio; + + const LOG: &[u8] = b"INFO start + ERROR db weg + INFO retry + ERROR db weg + "; + + fn text(b: &[u8]) -> &str { + str::from_utf8(b).unwrap() + } + + fn main() -> io::Result<()> { + let mut cmd = + Command::new("grep"); + cmd.args(["-c", "ERROR"]); + cmd.stdin(⟦Stdio::piped()⟧); + cmd.stdout(Stdio::piped()); + let mut kind = cmd.spawn()?; + + let mut ein = kind.stdin + .take() + .unwrap(); + ein.write_all(LOG)?; + ⟦drop(ein)⟧; + + let o = kind + .⟦wait_with_output()⟧?; + print!("{}", text(&o.stdout)); + Ok(()) + } + gaps: + - label: stdin als Pipe + hint: Ohne diese Einstellung erbt grep das stdin deines Programms, und `kind.stdin` ist `None`. + - label: Eingabe beenden + hint: grep zählt erst, wenn es EOF sieht. Das kommt, sobald das schreibende Ende geschlossen ist. + accept: ["std::mem::drop(ein)"] + - label: Warten und einsammeln + hint: Eine Methode von `Child`, die auf das Ende wartet und den Rest der abgefangenen Ausgabe liest. + explanation: | + `Stdio::piped()` legt eine Pipe zwischen deinem Programm und grep an; `kind.stdin` ist dann ein `ChildStdin`, in das du schreibst. grep liest bis EOF und gibt erst danach die Zahl aus. EOF sieht es erst, wenn kein schreibendes Ende mehr offen ist – deshalb `drop(ein)`. Fehlt die Zeile, wartet grep auf weitere Eingabe und `wait_with_output` auf grep, beide für immer. `wait_with_output` liest alle abgefangenen Ströme gleichzeitig zu Ende und wartet auf den Exit-Status, so kann auch eine große Ausgabe keinen Pipe-Puffer verstopfen. + resources: + - title: Stdio::piped + url: https://doc.rust-lang.org/std/process/struct.Stdio.html#method.piped + - title: Child::wait_with_output + url: https://doc.rust-lang.org/std/process/struct.Child.html#method.wait_with_output + - id: health-check-client + type: gap + title: Ein Health-Check als Client + prompt: "`pruefe` verbindet sich mit dem Health-Port, schickt genau eine Anfrage `PING`, signalisiert dann, dass nichts mehr kommt, und liest eine Antwortzeile. Schließt der Server ohne Antwort, soll `Ok(None)` herauskommen. Ergänze das Senden, die Richtung für `shutdown` und das Lesen." + lang: rust + code: | + use std::io::{self, Write}; + use std::io::{BufRead, BufReader}; + use std::net::Shutdown; + use std::net::TcpStream; + use std::time::Duration; + + fn pruefe(addr: &str) + -> io::Result> + { + let mut c = + TcpStream::connect(addr)?; + let t = + Duration::from_secs(2); + c.set_read_timeout(Some(t))?; + c.⟦write_all⟧(b"PING\n")?; + c.shutdown( + ⟦Shutdown::Write⟧, + )?; + let mut r = BufReader::new(c); + let mut z = String::new(); + let n = + r.⟦read_line(&mut z)⟧?; + Ok((n > 0).then_some(z)) + } + + const ZIEL: &str = "127.0.0.1:80"; + + fn main() { + let r = pruefe(ZIEL); + println!("{r:?}"); + } + gaps: + - label: Ganze Anfrage senden + hint: Die Methode, die so lange schreibt, bis alle Bytes draußen sind. `write` darf früher aufhören. + - label: Welche Richtung schließen? + hint: Nur die Senderichtung – lesen willst du danach noch. + - label: Eine Zeile lesen + hint: Liest bis einschließlich `\n` in den String und liefert die Zahl der Bytes, `0` bei EOF. + explanation: | + `write_all` ruft `write` so oft auf, bis die ganze Anfrage beim Kernel ist; ein einzelnes `write` dürfte nach einem Teil aufhören. `shutdown(Shutdown::Write)` schließt nur die Senderichtung. Der Server sieht EOF und weiß, dass keine weitere Anfrage kommt, die Antwort kann trotzdem noch zurückfließen. `Shutdown::Both` würde auch das Lesen beenden. `read_line` liest bis zum Zeilenende und gibt `0` zurück, wenn der Server geschlossen hat, ohne etwas zu senden. Daraus wird `None`. Der Read-Timeout sorgt dafür, dass ein Server, der die Verbindung annimmt und schweigt, den Health-Check nicht für immer festhält. Er schützt nur das Lesen: Antwortet die Gegenstelle gar nicht, kann schon `connect` lange hängen. Im Betrieb nimmst du deshalb [`TcpStream::connect_timeout`](https://doc.rust-lang.org/std/net/struct.TcpStream.html#method.connect_timeout). + resources: + - title: Write::write_all + url: https://doc.rust-lang.org/std/io/trait.Write.html#method.write_all + - title: TcpStream::shutdown + url: https://doc.rust-lang.org/std/net/struct.TcpStream.html#method.shutdown + - id: server-wartet-auf-eof + type: bug + title: Jeder Health-Check läuft in den Timeout + prompt: | + Der Load Balancer prüft den Dienst so: verbinden, `PING\n` senden, bis zu 5 s auf eine Antwortzeile warten. Jeder Check scheitert mit `Resource temporarily unavailable (os error 11)`, also mit dem Read-Timeout. Der Integrationstest ist dagegen grün. Er sendet `PING\n`, ruft `shutdown(Shutdown::Write)` auf und liest dann die Antwort. Finde die fehlerhafte Zeile und korrigiere sie. + lang: rust + code: | + use std::io::{self, BufRead}; + use std::io::{BufReader, Write}; + use std::net::TcpListener; + + fn bediene( + mut r: impl BufRead, + mut w: impl Write, + ) -> io::Result<()> { + let mut z = String::new(); + r.read_to_string(&mut z)?; + let a = match z.trim() { + "PING" => "PONG\n", + _ => "ERR unbekannt\n", + }; + w.write_all(a.as_bytes())?; + w.flush() + } + + fn main() -> io::Result<()> { + let l = TcpListener::bind( + "127.0.0.1:7070", + )?; + for s in l.incoming() { + let s = s?; + if let Err(e) = bediene( + BufReader::new(&s), + &s, + ) { + eprintln!("{e}"); + } + } + Ok(()) + } + lines: [10] + fix: + - line: 10 + answers: + - r.read_line(&mut z)?; + - let n = r.read_line(&mut z)?; + - let _ = r.read_line(&mut z)?; + hints: + - Wann kehrt ein Lesevorgang zurück, der „alles“ lesen will? Was tut der Test, was der Load Balancer nicht tut? + explanation: | + `read_to_string` liest bis EOF. Bei einer Datei kommt EOF am Dateiende, bei einem Socket erst, wenn die Gegenseite ihre Schreibrichtung schließt. Der Load Balancer hält die Verbindung offen, weil er auf die Antwort wartet; der Server wartet auf EOF. Beide warten, bis der Timeout des Clients zuschlägt. Der Test war grün, weil `shutdown(Shutdown::Write)` genau das EOF liefert, auf das der Server wartet – so versteckt sich dieser Fehler gern. Ein Protokoll über einen Strom braucht eine Nachrichtengrenze. Hier ist es das Zeilenende, und `read_line` liest genau bis dorthin. Auf die Server-Seite gehört außerdem ein Read-Timeout, damit ein schweigender Client die Schleife nicht blockiert. + resources: + - title: Read::read_to_string + url: https://doc.rust-lang.org/std/io/trait.Read.html#method.read_to_string + - title: BufRead::read_line + url: https://doc.rust-lang.org/std/io/trait.BufRead.html#method.read_line + - id: rename-ueber-dateisysteme + type: bug + title: Auf dem Server – Invalid cross-device link + prompt: | + `speichere` ersetzt `/etc/app/app.toml` nach dem Muster „temporäre Datei, sync, rename“. Im CI-Container läuft der Test durch. Auf dem Server scheitert jeder Aufruf mit `Error: Os { code: 18, kind: CrossesDevices, message: "Invalid cross-device link" }`. Finde die fehlerhafte Zeile und korrigiere sie. Die temporäre Datei soll weiter `app.tmp` heißen. + lang: rust + code: | + use std::env; + use std::fs::{self, File}; + use std::io::{self, Write}; + use std::path::Path; + + const DIR: &str = "/etc/app"; + + fn speichere(neu: &[u8]) + -> io::Result<()> + { + let ziel = Path::new(DIR) + .join("app.toml"); + let tmp = env::temp_dir() + .join("app.tmp"); + let mut f = + File::create(&tmp)?; + f.write_all(neu)?; + f.sync_all()?; + fs::rename(&tmp, &ziel) + } + + fn main() -> io::Result<()> { + speichere(b"port = 8080\n") + } + lines: [13] + fix: + - line: 13 + answers: + - let tmp = Path::new(DIR) + - let tmp = Path::new("/etc/app") + - let tmp = ziel.parent().unwrap() + explanation: | + `fs::rename` hängt nur einen Verzeichniseintrag um, die Daten bleiben, wo sie sind. Das geht nur innerhalb eines Dateisystems. Auf dem Server ist `/tmp` ein eigenes Dateisystem, oft ein tmpfs im Arbeitsspeicher, die Konfiguration liegt auf einem anderen. `rename(2)` meldet deshalb `EXDEV`, in Rust `ErrorKind::CrossesDevices`. Im CI-Container lag alles auf einem Dateisystem, dort fiel es nicht auf. Die temporäre Datei gehört ins Zielverzeichnis, und nur dort ist das Ersetzen atomar; ein Kopieren über Dateisystemgrenzen wäre es nie. Nebenbei: Scheitert `rename`, bleibt die temporäre Datei liegen. Produktiver Code räumt sie im Fehlerfall weg. + resources: + - title: std::fs::rename · Errors + url: https://doc.rust-lang.org/std/fs/fn.rename.html#errors + - title: Linux man-pages · rename(2), EXDEV + url: https://man7.org/linux/man-pages/man2/rename.2.html#ERRORS + - id: stdout-dann-stderr-haengt + type: explain + title: Die Migration hängt bei 0 % CPU + prompt: Ein Deploy-Werkzeug startet so das Migrationsprogramm. Bei kleinen Datenbanken klappt es. Bei einer großen, bei der `migrate` Tausende Warnungen auf stderr schreibt, hängt alles. Beide Prozesse laufen noch, keiner verbraucht CPU. Erkläre, was passiert, warum es nur bei viel Ausgabe auftritt und wie du es behebst. + lang: rust + code: | + use std::io::Read; + use std::process::Command; + use std::process::Stdio; + + fn main() -> std::io::Result<()> { + let mut cmd = + Command::new("./migrate"); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + let mut kind = cmd.spawn()?; + + let mut o = String::new(); + let mut e = String::new(); + kind.stdout.take().unwrap() + .read_to_string(&mut o)?; + kind.stderr.take().unwrap() + .read_to_string(&mut e)?; + let s = kind.wait()?; + println!("{s}\n{o}"); + Ok(()) + } + points: + - Eine Pipe hat einen begrenzten Puffer im Kernel (Linux standardmäßig 64 KiB). Ist er voll, blockiert der `write` des Kindes, bis jemand liest. + - Das Programm liest zuerst stdout bis EOF. EOF kommt erst, wenn `migrate` stdout schließt, praktisch also bei seinem Ende. + - "Schreibt `migrate` mehr als einen Puffer voll auf stderr, bleibt es stehen. Das Programm wartet auf stdout, `migrate` auf einen Leser für stderr: ein Deadlock ohne CPU-Last." + - Bei wenig Ausgabe passt stderr komplett in den Puffer, deshalb fällt der Fehler erst bei großen Ausgaben auf. + - "Lösung: beide Ströme gleichzeitig lesen – mit `wait_with_output()`, `output()` oder einem zweiten Thread für stderr – oder stderr gar nicht pipen (erben, `Stdio::null()`, Datei)." + explanation: | + Beide Ströme des Kindes hängen an Pipes, und jede Pipe hat einen festen Puffer im Kernel, unter Linux standardmäßig 64 KiB. Das Werkzeug liest zuerst stdout bis zum Ende. Dieses Ende kommt erst, wenn `migrate` stdout schließt, also wenn es fertig ist. Bei einer großen Datenbank schreibt `migrate` aber mehr als 64 KiB Warnungen auf stderr. Den stderr-Puffer leert niemand, denn das Werkzeug ist noch mit stdout beschäftigt. Der nächste `write` auf stderr blockiert, `migrate` steht, stdout endet nie, und das Werkzeug wartet für immer. Beide schlafen, deshalb 0 % CPU. Bei kleinen Datenbanken passen die Warnungen in den Puffer, das Programm läuft durch, und der Fehler bleibt bis zur ersten großen Migration unsichtbar. Die Lösung ist, beide Ströme gleichzeitig zu lesen. `kind.wait_with_output()` macht das intern; für gestreamte Ausgabe liest ein zweiter Thread stderr, während der Hauptthread stdout verarbeitet. Braucht man stderr nicht, lässt man es erben oder leitet es in eine Datei um. + resources: + - title: Linux man-pages · pipe(7), Pipe capacity + url: https://man7.org/linux/man-pages/man7/pipe.7.html#DESCRIPTION + - title: Stdio::piped + url: https://doc.rust-lang.org/std/process/struct.Stdio.html#method.piped + - id: health-protokoll-entwerfen + type: practice + title: Ein Health-Protokoll in sechs Zeilen + prompt: | + Dein Dienst bekommt einen eigenen TCP-Port für Load Balancer und Monitoring. Schreib die Spezifikation des Zeilenprotokolls in höchstens sechs kurzen Zeilen, eine Regel pro Zeile. Sie soll so eindeutig sein, dass jemand anderes Server und Client danach bauen kann, ohne dich zu fragen. + + Regle mindestens: woran eine Nachricht endet, welche Befehle es gibt und was sie antworten, was bei Unbekanntem passiert, wie lang eine Zeile höchstens sein darf und wer die Verbindung wann schließt. + lang: text + starter: | + Format: + Befehle: + Unbekannt: + Grenzen: + Ende: + solution: | + Zeile: GROSSBUCHSTABEN + \n + PING -> PONG + HEALTH -> OK | DEGRADED + Sonst -> ERR unbekannt, offen + >256 Bytes -> ERR zu lang, zu + 5 s still oder EOF -> Server zu + checklist: + - Das Nachrichtenende ist festgelegt, zum Beispiel `\n`. + - Jeder Befehl hat genau eine Antwortzeile mit festem erstem Wort, auch der Fehlerfall. + - Unbekannte Befehle bekommen eine eigene Fehlerantwort, und es steht fest, ob die Verbindung danach offen bleibt. + - Es gibt eine Obergrenze für die Zeilenlänge und eine Regel für das Überschreiten. + - Ein Leerlauf-Timeout ist genannt, und es ist klar, wer nach EOF oder Timeout schließt. + - Groß- und Kleinschreibung der Befehle ist festgelegt. + explanation: | + Ein Protokoll ist ein Vertrag zwischen zwei Programmen, die sich nicht absprechen können. Über TCP musst du die Nachrichtengrenze selbst festlegen, sonst weiß der Server nicht, wann eine Anfrage vollständig ist. Die Grenzen schützen den Dienst: Ohne Längenlimit füllt ein Client mit einer endlosen Zeile den Speicher – die Doku von `read_line` warnt genau davor –, ohne Timeout hält ein schweigender Client einen Thread fest. Feste erste Wörter wie `OK`, `DEGRADED` und `ERR` machen Antworten für Load Balancer und Skripte auswertbar, ohne Freitext zu parsen. Die Musterlösung ist eine von vielen guten. Vergleiche, welche Fälle deine regelt und welche nicht. + resources: + - title: BufRead::read_line + url: https://doc.rust-lang.org/std/io/trait.BufRead.html#method.read_line + - title: Read::take + url: https://doc.rust-lang.org/std/io/trait.Read.html#method.take diff --git a/content/rust/modules/performance-messen.yaml b/content/rust/modules/performance-messen.yaml new file mode 100644 index 00000000..6d797bd7 --- /dev/null +++ b/content/rust/modules/performance-messen.yaml @@ -0,0 +1,1063 @@ +id: performance-messen +title: Performance messen und lesen +level: 3 +minutes: 150 +summary: Messwerte, Benchmark-Ausgaben und Flamegraphs richtig lesen und daraus die nächste Handlung ableiten, statt zu raten, welche Zeile langsam ist. +goals: + - Du arbeitest nach dem Ablauf Hypothese, Messung, eine Änderung, erneute Messung und schätzt mit Amdahls Gesetz ab, was eine Optimierung höchstens bringt. + - Du misst mit `Instant` und `black_box` und begründest, warum eine Einzelmessung und ein Debug-Build nichts beweisen. + - "Du liest criterion-, divan- und hyperfine-Ausgaben: Konfidenzintervall, Ausreißer, `change` mit p-Wert, Rauschschwelle, Mittelwert ± σ." + - Du liest einen Flamegraph mit Breite, Selbstzeit und x-Achse richtig und erkennst, wo Inlining Funktionen versteckt. + - Du erkennst typische Allokationsquellen in heißen Schleifen und zählst Allokationen mit einem eigenen `GlobalAlloc`. +resources: + - title: The Rust Performance Book + url: https://nnethercote.github.io/perf-book/ + - title: criterion.rs · Command-Line Output + url: https://bheisler.github.io/criterion.rs/book/user_guide/command_line_output.html + - title: hyperfine · README + url: https://github.com/sharkdp/hyperfine + - title: Brendan Gregg · Flame Graphs + url: https://www.brendangregg.com/flamegraphs.html +place: unterwegs +lesson: | + Nach einem Deployment meldet das Monitoring, dass der nächtliche Job länger läuft. Er durchsucht eine Logdatei nach Fehlerzeilen, seit Monaten unverändert, bis auf den letzten Commit. Der Reflex ist, im Diff die verdächtigste Schleife zu suchen und sie „schneller“ zu machen. Das geht oft schief. Die verdächtige Stelle ist selten die teure, und ohne Messung weißt du hinterher nicht, ob deine Änderung geholfen hat. + + Dieses Modul zeigt, wie du so misst, dass eine Zahl etwas bedeutet, und wie du criterion-, hyperfine- und Flamegraph-Ausgaben liest. Alle Zahlen stammen aus echten Läufen auf einem Testrechner. Selbst messen, profilieren und optimieren kommt danach am PC in der [Werkstatt](/rust/profiling-werkstatt). + + ## Messen statt raten: der Arbeitsablauf + + Performance-Arbeit folgt einem festen Ablauf: + + 1. **Hypothese.** „Der Job ist seit Version 2.4 langsamer, und es liegt am Erkennen der Fehlerzeilen.“ + 2. **Messung.** Alte und neue Fassung unter denselben Bedingungen messen: Release-Build, dieselbe Eingabe, viele Läufe. + 3. **Eine Änderung.** Genau eine. Wer drei Dinge gleichzeitig ändert, weiß nicht, welche geholfen und welche geschadet hat. + 4. **Erneute Messung** mit demselben Aufbau wie in Schritt 2. Erst der Vergleich entscheidet, ob die Änderung bleibt. + + Gemessen wird im Release-Build. Was die Profile `dev` und `release` unterscheidet, steht in [Module, Crates & Cargo](/rust/module-cargo). Hier zählt die Größenordnung: Der Logparser aus diesem Modul brauchte für eine Million Zeilen im Debug-Build 178,2 ms, im Release-Build 45,1 ms, Faktor 4,0. Die kleine Iterator-Schleife im nächsten Abschnitt brauchte im Debug-Build 20 bis 28 ms, im Release-Build 1,4 bis 3,4 ms. Eine Zahl aus dem Debug-Build sagt über das ausgelieferte Programm nichts. + + ### Amdahls Gesetz: was eine Optimierung höchstens bringt + + Frag vor jeder Optimierung, welchen Anteil der Laufzeit dieser Teil hat. Was 5 % der Laufzeit ausmacht, kann höchstens 5 % sparen, selbst wenn es danach gar keine Zeit mehr braucht. Machst du es doppelt so schnell, sparst du 2,5 %. Das ist Amdahls Gesetz. Deshalb steht am Anfang ein Profil und nicht ein Code-Review: Der Code zeigt dir, was teuer aussieht. Das Profil zeigt, was teuer ist. + + ## Zeit messen mit `Instant` und `black_box` + + Die einfachste Messung braucht nur die Standardbibliothek: + + ```rust + use std::time::Instant; + + fn main() { + let log = + "08:00:01 ERROR db\n" + .repeat(100_000); + let start = Instant::now(); + let n = log + .lines() + .filter(|z| { + z.contains("ERROR") + }) + .count(); + let dauer = start.elapsed(); + println!("{n} in {dauer:?}"); + } + ``` + + [`Instant`](https://doc.rust-lang.org/std/time/struct.Instant.html) liest eine monotone Uhr: Ein späterer Zeitpunkt ist nie kleiner als ein früherer. [`SystemTime`](https://doc.rust-lang.org/std/time/struct.SystemTime.html) ist die Wanduhr des Systems, und ihre Doku sagt ausdrücklich, dass sie nicht monoton ist. Wird die Uhr während der Messung verstellt, stimmt die Differenz nicht mehr; deshalb liefert `SystemTime::duration_since` ein `Result`. Für Laufzeiten nimmst du `Instant`. + + Fünf Läufe dieses Programms im Release-Build: + + ```text + 100000 in 1.36388ms + 100000 in 1.897261ms + 100000 in 3.016989ms + 100000 in 3.306858ms + 100000 in 3.370658ms + ``` + + Dasselbe Programm, dieselbe Eingabe, und der langsamste Lauf braucht fast zweieinhalbmal so lange wie der schnellste. Eine Einzelmessung ist Rauschen. Die Ursachen liegen außerhalb deines Codes: Der Prozessor ändert seinen Takt, Caches sind kalt oder warm, der Kernel schiebt den Prozess auf einen anderen Kern, ein anderes Programm will gerade dieselbe CPU. Aufwärmen, Wiederholen und die Streuung auswerten übernehmen die Werkzeuge der nächsten Abschnitte. + + ### `black_box`: dem Optimierer die Abkürzung nehmen + + Der Optimierer darf Arbeit entfernen, deren Ergebnis niemand braucht, und Arbeit vorziehen, die er schon beim Übersetzen ausrechnen kann. Eine Messschleife lädt dazu ein: + + ```rust + use std::hint::black_box; + use std::time::Instant; + + fn fib(n: u64) -> u64 { + if n < 2 { + n + } else { + fib(n - 1) + fib(n - 2) + } + } + + fn main() { + let start = Instant::now(); + for _ in 0..1_000 { + let n = black_box(20); + black_box(fib(n)); + } + let je = + start.elapsed() / 1_000; + println!("fib(20): {je:?}"); + } + ``` + + So gemessen kamen 17 bis 21 µs pro Aufruf heraus. Ersetzt man die beiden Zeilen in der Schleife durch `fib(20);`, meldet dasselbe Programm 16 bis 31 ns: Der Optimierer rechnet `fib(20)` nur noch ein einziges Mal und entfernt die Schleife. Die 16 bis 31 ns sind ein Aufruf, geteilt durch 1 000; bei 10 000 Durchläufen zeigte dasselbe Programm 4 bis 5 ns. [`std::hint::black_box`](https://doc.rust-lang.org/std/hint/fn.black_box.html) ist eine Identitätsfunktion, bei der der Compiler annehmen soll, dass sie mit ihrem Wert alles Mögliche anstellt. Das erste `black_box(20)` verhindert, dass er den Aufruf einmal rechnet und das Ergebnis in jedem Durchlauf wiederverwendet: Für den Compiler kann jeder Durchlauf eine andere Eingabe bekommen. Das zweite sorgt dafür, dass das Ergebnis als benutzt gilt. Die Doku nennt die Funktion ausdrücklich „best-effort“: Wie viel sie verhindert, hängt von Plattform und Backend ab. Für Benchmarks soll man sie trotzdem einsetzen. Für Korrektheit oder Kryptografie taugt sie nicht. + + > [!WARNING] Zu schnell, um wahr zu sein + > Zeigt eine Messung Pikosekunden oder ein bis zwei Nanosekunden für eine Funktion, die Text parst oder sortiert, misst du die leere Schleife. Bei 4 GHz dauert ein Prozessortakt 0,25 ns. Prüfe zuerst, ob die Eingabe eine Konstante ist und ob das Ergebnis irgendwo landet. + + ## Benchmarks mit criterion + + Eine selbst gebaute Schleife beantwortet nicht, wie lange aufgewärmt wird, wie viele Wiederholungen genug sind und ob ein Unterschied zum letzten Lauf echt ist. Das übernimmt [criterion](https://docs.rs/criterion/latest/criterion/) (Version 0.8). Ein Benchmark ist eine eigene Datei unter `benches/`. `cargo bench` baut sie im Profil `bench`, das die Einstellungen von `release` erbt ([Cargo Book: bench](https://doc.rust-lang.org/cargo/reference/profiles.html#bench)). + + ```toml + [dev-dependencies] + criterion = "0.8" + + [[bench]] + name = "logparser" + harness = false + ``` + + `harness = false` schaltet Cargos eingebautes Test-Gerüst für diese Datei ab ([Cargo Book: harness](https://doc.rust-lang.org/cargo/reference/cargo-targets.html#the-harness-field)). Die `main`-Funktion liefert dann criterion mit `criterion_main!`. Die Datei `benches/logparser.rs` misst `zaehle_fehler` aus der Bibliothek `logparser`, an einem Log mit 10 000 Zeilen: + + ```rust + use criterion::{ + Criterion, criterion_group, + criterion_main, + }; + use logparser::{ + erzeuge_log, zaehle_fehler, + }; + use std::hint::black_box; + + fn bench(c: &mut Criterion) { + let log = erzeuge_log(10_000); + c.bench_function( + "zaehle_fehler", + |b| b.iter(|| { + zaehle_fehler( + black_box(&log), + ) + }), + ); + } + + criterion_group!(benches, bench); + criterion_main!(benches); + ``` + + Das Log entsteht vor `bench_function`, einmal und außerhalb der Messung. [`b.iter`](https://docs.rs/criterion/latest/criterion/struct.Bencher.html#method.iter) ruft die Closure viele tausend Mal auf und misst die Gesamtzeit. Und `black_box(&log)` hindert den Optimierer daran, Arbeit über die Wiederholungen hinweg zusammenzulegen. + + ### Die criterion-Ausgabe lesen + + Der erste Lauf (umbrochen): + + ```text + zaehle_fehler + time: [316.17 µs 316.68 µs + 317.27 µs] + Found 8 outliers among 100 + measurements (8.00%) + 6 (6.00%) high mild + 2 (2.00%) high severe + ``` + + **`time`** ist ein Konfidenzintervall für die Zeit pro Aufruf: untere Grenze, beste Schätzung, obere Grenze. criterion berechnet es per Bootstrap aus 100 Stichproben, standardmäßig mit 95 % Konfidenz ([criterion: Command-Line Output](https://bheisler.github.io/criterion.rs/book/user_guide/command_line_output.html)). Ein schmales Intervall heißt: Die Messung ist stabil. **Ausreißer** sind Stichproben weit weg vom Rest. „high“ heißt langsamer, „severe“ sehr weit draußen. Viele Ausreißer deuten auf Störungen durch andere Prozesse. + + Ab dem zweiten Lauf vergleicht criterion mit den gespeicherten Daten unter `target/criterion`: + + ```text + zaehle_fehler + time: [313.65 µs 314.22 µs + 314.79 µs] + change: [−0.8986% −0.6890% + −0.4759%] + (p = 0.00 < 0.05) + Change within noise threshold. + ``` + + **`change`** ist wieder ein Intervall, diesmal für die relative Änderung. Der **p-Wert** gibt an, wie wahrscheinlich ein so großer Unterschied wäre, wenn sich nichts geändert hätte. Unter 0,05 gilt er als signifikant ([`significance_level`](https://docs.rs/criterion/latest/criterion/struct.Criterion.html#method.significance_level)). Trotzdem lautet das Urteil „within noise threshold“, denn das Intervall reicht nicht über die **Rauschschwelle** von ±1 % hinaus ([`noise_threshold`](https://docs.rs/criterion/latest/criterion/struct.Criterion.html#method.noise_threshold)). Am Code hat sich nichts geändert. Die 0,7 % sind der Unterschied zwischen zwei Läufen. Signifikant heißt nicht relevant. + + | Urteil | Bedingung | + | --- | --- | + | No change … detected | p ≥ 0,05 | + | … within noise threshold | p < 0,05, Intervall nicht ganz jenseits ±1 % | + | regressed / improved | ganzes Intervall jenseits ±1 % | + + ### `iter` oder `iter_batched` + + `b.iter` misst alles in der Closure. Braucht die Routine bei jedem Aufruf eine frische Eingabe, weil sie sie verändert, gehört das Herstellen der Eingabe nicht in die Messung. Dafür gibt es [`iter_batched`](https://docs.rs/criterion/latest/criterion/struct.Bencher.html#method.iter_batched): + + ```rust + use criterion::BatchSize; + + fn bench_sort(c: &mut Criterion) { + let roh = latenzen(10_000); + c.bench_function( + "p95", + |b| b.iter_batched( + || roh.clone(), + |mut v| p95(&mut v), + BatchSize::SmallInput, + ), + ); + } + ``` + + Die erste Closure klont 10 000 unsortierte Antwortzeiten, die zweite sortiert den Klon und liest das 95. Perzentil. Gemessen wird nur die zweite. `BatchSize::SmallInput` ist laut Doku für fast alle Benchmarks richtig. Drei Fassungen im Vergleich: + + | Fassung | Schätzung | + | --- | --- | + | `iter_batched` | 73,46 µs | + | Klon in `iter` | 73,20 µs | + | ohne frische Eingabe | 2,54 µs | + + Den Klon mitzumessen hat hier kaum geschadet, er kostet allein 0,78 µs; dass die Fassung mit Klon sogar knapp vorn liegt, ist Rauschen (bei `iter_batched` liegen die vorbereiteten Klone zudem kalt im Cache). Der echte Fehler ist die dritte Fassung: Ab der zweiten Wiederholung ist der Vec schon sortiert, und sortierte Daten sortieren sich fast dreißigmal schneller. Der Benchmark misst einen Fall, der im Betrieb nie vorkommt. + + ### divan als Alternative + + [divan](https://docs.rs/divan/latest/divan/) (Version 0.1) registriert Benchmarks per Attribut und misst mehrere Eingabegrößen in einem Durchgang: + + ```rust + use divan::{Bencher, black_box}; + use logparser::{ + erzeuge_log, zaehle_fehler, + }; + + fn main() { + divan::main(); + } + + #[divan::bench( + args = [1_000, 10_000], + )] + fn zaehle( + bencher: Bencher, + n: usize, + ) { + let log = erzeuge_log(n); + bencher.bench(|| { + zaehle_fehler( + black_box(&log), + ) + }); + } + ``` + + divan gibt eine breite Tabelle aus. Hier stehen die Spalten untereinander: + + ```text + Timer precision: 13 ns + divan_logparser + ╰─ zaehle + ├─ 1000 + │ fastest 27.69 µs + │ slowest 38.88 µs + │ median 29.6 µs + │ mean 29.79 µs + │ samples 100, iters 100 + ╰─ 10000 + fastest 307.7 µs + slowest 371.3 µs + median 322 µs + mean 324.2 µs + samples 100, iters 100 + ``` + + divan zeigt die Verteilung: schnellster und langsamster Lauf, Median, Mittelwert. Liegt der Mittelwert über dem Median, ziehen einzelne langsame Läufe ihn hoch. Ein Konfidenzintervall und einen Vergleich mit einem gespeicherten früheren Lauf hat divan 0.1 nicht. Für Vorher-nachher-Urteile bleibt criterion das Hauptwerkzeug. + + ## Ganze Programme vergleichen mit hyperfine + + criterion misst eine Funktion im selben Prozess. Wie lange das ganze Programm braucht, mit Prozessstart, Datei lesen und Ausgabe, misst [hyperfine](https://github.com/sharkdp/hyperfine). Es startet ein Kommando viele Male und wertet die Läufe aus. Das erste Kommando ist die Referenz für alle weiteren. Der Vergleich Debug gegen Release vom Anfang, Ausgabe von hyperfine 2.0 mit umgestellten Spalten: + + ```text + $ hyperfine --warmup 3 \ + './logparser-debug app.log' \ + './logparser app.log' + Benchmark 1: + ./logparser-debug app.log + (16 runs) + Wall Time + mean ± σ 178.2 ms ± 2.4 ms + min … max 174.1 … 182.5 ms + Memory 43.7 MiB ± 0.1 MiB + + Benchmark 2: + ./logparser app.log (62 runs) + Wall Time + mean ± σ 45.1 ms ± 3.3 ms + min … max 41.5 … 55.7 ms + -74.7% (4.0x faster) + Memory 43.7 MiB, 0.0% + ``` + + - **Wall Time** ist die Zeit von Start bis Ende, als Mittelwert ± Standardabweichung σ, dazu der schnellste und langsamste Lauf. **Memory** ist der höchste belegte Arbeitsspeicher (Peak-RSS). + - Die Prozentzahl vergleicht mit dem ersten Kommando. Einen Faktor wie `(4.0x faster)` druckt hyperfine erst ab 30 % Unterschied. Ein fehlender Faktor sagt nichts über Signifikanz; einen Test wie criterion macht hyperfine nicht. + - `--warmup 3` startet das Programm vor der Messung dreimal. Danach liegen Programm und Eingabedatei im Page Cache, dem Dateicache des Kernels im RAM ([Warmup und Prepare](https://github.com/sharkdp/hyperfine#warmup-runs-and-preparation-commands)). + - `--prepare 'befehl'` läuft vor jedem einzelnen Lauf, etwa um den Cache zu leeren und kalte Starts zu messen. + - `--metrics time` zeigt zusätzlich CPU-, User- und System-Zeit ([Metriken](https://github.com/sharkdp/hyperfine#choosing-metrics-and-units)). User-Zeit verbringt das Programm in eigenem Code und Bibliotheken, System-Zeit im Kernel, etwa beim Lesen der Datei. Beim Logparser 2.3 waren es 36,6 ms User und 8,6 ms System bei 45,8 ms Wall Time. + + hyperfine nimmst du, wenn die Frage das ganze Programm betrifft: Startzeit, I/O, ein CLI-Aufruf, zwei Binaries oder zwei Git-Stände. criterion nimmst du für eine Funktion, wenn du kleine Unterschiede statistisch sauber sehen willst. + + > [!NOTE] Ältere Anleitungen + > Viele Blogposts zeigen hyperfine 1.x mit `Time (mean ± σ)` und einer Summary am Ende („… times faster than …“). Version 2.0 zeigt Zeit und Speicher als Tabelle, die Änderung je Metrik in Prozent und startet Kommandos ohne Shell. Für Pipes oder `*` brauchst du jetzt `-S` ([Changelog](https://github.com/sharkdp/hyperfine/blob/master/CHANGELOG.md)). + + ## Flamegraphs lesen + + Ein Sampling-Profiler unterbricht das Programm in festen Abständen, bei samply standardmäßig 1000-mal pro Sekunde, und notiert den aktuellen Aufruf-Stack ([samply](https://github.com/mstange/samply#description)). Gleiche Stacks werden gezählt. Die Rohform heißt **gefaltete Stacks** (folded stacks): ein Stack pro Zeile, Funktionen von außen nach innen mit `;` getrennt, am Ende die Zahl der Samples. Eine Aufnahme des Logparsers 2.3 mit dem Sampling-Profiler [pprof-rs](https://docs.rs/pprof/latest/pprof/) (1000 Samples pro Sekunde; gemeinsamer Anfang `main;zaehle_fehler;` weggelassen, Namen gekürzt): + + ```text + lines 250 + lines;memchr 311 + ist_fehler;split 80 + ist_fehler;split;memchr 289 + ``` + + Vollständig heißt die erste Zeile `main;zaehle_fehler;lines 250`: In 250 Samples war die CPU in `lines` selbst, in 311 weiteren in `memchr`, das `lines` aufgerufen hat. Werkzeuge wie `inferno-flamegraph` machen daraus ein SVG ([inferno](https://github.com/jonhoo/inferno#using-inferno)). Vereinfacht als Text, 32 Zeichen für 930 Samples: + + ```text + [memchr ] + [split ][memchr ] + [ist_fehler ][lines ] + [zaehle_fehler ] + [main ] + ``` + + So liest du ihn ([Brendan Gregg: Flame Graphs](https://www.brendangregg.com/flamegraphs.html)): + + - **Breite ist der Anteil der Samples, inklusive Kinder.** `lines` ist 19 von 32 Zeichen breit, weil 561 von 930 Samples `lines` enthalten: 250 in `lines` selbst, 311 in `memchr`. + - **Die Oberkante ist Selbstzeit.** Wo über einem Balken nichts mehr liegt, war die CPU in genau dieser Funktion. `zaehle_fehler` ist komplett zugedeckt: keine Selbstzeit, die Funktion verteilt nur Arbeit. + - **Die x-Achse ist keine Zeit.** Geschwister stehen alphabetisch, gleiche Stacks sind zusammengefasst. Dass `ist_fehler` links steht, heißt nicht, dass es zuerst lief. Den zeitlichen Verlauf zeigt die Zeitleiste im Profiler, nicht der Flamegraph. + - **Farben messen nichts.** Klassische Flamegraphs färben zufällig, manche Werkzeuge nach Codeart. + - **Inlining versteckt Funktionen.** Kleine Funktionen baut der Compiler in den Aufrufer ein. Ohne Debug-Infos fehlen sie im Stack, ihre Zeit erscheint beim Aufrufer. Für dieses Bild sind außerdem Adapter wie `filter` und `count` weggelassen. + + Damit Stacks vollständig sind, profilierst du einen Release-Build mit Debug-Infos. Das samply-README empfiehlt dafür ein eigenes Profil ([Turn on debug info](https://github.com/mstange/samply#turn-on-debug-info-for-full-stacks), [Cargo Book: custom profiles](https://doc.rust-lang.org/cargo/reference/profiles.html#custom-profiles)): + + ```toml + [profile.profiling] + inherits = "release" + debug = true + ``` + + `debug = true` ändert die Optimierung nicht, es legt nur Debug-Infos neben den Code. Wirken Stacks trotzdem abgeschnitten, hilft `-C force-frame-pointers=yes` ([Perf Book: Frame pointers](https://nnethercote.github.io/perf-book/profiling.html#frame-pointers)). + + > [!MERKE] + > Breit heißt teuer, oben heißt selbst gerechnet, links heißt nichts. Vergleiche ein Profil immer mit einer Messung: Ein Balken mit 28 % kann höchstens 28 % sparen. + + ## Allokationen finden und vermeiden + + Jede Heap-Allokation geht zum Allokator. Er sucht einen passenden freien Block, pflegt seine Verwaltungsdaten, stimmt sich mit anderen Threads ab und fragt manchmal den Kernel nach mehr Speicher. Das Rust Performance Book nennt Allokationen „moderately expensive“ und rät: Zeigt ein Profil `malloc` und `free` als heiß, lohnt es sich, die Zahl der Allokationen zu senken ([Heap Allocations](https://nnethercote.github.io/perf-book/heap-allocations.html#profiling)). Eine einzelne fällt nicht auf. Eine pro Zeile bei einer Million Zeilen schon. + + | Quelle in der Schleife | Abhilfe | + | --- | --- | + | `to_string`, `format!` | `&str` behalten | + | `collect` in Zwischen-`Vec` | Iterator weiterreichen | + | `Vec::new` + viele `push` | `with_capacity(n)` | + | `String`-Schlüssel | `&str`-Schlüssel, `entry` | + | neuer Puffer je Durchlauf | ein Puffer, `clear()` | + + Die Abhilfen stützen sich auf zugesicherte Eigenschaften: [`Vec::with_capacity(n)`](https://doc.rust-lang.org/std/vec/struct.Vec.html#method.with_capacity) nimmt mindestens `n` Elemente ohne neue Allokation auf. [`String::new`](https://doc.rust-lang.org/std/string/struct.String.html#method.new) alloziert nichts. [`clear`](https://doc.rust-lang.org/std/string/struct.String.html#method.clear) leert einen String, behält aber seine Kapazität. [`HashMap::entry`](https://doc.rust-lang.org/std/collections/hash_map/struct.HashMap.html#method.entry) sucht den Schlüssel einmal statt zweimal wie `contains_key` plus `insert`. Vorsicht bei `String`-Schlüsseln: `entry(z.to_string())` alloziert bei jedem Aufruf, auch wenn der Schlüssel schon existiert. + + ### Allokationen zählen mit `GlobalAlloc` + + Zeit schwankt, die Zahl der Allokationen nicht. Ein eigener globaler Allokator, der zählt und an den System-Allokator weiterreicht, liefert deshalb eine stabile Kennzahl, nur mit der Standardbibliothek ([`#[global_allocator]`](https://doc.rust-lang.org/std/alloc/index.html#the-global_allocator-attribute)): + + ```rust + use std::alloc::{ + GlobalAlloc, Layout, System, + }; + use std::hint::black_box; + use std::sync::atomic::{ + AtomicUsize, + Ordering::Relaxed, + }; + + struct Zaehler; + static N: AtomicUsize = + AtomicUsize::new(0); + + unsafe impl GlobalAlloc + for Zaehler + { + unsafe fn alloc(&self, + l: Layout) -> *mut u8 { + N.fetch_add(1, Relaxed); + unsafe { System.alloc(l) } + } + unsafe fn dealloc(&self, + p: *mut u8, l: Layout) { + unsafe { + System.dealloc(p, l) + } + } + } + + #[global_allocator] + static A: Zaehler = Zaehler; + + fn n() -> usize { + N.load(Relaxed) + } + + fn main() { + let t = n(); + let mut a = Vec::new(); + for i in 0..1_000u32 { + a.push(i); + } + let ohne = n() - t; + + let t = n(); + let mut b = + Vec::with_capacity(1_000); + for i in 0..1_000u32 { + b.push(i); + } + let mit = n() - t; + + black_box((&a, &b)); + println!( + "ohne: {ohne}, mit: {mit}" + ); + } + ``` + + Auf dem Testrechner gab das `ohne: 9, mit: 1` aus. Die 9 sind eine Allokation und acht Vergrößerungen. Sie zählen mit, weil die Standardfassung von `realloc` intern `alloc` aufruft ([Quelltext von `GlobalAlloc`](https://doc.rust-lang.org/src/core/alloc/global.rs.html)). Die genaue Zahl hängt von der Wachstumsstrategie von `Vec` ab, und die ist nicht zugesichert ([Vec: Capacity and reallocation](https://doc.rust-lang.org/std/vec/struct.Vec.html#capacity-and-reallocation)). Drei Regeln für solche Zähler: + + - **Nur den Abschnitt zählen, dann drucken.** `println!` kann selbst allozieren: Der erste Aufruf legte im Test einen Puffer für stdout an, eine Allokation. + - **Im Allokator nichts allozieren.** Kein `println!` in `alloc`, sonst ruft der Allokator sich selbst auf. + - **Ergebnisse benutzen.** Die [`GlobalAlloc`-Doku](https://doc.rust-lang.org/std/alloc/trait.GlobalAlloc.html) warnt, dass der Optimierer ungenutzte Allokationen entfernen darf. `black_box` hält sie am Leben. + + Wer wissen will, wo die Allokationen entstehen, nimmt [dhat-rs](https://docs.rs/dhat/latest/dhat/). Es ersetzt ebenfalls den globalen Allokator, merkt sich die Herkunft jeder Allokation und kann in Tests eine Obergrenze prüfen. + + ## Häufige Fehler + + - **Im Debug-Build messen.** Beim Logparser Faktor 4,0, bei der kleinen Schleife 20 bis 28 statt 1,4 bis 3,4 ms. Immer `--release` oder ein Profil, das davon erbt. + - **Eine Messung glauben.** Fünf Läufe desselben Programms lagen um mehr als das Doppelte auseinander. Ohne Wiederholung und Streuung ist eine Zahl Zufall. + - **`black_box` vergessen.** Ergebnis: Pikosekunden. Konstante Eingaben und unbenutzte Ergebnisse lädt der Optimierer zum Wegrechnen ein. + - **Setup mitmessen oder ohne frische Eingabe messen.** Ein Sortier-Benchmark auf schon sortierten Daten war fast dreißigmal zu schnell. + - **Signifikant mit relevant verwechseln.** p = 0,00 bei −0,69 % ist Rauschen zwischen zwei Läufen. Das Urteil steht in der letzten Zeile. + - **Mehrere Änderungen auf einmal.** Dann weißt du nicht, welche geholfen hat, und eine schädliche fährt bei der nützlichen mit. + - **Den Flamegraph als Zeitleiste lesen.** Links und rechts sind alphabetisch, Farben bedeuten nichts. + - **`println!` im gezählten Abschnitt.** Der erste Aufruf alloziert selbst und verfälscht die Zählung. + + > [!PRAXIS] Messprotokoll im Pull Request + > Wer eine Performance-Änderung zur Review stellt, schreibt dazu, was gemessen wurde: Eingabe, Werkzeug und Version, Build-Profil, vorher und nachher mit Streuung, bei Bedarf die Zahl der Allokationen und eine Entscheidung. „Fühlt sich schneller an“ ist kein Befund. Ein guter Reviewer fragt zuerst nach der Baseline und danach nach dem Code. + + > [!INTERVIEW] „Ein Dienst ist nach einem Release langsamer. Wie gehst du vor?“ + > Erst reproduzieren und beziffern: alte und neue Version im Release-Build mit derselben Eingabe vergleichen, etwa mit hyperfine oder einer criterion-Baseline. Dann eingrenzen: Profil beider Versionen aufnehmen und vergleichen, welche Balken neu oder breiter sind. Daraus eine Hypothese ableiten, genau eine Änderung machen und neu messen. Zum Schluss verhindern, dass es wieder passiert, etwa mit einem Benchmark gegen eine gespeicherte Baseline vor jedem Release. Ich optimiere nichts, bevor ich weiß, welcher Teil die Zeit kostet. + + ## Zusammenfassung + + - Ablauf: Hypothese, Messung, eine Änderung, erneute Messung. Immer im Release-Build. + - Amdahl: Ein Teil mit 5 % Anteil kann höchstens 5 % sparen. Erst das Profil, dann die Optimierung. + - `Instant` ist monoton, `SystemTime` nicht. Einzelmessungen sind Rauschen; `black_box` hält den Optimierer von Eingabe und Ergebnis fern, nach eigener Doku nur „best-effort“. + - criterion: `time` ist ein Konfidenzintervall, `change` vergleicht mit dem letzten Lauf, „regressed“ heißt erst: ganzes Intervall jenseits der Rauschschwelle und p < 0,05. `iter_batched` hält Setup aus der Messung. + - hyperfine misst ganze Programme: Mittelwert ± σ, min … max, Speicher, Prozent gegenüber dem ersten Kommando; `--warmup` für warmen, `--prepare` für kalten Cache. + - Flamegraph: Breite inklusive Kinder, Oberkante ist Selbstzeit, x-Achse alphabetisch, Farben ohne Bedeutung, Inlining versteckt Funktionen. + - Allokationen kosten; `with_capacity`, `clear`, `&str` statt `String` und `entry` vermeiden sie. Ein zählender `GlobalAlloc` liefert eine stabile Kennzahl. +exercises: + - id: amdahl-obergrenze + type: choice + title: Was ist das Maximum? + prompt: | + Ein Profil einer Log-Auswertung hat 974 Samples. `format!` steckt in 57 davon, also in 5,9 %. Eine Kollegin will `format!` durch eine Lösung ersetzen, die laut Microbenchmark doppelt so schnell ist. Wie viel Laufzeit spart das ganze Programm **höchstens**? + options: + - text: Knapp 3 % der Laufzeit. + correct: true + why: Nur der Anteil von 5,9 % wird schneller, und zwar um die Hälfte. 5,9 % × ½ ≈ 2,9 %. + - text: Die Hälfte der Laufzeit, weil die Funktion doppelt so schnell wird. + correct: false + why: Doppelt so schnell wird nur `format!`. Die übrigen 94,1 % der Laufzeit bleiben, wie sie sind. + - text: 5,9 % der Laufzeit. + correct: false + why: Das wäre die Grenze, wenn `format!` gar keine Zeit mehr bräuchte. Doppelt so schnell spart nur die Hälfte davon. + - text: Ohne neuen Benchmark des ganzen Programms lässt sich keine Obergrenze angeben. + correct: false + why: Die Obergrenze folgt aus dem Anteil im Profil. Messen musst du trotzdem, um zu sehen, ob sie erreicht wird; die Grenze kennst du aber vorher. + explanation: | + Amdahls Gesetz: Wird ein Anteil p der Laufzeit um den Faktor s schneller, sinkt die Gesamtzeit um p × (1 − 1/s). Mit p = 5,9 % und s = 2 sind das 2,9 %. Deshalb lohnt sich die Frage nach dem Anteil vor jeder Optimierung: In derselben Aufnahme stecken 32 % im Erzeugen der Testdaten und rund 30 % im Zerlegen der Zeilen. Dort liegt mehr zu holen als bei `format!`. + resources: + - title: The Rust Performance Book · Profiling + url: https://nnethercote.github.io/perf-book/profiling.html + + - id: flamegraph-regeln + type: choice + title: Was der Flamegraph sagt + prompt: | + Das ist der Text-Flamegraph des Logparsers aus der Lektion (930 Samples, vereinfacht). Welche Aussagen stimmen? **Mehrere Antworten richtig.** + lang: text + code: | + [memchr ] + [split ][memchr ] + [ist_fehler ][lines ] + [zaehle_fehler ] + [main ] + options: + - text: "`ist_fehler` steckte in rund 40 % der Samples, die Zeit in `split` und `memchr` eingerechnet." + correct: true + why: Die Breite zählt alle Samples, in denen die Funktion auf dem Stack lag, also auch die ihrer Kinder. Hier sind es 369 von 930. + - text: "`zaehle_fehler` hat keine Selbstzeit." + correct: true + why: Über `zaehle_fehler` liegen `ist_fehler` und `lines` und decken die ganze Oberkante ab. Kein Sample endete in `zaehle_fehler` selbst. + - text: "`ist_fehler` lief vor `lines`, weil es links steht." + correct: false + why: Die x-Achse ist keine Zeit. Geschwister stehen alphabetisch, gleiche Stacks sind zusammengefasst. + - text: Im SVG wäre ein roter Balken langsamer als ein gelber. + correct: false + why: Klassische Flamegraphs färben zufällig, manche Werkzeuge nach Codeart. Zeit zeigt nur die Breite. + - text: "`filter` fehlt im Bild, also hat `zaehle_fehler` es nie aufgerufen." + correct: false + why: Kleine Funktionen baut der Compiler in den Aufrufer ein; ohne Debug-Infos fehlen sie im Stack. Hier sind die Adapter außerdem für die Vereinfachung weggelassen. + explanation: | + Ein Flamegraph fasst gleiche Stacks zusammen. Die Breite eines Balkens ist der Anteil der Samples, in denen die Funktion irgendwo auf dem Stack lag; die Oberkante, über der nichts mehr liegt, ist die Zeit in der Funktion selbst. Reihenfolge und Farbe tragen keine Information über Zeit. Wer den Verlauf über die Zeit sehen will, nimmt die Zeitleiste des Profilers. + resources: + - title: Brendan Gregg · Flame Graphs + url: https://www.brendangregg.com/flamegraphs.html + + - id: mittelwert-median-ausreisser + type: output + title: Ein langsamer Lauf unter fünf + prompt: | + Fünf Läufe desselben Programms, einer davon gestört. Was gibt dieses Programm aus? `Duration` druckt sich mit `{:?}` zum Beispiel als `12ms` oder `1.5ms`. + lang: rust + code: | + use std::time::Duration; + + fn main() { + // Laufzeiten in ms + let ms = [12, 11, 13, 95, 12]; + let mut l = ms.map(|x| { + Duration::from_millis(x) + }); + let summe: Duration = + l.iter().sum(); + println!("{:?}", summe / 5); + l.sort(); + println!("{:?}", l[2]); + println!("{:?}", l[0]); + } + expected: | + 28.6ms + 12ms + 11ms + hints: + - Rechne erst die Summe aus. Durch 5 geteilt ergibt sich der Mittelwert. + - Nach dem Sortieren steht in der Mitte des Arrays der Median. + explanation: | + Die Summe ist 143 ms, der Mittelwert also 28,6 ms. Er liegt höher als vier der fünf Läufe, weil ein einzelner Ausreißer (95 ms) ihn hochzieht. Der Median, der mittlere Wert nach dem Sortieren, bleibt bei 12 ms, das Minimum bei 11 ms. Deshalb zeigen divan Median und Mittelwert nebeneinander und hyperfine zusätzlich min … max: Klaffen Mittelwert und Median auseinander, war die Messung gestört oder die Laufzeit schwankt wirklich. + resources: + - title: std · Duration + url: https://doc.rust-lang.org/std/time/struct.Duration.html + + - id: allokationen-vorhersagen + type: output + title: Drei Arten, Zahlen abzulegen + prompt: | + Der Zähler zählt jeden Aufruf von `alloc`. Vergrößerungen zählen mit, weil die Standardfassung von `realloc` intern `alloc` aufruft. Was gibt das Programm aus? + lang: rust + code: | + use std::alloc::{ + GlobalAlloc, Layout, System, + }; + use std::fmt::Write; + use std::hint::black_box; + use std::sync::atomic::{ + AtomicUsize, + Ordering::Relaxed, + }; + + struct Zaehler; + static N: AtomicUsize = + AtomicUsize::new(0); + + unsafe impl GlobalAlloc + for Zaehler + { + unsafe fn alloc(&self, + l: Layout) -> *mut u8 { + N.fetch_add(1, Relaxed); + unsafe { System.alloc(l) } + } + unsafe fn dealloc(&self, + p: *mut u8, l: Layout) { + unsafe { + System.dealloc(p, l) + } + } + } + + #[global_allocator] + static A: Zaehler = Zaehler; + + fn n() -> usize { + N.load(Relaxed) + } + + fn main() { + let ids = [7, 42, 1001]; + + let t = n(); + let mut v = + Vec::with_capacity(3); + for id in ids { + v.push(id); + } + let a = n() - t; + + let t = n(); + let s: Vec = ids + .iter() + .map(|id| id.to_string()) + .collect(); + let b = n() - t; + + let t = n(); + let mut buf = + String::with_capacity(16); + for id in ids { + buf.clear(); + write!(buf, "{id}") + .unwrap(); + } + let c = n() - t; + + black_box((&v, &s, &buf)); + println!("{a} {b} {c}"); + } + expected: | + 1 4 1 + hints: + - "`with_capacity(3)` reicht für drei `push` ohne Vergrößerung." + - Jeder `String` aus `to_string` braucht eigenen Speicher, und der `Vec` selbst auch. + - "`clear` behält die Kapazität von 16 Bytes." + explanation: | + `a` ist 1: `Vec::with_capacity(3)` alloziert einmal, und drei `push` passen ohne Vergrößerung hinein. `b` ist 4: Der `Vec` braucht eine Allokation, jeder der drei Strings aus `to_string` eine eigene. `c` ist 1: Der Puffer alloziert einmal 16 Bytes; `clear` setzt nur die Länge zurück, die Kapazität bleibt, und die Zahlen passen hinein. Gedruckt wird erst nach dem letzten Zählerstand, weil `println!` beim ersten Aufruf selbst einen Puffer anlegt. `black_box` verhindert, dass der Optimierer ungenutzte Allokationen entfernt. + resources: + - title: std · String::clear + url: https://doc.rust-lang.org/std/string/struct.String.html#method.clear + - title: std · Vec::with_capacity + url: https://doc.rust-lang.org/std/vec/struct.Vec.html#method.with_capacity + + - id: messschleife-ergaenzen + type: gap + title: Eine ehrliche Messschleife + prompt: | + Ergänze die Messung: Startzeitpunkt nehmen, die Eingabe und das Ergebnis vor dem Optimierer schützen, die vergangene Zeit lesen. Ausgegeben wird die Zeit pro Aufruf. + lang: rust + code: | + use std::hint::black_box; + use std::time::Instant; + + fn summe(v: &[u64]) -> u64 { + v.iter().sum() + } + + fn main() { + let daten: Vec = + (0..10_000).collect(); + let runden = 1_000; + let start = ⟦Instant::now()⟧; + for _ in 0..runden { + ⟦black_box⟧(summe( + ⟦black_box⟧(&daten), + )); + } + let dauer = start.⟦elapsed()⟧; + let je = dauer / runden; + println!("{je:?}"); + } + gaps: + - label: Startzeitpunkt + hint: Eine monotone Uhr aus `std::time`, nicht die Wanduhr. + accept: ["std::time::Instant::now()"] + - label: Ergebnis schützen + hint: Dieselbe Funktion wie bei der Eingabe. + accept: ["std::hint::black_box"] + - label: Eingabe schützen + hint: Eine Identitätsfunktion aus `std::hint`. + accept: ["std::hint::black_box"] + - label: Vergangene Zeit + hint: Eine Methode von `Instant`, die eine `Duration` liefert. + explanation: | + `Instant::now()` liest die monotone Uhr, `elapsed()` liefert die `Duration` seit diesem Zeitpunkt. Das innere `black_box(&daten)` verhindert, dass der Compiler die Summe einmal vorausberechnet und in jeder Runde wiederverwendet. Das äußere `black_box` lässt das Ergebnis als benutzt gelten, sonst dürfte der Aufruf ganz verschwinden. Eine solche Schleife ist eine schnelle Probe; Streuung, Aufwärmen und Vergleiche liefert erst criterion. + resources: + - title: std · black_box + url: https://doc.rust-lang.org/std/hint/fn.black_box.html + - title: std · Instant::elapsed + url: https://doc.rust-lang.org/std/time/struct.Instant.html#method.elapsed + + - id: schluessel-leihen + type: gap + title: Zählen ohne Kopien + prompt: | + `zaehle` soll die Log-Level zählen, ohne pro Zeile einen `String` anzulegen. `main` sortiert die Level und braucht dafür einen `Vec`, der nie wachsen muss. Ausgabe: `INFO=2` und `WARN=1`. + lang: rust + code: | + use std::collections::HashMap; + + fn zaehle(log: &str) + -> HashMap<⟦&str⟧, u32> + { + let mut m = HashMap::new(); + for z in log.lines() { + let lv = z + .split(' ') + .next() + .unwrap_or(""); + *m.⟦entry⟧(lv) + .or_insert(0) += 1; + } + m + } + + fn main() { + let log = + "INFO a\nWARN b\nINFO c"; + let m = zaehle(log); + let n = m.len(); + let mut namen: Vec<&str> = + Vec::⟦with_capacity⟧(n); + namen.extend(m.keys()); + namen.sort(); + for k in namen { + println!("{k}={}", m[k]); + } + } + gaps: + - label: Schlüsseltyp + hint: Ein geliehenes Stück von `log` statt eines eigenen Strings. + accept: ["&'_ str"] + - label: Einmal suchen + hint: Die API, die einen Eintrag liefert, egal ob er schon existiert. + - label: Feste Kapazität + hint: Ein Konstruktor, der Platz für `n` Elemente reserviert. + explanation: | + Mit `&str` als Schlüssel zeigt die Map auf Stücke von `log`, statt sie zu kopieren; der Compiler leitet die Lebensdauer aus dem einzigen Referenz-Parameter ab. `entry(lv)` sucht den Schlüssel einmal und liefert den Eintrag, `or_insert(0)` legt ihn bei Bedarf an. `contains_key` plus `insert` würde zweimal suchen. `Vec::with_capacity(n)` reserviert genau so viel Platz, wie es Level gibt, und muss beim `extend` nicht wachsen. + resources: + - title: std · HashMap::entry + url: https://doc.rust-lang.org/std/collections/hash_map/struct.HashMap.html#method.entry + - title: Perf Book · Hash tables + url: https://nnethercote.github.io/perf-book/heap-allocations.html#hash-tables + + - id: zu-schnell-fuer-echt + type: bug + title: 251 Pikosekunden + prompt: | + `status` liest aus einer HTTP-Statuszeile die drei Ziffern und parst sie. criterion meldet dafür `time: [251.27 ps 251.42 ps 251.72 ps]`, bei 20 Milliarden Iterationen in fünf Sekunden. Das ist ungefähr ein einziger Prozessortakt bei 4 GHz (0,25 ns). Finde die Zeile, die die Messung wertlos macht, und korrigiere sie. + lang: rust + code: | + use criterion::{ + Criterion, criterion_group, + criterion_main, + }; + use std::hint::black_box; + + const ZEILE: &str = + "HTTP/1.1 503 Unavailable"; + + fn status(z: &str) -> u16 { + let s = z.get(9..12) + .unwrap_or("0"); + s.parse().unwrap_or(0) + } + + fn run(c: &mut Criterion) { + c.bench_function( + "status", + |b| b.iter(|| { + status(ZEILE) + }), + ); + } + + criterion_group!(benches, run); + criterion_main!(benches); + lines: [20] + fix: + - line: 20 + answers: + - status(black_box(ZEILE)) + - black_box(status(black_box(ZEILE))) + - status(std::hint::black_box(ZEILE)) + - status(black_box(&ZEILE)) + hints: + - Woher kennt der Compiler die Eingabe? + - Beim Bauen meldet Cargo eine Warnung zu einem Import. + explanation: | + `ZEILE` ist eine Konstante. Der Optimierer kann `status(ZEILE)` deshalb beim Übersetzen ausrechnen, und übrig bleibt eine Schleife, die eine fertige Zahl zurückgibt. Mit `status(black_box(ZEILE))` kennt der Compiler den Inhalt nicht mehr und muss wirklich parsen. Auf dem Testrechner stieg die Zeit damit auf 1,98 ns, und criterion meldete „Performance has regressed“ mit +686 %. Der Code war nicht langsamer geworden, die alte Zahl war falsch. Der Hinweis lag schon vorher in der Warnung `unused import: std::hint::black_box`. + resources: + - title: criterion.rs · Getting Started + url: https://bheisler.github.io/criterion.rs/book/getting_started.html + - title: std · black_box + url: https://doc.rust-lang.org/std/hint/fn.black_box.html + + - id: kalter-erster-lauf + type: explain + title: Warum der erste Lauf ausreißt + prompt: | + Drei hyperfine-Läufe für den Logparser mit einer 216-MB-Datei (Spalten umgestellt, Warnung gekürzt). Vor dem ersten Lauf lag die Datei nicht im Page Cache. Erkläre, was die Warnung bedeutet, warum σ im zweiten Lauf so viel kleiner ist und welche Messung ins Protokoll gehört, wenn der Job jeden Morgen direkt nach dem Neustart des Servers läuft. + lang: text + code: | + $ hyperfine \ + './logparser gross.log' + Wall Time + mean ± σ 218.2 ms ± 23.2 ms + min … max 204.0 … 282.9 ms + Warning: The first benchmarking + run for this command was + significantly slower than the + rest (282.9 ms). This could be + caused by (filesystem) caches + that were not filled until + after the first run. […] + + $ hyperfine --warmup 3 \ + './logparser gross.log' + Wall Time + mean ± σ 208.8 ms ± 3.9 ms + min … max 203.6 … 217.0 ms + + $ hyperfine --prepare \ + 'dd if=gross.log \ + iflag=nocache count=0 \ + status=none' \ + './logparser gross.log' + Wall Time + mean ± σ 295.7 ms ± 10.5 ms + min … max 289.4 … 324.8 ms + points: + - Der erste Lauf musste die Datei von der Platte lesen, alle weiteren bekamen sie aus dem Page Cache im RAM. Daher 282,9 ms gegen rund 205 ms. + - "`--warmup 3` füllt den Cache vor der Messung. Der Ausreißer fällt weg, σ sinkt von 23,2 auf 3,9 ms." + - Ohne Warmup mischt der Mittelwert einen kalten mit neun warmen Läufen und beschreibt keinen der beiden Fälle. + - Nach einem Neustart ist der Cache leer. Für diesen Job zählt der kalte Fall, also die Messung mit `--prepare`, die vor jedem Lauf den Cache für die Datei verwirft (295,7 ms ± 10,5 ms). + explanation: | + Die Warnung sagt: Der erste Lauf war viel langsamer als der Rest, vermutlich wegen eines Caches. Hier war es der Page Cache des Kernels. Nach dem ersten Lesen liegt die Datei im RAM, und jeder weitere Lauf spart sich die Platte. Ein Mittelwert über einen kalten und neun warme Läufe beschreibt keinen echten Fall, das zeigt auch das breite σ von 23,2 ms. Mit `--warmup 3` misst du sauber den warmen Fall. Ein Job direkt nach dem Neustart trifft aber auf einen leeren Cache. Dafür verwirft `--prepare` mit `dd … iflag=nocache count=0` vor jedem Lauf den Cache dieser Datei, ohne root-Rechte, und misst den kalten Fall: knapp 300 ms. Ins Protokoll gehört die Messung, die zum Betrieb passt, mit dem Hinweis, ob kalt oder warm. + resources: + - title: hyperfine · Warmup runs and preparation commands + url: https://github.com/sharkdp/hyperfine#warmup-runs-and-preparation-commands + - title: GNU coreutils · dd, iflag=nocache + url: https://www.gnu.org/software/coreutils/manual/html_node/dd-invocation.html#index-nocache + + - id: fall-erste-messung + type: choice + title: "Logparser-Fall (1/4): Der erste Schritt" + prompt: | + Der nächtliche Job `logparser` zählt Fehlerzeilen in einer Logdatei mit einer Million Zeilen. Seit Version 2.4 meldet das Monitoring längere Laufzeiten. Der einzige Commit seit 2.3 heißt „Level ohne Rücksicht auf Groß-/Kleinschreibung erkennen“. Was machst du **zuerst**? + options: + - text: Version 2.3 und 2.4 im Release-Build mit derselben Logdatei vergleichen, zum Beispiel mit `hyperfine --warmup 3`. + correct: true + why: Das bestätigt die Regression unter kontrollierten Bedingungen und beziffert sie. Alles Weitere, auch das Profil, misst du gegen diese Zahl. + - text: Sofort einen Flamegraph von 2.4 aufnehmen. + correct: false + why: Ein Profil zeigt, wo Zeit hingeht, aber nicht, ob 2.4 überhaupt langsamer ist und um wie viel. Ohne Vergleich weißt du nicht, welcher Balken neu ist. + - text: Den Diff lesen und die verdächtigste Zeile optimieren. + correct: false + why: Das ist Raten. Ohne Messung weißt du weder, ob du die richtige Zeile triffst, noch ob deine Änderung hilft. + - text: "`Instant::now()` um `main` legen und beide Versionen je einmal mit `cargo run` starten." + correct: false + why: "`cargo run` baut im Debug-Profil, und eine Einzelmessung schwankt stark. Beides macht die Zahl wertlos." + - text: Die Laufzeiten des Jobs im Monitoring der letzten Woche mitteln. + correct: false + why: Im Betrieb ändern sich Eingabegröße und Last von Nacht zu Nacht. Das Monitoring zeigt, dass etwas passiert ist; die Ursache isolierst du nur mit kontrollierter Messung. + explanation: | + Der erste Schritt einer Regressionsanalyse ist, sie reproduzierbar zu machen und zu beziffern: gleiche Eingabe, Release-Build, beide Versionen, viele Läufe. Erst dann lohnen sich Profil und Hypothese. hyperfine passt, weil die Meldung das ganze Programm betrifft. Gibt es schon einen criterion-Benchmark mit gespeicherter Baseline von 2.3, ist auch der ein guter Start. + resources: + - title: hyperfine · Basic benchmarks + url: https://github.com/sharkdp/hyperfine#basic-benchmarks + - title: The Rust Performance Book · Benchmarking + url: https://nnethercote.github.io/perf-book/benchmarking.html + + - id: fall-echt-oder-rauschen + type: choice + title: "Logparser-Fall (2/4): Echt oder Rauschen?" + prompt: | + Oben hyperfine für das ganze Programm, unten criterion für zwei Funktionen der Version 2.4, verglichen mit der gespeicherten Baseline von 2.3 (umbrochen, gekürzt). `zaehle_zeilen` zählt nur Zeilen, `zaehle_fehler` erkennt zusätzlich das Level. Welche Aussagen stimmen? **Mehrere Antworten richtig.** + lang: text + code: | + hyperfine --warmup 3 + logparser-2.3 (71 runs) + 44.6 ms ± 3.3 ms + min … max 41.5 … 57.1 ms + logparser-2.4 (53 runs) + 59.4 ms ± 6.6 ms + min … max 54.4 … 99.1 ms + +33.0% (1.3x slower) + + logparser/zaehle_fehler + time: [444.97 µs 450.52 µs + 457.93 µs] + change: [+37.949% +39.191% + +41.082%] + (p = 0.00 < 0.05) + Performance has regressed. + logparser/zaehle_zeilen + time: [189.00 µs 189.39 µs + 189.75 µs] + change: [+0.5876% +0.8812% + +1.1837%] + (p = 0.00 < 0.05) + Change within noise threshold. + options: + - text: Die Verschlechterung von `zaehle_fehler` ist echt. Das ganze Intervall liegt weit über der Rauschschwelle, und p liegt unter 0,05. + correct: true + why: Selbst die untere Grenze (+37,9 %) liegt weit über 1 %. criterion urteilt deshalb „regressed“. + - text: Das reine Zeilenlesen hat sich nicht relevant verändert. Die zusätzliche Zeit entsteht beim Erkennen der Fehlerzeilen. + correct: true + why: "`zaehle_zeilen` bleibt im Rauschen. Was `zaehle_fehler` zusätzlich tut, ist das Erkennen des Levels; dort muss die Ursache liegen." + - text: hyperfine (+33 %) und criterion (+39 %) widersprechen sich, eine der Messungen ist kaputt. + correct: false + why: hyperfine misst das ganze Programm mit Prozessstart und Datei lesen. Dieselbe zusätzliche Zeit verteilt sich auf eine größere Gesamtzeit, der Prozentwert fällt kleiner aus. + - text: "`zaehle_zeilen` ist 0,9 % langsamer geworden, das ist eine zweite Regression." + correct: false + why: Die untere Grenze (+0,59 %) liegt innerhalb der Rauschschwelle von 1 %. criterion wertet das als Rauschen, und am Zeilenlesen hat der Commit nichts geändert. + - text: Wegen des Ausreißers bis 99,1 ms ist der hyperfine-Vergleich wertlos. + correct: false + why: Ein Ausreißer unter 53 Läufen vergrößert σ, aber schon die Minima (41,5 gegen 54,4 ms) liegen klar auseinander. criterion bestätigt die Richtung unabhängig davon. + explanation: | + Zwei Werkzeuge, ein Befund: Version 2.4 ist langsamer, und es liegt am Erkennen der Fehlerzeilen. criterion liefert das statistische Urteil auf Funktionsebene, hyperfine die Wirkung auf das ganze Programm. Dass hyperfine einen kleineren Prozentwert zeigt, ist zu erwarten, denn Start und I/O bleiben gleich. Mit `--metrics time` sieht man das direkt: Die User-Zeit stieg von 36,6 auf 49,2 ms (+34,5 %), die System-Zeit blieb bei 8,6 bis 8,8 ms. + resources: + - title: criterion.rs · Command-Line Output + url: https://bheisler.github.io/criterion.rs/book/user_guide/command_line_output.html + - title: criterion · noise_threshold + url: https://docs.rs/criterion/latest/criterion/struct.Criterion.html#method.noise_threshold + + - id: fall-flamegraph + type: explain + title: "Logparser-Fall (3/4): Wo bleibt die Zeit?" + prompt: | + Eine Aufnahme von Version 2.4 mit derselben Arbeit wie die Aufnahme von 2.3 in der Lektion (dieselbe Eingabe, 1000 Samples pro Sekunde). Oben vereinfacht als Text, 32 Zeichen für 1382 Samples, unten gefaltet (Anfang `main;zaehle_fehler;` weggelassen). Erkläre, wohin die zusätzliche Zeit geht, woran du es erkennst und warum der Anteil zur Messung aus Teil 2 passt. + lang: text + code: | + [][memch] [ma] + [][split ][to_lo][memchr ] + [ist_fehler ][lines ] + [zaehle_fehler ] + [main ] + + lines 193 + lines;memchr 381 + ist_fehler;drop;free 96 + ist_fehler;split 112 + ist_fehler;split;memchr 302 + ist_fehler;to_lowercase 141 + ist_fehler;to_lowercase;malloc 157 + points: + - Neu unter `ist_fehler` sind `to_lowercase` mit `malloc` und `drop` mit `free`, zusammen 394 von 1382 Samples, rund 28,5 %. + - Das passt zur Messung. 39 % mehr Zeit heißt, dass der neue Teil 0,39 / 1,39 ≈ 28 % der neuen Laufzeit ausmacht. + - "`lines` und `split` sind dieselben Türme wie in 2.3. Die alte Arbeit ist geblieben, neue kam dazu; verglichen werden Anteile, weil absolute Sample-Zahlen zwischen Aufnahmen streuen." + - Ursache ist ein neuer `String` pro Zeile, nur für den Vergleich, mit Allokation und Freigabe. + - "`ist_fehler` hat keine Selbstzeit. Seine Breite von 58 % kommt ganz aus den Kindern." + explanation: | + In 2.4 trägt `ist_fehler` zwei neue Türme: `to_lowercase` (141 Samples selbst, 157 in `malloc`) und `drop` mit `free` (96). Zusammen sind das 394 Samples oder 28,5 % der Aufnahme. Absolute Sample-Zahlen streuen zwischen zwei Aufnahmen (insgesamt 1382 statt 930, das wären +49 % statt der +39 % aus criterion), deshalb vergleichst du Anteile: `lines` und `split` tragen dieselbe Arbeit wie vorher, neu sind die rund 400 Samples für Kleinschreibung, Allokation und Freigabe. Das deckt sich mit criterion: +39 % auf die alte Zeit sind 28 % der neuen. Die Ursache ist damit eingegrenzt. `to_lowercase` legt für jede Zeile einen neuen `String` an, nur um ihn mit „error“ zu vergleichen, und gibt ihn sofort wieder frei. + resources: + - title: std · str::to_lowercase + url: https://doc.rust-lang.org/std/primitive.str.html#method.to_lowercase + - title: Perf Book · Heap Allocations + url: https://nnethercote.github.io/perf-book/heap-allocations.html#profiling + + - id: fall-teure-zeile + type: bug + title: "Logparser-Fall (4/4): Die teure Zeile" + prompt: | + Das ist `ist_fehler` in Version 2.4; `zaehle_fehler` ruft es für jede Zeile auf. Die Fehlerzeilen sollen weiter als `ERROR`, `error` oder `Error` erkannt werden, aber ohne die Kosten aus Teil 3. Finde die Zeile und schreibe sie so, dass sie dasselbe erkennt, ohne zu allozieren. + lang: rust + code: | + fn ist_fehler(z: &str) -> bool { + let lv = z + .split(' ') + .nth(1) + .unwrap_or(""); + lv.to_lowercase() == "error" + } + lines: [6] + fix: + - line: 6 + answers: + - lv.eq_ignore_ascii_case("error") + - lv.eq_ignore_ascii_case("ERROR") + - '"error".eq_ignore_ascii_case(lv)' + - '"ERROR".eq_ignore_ascii_case(lv)' + - lv.eq_ignore_ascii_case("Error") + - '"Error".eq_ignore_ascii_case(lv)' + hints: + - "`str` hat eine Methode, die zwei Texte ohne Rücksicht auf ASCII-Groß-/Kleinschreibung vergleicht." + - Sie heißt `eq_ignore_ascii_case` und braucht keinen neuen String. + explanation: | + `to_lowercase` baut einen neuen `String`, also eine Allokation und eine Freigabe pro Zeile. [`eq_ignore_ascii_case`](https://doc.rust-lang.org/std/primitive.str.html#method.eq_ignore_ascii_case) vergleicht Byte für Byte und ignoriert dabei nur ASCII-Groß-/Kleinschreibung. Für ein Log-Level reicht das. Im Testlauf zählte der Allokator in `zaehle_fehler` danach 0 statt 10 000 Allokationen pro 10 000 Zeilen, und criterion meldete gegen die Baseline von 2.3 „No change in performance detected“ (−0,2 %, p = 0,20). Die Regression ist weg, und die neue Funktion bleibt. + resources: + - title: std · str::eq_ignore_ascii_case + url: https://doc.rust-lang.org/std/primitive.str.html#method.eq_ignore_ascii_case + + - id: messprotokoll-schreiben + type: practice + title: Das Messprotokoll zum Fall + prompt: | + Schreibe das Messprotokoll für den Pull Request, der Version 2.4.1 mit der Korrektur aus Teil 4 bringt. Fünf bis acht kurze Zeilen genügen. Diese Messwerte liegen vor (Testrechner, Release-Build): + + ```text + Eingabe: app.log, 1 Mio. Zeilen + hyperfine --warmup 3, Wall Time + 2.3 44.6 ms ± 3.3 ms + 2.4 59.4 ms ± 6.6 ms + 2.3 44.6 ms ± 2.5 ms + 2.4.1 46.0 ms ± 3.7 ms + criterion zaehle_fehler, + 10 000 Zeilen, gegen 2.3: + 2.4 +39.2 %, regressed + 2.4.1 −0.2 %, p = 0.20, + no change detected + Allokationen in zaehle_fehler + je 10 000 Zeilen: + 2.3: 0 2.4: 10000 2.4.1: 0 + Profil 2.4: to_lowercase, + malloc und free 28,5 % + ``` + lang: text + starter: | + Problem: + Messaufbau: + Befund: + Änderung: + Ergebnis: + Entscheidung: + solution: | + Problem: Der Job läuft seit 2.4 + länger. + Messaufbau: Release-Build, app.log + (1 Mio. Zeilen), hyperfine mit 3 + Warmups, criterion gegen die + Baseline 2.3. + Befund: 2.4 ist 33 % langsamer + (ganzes Programm), zaehle_fehler + 39 %. Das Profil zeigt + to_lowercase mit malloc und free + (28,5 %): ein neuer String pro + Zeile, 10 000 Allokationen je + 10 000 Zeilen. + Änderung: nur diese Zeile, jetzt + eq_ignore_ascii_case. + Ergebnis: 0 Allokationen; + criterion gegen 2.3 ohne Änderung + (p = 0,20); hyperfine +3,1 %, + kleiner als die Streuung (σ 2,5 + und 3,7 ms). + Entscheidung: 2.4.1 ausliefern. + Vor jedem Release läuft künftig + criterion gegen die Vorversion. + checklist: + - Das Protokoll nennt Eingabe, Build-Profil und Werkzeuge, sodass jemand die Messung wiederholen kann. + - Die Regression ist mit beiden Werkzeugen beziffert, und der Unterschied zwischen 33 % und 39 % ist begründet oder zumindest richtig zugeordnet. + - Die Ursache ist mit einem Beleg verknüpft, also Profil oder Allokationszähler, nicht nur mit dem Diff. + - Es ist genau eine Änderung beschrieben, und ihr Ergebnis ist gegen die Baseline von 2.3 angegeben. + - Die +3,1 % von hyperfine für 2.4.1 sind als Streuung eingeordnet, nicht als neue Regression. + - Die Entscheidung ist klar und sagt, wie eine Wiederholung künftig auffällt. + explanation: | + Ein Messprotokoll beantwortet drei Fragen: Was wurde wie gemessen? Was ist der Befund? Was folgt daraus? Die +3,1 % von hyperfine für 2.4.1 sind kleiner als die Streuung beider Messreihen, und hyperfine macht keinen Signifikanztest. criterion dagegen findet auf Funktionsebene keinen Unterschied (p = 0,20). Wer das sauber trennt, erspart dem Team eine Geisterjagd nach einer Regression, die es nicht gibt. + resources: + - title: criterion.rs · Command-Line Output + url: https://bheisler.github.io/criterion.rs/book/user_guide/command_line_output.html diff --git a/content/rust/modules/profiling-werkstatt.yaml b/content/rust/modules/profiling-werkstatt.yaml new file mode 100644 index 00000000..0a8270c6 --- /dev/null +++ b/content/rust/modules/profiling-werkstatt.yaml @@ -0,0 +1,1708 @@ +id: profiling-werkstatt +title: "Werkstatt: Profilieren und optimieren" +level: 3 +minutes: 240 +summary: Ein absichtlich langsames Rust-Programm Schritt für Schritt schneller machen, mit Baseline, Benchmark, Profil, genau einer Änderung pro Runde und einem Messprotokoll. +goals: + - Du legst ein Cargo-Profil für Messungen an und begründest, warum Profile auf einem Release-Build mit Debug-Infos beruhen. + - Du misst eine Baseline mit hyperfine und einen criterion-Benchmark mit gespeicherter Baseline und vergleichst danach jede Änderung damit. + - Du nimmst ein Programm mit samply auf, findest im Profil die teure Stelle und schätzt mit Amdahls Gesetz ab, welche Änderung zuerst kommt. + - Du zählst Allokationen mit einem eigenen `GlobalAlloc` und senkst sie gezielt, ohne das Ergebnis zu ändern. + - Du schreibst ein Messprotokoll, das Messaufbau, Befund, Änderung und Entscheidung belegt. +resources: + - title: samply · README + url: https://github.com/mstange/samply + - title: Cargo Book · Custom profiles + url: https://doc.rust-lang.org/cargo/reference/profiles.html#custom-profiles + - title: criterion.rs · Command-Line Options + url: https://bheisler.github.io/criterion.rs/book/user_guide/command_line_options.html + - title: The Rust Performance Book · Profiling + url: https://nnethercote.github.io/perf-book/profiling.html + - title: Firefox Profiler · UI Tour + url: https://github.com/firefox-devtools/profiler/blob/main/docs-user/guide-ui-tour-panels.md +place: pc +lesson: | + In [Performance messen und lesen](/rust/performance-messen) hast du Ausgaben gelesen. Jetzt erzeugst du sie selbst. Das Werkstück ist `logauswertung`, ein kleines Programm ohne fremde Crates. Es erzeugt 200 000 Zeilen eines Access-Logs und berechnet daraus Anfragen, Serverfehler (5xx) und die p95-Latenz je Methode und Pfad. Auf einem Testrechner braucht es dafür gut drei Sekunden. Am Ende dieser Werkstatt sind es 63 ms, und du kannst für jeden Schritt belegen, warum. + + Der Code enthält mehrere Bremsen, wie sie in echten Codebasen vorkommen. Such sie nicht durch Lesen. Der Sinn der Werkstatt ist, dass Messung und Profil dir sagen, wo du anfängst. Alle Zahlen in dieser Lektion stammen aus Läufen auf dem Testrechner. Deine werden anders aussehen; die Verhältnisse sollten ähnlich sein. + + ## Das Werkstück: eine absichtlich langsame Log-Auswertung + + Leg das Projekt an und ersetze den Inhalt der Dateien: + + ```console + $ cargo new logauswertung + $ cd logauswertung + ``` + + `Cargo.toml`, mit allem, was diese Werkstatt braucht: + + ```toml + [package] + name = "logauswertung" + version = "0.1.0" + edition = "2024" + + [dev-dependencies] + criterion = "0.8" + + [[bench]] + name = "auswerten" + harness = false + + [profile.profiling] + inherits = "release" + debug = true + ``` + + `src/lib.rs` enthält den Generator für das Log, die Datentypen und die Auswertung: + + ```rust + use std::collections::HashMap; + use std::fmt::{self, Write}; + + /// Erzeugt `zeilen` synthetische Access-Log-Zeilen. Gleicher `seed`, gleiche Zeilen. + /// Format: `2026-10-24T08:15:02Z 10.0.3.17 GET /api/orders 200 87ms` + pub fn erzeuge_log(zeilen: usize, seed: u64) -> String { + // (Pfad, Anteil in Prozent, POST möglich, typische Latenz in ms) + const PFADE: [(&str, u64, bool, u64); 12] = [ + ("/health", 22, false, 2), + ("/static/app.js", 14, false, 6), + ("/static/app.css", 12, false, 5), + ("/api/orders", 10, true, 40), + ("/api/orders/:id", 9, false, 25), + ("/api/search", 8, false, 90), + ("/api/users/:id", 7, false, 20), + ("/api/cart", 6, true, 35), + ("/login", 4, true, 60), + ("/api/users", 3, false, 45), + ("/api/checkout", 3, true, 120), + ("/logout", 2, true, 15), + ]; + let mut x = seed.max(1); + let mut log = String::with_capacity(zeilen * 64); + for i in 0..zeilen { + // xorshift64: schnell, deterministisch, kein Crate nötig + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + let mut wahl = x % 100; + let mut eintrag = PFADE[0]; + for p in PFADE { + if wahl < p.1 { + eintrag = p; + break; + } + wahl -= p.1; + } + let (pfad, _, post_moeglich, basis) = eintrag; + let methode = if post_moeglich && (x >> 4) % 3 == 0 { "POST" } else { "GET" }; + let status = match (x >> 8) % 100 { + 0..=88 => 200, + 89..=93 => 404, + 94..=97 => 500, + _ => 503, + }; + // jede 12. Anfrage hängt bis zu 1,5 s länger (langer Schwanz für p95) + let extra = if (x >> 24) % 12 == 0 { (x >> 32) % 1500 } else { 0 }; + let ms = basis + (x >> 16) % (2 * basis) + extra; + let _ = writeln!( + log, + "2026-10-24T{:02}:{:02}:{:02}Z 10.0.{}.{} {methode} {pfad} {status} {ms}ms", + (i / 3600) % 24, + (i / 60) % 60, + i % 60, + (x >> 40) % 256, + (x >> 48) % 256, + ); + } + log + } + + #[derive(Debug, Clone, PartialEq, Default)] + pub struct Pfad { + pub anfragen: u32, + pub fehler: u32, + pub latenzen: Vec, + pub p95: u32, + } + + #[derive(Debug, PartialEq)] + pub struct Bericht { + /// ("GET /api/orders", Statistik), meiste Anfragen zuerst + pub pfade: Vec<(String, Pfad)>, + /// ("200", Anzahl), aufsteigend nach Code + pub status: Vec<(String, u32)>, + } + + impl fmt::Display for Bericht { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (name, p) in &self.pfade { + writeln!( + f, + "{name:<24} {:>7} Anfragen {:>6} Fehler p95 {:>5} ms", + p.anfragen, p.fehler, p.p95 + )?; + } + let codes: Vec = self.status.iter().map(|(c, n)| format!("{c}={n}")).collect(); + writeln!(f, "Status: {}", codes.join(" ")) + } + } + + /// Wertet das Log aus: Anfragen, Fehler (5xx) und p95-Latenz je Methode und Pfad, + /// dazu die Häufigkeit jedes Statuscodes. + pub fn auswerten(log: &str) -> Bericht { + let mut pfade: HashMap = HashMap::new(); + let mut status: HashMap = HashMap::new(); + + for zeile in log.lines() { + let zeile = zeile.to_string(); + let teile: Vec = zeile.split(' ').map(|t| t.to_string()).collect(); + if teile.len() != 6 { + continue; + } + let schluessel = format!("{} {}", teile[2], teile[3]); + let code = teile[4].clone(); + let ms: u32 = teile[5].trim_end_matches("ms").parse().unwrap_or(0); + + if !pfade.contains_key(&schluessel) { + pfade.insert(schluessel.clone(), Pfad::default()); + } + let pfad = pfade.get_mut(&schluessel).unwrap(); + pfad.anfragen += 1; + if code.starts_with('5') { + pfad.fehler += 1; + } + pfad.latenzen.push(ms); + pfad.latenzen.sort(); + let rang = (pfad.latenzen.len() * 95).div_ceil(100); + pfad.p95 = pfad.latenzen[rang - 1]; + + if status.contains_key(&code) { + *status.get_mut(&code).unwrap() += 1; + } else { + status.insert(code.clone(), 1); + } + } + + let mut pfade: Vec<(String, Pfad)> = pfade.into_iter().collect(); + pfade.sort_by(|a, b| b.1.anfragen.cmp(&a.1.anfragen).then(a.0.cmp(&b.0))); + let mut status: Vec<(String, u32)> = status.into_iter().collect(); + status.sort(); + Bericht { pfade, status } + } + ``` + + `src/main.rs` liest optional die Zeilenzahl und druckt den Bericht: + + ```rust + use logauswertung::{auswerten, erzeuge_log}; + use std::env; + use std::process::ExitCode; + + fn main() -> ExitCode { + let zeilen: usize = match env::args().nth(1) { + None => 200_000, + Some(arg) => match arg.parse() { + Ok(n) => n, + Err(_) => { + eprintln!("Aufruf: logauswertung [zeilen]"); + return ExitCode::from(2); + } + }, + }; + let log = erzeuge_log(zeilen, 42); + let bericht = auswerten(&log); + print!("{bericht}"); + ExitCode::SUCCESS + } + ``` + + Das Log entsteht im Programm selbst und ist für denselben `seed` immer gleich. So misst jeder Lauf exakt dieselbe Arbeit, und du brauchst keine echte Logdatei mit echten IP-Adressen. + + ## Schritt 1: Messprofil und Baseline + + Gemessen wird nie im Debug-Build. Wie groß der Unterschied ist, zeigt die fertig optimierte Fassung vom Ende dieser Werkstatt: 349,0 ms im Debug-Build, 62,8 ms im Release-Build. Funktionsnamen findet der Profiler auch so, denn `release` entfernt die Symbole standardmäßig nicht (`strip = "none"`). Für eingebaute (inlined) Funktionen und Quellzeilen braucht er zusätzlich Debug-Infos. Das leistet das Profil `profiling` aus der `Cargo.toml` oben: Es erbt alle Einstellungen von `release` und legt Debug-Infos dazu ([Cargo Book: Custom profiles](https://doc.rust-lang.org/cargo/reference/profiles.html#custom-profiles), [debug](https://doc.rust-lang.org/cargo/reference/profiles.html#debug)). Das samply-README empfiehlt genau dieses Profil ([Turn on debug info](https://github.com/mstange/samply#turn-on-debug-info-for-full-stacks)). Bauen und kurz prüfen (Ausgabe gekürzt): + + ```console + $ cargo build --profile profiling + Finished `profiling` profile [optimized + debuginfo] target(s) in 0.57s + $ target/profiling/logauswertung | head -3 + GET /health 43922 Anfragen 2579 Fehler p95 565 ms + GET /static/app.js 27882 Anfragen 1667 Fehler p95 578 ms + GET /static/app.css 24222 Anfragen 1516 Fehler p95 601 ms + ``` + + Das Binary liegt unter `target/profiling/`, getrennt von `target/release/`. `debug = true` ändert die Optimierung nicht, nur die Größe der Datei. Profile wirken nur in der `Cargo.toml` an der Wurzel eines Workspace oder in einer `.cargo/config.toml` (dort schlägt das samply-README das Profil für alle Projekte vor). In einem Mitglied meldet Cargo `profiles for the non root package will be ignored` ([Cargo Book: Profiles](https://doc.rust-lang.org/cargo/reference/profiles.html)). + + Bevor du etwas änderst, hältst du fest, wo du stehst. hyperfine misst das ganze Programm ([hyperfine](https://github.com/sharkdp/hyperfine#basic-benchmarks)). Danach kopierst du das Binary weg, denn jeder Build überschreibt `target/profiling/logauswertung`, und spätere Vergleiche sollen gegen genau dieses Programm laufen: + + ```console + $ hyperfine --warmup 3 target/profiling/logauswertung + Benchmark 1: target/profiling/logauswertung (10 runs) + mean ± σ min … max + Wall Time 3.142 s ± 0.009 s 3.129 s … 3.157 s + Memory 15.6 MiB ± 0.2 MiB 15.2 MiB … 15.8 MiB + $ cp target/profiling/logauswertung logauswertung-vorher + ``` + + 3,142 s ± 0,009 s, sehr geringe Streuung. Das ist die Baseline. + + ## Schritt 2: ein criterion-Benchmark für die heiße Funktion + + hyperfine misst alles, auch das Erzeugen des Logs. Für die Funktion, um die es geht, legst du einen criterion-Benchmark an. Die Einträge in der `Cargo.toml` stehen schon oben. `benches/auswerten.rs`: + + ```rust + use criterion::{Criterion, criterion_group, criterion_main}; + use logauswertung::{auswerten, erzeuge_log}; + use std::hint::black_box; + + fn bench_auswerten(c: &mut Criterion) { + let log = erzeuge_log(50_000, 42); + let mut gruppe = c.benchmark_group("logauswertung"); + gruppe.sample_size(10); + gruppe.bench_function("auswerten_50k", |b| { + b.iter(|| auswerten(black_box(&log))) + }); + gruppe.finish(); + } + + criterion_group!(benches, bench_auswerten); + criterion_main!(benches); + ``` + + Eine Iteration der Ausgangsfassung dauert mit 50 000 Zeilen rund 200 ms. Mit den üblichen 100 Stichproben würde criterion sehr lange messen, deshalb senkt `sample_size(10)` die Zahl auf das erlaubte Minimum ([sample_size](https://docs.rs/criterion/latest/criterion/struct.BenchmarkGroup.html#method.sample_size)). Gespeichert wird das Ergebnis unter einem Namen, damit du später gezielt dagegen vergleichen kannst ([Command-Line Options](https://bheisler.github.io/criterion.rs/book/user_guide/command_line_options.html)). Der naheliegende Aufruf scheitert (Ausgabe gekürzt): + + ```console + $ cargo bench -- --save-baseline vorher + Running unittests src/lib.rs (target/release/deps/logauswertung-c3c50f6602f2758b) + error: Unrecognized option: 'save-baseline' + error: bench failed, to rerun pass `--lib` + ``` + + `cargo bench` startet zuerst das eingebaute Test-Gerüst für `src/lib.rs` und `src/main.rs`, und das kennt `--save-baseline` nicht. Die criterion-FAQ nennt zwei Auswege: nur den criterion-Benchmark starten oder für Bibliothek und Binary `bench = false` setzen ([FAQ](https://bheisler.github.io/criterion.rs/book/faq.html), [Cargo Book: bench field](https://doc.rust-lang.org/cargo/reference/cargo-targets.html#the-bench-field)). Mit `--bench auswerten` (Ausgabe gekürzt): + + ```console + $ cargo bench --bench auswerten -- --save-baseline vorher + logauswertung/auswerten_50k + time: [210.45 ms 210.87 ms 211.28 ms] + ``` + + Ein erster Befund steckt schon in den beiden Zahlen. Das ganze Programm mit 200 000 Zeilen braucht 3,1 s, die Auswertung von 50 000 Zeilen 0,21 s. Viermal so viele Zeilen kosten also rund fünfzehnmal so viel Zeit. Irgendetwas wächst schneller als die Eingabe. + + ## Schritt 3: die Aufnahme mit samply + + [samply](https://github.com/mstange/samply) ist ein Sampling-Profiler. Er startet das Programm, nimmt standardmäßig 1000-mal pro Sekunde den Stack auf und öffnet danach das Ergebnis im Firefox Profiler im Browser. Die Daten bleiben auf deinem Rechner, bis du sie selbst hochlädst. Installiert wird samply mit `cargo install --locked samply` oder über die Installer im README. + + ```console + $ samply record ./target/profiling/logauswertung + ``` + + Unter Linux braucht samply Zugriff auf Performance-Events des Kernels. Ist das normalen Benutzern verboten, bricht samply sofort ab. So sah es auf dem Testrechner aus: + + ```text + '/proc/sys/kernel/perf_event_paranoid' is currently set to 2. + In order for samply to work with a non-root user, this level needs + to be set to 1 or lower. + You can execute the following command and then try again: + echo '1' | sudo tee /proc/sys/kernel/perf_event_paranoid + ``` + + Der Wert 2 verbietet unprivilegierten Benutzern das Profilieren des Kernels, samply verlangt 1 oder weniger ([Kernel-Doku: perf_event_paranoid](https://docs.kernel.org/admin-guide/sysctl/kernel.html#perf-event-paranoid)). Der Befehl aus der Meldung gilt bis zum nächsten Neustart; `sudo sysctl kernel.perf_event_paranoid=1` wirkt genauso. Auf einem eigenen Entwicklungsrechner ist das üblich. Auf geteilten Servern oder in Containern entscheidet der Administrator. + + > [!WARNING] Systemeinstellung, nicht Projekteinstellung + > `perf_event_paranoid` gilt für alle Benutzer des Rechners. Setz ihn nur dort herunter, wo du allein arbeitest, und nur so lange, wie du profilierst. Eine dauerhafte Änderung über `/etc/sysctl.d/` gehört nicht in ein Projekt-README. + + Im Firefox Profiler zeigen drei Ansichten dieselben Daten unterschiedlich ([UI Tour](https://github.com/firefox-devtools/profiler/blob/main/docs-user/guide-ui-tour-panels.md)): + + - **Call Tree**: Aufrufe als Baum mit Gesamt- und Selbstzeit. Mit „Invert call stack“ stehen die Funktionen mit der meisten Selbstzeit oben ([Filtering call trees](https://github.com/firefox-devtools/profiler/blob/main/docs-user/guide-filtering-call-trees.md)). + - **Flame Graph**: derselbe Baum als Flamegraph, gelesen wie in [Performance messen und lesen](/rust/performance-messen). + - **Stack Chart**: die Stacks in zeitlicher Reihenfolge, passend zur Zeitleiste oben. + + Eine Aufnahme der Ausgangsfassung ergibt 3151 Samples, hier gefaltet und auf eine Ebene unter `auswerten` vereinfacht. Die Zahlen in dieser Lektion stammen aus [pprof-rs](https://docs.rs/pprof/latest/pprof/), einem Sampling-Profiler, der im Prozess selbst läuft (ebenfalls 1000 Hz, Profil `profiling`), weil samply auf dem Testrechner nicht messen durfte. Im Call Tree von samply suchst du nach denselben Funktionsnamen. + + ```text + main;auswerten;sort 3057 # 97,0 % + main;erzeuge_log 32 # 1,0 % + main;auswerten;collect 30 # 1,0 % + main;auswerten;hashmap 15 # 0,5 % + main;auswerten;format! 8 # 0,3 % + main;auswerten;… 9 # 0,3 % + ``` + + Fast jedes Sample endet im Sortieren. Der Stack darunter, gekürzt um die Typparameter: + + ```text + logauswertung::auswerten + <[u32]>::sort + alloc::slice::stable_sort + core::slice::sort::stable::sort + core::slice::sort::stable::driftsort_main + core::slice::sort::stable::drift::sort + core::slice::sort::stable::drift::logical_merge + core::slice::sort::stable::merge::merge + MergeState::merge_down # 3049 Samples + ``` + + `<[u32]>::sort` wird nur an einer Stelle aufgerufen: `pfad.latenzen.sort()` in der Schleife über die Zeilen. Nach jeder Zeile sortiert das Programm alle bisherigen Latenzen dieses Pfads neu, um den p95-Wert aktuell zu halten. Der Pfad `/health` hat 43 922 Anfragen; seine Liste wird 43 922-mal sortiert, jedes Mal ein Stück länger. Die Arbeit wächst damit quadratisch mit der Zahl der Zeilen pro Pfad. Das erklärt den Befund aus Schritt 2. + + Amdahl sagt dir auch, was du nicht tun solltest: Die Allokationen durch `to_string`, `collect` und `format!` sind im Code gut sichtbar. Im Profil stecken `collect`, `format!`, `drop` und `clone` zusammen in 44 von 3151 Samples, also in 1,4 %. Sie zuerst zu entfernen, würde höchstens 1,4 % sparen. + + ## Schritt 4: genau eine Änderung, dann neu messen + + Die erste Änderung verschiebt die p95-Berechnung hinter die Schleife. Jeder Pfad wird einmal sortiert, mehr nicht: + + ```diff + pfad.latenzen.push(ms); + - pfad.latenzen.sort(); + - let rang = (pfad.latenzen.len() * 95).div_ceil(100); + - pfad.p95 = pfad.latenzen[rang - 1]; + + if status.contains_key(&code) { + *status.get_mut(&code).unwrap() += 1; + } else { + status.insert(code.clone(), 1); + } + } + + + for pfad in pfade.values_mut() { + + pfad.latenzen.sort(); + + let rang = (pfad.latenzen.len() * 95).div_ceil(100); + + pfad.p95 = pfad.latenzen[rang - 1]; + + } + + + let mut pfade: Vec<(String, Pfad)> = pfade.into_iter().collect(); + ``` + + Das Ergebnis muss gleich bleiben. Weil die Ausgabe deterministisch ist, reicht ein Vergleich der beiden Programme: `diff <(./logauswertung-vorher) <(target/profiling/logauswertung)` darf nichts ausgeben. Besser ist ein Test, der alte und neue Funktion auf denselben Daten vergleicht; das übst du in den Aufgaben. Dann misst du gegen die Baseline (criterion-Ausgabe gekürzt): + + ```console + $ cargo build --profile profiling + $ hyperfine --warmup 3 ./logauswertung-vorher target/profiling/logauswertung + Benchmark 1: ./logauswertung-vorher (10 runs) + mean ± σ min … max + Wall Time 3.143 s ± 0.004 s 3.134 s … 3.147 s + Memory 15.6 MiB ± 0.1 MiB 15.3 MiB … 15.8 MiB + + Benchmark 2: target/profiling/logauswertung (29 runs) + mean ± σ min … max + Wall Time 0.103 s ± 0.002 s 0.100 s … 0.107 s -96.7% (30.4x faster) + Memory 15.8 MiB ± 0.1 MiB 15.5 MiB … 15.9 MiB +1.2% + $ cargo bench --bench auswerten -- --baseline vorher + logauswertung/auswerten_50k + time: [17.018 ms 17.032 ms 17.051 ms] + change: [−91.937% −91.919% −91.900%] (p = 0.00 < 0.05) + Performance has improved. + ``` + + Das ganze Programm ist 30,4-mal schneller. Der Benchmark mit 50 000 Zeilen zeigt nur Faktor 12: Bei weniger Zeilen pro Pfad fiel das quadratische Sortieren weniger ins Gewicht. Der Benchmark hat die Richtung richtig gezeigt, das Ausmaß im Betrieb hängt aber von der Eingabegröße ab. Miss mit Daten in der Größenordnung, die das Programm wirklich sieht. + + > [!MERKE] + > Eine Runde ist immer: messen, genau eine Änderung, Ergebnis prüfen, gegen dieselbe Baseline messen, festhalten. Danach wird das neue Programm die nächste Baseline. + + ## Schritt 5: neues Profil, Allokationen zählen + + Nach der ersten Änderung sieht das Profil ganz anders aus. Damit genug Samples zusammenkommen, wurde es mit 2 000 000 Zeilen aufgenommen (974 Samples, vereinfacht, kleine Posten zusammengefasst): + + ```text + main;erzeuge_log 314 # 32,2 % + main;auswerten;collect 144 # 14,8 % + main;auswerten;collect;malloc 107 # 11,0 % + main;auswerten;collect;realloc 38 # 3,9 % + main;auswerten;hashmap 95 # 9,8 % + main;auswerten;parse 75 # 7,7 % + main;auswerten;format! 57 # 5,9 % + main;auswerten;lines 42 # 4,3 % + main;auswerten;drop;free 33 # 3,4 % + main;auswerten;to_string 32 # 3,3 % + main;auswerten;… 37 # 3,8 % + ``` + + `erzeuge_log` ist das Testgerüst und nicht Teil der Aufgabe. In `auswerten` fällt `collect` auf: die Zeile `zeile.split(' ').map(|t| t.to_string()).collect()`, die jede Zeile in einen `Vec` kopiert, mit `malloc` und `realloc` darunter. Zusammen mit `format!`, `to_string` und `free` sind Allokationen jetzt der größte Posten in `auswerten`. Ab hier lohnt es sich, sie zu zählen. Der Zähler aus dem Lese-Modul kommt als Beispiel ins Projekt, `examples/allokationen.rs`: + + ```rust + //! Zählt Heap-Allokationen nur während `auswerten` (nicht beim Erzeugen, nicht beim Drucken). + use logauswertung::{auswerten, erzeuge_log}; + use std::alloc::{GlobalAlloc, Layout, System}; + use std::sync::atomic::{AtomicUsize, Ordering::Relaxed}; + + struct Zaehler; + static ALLOKATIONEN: AtomicUsize = AtomicUsize::new(0); + + unsafe impl GlobalAlloc for Zaehler { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + ALLOKATIONEN.fetch_add(1, Relaxed); + unsafe { System.alloc(layout) } + } + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } + } + + #[global_allocator] + static GLOBAL: Zaehler = Zaehler; + + fn main() { + let zeilen: usize = std::env::args().nth(1).map_or(200_000, |a| a.parse().unwrap()); + let log = erzeuge_log(zeilen, 42); + let vorher = ALLOKATIONEN.load(Relaxed); + let bericht = auswerten(&log); + let nachher = ALLOKATIONEN.load(Relaxed); + std::hint::black_box(&bericht); + println!("{} Allokationen in auswerten ({zeilen} Zeilen)", nachher - vorher); + } + ``` + + ```console + $ cargo run --profile profiling --example allokationen + 2400254 Allokationen in auswerten (200000 Zeilen) + ``` + + Zwölf Allokationen pro Zeile. Die nächsten drei Runden folgen demselben Muster, je eine Änderung: + + 1. **Teile als `&str`.** `zeile.to_string()` fällt weg, `teile` wird ein `Vec<&str>`, der auf die Zeile zeigt. + 2. **Geliehene Schlüssel und `entry`.** Die Maps bekommen `(&str, &str)` und `&str` als Schlüssel, `entry(…).or_default()` ersetzt `contains_key` plus `insert`. `format!` läuft nur noch einmal pro Pfad beim Erstellen des Berichts. + 3. **Kein `Vec` pro Zeile.** Die sechs Felder kommen mit `let (Some(_), …, None) = (teile.next(), …) else { continue };` direkt aus dem Iterator. + + ## Schritt 6: das Messprotokoll + + Jede Zeile stammt aus einem hyperfine-Vergleich von vorheriger und neuer Fassung im selben Lauf (`--warmup 3`), die Allokationen aus `examples/allokationen.rs`: + + Die Prozentangaben vergleichen mit der vorherigen Fassung im selben hyperfine-Lauf; deshalb weichen sie leicht von dem ab, was du aus den gerundeten Zeilen nachrechnest. + + | Runde | Änderung | Wall Time | Allokationen | + | --- | --- | --- | --- | + | 0 | Ausgangsfassung | 3,142 s ± 0,009 s | 2 582 829 | + | 1 | p95 nach der Schleife | 103 ms ± 2 ms (−96,7 %) | 2 400 254 | + | 2 | Teile als `&str` | 89,3 ms ± 2,2 ms (−13,4 %) | 800 254 | + | 3 | `entry`, geliehene Schlüssel | 70,6 ms ± 2,5 ms (−21,1 %) | 400 271 | + | 4 | kein `Vec` pro Zeile | 63,5 ms ± 2,3 ms (−10,3 %) | 271 | + + Die erste Runde hat 97 % der Zeit gespart, die drei weiteren zusammen knapp 40 % des Rests. Nach Runde 4 steckt in der Aufnahme mehr als die Hälfte der Samples in `erzeuge_log`. Die Auswertung selbst ist kein großer Posten mehr. Hier hörst du auf, es sei denn, eine Anforderung verlangt mehr. Ein Messprotokoll sollte enthalten: + + - **Messaufbau:** Rechner (allgemein), Build-Profil, Eingabe, Werkzeuge mit Version und Optionen. + - **Baseline** mit Streuung, bevor etwas geändert wurde. + - **Pro Runde:** Hypothese aus dem Profil, genau eine Änderung, Nachweis, dass das Ergebnis gleich bleibt, Messung gegen die vorherige Fassung. + - **Entscheidung:** behalten oder verwerfen, und wann du aufhörst. + + ## Häufige Fehler + + - **Debug-Build profilieren.** 349 statt 63 ms bei der fertigen Fassung, und das Profil zeigt Kosten, die es im Release-Build nicht gibt, weil nichts eingebaut wird. + - **Ohne Debug-Infos profilieren.** Eingebaute Funktionen verschwinden, ihre Zeit erscheint beim Aufrufer. + - **`cargo bench -- --save-baseline …`** ohne `--bench`: `Unrecognized option`, weil das eingebaute Test-Gerüst die Option nicht kennt. + - **Das alte Binary nicht aufheben.** Nach dem nächsten Build gibt es nichts mehr, wogegen du vergleichen kannst. + - **Zwei Änderungen in einer Runde.** Dann bleibt offen, welche wie viel gebracht hat. + - **Benchmark mit zu kleiner Eingabe.** Mit 50 000 Zeilen zeigte die erste Änderung Faktor 12, im Programm mit 200 000 Zeilen Faktor 30. + - **Das Testgerüst optimieren.** `erzeuge_log` dominiert am Ende das Profil, gehört aber nicht zur Aufgabe. + - **Zahlen von verschiedenen Tagen vergleichen.** Last, Takt und Temperatur ändern sich. Vorher und nachher gehören in denselben hyperfine-Lauf. + + > [!PRAXIS] Baselines im Team + > In Projekten mit Performance-Anforderungen läuft ein criterion-Vergleich gegen die Baseline des Hauptzweigs vor jedem Release, immer auf derselben Maschine. Auf geteilten CI-Runnern teilen sich mehrere Jobs dieselbe Hardware, dort schwanken Messungen stärker als auf einer festen Maschine. Jede Optimierung bekommt einen eigenen Commit mit dem Messprotokoll in der Beschreibung. So lässt sich eine einzelne Änderung zurücknehmen, wenn sie später Ärger macht. + + > [!INTERVIEW] „Erzähl von einer Optimierung, die du gemacht hast.“ + > Antworte mit dem Protokoll statt mit dem Trick: „Das Programm brauchte 3,1 Sekunden. Ich habe zuerst eine Baseline mit hyperfine genommen und dann mit samply profiliert. 97 % der Samples lagen im Sortieren, weil nach jeder Zeile neu sortiert wurde. Ich habe nur das geändert, ein Test hat bestätigt, dass der Bericht gleich bleibt, und die Laufzeit fiel auf 103 ms. Danach habe ich in drei weiteren Runden die Allokationen von zwölf pro Zeile auf fast null gesenkt und bei 63 ms aufgehört, weil der Rest im Testgerüst lag.“ Interviewer hören dabei auf drei Dinge: Messung vor Änderung, eine Änderung pro Runde, ein begründeter Schluss. + + ## Zusammenfassung + + - Gemessen und profiliert wird ein Release-Build mit Debug-Infos, am einfachsten über ein eigenes Profil `profiling` mit `inherits = "release"` und `debug = true`. + - Zuerst die Baseline: hyperfine für das ganze Programm, das alte Binary aufheben, criterion mit `--bench name -- --save-baseline vorher` für die heiße Funktion. + - samply braucht unter Linux `perf_event_paranoid` ≤ 1. Call Tree mit „Invert call stack“ zeigt Selbstzeit, Flame Graph die Struktur, Stack Chart den zeitlichen Verlauf. + - Das Profil entscheidet die Reihenfolge. Hier lagen 97 % im Sortieren, die gut sichtbaren Allokationen bei 1,4 %. + - Eine Runde ist: eine Änderung, Ergebnis per Test prüfen, gegen dieselbe Baseline messen, festhalten. + - Allokationen zählt ein eigener `GlobalAlloc` stabil. `&str` statt `String`, `entry` und Iteratoren statt Zwischen-`Vec` senkten sie von 2,4 Millionen auf 271. + - Aufhören, wenn das Profil keinen lohnenden Posten im eigenen Code mehr zeigt. +exercises: + - id: profil-wahl + type: choice + title: Welches Binary kommt unter den Profiler? + prompt: | + Du willst `logauswertung` mit samply aufnehmen und danach im Call Tree nachsehen, welche Zeile im Quelltext teuer ist. Welches Binary nimmst du? + options: + - text: "`target/profiling/logauswertung`, gebaut mit `inherits = \"release\"` und `debug = true`." + correct: true + why: Optimiert wie das ausgelieferte Programm, und die Debug-Infos ordnen Samples Funktionen, eingebauten Funktionen und Quellzeilen zu. + - text: "`target/debug/logauswertung` aus `cargo build`." + correct: false + why: Im Debug-Build war die fertige Fassung 5,6-mal langsamer, und ohne Optimierung verteilt sich die Zeit anders. Du würdest Stellen optimieren, die im Release-Build kein Problem sind. + - text: "`target/release/logauswertung` ohne Debug-Infos." + correct: false + why: Die Zeiten stimmen, aber eingebaute Funktionen verschwinden im Stack, und die Quelltext-Ansicht findet keine Zeilen. + - text: "Das Binary, das `cargo run --release` gerade baut, aufgenommen mit `samply record cargo run --release`." + correct: false + why: Dann nimmt samply auch Cargo selbst auf, samt Prüfung, ob neu gebaut werden muss. Nimm das fertige Binary direkt auf. + explanation: | + Ein Profil soll zeigen, wo das ausgelieferte Programm Zeit verbringt. Das verlangt die Optimierungen von `release`. Damit der Profiler die Samples Funktionen und Zeilen zuordnen kann, braucht er Debug-Infos. Das eigene Profil `profiling` liefert beides, ohne `release` selbst zu verändern. Das samply-README empfiehlt genau diesen Aufbau. + resources: + - title: samply · Turn on debug info for full stacks + url: https://github.com/mstange/samply#turn-on-debug-info-for-full-stacks + - title: Cargo Book · debug + url: https://doc.rust-lang.org/cargo/reference/profiles.html#debug + + - id: entry-allokationen + type: output + title: Drei Wege zur selben Zählung + prompt: | + Der Zähler zählt jeden Aufruf von `alloc`. Die drei Maps haben vorab Platz für acht Einträge, wachsen also nicht. Was gibt das Programm aus? + lang: rust + code: | + use std::alloc::{GlobalAlloc, Layout, System}; + use std::collections::HashMap; + use std::sync::atomic::{AtomicUsize, Ordering::Relaxed}; + + struct Zaehler; + static N: AtomicUsize = AtomicUsize::new(0); + + unsafe impl GlobalAlloc for Zaehler { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + N.fetch_add(1, Relaxed); + unsafe { System.alloc(layout) } + } + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } + } + + #[global_allocator] + static A: Zaehler = Zaehler; + + fn main() { + let methoden = ["GET", "POST", "GET", "GET", "DELETE", "GET"]; + + // A: entry mit eigenem String-Schlüssel + let mut a: HashMap = HashMap::with_capacity(8); + let t = N.load(Relaxed); + for m in methoden { + *a.entry(m.to_string()).or_insert(0) += 1; + } + let na = N.load(Relaxed) - t; + + // B: erst nachsehen, nur neue Schlüssel kopieren + let mut b: HashMap = HashMap::with_capacity(8); + let t = N.load(Relaxed); + for m in methoden { + if let Some(n) = b.get_mut(m) { + *n += 1; + } else { + b.insert(m.to_string(), 1); + } + } + let nb = N.load(Relaxed) - t; + + // C: geliehene Schlüssel + let mut c: HashMap<&str, u32> = HashMap::with_capacity(8); + let t = N.load(Relaxed); + for m in methoden { + *c.entry(m).or_insert(0) += 1; + } + let nc = N.load(Relaxed) - t; + + println!("{na} {nb} {nc}"); + println!("{} {} {}", a["GET"], b["GET"], c["GET"]); + } + expected: | + 6 3 0 + 4 4 4 + hints: + - "`entry` nimmt den Schlüssel als Wert. Bei `HashMap` muss er also vorher ein `String` sein." + - "`get_mut` sucht mit einem `&str`, ohne etwas anzulegen." + explanation: | + Variante A ruft für jede der sechs Methoden `m.to_string()` auf, bevor `entry` überhaupt nachsieht. Das sind sechs Allokationen, auch für die vier Treffer, deren String danach sofort wieder freigegeben wird. Variante B sucht zuerst mit `get_mut(m)`; das geht mit `&str`, weil `String` sich als `str` ausleihen lässt. Nur die drei neuen Schlüssel GET, POST und DELETE werden kopiert. Variante C leiht die Schlüssel aus dem Array und alloziert nie. In allen drei Fällen zählt GET viermal. + resources: + - title: std · HashMap::entry + url: https://doc.rust-lang.org/std/collections/hash_map/struct.HashMap.html#method.entry + - title: std · HashMap::get_mut + url: https://doc.rust-lang.org/std/collections/hash_map/struct.HashMap.html#method.get_mut + + - id: profil-bauen + type: command + title: Das Messprofil bauen + prompt: | + In der `Cargo.toml` steht das Profil `[profile.profiling]`. Baue das Projekt mit genau diesem Profil. + context: Du bist im Verzeichnis `logauswertung`. + answers: + - cargo build --profile profiling + - cargo build --profile=profiling + - cargo b --profile profiling + - cargo b --profile=profiling + output: | + Finished `profiling` profile [optimized + debuginfo] target(s) in 0.57s + explanation: | + `--profile ` wählt ein beliebiges Profil, auch ein eigenes ([cargo build](https://doc.rust-lang.org/cargo/commands/cargo-build.html#compilation-options)). `--release` ist die Kurzform für `--profile release`. Das Ergebnis liegt unter `target/profiling/`. Die Meldung „optimized + debuginfo“ bestätigt, dass `inherits = "release"` und `debug = true` gegriffen haben. + resources: + - title: Cargo Book · cargo build, Compilation Options + url: https://doc.rust-lang.org/cargo/commands/cargo-build.html#compilation-options + + - id: hyperfine-vergleich + type: command + title: Vorher gegen nachher + prompt: | + Du hast die alte Fassung nach `./logauswertung-vorher` kopiert und die neue gebaut. Vergleiche beide mit hyperfine, mit **drei Aufwärmläufen**, die alte Fassung als Referenz. + context: Du bist im Projektverzeichnis. Die neue Fassung liegt unter `target/profiling/logauswertung`. + answers: + - hyperfine --warmup 3 ./logauswertung-vorher target/profiling/logauswertung + - hyperfine --warmup 3 ./logauswertung-vorher ./target/profiling/logauswertung + - hyperfine -w 3 ./logauswertung-vorher target/profiling/logauswertung + - hyperfine -w 3 ./logauswertung-vorher ./target/profiling/logauswertung + - hyperfine --warmup=3 ./logauswertung-vorher target/profiling/logauswertung + - hyperfine --warmup=3 ./logauswertung-vorher ./target/profiling/logauswertung + - hyperfine ./logauswertung-vorher target/profiling/logauswertung --warmup 3 + - hyperfine ./logauswertung-vorher target/profiling/logauswertung -w 3 + output: | + Benchmark 1: ./logauswertung-vorher (28 runs) + mean ± σ min … max + Wall Time 103.1 ms ± 2.2 ms 100.2 ms … 108.0 ms + Memory 15.8 MiB ± 0.1 MiB 15.5 MiB … 15.9 MiB + + Benchmark 2: target/profiling/logauswertung (33 runs) + mean ± σ min … max + Wall Time 89.3 ms ± 2.2 ms 86.9 ms … 96.7 ms -13.4% + Memory 15.9 MiB ± 0.2 MiB 15.6 MiB … 16.1 MiB +0.7% + hints: + - Die Option für Aufwärmläufe heißt `--warmup` oder kurz `-w`. + - Das erste Kommando ist die Referenz, mit der alle weiteren verglichen werden. + explanation: | + `--warmup 3` startet jedes Kommando vor der Messung dreimal, damit Binary und Daten im Cache liegen. hyperfine vergleicht jedes weitere Kommando mit dem ersten; die Prozentzahl in der Zeile von Benchmark 2 bezieht sich also auf `logauswertung-vorher`. Beide Kommandos laufen im selben Aufruf und damit unter möglichst gleichen Bedingungen. Seit hyperfine 2.0 werden sie direkt gestartet, ohne Shell. + resources: + - title: hyperfine · Warmup runs and preparation commands + url: https://github.com/sharkdp/hyperfine#warmup-runs-and-preparation-commands + + - id: samply-aufnahme + type: command + title: Ein Profil aufnehmen + prompt: | + Nimm das Binary `target/profiling/logauswertung` mit samply auf, ohne Argumente (es nutzt dann 200 000 Zeilen). + context: Du bist im Projektverzeichnis, `perf_event_paranoid` steht auf 1. + answers: + - samply record ./target/profiling/logauswertung + - samply record target/profiling/logauswertung + - samply record -- ./target/profiling/logauswertung + - samply record -- target/profiling/logauswertung + explanation: | + `samply record` startet das Kommando als Kindprozess, nimmt standardmäßig mit 1000 Hz auf und öffnet danach den Firefox Profiler mit einem lokalen Server, der Symbole und Quelltext liefert ([samply](https://github.com/mstange/samply#description)). Mit `--save-only -o datei.json.gz` speichert samply nur, `samply load datei.json.gz` öffnet die Aufnahme später. Steht `perf_event_paranoid` auf 2, bricht samply mit einer Meldung ab, die den nötigen Befehl nennt. + resources: + - title: samply · README + url: https://github.com/mstange/samply + + - id: baseline-speichern + type: command + title: Die Baseline sichern + prompt: | + Lass nur den criterion-Benchmark `benches/auswerten.rs` laufen und speichere das Ergebnis als Baseline mit dem Namen `vorher`. + context: In der `Cargo.toml` steht `[[bench]] name = "auswerten"` mit `harness = false`. + answers: + - cargo bench --bench auswerten -- --save-baseline vorher + - cargo bench --bench auswerten -- --save-baseline=vorher + - cargo bench --bench=auswerten -- --save-baseline vorher + - cargo bench --bench=auswerten -- --save-baseline=vorher + hints: + - Optionen für criterion stehen hinter `--`. + - Ohne `--bench auswerten` startet Cargo zuerst das eingebaute Test-Gerüst, das die Option nicht kennt. + explanation: | + Alles vor `--` gehört Cargo, alles dahinter dem Benchmark-Programm. `--bench auswerten` startet nur diesen Benchmark; ohne die Angabe startet Cargo auch das Test-Gerüst von `src/lib.rs` und bricht mit `Unrecognized option: 'save-baseline'` ab. `--save-baseline vorher` legt die Messung unter `target/criterion/…/vorher/` ab. Sie bleibt dort, bis du sie mit demselben Namen überschreibst. + resources: + - title: criterion.rs · Command-Line Options + url: https://bheisler.github.io/criterion.rs/book/user_guide/command_line_options.html + - title: criterion.rs · FAQ + url: https://bheisler.github.io/criterion.rs/book/faq.html + + - id: baseline-vergleichen + type: command + title: Gegen die Baseline messen + prompt: | + Du hast eine Änderung gemacht. Miss den Benchmark `auswerten` erneut und vergleiche mit der gespeicherten Baseline `vorher`, ohne sie zu überschreiben. + answers: + - cargo bench --bench auswerten -- --baseline vorher + - cargo bench --bench auswerten -- --baseline=vorher + - cargo bench --bench=auswerten -- --baseline vorher + - cargo bench --bench=auswerten -- --baseline=vorher + output: | + logauswertung/auswerten_50k + time: [12.653 ms 12.674 ms 12.697 ms] + change: [−25.113% −24.883% −24.603%] (p = 0.00 < 0.05) + Performance has improved. + Found 1 outliers among 10 measurements (10.00%) + 1 (10.00%) high mild + explanation: | + `--baseline vorher` vergleicht mit der benannten Baseline statt mit dem letzten Lauf. So kannst du mehrere Versuche gegen denselben Ausgangspunkt messen. `--save-baseline` würde sie dagegen überschreiben. Die Ausgabe oben stammt aus Runde 2 gegen Runde 1 (50 000 Zeilen): Die Baseline `vorher` war nach Runde 1 neu gespeichert worden, damit jede Runde gegen die direkt vorherige misst. Ergebnis −24,9 %, das ganze Intervall weit jenseits der Rauschschwelle. + resources: + - title: criterion.rs · Command-Line Options + url: https://bheisler.github.io/criterion.rs/book/user_guide/command_line_options.html + + - id: wachstum-messen + type: practice + title: Wie wächst die Laufzeit? + prompt: | + In Schritt 2 hast du aus zwei Zahlen geschlossen, dass etwas schneller wächst als die Eingabe. Mach daraus eine Messung: Schreibe einen zweiten criterion-Benchmark `benches/wachstum.rs`, der `auswerten` für 10 000, 20 000 und 50 000 Zeilen misst und den Durchsatz in Zeilen pro Sekunde anzeigt. + + 1. Trag `[[bench]] name = "wachstum"` mit `harness = false` in die `Cargo.toml` ein. + 2. Nutze eine `BenchmarkGroup`, `Throughput::Elements` und `bench_with_input` mit `BenchmarkId::from_parameter`. + 3. Erzeuge die Logs außerhalb der Messung. + 4. Lass ihn für die Ausgangsfassung und nach der ersten Änderung laufen (`cargo bench --bench wachstum`) und notiere die `thrpt`-Zeilen. + 5. Erkläre in zwei, drei Sätzen, was der Durchsatz über die Komplexität verrät. + lang: rust + starter: | + use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; + use logauswertung::{auswerten, erzeuge_log}; + use std::hint::black_box; + + fn wachstum(c: &mut Criterion) { + let mut gruppe = c.benchmark_group("wachstum"); + // … + gruppe.finish(); + } + + criterion_group!(benches, wachstum); + criterion_main!(benches); + solution: | + use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; + use logauswertung::{auswerten, erzeuge_log}; + use std::hint::black_box; + + fn wachstum(c: &mut Criterion) { + let mut gruppe = c.benchmark_group("wachstum"); + gruppe.sample_size(10); + for zeilen in [10_000, 20_000, 50_000] { + let log = erzeuge_log(zeilen, 42); + gruppe.throughput(Throughput::Elements(zeilen as u64)); + gruppe.bench_with_input(BenchmarkId::from_parameter(zeilen), &log, |b, log| { + b.iter(|| auswerten(black_box(log))) + }); + } + gruppe.finish(); + } + + criterion_group!(benches, wachstum); + criterion_main!(benches); + verify: false + checklist: + - Die Logs entstehen vor `bench_with_input`, nicht in der gemessenen Closure. + - "`gruppe.throughput(Throughput::Elements(zeilen as u64))` steht vor jedem `bench_with_input`, damit criterion Zeilen pro Sekunde ausgibt." + - Die Eingabe läuft durch `black_box`. + - Du hast beide Fassungen gemessen und die drei `thrpt`-Werte je Fassung notiert. + - Deine Erklärung sagt, dass sinkender Durchsatz bei wachsender Eingabe auf mehr als lineare Arbeit hindeutet und gleichbleibender Durchsatz auf lineare. + explanation: | + Auf dem Testrechner fiel der Durchsatz der Ausgangsfassung von 893,6 über 520,8 auf 237,6 Kelem/s, also Tausend Zeilen pro Sekunde. Je mehr Zeilen, desto teurer wird jede einzelne: Das passt zum Sortieren nach jeder Zeile, dessen Arbeit mit der Länge der Liste wächst. Nach der ersten Änderung blieb er bei 2,98, 2,88 und 2,91 Melem/s, die Arbeit pro Zeile ist also konstant. `Throughput::Elements` rechnet die Zeit pro Iteration in Elemente pro Sekunde um; `BenchmarkId::from_parameter` benennt die Messungen nach der Zeilenzahl. + resources: + - title: criterion · BenchmarkGroup::bench_with_input + url: https://docs.rs/criterion/latest/criterion/struct.BenchmarkGroup.html#method.bench_with_input + - title: criterion · Throughput + url: https://docs.rs/criterion/latest/criterion/enum.Throughput.html + + - id: profil-erbt-falsch + type: bug + title: Fünfmal langsamer unter dem Profiler + prompt: | + Ein Kollege hat das Messprofil angelegt. Seitdem misst hyperfine für `target/profiling/logauswertung` 350,9 ms ± 6,3 ms. Dasselbe Programm aus `cargo build --release` braucht 62,8 ms ± 2,4 ms. Finde die fehlerhafte Zeile und korrigiere sie. + lang: toml + code: | + [profile.profiling] + inherits = "dev" + debug = true + lines: [2] + fix: + - line: 2 + answers: + - inherits = "release" + - inherits = 'release' + explanation: | + Ein eigenes Profil braucht `inherits` und übernimmt alle Einstellungen des genannten Profils ([Custom profiles](https://doc.rust-lang.org/cargo/reference/profiles.html#custom-profiles)). Mit `inherits = "dev"` entsteht ein Debug-Build mit `opt-level = 0`, Überlaufprüfungen und `debug-assertions`. Auf dem Testrechner war das Binary damit 5,6-mal langsamer als mit `inherits = "release"` (62,6 ms ± 2,1 ms). Ein Profil davon zeigt Kosten von Funktionen, die der Release-Build einbaut oder ganz wegoptimiert. `debug = true` allein macht keinen Debug-Build; es legt nur Debug-Infos dazu. + resources: + - title: Cargo Book · Custom profiles + url: https://doc.rust-lang.org/cargo/reference/profiles.html#custom-profiles + - title: Cargo Book · Default profiles + url: https://doc.rust-lang.org/cargo/reference/profiles.html#default-profiles + + - id: profil-deuten + type: explain + title: Was dieses Profil verlangt + prompt: | + Das ist die Aufnahme der Ausgangsfassung aus Schritt 3 (3151 Samples, vereinfacht) und die Schleife aus `auswerten`, gekürzt. Dazu die Messungen: 50 000 Zeilen brauchen im Benchmark 210,9 ms, 200 000 Zeilen im ganzen Programm 3,142 s. Erkläre, wo die Zeit hingeht, warum sie stärker als die Zeilenzahl wächst und was du als Erstes änderst. Begründe auch, warum die Allokationen warten. + lang: text + code: | + main;auswerten;sort 3057 # 97,0 % + main;erzeuge_log 32 # 1,0 % + main;auswerten;collect 30 # 1,0 % + main;auswerten;hashmap 15 # 0,5 % + main;auswerten;format! 8 # 0,3 % + main;auswerten;… 9 # 0,3 % + + for zeile in log.lines() { + let zeile = zeile.to_string(); + let teile: Vec = zeile.split(' ').map(|t| t.to_string()).collect(); + // … + let pfad = pfade.get_mut(&schluessel).unwrap(); + // … + pfad.latenzen.push(ms); + pfad.latenzen.sort(); + let rang = (pfad.latenzen.len() * 95).div_ceil(100); + pfad.p95 = pfad.latenzen[rang - 1]; + // … + } + points: + - 97 % der Samples liegen im Sortieren, aufgerufen aus `auswerten`. Die einzige Stelle ist `pfad.latenzen.sort()` in der Schleife. + - Nach jeder Zeile wird die ganze bisherige Liste des Pfads sortiert. Die Arbeit pro Zeile wächst mit der Länge der Liste, insgesamt also quadratisch mit den Zeilen pro Pfad. Viermal so viele Zeilen kosten deshalb rund fünfzehnmal so viel Zeit. + - Erste Änderung, und nur diese, p95 erst nach der Schleife berechnen und jeden Pfad einmal sortieren. Danach neu messen und prüfen, dass der Bericht gleich bleibt. + - Die Allokationen (`collect` mit den `to_string`-Kopien, `format!`, Freigaben) liegen zusammen bei rund 1,4 % der Samples. Nach Amdahl spart ihr Entfernen jetzt höchstens so viel. Sie kommen dran, wenn das nächste Profil sie als großen Posten zeigt. + explanation: | + Das Profil ist eindeutig: 3057 von 3151 Samples enden im Sortieren unter `auswerten`. Im Code gibt es genau eine Sortierstelle, und die steht in der Schleife über alle Zeilen. Sie sortiert nach jeder Zeile die wachsende Latenzliste des Pfads neu, damit `p95` immer aktuell ist. Das macht die Arbeit quadratisch in der Zahl der Zeilen pro Pfad und erklärt, warum viermal so viele Zeilen fünfzehnmal so lange brauchen. Die erste Änderung verschiebt deshalb die p95-Berechnung hinter die Schleife. Die Allokationen sind im Code auffälliger, im Profil aber klein. Wer mit ihnen anfinge, würde nach Amdahl höchstens 1,4 % gewinnen und die 97 % übersehen. Auf dem Testrechner brachte die Sortier-Änderung Faktor 30,4. + resources: + - title: std · slice::sort + url: https://doc.rust-lang.org/std/primitive.slice.html#method.sort + - title: Firefox Profiler · Invert call stack + url: https://github.com/firefox-devtools/profiler/blob/main/docs-user/guide-filtering-call-trees.md + + - id: p95-aus-der-schleife + type: practice + title: Die heiße Funktion umbauen + prompt: | + Schreibe `auswerten` so um, dass p95 erst nach der Schleife berechnet wird. Der Bericht muss exakt gleich bleiben. Die Ausgangsfassung bleibt als `auswerten_langsam` im Code, damit ein Test beide auf denselben Daten vergleicht. + + 1. Ändere nur die p95-Berechnung, sonst nichts (eine Änderung pro Runde). + 2. Ergänze die Tests: gleicher Bericht für mehrere Größen und Seeds, darunter ein leeres Log, und ein Fall mit bekanntem p95 und kaputten Zeilen. + 3. Übertrag die neue Funktion in dein Projekt und miss mit hyperfine gegen `logauswertung-vorher` und mit criterion gegen die Baseline `vorher`. + 4. Halte im Messprotokoll fest: Befund aus dem Profil, Änderung, beide Messungen, Testergebnis. + lang: rust + starter: | + use std::collections::HashMap; + use std::fmt::Write; + + /// Erzeugt `zeilen` synthetische Access-Log-Zeilen. Gleicher `seed`, gleiche Zeilen. + /// Format: `2026-10-24T08:15:02Z 10.0.3.17 GET /api/orders 200 87ms` + pub fn erzeuge_log(zeilen: usize, seed: u64) -> String { + // (Pfad, Anteil in Prozent, POST möglich, typische Latenz in ms) + const PFADE: [(&str, u64, bool, u64); 12] = [ + ("/health", 22, false, 2), + ("/static/app.js", 14, false, 6), + ("/static/app.css", 12, false, 5), + ("/api/orders", 10, true, 40), + ("/api/orders/:id", 9, false, 25), + ("/api/search", 8, false, 90), + ("/api/users/:id", 7, false, 20), + ("/api/cart", 6, true, 35), + ("/login", 4, true, 60), + ("/api/users", 3, false, 45), + ("/api/checkout", 3, true, 120), + ("/logout", 2, true, 15), + ]; + let mut x = seed.max(1); + let mut log = String::with_capacity(zeilen * 64); + for i in 0..zeilen { + // xorshift64: schnell, deterministisch, kein Crate nötig + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + let mut wahl = x % 100; + let mut eintrag = PFADE[0]; + for p in PFADE { + if wahl < p.1 { + eintrag = p; + break; + } + wahl -= p.1; + } + let (pfad, _, post_moeglich, basis) = eintrag; + let methode = if post_moeglich && (x >> 4) % 3 == 0 { "POST" } else { "GET" }; + let status = match (x >> 8) % 100 { + 0..=88 => 200, + 89..=93 => 404, + 94..=97 => 500, + _ => 503, + }; + // jede 12. Anfrage hängt bis zu 1,5 s länger (langer Schwanz für p95) + let extra = if (x >> 24) % 12 == 0 { (x >> 32) % 1500 } else { 0 }; + let ms = basis + (x >> 16) % (2 * basis) + extra; + let _ = writeln!( + log, + "2026-10-24T{:02}:{:02}:{:02}Z 10.0.{}.{} {methode} {pfad} {status} {ms}ms", + (i / 3600) % 24, + (i / 60) % 60, + i % 60, + (x >> 40) % 256, + (x >> 48) % 256, + ); + } + log + } + + #[derive(Debug, Clone, PartialEq, Default)] + pub struct Pfad { + pub anfragen: u32, + pub fehler: u32, + pub latenzen: Vec, + pub p95: u32, + } + + #[derive(Debug, PartialEq)] + pub struct Bericht { + /// ("GET /api/orders", Statistik), meiste Anfragen zuerst + pub pfade: Vec<(String, Pfad)>, + /// ("200", Anzahl), aufsteigend nach Code + pub status: Vec<(String, u32)>, + } + + /// Die Ausgangsfassung aus der Lektion, unverändert. + fn auswerten_langsam(log: &str) -> Bericht { + let mut pfade: HashMap = HashMap::new(); + let mut status: HashMap = HashMap::new(); + + for zeile in log.lines() { + let zeile = zeile.to_string(); + let teile: Vec = zeile.split(' ').map(|t| t.to_string()).collect(); + if teile.len() != 6 { + continue; + } + let schluessel = format!("{} {}", teile[2], teile[3]); + let code = teile[4].clone(); + let ms: u32 = teile[5].trim_end_matches("ms").parse().unwrap_or(0); + + if !pfade.contains_key(&schluessel) { + pfade.insert(schluessel.clone(), Pfad::default()); + } + let pfad = pfade.get_mut(&schluessel).unwrap(); + pfad.anfragen += 1; + if code.starts_with('5') { + pfad.fehler += 1; + } + pfad.latenzen.push(ms); + pfad.latenzen.sort(); + let rang = (pfad.latenzen.len() * 95).div_ceil(100); + pfad.p95 = pfad.latenzen[rang - 1]; + + if status.contains_key(&code) { + *status.get_mut(&code).unwrap() += 1; + } else { + status.insert(code.clone(), 1); + } + } + + let mut pfade: Vec<(String, Pfad)> = pfade.into_iter().collect(); + pfade.sort_by(|a, b| b.1.anfragen.cmp(&a.1.anfragen).then(a.0.cmp(&b.0))); + let mut status: Vec<(String, u32)> = status.into_iter().collect(); + status.sort(); + Bericht { pfade, status } + } + + /// p95 erst nach der Schleife berechnen; der Bericht muss gleich bleiben. + fn auswerten(log: &str) -> Bericht { + todo!() + } + + fn main() { + let log = erzeuge_log(200_000, 42); + let bericht = auswerten(&log); + println!("{} Pfade, Status: {:?}", bericht.pfade.len(), bericht.status); + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn gleicher_bericht_wie_vorher() { + let log = erzeuge_log(5_000, 42); + assert_eq!(auswerten(&log), auswerten_langsam(&log)); + // weitere Größen und Seeds, auch ein leeres Log + } + + #[test] + fn p95_und_kaputte_zeilen() { + // ein Log, dessen p95 du von Hand nachrechnen kannst, plus kaputte Zeilen + todo!() + } + } + solution: | + use std::collections::HashMap; + use std::fmt::Write; + + /// Erzeugt `zeilen` synthetische Access-Log-Zeilen. Gleicher `seed`, gleiche Zeilen. + /// Format: `2026-10-24T08:15:02Z 10.0.3.17 GET /api/orders 200 87ms` + pub fn erzeuge_log(zeilen: usize, seed: u64) -> String { + // (Pfad, Anteil in Prozent, POST möglich, typische Latenz in ms) + const PFADE: [(&str, u64, bool, u64); 12] = [ + ("/health", 22, false, 2), + ("/static/app.js", 14, false, 6), + ("/static/app.css", 12, false, 5), + ("/api/orders", 10, true, 40), + ("/api/orders/:id", 9, false, 25), + ("/api/search", 8, false, 90), + ("/api/users/:id", 7, false, 20), + ("/api/cart", 6, true, 35), + ("/login", 4, true, 60), + ("/api/users", 3, false, 45), + ("/api/checkout", 3, true, 120), + ("/logout", 2, true, 15), + ]; + let mut x = seed.max(1); + let mut log = String::with_capacity(zeilen * 64); + for i in 0..zeilen { + // xorshift64: schnell, deterministisch, kein Crate nötig + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + let mut wahl = x % 100; + let mut eintrag = PFADE[0]; + for p in PFADE { + if wahl < p.1 { + eintrag = p; + break; + } + wahl -= p.1; + } + let (pfad, _, post_moeglich, basis) = eintrag; + let methode = if post_moeglich && (x >> 4) % 3 == 0 { "POST" } else { "GET" }; + let status = match (x >> 8) % 100 { + 0..=88 => 200, + 89..=93 => 404, + 94..=97 => 500, + _ => 503, + }; + // jede 12. Anfrage hängt bis zu 1,5 s länger (langer Schwanz für p95) + let extra = if (x >> 24) % 12 == 0 { (x >> 32) % 1500 } else { 0 }; + let ms = basis + (x >> 16) % (2 * basis) + extra; + let _ = writeln!( + log, + "2026-10-24T{:02}:{:02}:{:02}Z 10.0.{}.{} {methode} {pfad} {status} {ms}ms", + (i / 3600) % 24, + (i / 60) % 60, + i % 60, + (x >> 40) % 256, + (x >> 48) % 256, + ); + } + log + } + + #[derive(Debug, Clone, PartialEq, Default)] + pub struct Pfad { + pub anfragen: u32, + pub fehler: u32, + pub latenzen: Vec, + pub p95: u32, + } + + #[derive(Debug, PartialEq)] + pub struct Bericht { + /// ("GET /api/orders", Statistik), meiste Anfragen zuerst + pub pfade: Vec<(String, Pfad)>, + /// ("200", Anzahl), aufsteigend nach Code + pub status: Vec<(String, u32)>, + } + + /// Die Ausgangsfassung aus der Lektion, unverändert. + fn auswerten_langsam(log: &str) -> Bericht { + let mut pfade: HashMap = HashMap::new(); + let mut status: HashMap = HashMap::new(); + + for zeile in log.lines() { + let zeile = zeile.to_string(); + let teile: Vec = zeile.split(' ').map(|t| t.to_string()).collect(); + if teile.len() != 6 { + continue; + } + let schluessel = format!("{} {}", teile[2], teile[3]); + let code = teile[4].clone(); + let ms: u32 = teile[5].trim_end_matches("ms").parse().unwrap_or(0); + + if !pfade.contains_key(&schluessel) { + pfade.insert(schluessel.clone(), Pfad::default()); + } + let pfad = pfade.get_mut(&schluessel).unwrap(); + pfad.anfragen += 1; + if code.starts_with('5') { + pfad.fehler += 1; + } + pfad.latenzen.push(ms); + pfad.latenzen.sort(); + let rang = (pfad.latenzen.len() * 95).div_ceil(100); + pfad.p95 = pfad.latenzen[rang - 1]; + + if status.contains_key(&code) { + *status.get_mut(&code).unwrap() += 1; + } else { + status.insert(code.clone(), 1); + } + } + + let mut pfade: Vec<(String, Pfad)> = pfade.into_iter().collect(); + pfade.sort_by(|a, b| b.1.anfragen.cmp(&a.1.anfragen).then(a.0.cmp(&b.0))); + let mut status: Vec<(String, u32)> = status.into_iter().collect(); + status.sort(); + Bericht { pfade, status } + } + + /// p95 erst nach der Schleife: einmal sortieren je Pfad statt nach jeder Zeile. + fn auswerten(log: &str) -> Bericht { + let mut pfade: HashMap = HashMap::new(); + let mut status: HashMap = HashMap::new(); + + for zeile in log.lines() { + let zeile = zeile.to_string(); + let teile: Vec = zeile.split(' ').map(|t| t.to_string()).collect(); + if teile.len() != 6 { + continue; + } + let schluessel = format!("{} {}", teile[2], teile[3]); + let code = teile[4].clone(); + let ms: u32 = teile[5].trim_end_matches("ms").parse().unwrap_or(0); + + if !pfade.contains_key(&schluessel) { + pfade.insert(schluessel.clone(), Pfad::default()); + } + let pfad = pfade.get_mut(&schluessel).unwrap(); + pfad.anfragen += 1; + if code.starts_with('5') { + pfad.fehler += 1; + } + pfad.latenzen.push(ms); + + if status.contains_key(&code) { + *status.get_mut(&code).unwrap() += 1; + } else { + status.insert(code.clone(), 1); + } + } + + for pfad in pfade.values_mut() { + pfad.latenzen.sort(); + let rang = (pfad.latenzen.len() * 95).div_ceil(100); + pfad.p95 = pfad.latenzen[rang - 1]; + } + + let mut pfade: Vec<(String, Pfad)> = pfade.into_iter().collect(); + pfade.sort_by(|a, b| b.1.anfragen.cmp(&a.1.anfragen).then(a.0.cmp(&b.0))); + let mut status: Vec<(String, u32)> = status.into_iter().collect(); + status.sort(); + Bericht { pfade, status } + } + + fn main() { + let log = erzeuge_log(200_000, 42); + let bericht = auswerten(&log); + println!("{} Pfade, Status: {:?}", bericht.pfade.len(), bericht.status); + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn gleicher_bericht_wie_vorher() { + for (zeilen, seed) in [(0, 1), (1, 7), (3_000, 42), (10_000, 3)] { + let log = erzeuge_log(zeilen, seed); + assert_eq!(auswerten(&log), auswerten_langsam(&log), "{zeilen} Zeilen, seed {seed}"); + } + } + + #[test] + fn p95_und_kaputte_zeilen() { + let mut log = String::from("kaputte Zeile\n\n"); + for ms in 1..=20 { + log.push_str(&format!("2026-10-24T00:00:00Z 10.0.0.1 GET /health 200 {ms}ms\n")); + } + let b = auswerten(&log); + assert_eq!(b.pfade.len(), 1); + assert_eq!(b.pfade[0].1.anfragen, 20); + assert_eq!(b.pfade[0].1.p95, 19); + assert_eq!(b, auswerten_langsam(&log)); + } + } + checklist: + - Nur die p95-Berechnung ist verschoben; Schlüssel, Allokationen und Sortierung des Berichts sind unverändert. + - Ein Test vergleicht `auswerten` und `auswerten_langsam` auf denselben erzeugten Logs, auch mit 0 Zeilen. + - Ein Test prüft p95 an einem Fall, den du von Hand nachrechnen kannst, und überspringt kaputte Zeilen. + - Du hast hyperfine (vorher gegen nachher im selben Lauf) und criterion (`--baseline vorher`) gemessen und beide Zahlen notiert. + - Du kannst erklären, warum der Faktor im Benchmark mit 50 000 Zeilen kleiner ausfällt als im Programm mit 200 000 Zeilen. + explanation: | + Die Musterlösung verschiebt drei Zeilen: Sortieren und p95 laufen einmal pro Pfad in einer eigenen Schleife über `pfade.values_mut()`. Das Ergebnis ist gleich, weil der p95-Wert nur vom Endstand der Liste abhängt; der Zwischenstand nach jeder Zeile wurde nie gebraucht. Die Tests sichern genau das ab, auch für die Randfälle leeres Log und kaputte Zeilen. Auf dem Testrechner fiel das Programm von 3,143 s auf 0,103 s (30,4-mal schneller), der Benchmark mit 50 000 Zeilen um 91,9 %. Der Unterschied kommt daher, dass die quadratische Arbeit mit der Zahl der Zeilen pro Pfad wächst. + resources: + - title: The Rust Book · Writing tests + url: https://doc.rust-lang.org/book/ch11-01-writing-tests.html + - title: std · slice::sort + url: https://doc.rust-lang.org/std/primitive.slice.html#method.sort + + - id: allokationen-halbieren + type: practice + title: Allokationen mindestens halbieren + prompt: | + Ausgangspunkt ist der Stand nach der ersten Änderung (`auswerten_vorher`, zwölf Allokationen pro Zeile). Schreibe `auswerten` so um, dass sie **höchstens halb so viele** Allokationen braucht und denselben Bericht liefert. + + 1. Zähle mit einem eigenen `GlobalAlloc`. Weil Tests parallel in eigenen Threads laufen, zählt der Zähler pro Thread (`thread_local!` mit `Cell`). + 2. Ein Test vergleicht die Berichte und prüft `nachher * 2 <= vorher`. + 3. Geh in Runden vor: Nimm dir die größten Allokationsquellen aus dem Profil zuerst vor und miss nach jeder Änderung. + 4. Notiere für jede Runde die Allokationen und die hyperfine-Zeit. + lang: rust + starter: | + use std::alloc::{GlobalAlloc, Layout, System}; + use std::cell::Cell; + use std::collections::HashMap; + use std::fmt::Write; + + // Zählt Allokationen pro Thread: Tests laufen parallel in eigenen Threads. + thread_local! { + static ALLOKATIONEN: Cell = const { Cell::new(0) }; + } + + struct Zaehler; + + unsafe impl GlobalAlloc for Zaehler { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + let _ = ALLOKATIONEN.try_with(|n| n.set(n.get() + 1)); + unsafe { System.alloc(layout) } + } + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } + } + + #[global_allocator] + static GLOBAL: Zaehler = Zaehler; + + /// Allokationen, die `f` im aktuellen Thread auslöst. + fn zaehle(f: impl FnOnce() -> T) -> (T, usize) { + let vorher = ALLOKATIONEN.with(Cell::get); + let ergebnis = f(); + let nachher = ALLOKATIONEN.with(Cell::get); + (ergebnis, nachher - vorher) + } + + /// Erzeugt `zeilen` synthetische Access-Log-Zeilen. Gleicher `seed`, gleiche Zeilen. + /// Format: `2026-10-24T08:15:02Z 10.0.3.17 GET /api/orders 200 87ms` + pub fn erzeuge_log(zeilen: usize, seed: u64) -> String { + // (Pfad, Anteil in Prozent, POST möglich, typische Latenz in ms) + const PFADE: [(&str, u64, bool, u64); 12] = [ + ("/health", 22, false, 2), + ("/static/app.js", 14, false, 6), + ("/static/app.css", 12, false, 5), + ("/api/orders", 10, true, 40), + ("/api/orders/:id", 9, false, 25), + ("/api/search", 8, false, 90), + ("/api/users/:id", 7, false, 20), + ("/api/cart", 6, true, 35), + ("/login", 4, true, 60), + ("/api/users", 3, false, 45), + ("/api/checkout", 3, true, 120), + ("/logout", 2, true, 15), + ]; + let mut x = seed.max(1); + let mut log = String::with_capacity(zeilen * 64); + for i in 0..zeilen { + // xorshift64: schnell, deterministisch, kein Crate nötig + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + let mut wahl = x % 100; + let mut eintrag = PFADE[0]; + for p in PFADE { + if wahl < p.1 { + eintrag = p; + break; + } + wahl -= p.1; + } + let (pfad, _, post_moeglich, basis) = eintrag; + let methode = if post_moeglich && (x >> 4) % 3 == 0 { "POST" } else { "GET" }; + let status = match (x >> 8) % 100 { + 0..=88 => 200, + 89..=93 => 404, + 94..=97 => 500, + _ => 503, + }; + // jede 12. Anfrage hängt bis zu 1,5 s länger (langer Schwanz für p95) + let extra = if (x >> 24) % 12 == 0 { (x >> 32) % 1500 } else { 0 }; + let ms = basis + (x >> 16) % (2 * basis) + extra; + let _ = writeln!( + log, + "2026-10-24T{:02}:{:02}:{:02}Z 10.0.{}.{} {methode} {pfad} {status} {ms}ms", + (i / 3600) % 24, + (i / 60) % 60, + i % 60, + (x >> 40) % 256, + (x >> 48) % 256, + ); + } + log + } + + #[derive(Debug, Clone, PartialEq, Default)] + pub struct Pfad { + pub anfragen: u32, + pub fehler: u32, + pub latenzen: Vec, + pub p95: u32, + } + + #[derive(Debug, PartialEq)] + pub struct Bericht { + /// ("GET /api/orders", Statistik), meiste Anfragen zuerst + pub pfade: Vec<(String, Pfad)>, + /// ("200", Anzahl), aufsteigend nach Code + pub status: Vec<(String, u32)>, + } + + /// Stand nach der ersten Änderung (p95 nach der Schleife). + fn auswerten_vorher(log: &str) -> Bericht { + let mut pfade: HashMap = HashMap::new(); + let mut status: HashMap = HashMap::new(); + + for zeile in log.lines() { + let zeile = zeile.to_string(); + let teile: Vec = zeile.split(' ').map(|t| t.to_string()).collect(); + if teile.len() != 6 { + continue; + } + let schluessel = format!("{} {}", teile[2], teile[3]); + let code = teile[4].clone(); + let ms: u32 = teile[5].trim_end_matches("ms").parse().unwrap_or(0); + + if !pfade.contains_key(&schluessel) { + pfade.insert(schluessel.clone(), Pfad::default()); + } + let pfad = pfade.get_mut(&schluessel).unwrap(); + pfad.anfragen += 1; + if code.starts_with('5') { + pfad.fehler += 1; + } + pfad.latenzen.push(ms); + + if status.contains_key(&code) { + *status.get_mut(&code).unwrap() += 1; + } else { + status.insert(code.clone(), 1); + } + } + + for pfad in pfade.values_mut() { + pfad.latenzen.sort(); + let rang = (pfad.latenzen.len() * 95).div_ceil(100); + pfad.p95 = pfad.latenzen[rang - 1]; + } + + let mut pfade: Vec<(String, Pfad)> = pfade.into_iter().collect(); + pfade.sort_by(|a, b| b.1.anfragen.cmp(&a.1.anfragen).then(a.0.cmp(&b.0))); + let mut status: Vec<(String, u32)> = status.into_iter().collect(); + status.sort(); + Bericht { pfade, status } + } + + /// Derselbe Bericht wie `auswerten_vorher`, mit höchstens halb so vielen Allokationen. + fn auswerten(log: &str) -> Bericht { + todo!() + } + + fn main() { + let log = erzeuge_log(200_000, 42); + let (_, vorher) = zaehle(|| auswerten_vorher(&log)); + let (_, nachher) = zaehle(|| auswerten(&log)); + println!("vorher {vorher} Allokationen, nachher {nachher}"); + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn gleicher_bericht() { + for (zeilen, seed) in [(0, 1), (1, 7), (5_000, 42)] { + let log = erzeuge_log(zeilen, seed); + assert_eq!(auswerten(&log), auswerten_vorher(&log)); + } + } + + #[test] + fn hoechstens_halb_so_viele_allokationen() { + let log = erzeuge_log(20_000, 42); + let (a, vorher) = zaehle(|| auswerten_vorher(&log)); + let (b, nachher) = zaehle(|| auswerten(&log)); + assert_eq!(a, b); + println!("vorher {vorher}, nachher {nachher}"); + assert!(nachher * 2 <= vorher, "vorher {vorher}, nachher {nachher}"); + } + } + solution: | + use std::alloc::{GlobalAlloc, Layout, System}; + use std::cell::Cell; + use std::collections::HashMap; + use std::fmt::Write; + + // Zählt Allokationen pro Thread: Tests laufen parallel in eigenen Threads. + thread_local! { + static ALLOKATIONEN: Cell = const { Cell::new(0) }; + } + + struct Zaehler; + + unsafe impl GlobalAlloc for Zaehler { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + let _ = ALLOKATIONEN.try_with(|n| n.set(n.get() + 1)); + unsafe { System.alloc(layout) } + } + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } + } + + #[global_allocator] + static GLOBAL: Zaehler = Zaehler; + + /// Allokationen, die `f` im aktuellen Thread auslöst. + fn zaehle(f: impl FnOnce() -> T) -> (T, usize) { + let vorher = ALLOKATIONEN.with(Cell::get); + let ergebnis = f(); + let nachher = ALLOKATIONEN.with(Cell::get); + (ergebnis, nachher - vorher) + } + + /// Erzeugt `zeilen` synthetische Access-Log-Zeilen. Gleicher `seed`, gleiche Zeilen. + /// Format: `2026-10-24T08:15:02Z 10.0.3.17 GET /api/orders 200 87ms` + pub fn erzeuge_log(zeilen: usize, seed: u64) -> String { + // (Pfad, Anteil in Prozent, POST möglich, typische Latenz in ms) + const PFADE: [(&str, u64, bool, u64); 12] = [ + ("/health", 22, false, 2), + ("/static/app.js", 14, false, 6), + ("/static/app.css", 12, false, 5), + ("/api/orders", 10, true, 40), + ("/api/orders/:id", 9, false, 25), + ("/api/search", 8, false, 90), + ("/api/users/:id", 7, false, 20), + ("/api/cart", 6, true, 35), + ("/login", 4, true, 60), + ("/api/users", 3, false, 45), + ("/api/checkout", 3, true, 120), + ("/logout", 2, true, 15), + ]; + let mut x = seed.max(1); + let mut log = String::with_capacity(zeilen * 64); + for i in 0..zeilen { + // xorshift64: schnell, deterministisch, kein Crate nötig + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + let mut wahl = x % 100; + let mut eintrag = PFADE[0]; + for p in PFADE { + if wahl < p.1 { + eintrag = p; + break; + } + wahl -= p.1; + } + let (pfad, _, post_moeglich, basis) = eintrag; + let methode = if post_moeglich && (x >> 4) % 3 == 0 { "POST" } else { "GET" }; + let status = match (x >> 8) % 100 { + 0..=88 => 200, + 89..=93 => 404, + 94..=97 => 500, + _ => 503, + }; + // jede 12. Anfrage hängt bis zu 1,5 s länger (langer Schwanz für p95) + let extra = if (x >> 24) % 12 == 0 { (x >> 32) % 1500 } else { 0 }; + let ms = basis + (x >> 16) % (2 * basis) + extra; + let _ = writeln!( + log, + "2026-10-24T{:02}:{:02}:{:02}Z 10.0.{}.{} {methode} {pfad} {status} {ms}ms", + (i / 3600) % 24, + (i / 60) % 60, + i % 60, + (x >> 40) % 256, + (x >> 48) % 256, + ); + } + log + } + + #[derive(Debug, Clone, PartialEq, Default)] + pub struct Pfad { + pub anfragen: u32, + pub fehler: u32, + pub latenzen: Vec, + pub p95: u32, + } + + #[derive(Debug, PartialEq)] + pub struct Bericht { + /// ("GET /api/orders", Statistik), meiste Anfragen zuerst + pub pfade: Vec<(String, Pfad)>, + /// ("200", Anzahl), aufsteigend nach Code + pub status: Vec<(String, u32)>, + } + + /// Stand nach der ersten Änderung (p95 nach der Schleife). + fn auswerten_vorher(log: &str) -> Bericht { + let mut pfade: HashMap = HashMap::new(); + let mut status: HashMap = HashMap::new(); + + for zeile in log.lines() { + let zeile = zeile.to_string(); + let teile: Vec = zeile.split(' ').map(|t| t.to_string()).collect(); + if teile.len() != 6 { + continue; + } + let schluessel = format!("{} {}", teile[2], teile[3]); + let code = teile[4].clone(); + let ms: u32 = teile[5].trim_end_matches("ms").parse().unwrap_or(0); + + if !pfade.contains_key(&schluessel) { + pfade.insert(schluessel.clone(), Pfad::default()); + } + let pfad = pfade.get_mut(&schluessel).unwrap(); + pfad.anfragen += 1; + if code.starts_with('5') { + pfad.fehler += 1; + } + pfad.latenzen.push(ms); + + if status.contains_key(&code) { + *status.get_mut(&code).unwrap() += 1; + } else { + status.insert(code.clone(), 1); + } + } + + for pfad in pfade.values_mut() { + pfad.latenzen.sort(); + let rang = (pfad.latenzen.len() * 95).div_ceil(100); + pfad.p95 = pfad.latenzen[rang - 1]; + } + + let mut pfade: Vec<(String, Pfad)> = pfade.into_iter().collect(); + pfade.sort_by(|a, b| b.1.anfragen.cmp(&a.1.anfragen).then(a.0.cmp(&b.0))); + let mut status: Vec<(String, u32)> = status.into_iter().collect(); + status.sort(); + Bericht { pfade, status } + } + + /// Teile als &str, Schlüssel geliehen, entry statt contains_key + insert. + fn auswerten(log: &str) -> Bericht { + let mut pfade: HashMap<(&str, &str), Pfad> = HashMap::new(); + let mut status: HashMap<&str, u32> = HashMap::new(); + + for zeile in log.lines() { + let teile: Vec<&str> = zeile.split(' ').collect(); + if teile.len() != 6 { + continue; + } + let code = teile[4]; + let ms: u32 = teile[5].trim_end_matches("ms").parse().unwrap_or(0); + + let pfad = pfade.entry((teile[2], teile[3])).or_default(); + pfad.anfragen += 1; + if code.starts_with('5') { + pfad.fehler += 1; + } + pfad.latenzen.push(ms); + *status.entry(code).or_insert(0) += 1; + } + + for pfad in pfade.values_mut() { + pfad.latenzen.sort(); + let rang = (pfad.latenzen.len() * 95).div_ceil(100); + pfad.p95 = pfad.latenzen[rang - 1]; + } + + let mut pfade: Vec<(String, Pfad)> = pfade + .into_iter() + .map(|((methode, pfad), s)| (format!("{methode} {pfad}"), s)) + .collect(); + pfade.sort_by(|a, b| b.1.anfragen.cmp(&a.1.anfragen).then(a.0.cmp(&b.0))); + let mut status: Vec<(String, u32)> = + status.into_iter().map(|(c, n)| (c.to_string(), n)).collect(); + status.sort(); + Bericht { pfade, status } + } + + fn main() { + let log = erzeuge_log(200_000, 42); + let (_, vorher) = zaehle(|| auswerten_vorher(&log)); + let (_, nachher) = zaehle(|| auswerten(&log)); + println!("vorher {vorher} Allokationen, nachher {nachher}"); + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn gleicher_bericht() { + for (zeilen, seed) in [(0, 1), (1, 7), (5_000, 42)] { + let log = erzeuge_log(zeilen, seed); + assert_eq!(auswerten(&log), auswerten_vorher(&log)); + } + } + + #[test] + fn hoechstens_halb_so_viele_allokationen() { + let log = erzeuge_log(20_000, 42); + let (a, vorher) = zaehle(|| auswerten_vorher(&log)); + let (b, nachher) = zaehle(|| auswerten(&log)); + assert_eq!(a, b); + println!("vorher {vorher}, nachher {nachher}"); + assert!(nachher * 2 <= vorher, "vorher {vorher}, nachher {nachher}"); + } + } + checklist: + - Der Zähler zählt pro Thread und liest den Stand nur vor und nach dem gemessenen Aufruf. + - Der Test vergleicht die Berichte von `auswerten_vorher` und `auswerten` und prüft die Halbierung mit einer Meldung, die beide Zahlen nennt. + - Die Teile der Zeile werden geliehen (`&str`), nicht kopiert. + - Die Maps sind mit geliehenen Schlüsseln und `entry` gebaut; `format!` läuft nur noch beim Erstellen des Berichts. + - Du hast pro Runde Allokationen und Laufzeit notiert und kannst sagen, welche Änderung wie viel gebracht hat. + explanation: | + Die Musterlösung leiht alle Teile der Zeile, nutzt `(&str, &str)` und `&str` als Schlüssel mit `entry` und baut die Strings des Berichts erst am Ende, einmal pro Pfad. Mit 200 000 Zeilen fiel die Zahl von 2 400 254 auf 400 271. Übrig sind zwei pro Zeile für den `Vec<&str>`, der beim `collect` einmal wächst. Wer auch ihn ersetzt, landet bei 271. Der Zähler pro Thread ist wichtig: Ein globaler Zähler würde Allokationen anderer Tests mitzählen, die gleichzeitig laufen. `try_with` statt `with` vermeidet eine Panik, falls während des Thread-Endes noch alloziert wird. + resources: + - title: std · GlobalAlloc + url: https://doc.rust-lang.org/std/alloc/trait.GlobalAlloc.html + - title: std · LocalKey::try_with + url: https://doc.rust-lang.org/std/thread/struct.LocalKey.html#method.try_with + - title: Perf Book · Heap Allocations + url: https://nnethercote.github.io/perf-book/heap-allocations.html + + - id: messprotokoll-werkstatt + type: practice + title: Dein Messprotokoll + prompt: | + Schreibe das Messprotokoll deiner eigenen Werkstatt-Runden, so wie es in einen Pull Request gehört. Nimm deine Zahlen, nicht die aus der Lektion. Mindestens: Messaufbau, Baseline mit Streuung, jede Runde mit Hypothese aus dem Profil, Änderung, Nachweis gleicher Ergebnisse und Messung, dazu eine Entscheidung, wann du aufgehört hast. + lang: text + starter: | + Messaufbau: + Baseline: + Runde 1: + Entscheidung: + solution: | + Messaufbau: Testrechner, Linux, Rust 1.99.1, Profil profiling + (inherits = "release", debug = true), 200 000 erzeugte Zeilen + (seed 42), hyperfine 2.0 mit --warmup 3, criterion 0.8 mit + 50 000 Zeilen, Allokationen mit examples/allokationen.rs. + + Baseline: 3,142 s ± 0,009 s; criterion 210,87 ms; 2 582 829 + Allokationen. 50 000 Zeilen 0,21 s, 200 000 Zeilen 3,1 s: wächst + stärker als die Eingabe. + + Runde 1: Profil 97,0 % in slice::sort aus auswerten (Sortieren + nach jeder Zeile). Änderung: p95 nach der Schleife. Bericht + gleich (Test und diff). 103 ms ± 2 ms (30,4-mal schneller), + criterion −91,9 %. + Runde 2: Profil zeigt collect (Vec) mit malloc/realloc + als größten Posten in auswerten. Änderung: Teile als &str. + Bericht gleich (Test). 89,3 ms ± 2,2 ms (−13,4 %), + 800 254 Allokationen. + Runde 3: Profil zeigt hashmap, format! und to_string für die + Schlüssel. Änderung: entry und geliehene Schlüssel. Bericht + gleich (Test). 70,6 ms ± 2,5 ms (−21,1 %), 400 271 Allokationen. + Runde 4: Profil zeigt noch collect mit malloc für den Vec<&str> + pro Zeile. Änderung: Felder direkt aus dem Iterator. Bericht + gleich (Test). 63,5 ms ± 2,3 ms (−10,3 %), 271 Allokationen. + + Entscheidung: Stand von Runde 4 behalten. Mehr als die Hälfte + der Samples liegt jetzt in erzeuge_log (Testgerüst). Weitere + Arbeit an auswerten lohnt erst mit einer neuen Anforderung. + checklist: + - Jemand anderes könnte deine Messung mit den Angaben im Messaufbau wiederholen (Profil, Eingabe, Werkzeuge mit Optionen). + - Die Baseline steht mit Streuung da, bevor die erste Änderung beschrieben wird. + - Jede Runde nennt den Befund aus dem Profil, der sie begründet, nicht nur die Änderung. + - Jede Runde enthält genau eine Änderung und einen Nachweis, dass der Bericht gleich bleibt. + - Jede Messung ist gegen die direkt vorherige Fassung im selben hyperfine-Lauf oder gegen eine benannte criterion-Baseline gemacht. + - Die Entscheidung zum Aufhören ist mit dem letzten Profil begründet. + explanation: | + Die Musterlösung zeigt das Protokoll mit den Zahlen des Testrechners. Deine Zahlen weichen ab, die Struktur sollte gleich sein. Ein gutes Protokoll lässt sich prüfen: Jede Aussage hat eine Messung, jede Änderung eine Begründung aus dem Profil, und das Ende ist eine Entscheidung, keine Erschöpfung. Genau so liest ein Reviewer eine Performance-Änderung, und genau so erzählst du sie im Vorstellungsgespräch. + resources: + - title: The Rust Performance Book · Benchmarking + url: https://nnethercote.github.io/perf-book/benchmarking.html diff --git a/content/rust/modules/rust-praxis.yaml b/content/rust/modules/rust-praxis.yaml index acca7885..13291521 100644 --- a/content/rust/modules/rust-praxis.yaml +++ b/content/rust/modules/rust-praxis.yaml @@ -1,13 +1,15 @@ id: rust-praxis title: 'Rust in der Praxis: ein robustes CLI' level: 3 -minutes: 180 +minutes: 240 summary: Ownership, Fehler, I/O und Tests zu einem kleinen, nachvollziehbar auslieferbaren Kommandozeilenwerkzeug verbinden. +place: pc goals: -- Du formulierst Eingabe-, Ausgabe- und Fehlerverträge für ein CLI. -- Du trennst Parser, I/O und Prozessgrenze. -- Du verarbeitest Zeilen ohne stillen Datenverlust. +- Du formulierst Eingabe-, Ausgabe- und Fehlerverträge für ein CLI, einschließlich der Exit-Status 0, 1, 2 und 130. +- Du trennst Parser, I/O und Prozessgrenze und liest Datei und stdin über dieselbe `BufRead`-Schnittstelle. +- Du baust Subcommands und Optionen mit clap und legst den Vorrang von Kommandozeile, Umgebung, TOML-Datei und Standardwert fest. +- Du schreibst Ausgaben gepuffert, behandelst `BrokenPipe` und beendest bei Strg+C geordnet. - Du lieferst reproduzierbare Beispiele, Tests und dokumentierte Grenzen. resources: - &id001 @@ -15,20 +17,20 @@ resources: url: https://doc.rust-lang.org/book/ch12-03-improving-error-handling-and-modularity.html - title: Standardbibliothek · BufRead url: https://doc.rust-lang.org/std/io/trait.BufRead.html -- title: Standardbibliothek · Write - url: https://doc.rust-lang.org/std/io/trait.Write.html -- title: Standardbibliothek · args_os - url: https://doc.rust-lang.org/std/env/fn.args_os.html +- title: clap · Derive-Referenz + url: https://docs.rs/clap/latest/clap/_derive/index.html +- title: Standardbibliothek · BufWriter + url: https://doc.rust-lang.org/std/io/struct.BufWriter.html - title: Standardbibliothek · ExitCode url: https://doc.rust-lang.org/std/process/struct.ExitCode.html lesson: | - Du sollst für ein kleines Betriebsteam ein CLI liefern, das Logdateien nach Schweregrad zusammenfasst. Das Team will es im Terminal benutzen und später in einem Skript aufrufen. „Es kompiliert“ reicht dafür nicht: Eine fehlende Datei darf keinen kryptischen Panic verursachen, kaputte Zeilen dürfen nicht als Erfolg verschwinden und die Ausgabe muss zuverlässig weiterverarbeitbar sein. + Du sollst für ein kleines Betriebsteam ein CLI liefern, das Logdateien nach Schweregrad zusammenfasst. Das Team will es im Terminal benutzen, in Skripten aufrufen und hinter `| head` hängen. „Es kompiliert“ reicht dafür nicht: Eine fehlende Datei darf keinen kryptischen Panic auslösen, kaputte Zeilen dürfen nicht als Erfolg verschwinden, Strg+C darf keinen halben Bericht hinterlassen, und die Ausgabe muss sich zuverlässig weiterverarbeiten lassen. - Dieses Modul verbindet die bisherigen Themen zu einem kleinen vertikalen Ablauf: Argumente lesen, Daten einlesen, fachlich parsen, auswerten, Ergebnis ausgeben und Fehler an der Prozessgrenze behandeln. Du baust zuerst eine synchrone Version. Async oder mehrere Threads kommen erst hinzu, wenn ein konkret gemessener Bedarf entsteht. Das Abschlussprojekt im Projektbereich erweitert denselben Arbeitsstil zu einem vollständigen Portfolio-Werkzeug. + Dieses Modul verbindet die bisherigen Themen zu einem vertikalen Ablauf: Argumente und Konfiguration lesen, Daten einlesen, fachlich parsen, auswerten, ausgeben und Fehler an der Prozessgrenze behandeln. Dateien, Puffer und Prozesse aus der Standardbibliothek kennst du aus [Ein- und Ausgabe](/rust/ein-ausgabe). Du baust eine synchrone Version; Async oder Threads kommen erst bei gemessenem Bedarf dazu. ## Anforderungen werden zu einem überprüfbaren Vertrag - Definiere vor dem Implementieren ein kleines Eingabeformat. In unserem Beispiel enthält jede nicht leere Zeile eine Stufe, ein Leerzeichen und eine Meldung. Erlaubt sind `INFO`, `WARN` und `ERROR`. Leere Zeilen werden ignoriert; unbekannte Stufen und leere Meldungen sind Fehler. Das ist eine bewusste Formatentscheidung, keine allgemeine Behauptung über alle Logdateien. + Definiere vor dem Implementieren ein kleines Eingabeformat. Jede nicht leere Zeile enthält eine Stufe, ein Leerzeichen und eine Meldung. Erlaubt sind `INFO`, `WARN` und `ERROR`. Leere Zeilen werden ignoriert; unbekannte Stufen und leere Meldungen sind Fehler. Das ist eine bewusste Formatentscheidung, keine Aussage über alle Logdateien. ```text INFO Worker gestartet @@ -37,13 +39,11 @@ lesson: | INFO Worker beendet ``` - Die Zusammenfassung zählt jede gültige Stufe. Bei einem ungültigen Datensatz bricht die strenge Variante mit einer Zeilennummer ab und liefert keinen scheinbar vollständigen Erfolgsbericht. Eine spätere tolerante Variante müsste Fehleranzahl und teilweise Verarbeitung ausdrücklich ausgeben. Lege außerdem fest: Ergebnisse gehen auf stdout, Diagnosen auf stderr, Erfolg und Fehler haben unterscheidbare Exit-Status. - - Diese Entscheidungen sind klein, aber wichtig. Wenn man sie erst beim Testen errät, entstehen widersprüchliche Erwartungen zwischen Parser, Oberfläche und Skript. Das [CLI-Kapitel des Rust Book](https://doc.rust-lang.org/book/ch12-03-improving-error-handling-and-modularity.html) zeigt die Trennung von Konfiguration, Verarbeitung und Fehlerbehandlung. + Die strenge Variante bricht bei einem ungültigen Datensatz mit Zeilennummer ab und liefert keinen scheinbar vollständigen Erfolgsbericht. Lege außerdem fest: Ergebnisse gehen auf stdout, Diagnosen auf stderr, Erfolg, Fehler und Nutzungsfehler haben unterscheidbare Exit-Status. Wer das erst beim Testen errät, bekommt widersprüchliche Erwartungen zwischen Parser, Oberfläche und Skript. Das [CLI-Kapitel des Rust Book](https://doc.rust-lang.org/book/ch12-03-improving-error-handling-and-modularity.html) zeigt die Trennung von Konfiguration, Verarbeitung und Fehlerbehandlung. ## Ein Parser braucht noch keine Datei - Die kleinste fachliche Funktion nimmt einen `&str` und liefert ein Ergebnis. Sie weiß nichts über Dateipfade oder Konsolenausgabe. Das macht sie direkt testbar und wiederverwendbar. + Die kleinste fachliche Funktion nimmt einen `&str` und liefert ein Ergebnis. Sie weiß nichts über Dateipfade oder Konsolenausgabe und ist deshalb direkt testbar. ```rust #[derive(Debug, PartialEq)] @@ -68,139 +68,327 @@ lesson: | } ``` - `Result, E>` bildet drei Ergebnisse ab: eine gültige Zeile, eine bewusst ignorierte Leerzeile oder einen Fehler. Ein einzelnes `Option` würde ignorierte und ungültige Zeilen zusammenwerfen. Der Parser leiht den Text; nur die Stufe wird als eigenes kleines Enum zurückgegeben. Möchtest du die Meldung später behalten, entscheide bewusst zwischen geliehenem Ausschnitt und eigenem String. - - Für größere Fehlermodelle verwendest du ein eigenes Fehler-Enum statt statischer Texte. Die Zeilennummer gehört aber nicht in diese Funktion, denn sie kennt die Position im Dokument nicht. Die äußere Leseschicht ergänzt den Kontext, statt dem Parser unnötige Zuständigkeiten zu geben. + `Result, E>` bildet drei Ergebnisse ab: gültige Zeile, bewusst ignorierte Leerzeile, Fehler. Ein einzelnes `Option` würde ignorierte und ungültige Zeilen zusammenwerfen. Die Zeilennummer gehört nicht in diese Funktion, sie kennt die Position im Dokument nicht; die äußere Leseschicht ergänzt diesen Kontext. - ## Reader und Writer machen Grenzen austauschbar + ## Reader, Writer und stdin als austauschbare Grenzen - Eine ganze Datei als String zu laden ist für kleine Eingaben verständlich. Für größere Logs ist zeilenweises Lesen über [`BufRead`](https://doc.rust-lang.org/std/io/trait.BufRead.html#method.lines) passender. `BufReader` puffert eine Datei; `Cursor` liefert dieselbe Schnittstelle für Bytes im Speicher. + Für größere Logs liest du zeilenweise über [`BufRead::lines`](https://doc.rust-lang.org/std/io/trait.BufRead.html#method.lines). Jede Zeile ist ein `Result`; das `?` ist Pflicht, denn `filter_map(Result::ok)` ließe unlesbare Daten still verschwinden. Unix-Werkzeuge lesen nach Konvention von stdin, wenn statt eines Dateinamens `-` steht. Weil [`Stdin::lock`](https://doc.rust-lang.org/std/io/struct.Stdin.html#method.lock) ein `StdinLock<'static>` liefert, das `BufRead` implementiert, deckt eine Funktion beide Quellen ab: ```rust - use std::io::{self, BufRead}; - - fn zeilen(reader: impl BufRead) -> io::Result { - let mut anzahl = 0; - for line in reader.lines() { - let line = line?; - if !line.trim().is_empty() { anzahl += 1; } + use std::fs::File; + use std::io::{self, BufRead, BufReader}; + use std::path::Path; + + fn oeffnen(pfad: &Path) -> io::Result> { + if pfad == Path::new("-") { + Ok(Box::new(io::stdin().lock())) + } else { + Ok(Box::new(BufReader::new(File::open(pfad)?))) } - Ok(anzahl) } fn main() -> io::Result<()> { - let daten = io::Cursor::new("INFO Start\n\nWARN Speicher\n"); - println!("{}", zeilen(daten)?); + let mut anzahl = 0; + for zeile in oeffnen(Path::new("-"))?.lines() { + if !zeile?.trim().is_empty() { + anzahl += 1; + } + } + println!("{anzahl} Zeilen"); Ok(()) } ``` - `lines()` liefert pro Zeile ein `Result`. Das `?` ist wichtig: `filter_map(Result::ok)` würde defekte oder unlesbare Daten still verschwinden lassen. Der Iterator entfernt die Zeilenenden, damit dein Parser nicht selbst zwischen LF und CRLF unterscheiden muss. Ungültiges UTF-8 ist für diese String-Schnittstelle ein Fehler; binäre Formate benötigen eine andere Verarbeitung. + Mit `INFO start`, einer Leerzeile, `WARN platte` und `ERROR db` auf stdin gibt das Beispiel `3 Zeilen` aus. Der Rest des Programms sieht nur `Box` – und Tests übergeben ein `io::Cursor` mit Bytes im Speicher. Dasselbe gilt für die Ausgabe: Eine Funktion, die `impl Write` nimmt, schreibt in stdout, eine Datei oder einen `Vec` im Test. Schreiben liefert ein `Result`, denn auch eine volle Platte oder eine geschlossene Pipe ist ein Fehler ([`Write`](https://doc.rust-lang.org/std/io/trait.Write.html)). + + Streaming begrenzt den Speicher nicht automatisch: Eine einzelne riesige Zeile belegt trotzdem viel Platz. Für unkontrollierte Eingaben legst du eine Zeilenlängengrenze fest, statt im README konstante Speichernutzung zu behaupten. + + ## Argumente mit clap: Subcommands, Werte, Umgebung - Streaming begrenzt den Speicher nicht automatisch auf eine feste Größe. Eine einzelne riesige Zeile kann weiterhin viel Speicher belegen. Für unkontrollierte Eingaben definierst du daher Dateigrößen- oder Zeilenlängengrenzen und prüfst sie bewusst. Behaupte im README keine konstante Speichernutzung, wenn die längste Zeile unbegrenzt ist. + Dateipfade sind keine Texte. [`args_os`](https://doc.rust-lang.org/std/env/fn.args_os.html) liefert Argumente als `OsString`, auch wenn ein Pfad kein gültiges Unicode ist; halte sie als `PathBuf`. Ab zwei Optionen lohnt sich [clap](https://docs.rs/clap/latest/clap/_derive/index.html) mit Derive (`clap = { version = "4.6", features = ["derive", "env"] }`): ```rust - use std::io::{self, Write}; + use std::path::PathBuf; - fn bericht(mut out: impl Write, anzahl: usize) -> io::Result<()> { - writeln!(out, "Zeilen: {anzahl}") + use clap::{Parser, Subcommand, ValueEnum}; + + /// Fasst Logdateien nach Schweregrad zusammen. + #[derive(Parser)] + #[command(name = "logwerk", version)] + struct Cli { + #[command(subcommand)] + befehl: Befehl, } - fn main() -> io::Result<()> { - let mut bytes = Vec::new(); - bericht(&mut bytes, 3)?; - assert_eq!(bytes, b"Zeilen: 3\n"); - Ok(()) + #[derive(Subcommand)] + enum Befehl { + /// Zählt die Zeilen je Stufe + Zaehlen { + /// Logdatei, `-` liest von stdin + datei: PathBuf, + }, + /// Gibt nur Zeilen ab einer Mindeststufe aus + Filtern { + /// Mindeststufe + #[arg(long, value_enum, env = "LOGWERK_STUFE", default_value_t = Stufe::Warn)] + stufe: Stufe, + /// Logdatei, `-` liest von stdin + datei: PathBuf, + }, + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] + enum Stufe { + Info, + Warn, + Error, + } + + fn main() { + let cli = Cli::parse(); + match cli.befehl { + Befehl::Zaehlen { datei } => println!("zähle {}", datei.display()), + Befehl::Filtern { stufe, datei } => { + println!("filtere {} ab {stufe:?}", datei.display()) + } + } + } + ``` + + Die Doc-Kommentare werden zum Hilfetext ([Doc Comments](https://docs.rs/clap/latest/clap/_derive/index.html#doc-comments)), Enum-Varianten zu Subcommands in kebab-case, `ValueEnum` zur Liste erlaubter Werte, und `env` liest die Umgebungsvariable, wenn die Option fehlt ([Arg-Attribute](https://docs.rs/clap/latest/clap/_derive/index.html#arg-attributes)). So sieht das aus: + + ```console + $ logwerk filtern --help + Gibt nur Zeilen ab einer Mindeststufe aus + + Usage: logwerk filtern [OPTIONS] + + Arguments: + Logdatei, `-` liest von stdin + + Options: + --stufe Mindeststufe [env: LOGWERK_STUFE=] [default: warn] [possible values: info, warn, error] + -h, --help Print help + $ logwerk filtern --stufe fatal app.log + error: invalid value 'fatal' for '--stufe ' + [possible values: info, warn, error] + + For more information, try '--help'. + $ echo $? + 2 + $ LOGWERK_STUFE=error logwerk filtern app.log + filtere app.log ab Error + ``` + + Bei Nutzungsfehlern schreibt clap auf stderr und endet mit Status 2, bei `--help` und `--version` auf stdout mit 0 ([`Error::exit`](https://docs.rs/clap/latest/clap/error/struct.Error.html#method.exit)). Reserviere deshalb 1 für fachliche Fehler, dann unterscheidet ein Skript „falsch aufgerufen“ von „Datei kaputt“. clap prüft Syntax, Typen und erlaubte Werte. Ob die Datei existiert, ob `--max-zeilen 0` sinnvoll ist oder ob zwei Optionen fachlich zusammenpassen, bleibt deine Validierung nach dem Parsen. + + > [!WARNING] `process::exit` räumt nicht auf + > [`process::exit`](https://doc.rust-lang.org/std/process/fn.exit.html) mitten im Programm beendet sofort: Kein `Drop` läuft, ein `BufWriter` wird nicht geleert, Temp-Dateien bleiben liegen. Gib einen [`ExitCode`](https://doc.rust-lang.org/std/process/struct.ExitCode.html) aus `main` zurück und lass Fehler per `?` dorthin wandern. + + ## Konfiguration: Umgebung, TOML-Datei und Vorrang + + [`env::var`](https://doc.rust-lang.org/std/env/fn.var.html) liefert `Result`: `VarError::NotPresent`, wenn die Variable fehlt, und `VarError::NotUnicode(OsString)`, wenn ihr Wert kein gültiges UTF-8 ist ([VarError](https://doc.rust-lang.org/std/env/enum.VarError.html)). Für Pfade nimmst du [`env::var_os`](https://doc.rust-lang.org/std/env/fn.var_os.html), das `Option` ohne Umwandlung liefert. Ein gesetzter, aber leerer Wert (`LOGWERK_STUFE=`) ist `Ok("")` – entscheide bewusst, ob er zählt. + + Seit Edition 2024 ist `env::set_var` `unsafe`: Ohne `unsafe`-Block meldet der Compiler ``error[E0133]: call to unsafe function `set_var` is unsafe and requires unsafe block``. Der Grund steht in der [Safety-Doku](https://doc.rust-lang.org/std/env/fn.set_var.html#safety): Auf Linux darf kein anderer Thread die Umgebung gleichzeitig lesen, und `cargo test` lässt Tests parallel in Threads laufen. Tests setzen deshalb keine Umgebungsvariablen im eigenen Prozess. Übergib die Werte als Parameter oder setze sie für einen Kindprozess mit `Command::env` ([Edition Guide](https://doc.rust-lang.org/edition-guide/rust-2024/newly-unsafe-functions.html)). + + Für eine Konfigurationsdatei ist TOML mit serde üblich (`serde = { version = "1", features = ["derive"] }`, `toml = "1.1"`): + + ```rust + use serde::Deserialize; + + #[derive(Debug, Deserialize)] + #[serde(default, deny_unknown_fields)] + struct Konfig { + stufe: String, + farbe: bool, + max_zeilenlaenge: usize, + } + + impl Default for Konfig { + fn default() -> Self { + Konfig { stufe: "warn".into(), farbe: true, max_zeilenlaenge: 4096 } + } + } + + fn main() { + let gut = "stufe = \"error\"\nmax_zeilenlaenge = 512\n"; + let konfig: Konfig = toml::from_str(gut).expect("gültige Konfiguration"); + println!("{konfig:?}"); + + let vertippt = "stufe = \"error\"\nmax_zeilenlange = 512\n"; + if let Err(fehler) = toml::from_str::(vertippt) { + eprintln!("logwerk.toml: {fehler}"); + } } ``` - Der Writer erlaubt Tests ohne globale Umleitung von stdout. Er macht außerdem Schreibfehler sichtbar: Auch eine volle Platte oder geschlossene Pipe ist eine fehlgeschlagene Operation. Die [`Write`-Dokumentation](https://doc.rust-lang.org/std/io/trait.Write.html) erklärt, warum Schreiben einen Result-Wert liefert. + `#[serde(default)]` füllt fehlende Felder aus `Default`, `deny_unknown_fields` macht aus einem Tippfehler einen Fehler statt eines still ignorierten Schlüssels ([Container-Attribute](https://serde.rs/container-attrs.html#deny_unknown_fields)). Die erste Zeile gibt `Konfig { stufe: "error", farbe: true, max_zeilenlaenge: 512 }` aus, der Tippfehler ergibt: + + ```text + logwerk.toml: TOML parse error at line 2, column 1 + | + 2 | max_zeilenlange = 512 + | ^^^^^^^^^^^^^^^ + unknown field `max_zeilenlange`, expected one of `stufe`, `farbe`, `max_zeilenlaenge` + ``` - ## Argumente und Prozessstatus bleiben am Rand + Ein falscher Typ (`max_zeilenlaenge = "512"`) meldet `invalid type: string "512", expected usize` mit Zeile und Spalte. Gib diese Meldung unverändert mit dem Dateinamen weiter; sie zeigt dem Nutzer die Stelle. - Eine kleine `Config` enthält den geprüften Aufruf. Dateipfade sind keine fachlichen Texte. [`args_os`](https://doc.rust-lang.org/std/env/fn.args_os.html) erhält die plattformspezifische Argumentdarstellung, auch wenn ein Pfad nicht gültiges Unicode enthält. Verwende für Pfade `PathBuf`, statt sie früh verlustbehaftet in Strings zu verwandeln. + Bleibt die Frage, welcher Wert gewinnt. Die übliche Regel lautet: Kommandozeile vor Umgebungsvariable vor Datei vor Standardwert. Schreib sie als reine Funktion; dann testest du sie ohne Dateien und ohne `set_var`: ```rust - use std::{ffi::OsString, path::PathBuf}; + fn waehle(cli: Option, umgebung: Option, datei: Option, standard: T) -> T { + cli.or(umgebung).or(datei).unwrap_or(standard) + } - fn eingabe(mut args: impl Iterator) -> Result { - let _programm = args.next(); - let pfad = args.next().ok_or("Aufruf: logwerk DATEI")?; - if args.next().is_some() { return Err("Zu viele Argumente"); } - Ok(PathBuf::from(pfad)) + #[test] + fn cli_schlaegt_alles() { + assert_eq!(waehle(Some("debug"), Some("error"), Some("info"), "warn"), "debug"); + assert_eq!(waehle(None, None, Some("info"), "warn"), "info"); + assert_eq!(waehle(None, None, None, "warn"), "warn"); } fn main() { - let args = [OsString::from("logwerk"), OsString::from("app.log")]; - println!("{}", eingabe(args.into_iter()).unwrap().display()); + println!("{}", waehle(None, Some("error"), Some("info"), "warn")); } ``` - Bei mehr Optionen lohnt sich eine etablierte Argumentbibliothek wie [clap](https://docs.rs/clap/latest/clap/). Sie übernimmt Syntax, Hilfetext und Validierungsmechanik, aber nicht deine fachlichen Entscheidungen. Die erste Version braucht noch keine verschachtelten Subcommands oder Plugin-Architektur. Halte die CLI so klein, dass ihr Verhalten mit wenigen Integrationstests vollständig nachvollziehbar bleibt. + Mit clap und `env` sind die ersten beiden Stufen schon zusammengelegt. Mach das Feld dafür zu `Option` ohne `default_value_t`: clap füllt es aus Option oder Variable, und deine Funktion ergänzt nur noch Datei und Standard. + + ## Ausgabe auf stdout: Puffer, Sperre und Broken Pipe + + `println!` sperrt stdout bei jedem Aufruf ([print!](https://doc.rust-lang.org/std/macro.print.html)), und stdout ist zeilengepuffert: Laut [Doku](https://doc.rust-lang.org/std/io/struct.Stdout.html) leert der Handle am Terminal bei jedem `\n`. Ausprobiert mit Rust 1.99.1 gilt das auch an einer Pipe – zwei `println!` im Abstand von 500 ms kamen beim Leser im Abstand von 500 ms an. Jede Zeile kostet also einen Systemaufruf. Gemessen mit `hyperfine -N --warmup 3 --output=pipe`, 1 000 000 Zeilen, Release-Build, ein Linux-Rechner (Mittelwerte, gekürzt): + + | Variante | Zeit | + | --- | --- | + | `println!` in der Schleife | 364 ms | + | `let mut out = io::stdout().lock();` + `writeln!` | 376 ms | + | `BufWriter::new(io::stdout().lock())` + `writeln!` + `flush` | 23 ms | + + Die Sperre allein bringt nichts; die Zeit steckt im Leeren nach jeder Zeile. Erst der [`BufWriter`](https://doc.rust-lang.org/std/io/struct.BufWriter.html) sammelt 8 KiB und schreibt sie am Stück – hier 15-mal schneller. Am Ende rufst du `flush()` selbst auf: Der Drop des `BufWriter` versucht es zwar auch, verschluckt aber einen Fehler dabei. + + Hängt das Werkzeug hinter `| head -1`, schließt `head` die Pipe nach der ersten Zeile. Rust setzt SIGPIPE vor `main` auf „ignorieren“, deshalb beendet der Kernel den Prozess nicht, sondern das nächste `write` scheitert mit `EPIPE`, in Rust `ErrorKind::BrokenPipe` ([on-broken-pipe](https://doc.rust-lang.org/unstable-book/compiler-flags/on-broken-pipe.html)). `println!` panikt bei jedem Schreibfehler. Ein Testprogramm, das 100 000 Zeilen mit `println!` schreibt, hinter `head -1`: + + ```console + $ ./logwerk | head -1 + INFO request 1 ok - [`ExitCode`](https://doc.rust-lang.org/std/process/struct.ExitCode.html) lässt `main` den Status zurückgeben. Anders als ein tiefer Aufruf von `process::exit` bewahrt die normale Rückkehr einen klaren Aufräumpfad. + thread 'main' (1415760) panicked at library/std/src/io/stdio.rs:1168:9: + failed printing to stdout: Broken pipe (os error 32) + note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace + ``` + + Der Exit-Status war 101; mit diesem Code endet ein Rust-Programm, dessen `main` panikt. Mit `writeln!` kommt der Fehler als `io::Error` zurück, und eine geschlossene Pipe ist für ein Filterwerkzeug kein Fehler – der Leser hat, was er wollte: ```rust + use std::io::{self, BufWriter, ErrorKind, Write}; use std::process::ExitCode; - fn run() -> Result<(), &'static str> { - Ok(()) + fn ausgeben(zeilen: impl Iterator) -> io::Result<()> { + let mut out = BufWriter::new(io::stdout().lock()); + for zeile in zeilen { + writeln!(out, "{zeile}")?; + } + out.flush() } fn main() -> ExitCode { - match run() { + let zeilen = (1..=100_000).map(|n| format!("INFO request {n} ok")); + match ausgeben(zeilen) { Ok(()) => ExitCode::SUCCESS, - Err(error) => { - eprintln!("logwerk: {error}"); + Err(e) if e.kind() == ErrorKind::BrokenPipe => ExitCode::SUCCESS, + Err(e) => { + eprintln!("logwerk: {e}"); ExitCode::FAILURE } } } ``` - ## Eine vollständige Scheibe zuerst + Mit `| head -1` gibt diese Fassung eine Zeile aus und endet mit 0. Andere Schreibfehler, etwa eine volle Platte, landen weiter als Meldung auf stderr mit Status 1. - Verbinde nun Parser, Reader und Bericht für genau eine Datei. Prüfe mit einer kleinen Fixture zuerst das gesamte Verhalten: Der Aufruf öffnet die richtige Datei, verarbeitet ihre Zeilen, erzeugt den erwarteten Bericht und endet erfolgreich. Danach prüfst du dieselbe Grenze mit fehlendem Pfad und ungültiger Zeile. Erst wenn diese Scheibe funktioniert, ergänzt du Filter oder weitere Ausgabeformate. + ## Strg+C: Signale und sauberes Beenden - Ein `BTreeMap` liefert eine deterministische Schlüsselreihenfolge. Eine HashMap hat diese Garantie nicht; sortiere ihre Ergebnisse vor stabiler Ausgabe. JSON solltest du mit einem Serializer erzeugen, nicht durch Stringverkettung: Anführungszeichen, Steuerzeichen und Unicode machen selbst gebaute Escaping-Regeln fehleranfällig. + Bei Strg+C schickt das Terminal SIGINT an die Prozesse im Vordergrund. Ohne eigenen Handler beendet der Kernel den Prozess sofort, es laufen keine Destruktoren, und der Inhalt eines `BufWriter` geht verloren. Ausprobiert mit einer Schleife, die alle 5 ms eine Zeile in einen `BufWriter` schreibt, nach 0,5 s per SIGINT beendet: Die Ausgabedatei blieb leer, obwohl rund hundert Zeilen verarbeitet waren. Die Shell meldet Status 130, denn für ein Signal N setzt Bash 128 + N, und SIGINT ist 2 ([Bash: Exit Status](https://www.gnu.org/software/bash/manual/html_node/Exit-Status.html)). - ```rust - use std::collections::BTreeMap; + Die Crate [ctrlc](https://docs.rs/ctrlc/latest/ctrlc/) (`ctrlc = "3.5"`) installiert einen Handler, der in einem eigenen Thread läuft. Er setzt nur ein Flag; die Hauptschleife prüft es und endet geordnet: - fn main() { - let mut counts = BTreeMap::new(); - for stufe in ["WARN", "INFO", "INFO"] { - *counts.entry(stufe).or_insert(0_u32) += 1; + ```rust + use std::io::{self, BufWriter, Write}; + use std::process::ExitCode; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::{thread, time::Duration}; + + use ctrlc::set_handler; + + fn verarbeiten(abbruch: &AtomicBool) -> io::Result { + let mut out = BufWriter::new(io::stdout().lock()); + for n in 1..=1000 { + if abbruch.load(Ordering::Relaxed) { + out.flush()?; + return Ok(false); + } + writeln!(out, "Zeile {n} verarbeitet")?; + thread::sleep(Duration::from_millis(5)); // steht für Arbeit } - for (stufe, anzahl) in counts { - println!("{stufe} {anzahl}"); + out.flush()?; + Ok(true) + } + + fn main() -> ExitCode { + let abbruch = Arc::new(AtomicBool::new(false)); + let signal = Arc::clone(&abbruch); + set_handler(move || signal.store(true, Ordering::Relaxed)) + .expect("Ctrl-C-Handler setzen"); + + match verarbeiten(&abbruch) { + Ok(true) => ExitCode::SUCCESS, + Ok(false) => { + eprintln!("logwerk: abgebrochen, Ausgabe bis hier vollständig"); + ExitCode::from(130) + } + Err(e) => { + eprintln!("logwerk: {e}"); + ExitCode::FAILURE + } } } ``` - > [!PRAXIS] - > Eine professionelle Abgabe enthält nicht nur Code. Lege eine kleine gültige und eine ungültige Fixture bei, dokumentiere beide Aufrufe und erkläre die Exit-Status. Ein Reviewer sollte den Nutzen in wenigen Minuten selbst überprüfen können. + Derselbe Versuch ergab jetzt 99 vollständige Zeilen in der Datei und Status 130. Den Code 130 setzt das Programm selbst, damit Skripte den Abbruch weiter von einem Erfolg unterscheiden. `Relaxed` reicht, weil nur das Flag selbst zählt (siehe [Nebenläufigkeit](/rust/nebenlaeufigkeit)). Für SIGTERM, das `kill` und systemd schicken, schaltest du das Feature `termination` ein. In async-Programmen wartest du stattdessen mit `tokio::signal::ctrl_c()` in einem `select!` ([Async Rust](/rust/async-rust)). - ## Qualität und Übergabe gehören zum Produkt + ## Eine vollständige Scheibe, Qualität und Übergabe - Halte `main` klein und die Kernlogik unabhängig vom Prozess. Schreibe Tests für leere Eingabe, jede Stufe, unbekannte Stufe, fehlende Meldung, CRLF und einen Fehler in einer späteren Zeile. Der spätere Fehlerfall prüft, dass ein bereits teilweise berechnetes Ergebnis nicht unbemerkt als vollständig ausgegeben wird. + Verbinde Parser, Reader und Bericht zuerst für genau eine Datei und prüfe das ganze Verhalten mit einer kleinen Fixture: richtige Datei geöffnet, Zeilen verarbeitet, Bericht wie erwartet, Status 0. Danach dieselbe Grenze mit fehlendem Pfad, ungültiger Zeile und `| head -1`. Erst dann kommen Filter und weitere Ausgabeformate. Für stabile Ausgabe zählst du in einer `BTreeMap`, die in Schlüsselreihenfolge iteriert; eine `HashMap` müsstest du vorher sortieren. JSON erzeugt ein Serializer, keine Stringverkettung. - Im README stehen Installation, unterstütztes Format, Beispiele, Fehlerverhalten und bekannte Grenzen. `cargo fmt`, Clippy und Tests laufen in CI. Ein Release-Build wird mit denselben Fixtures geprüft. Speichere keine produktiven Logdateien mit Geheimnissen im Repository; synthetische Beispieldaten reichen aus und lassen sich dauerhaft erklären. + Halte `main` klein und die Kernlogik unabhängig vom Prozess. Teste leere Eingabe, jede Stufe, unbekannte Stufe, fehlende Meldung, CRLF und einen Fehler in einer späteren Zeile. Wie du das Binary selbst mit Exit-Status, stdout und stderr testest, zeigt [Tests & Code-Qualität](/rust/testen-qualitaet). Im README stehen Installation, Format, Beispiele, Exit-Status, Konfigurationsvorrang und bekannte Grenzen; `cargo fmt`, Clippy und Tests laufen in CI. Produktive Logdateien mit Geheimnissen gehören nicht ins Repository, synthetische Beispiele reichen. - Entscheidungen sollen begründet sein: Warum synchron? Weil eine einzelne lokale Datei zuerst keinen Task-Overhead braucht. Warum ein Enum? Weil die erlaubten Stufen endlich und explizit sind. Warum ein Reader-Parameter? Weil Fachlogik ohne Dateisystem testbar sein soll. Diese Antworten sind im Gespräch wertvoller als eine lange Liste eingesetzter Crates. + > [!PRAXIS] + > Eine professionelle Abgabe enthält mehr als Code. Lege eine gültige und eine ungültige Fixture bei, dokumentiere beide Aufrufe und die Exit-Status 0, 1, 2 und 130. Ein Reviewer probiert als Erstes `--help`, einen Tippfehler in der Konfiguration und `| head` – alle drei sollten verständlich enden. > [!INTERVIEW] - > Präsentiere den Ablauf aus Nutzersicht, dann eine technische Abwägung und einen Fehlerfall. Zeige etwa, wie eine ungültige Zeile mit Nummer gemeldet wird und warum kein Erfolgsbericht danebensteht. Das belegt Verständnis von Datenintegrität und Schnittstellen. - - ## Häufige Fehler und Zusammenfassung - - - Definiere gültige, ignorierte und fehlerhafte Daten getrennt. - - Parser benötigen weder Dateisystem noch Konsolenausgabe. - - Reader und Writer halten I/O testbar und Fehler sichtbar. - - Verwirf keine Lesefehler mit `filter_map(Result::ok)`. - - CLI-Argumente, Hilfetext und Exit-Status sind Teil des Vertrags. - - Stabile Ausgabe braucht eine definierte Reihenfolge. - - Liefere Fixtures, Tests und eine nachvollziehbare Anleitung mit. + > Präsentiere den Ablauf aus Nutzersicht, dann eine Abwägung und einen Fehlerfall. Etwa: „Ich schreibe über einen `BufWriter` auf stdout, weil `println!` an einer Pipe jede Zeile einzeln schreibt – gemessen 15-mal langsamer. Dafür muss ich `flush` aufrufen und eine geschlossene Pipe als normales Ende behandeln.“ + + ## Häufige Fehler + + - **`println!` für die Hauptausgabe.** Langsam an Pipes und ein Panic bei `| head`. `BufWriter` plus `writeln!(…)?`. + - **`flush` vergessen.** Der Drop leert zwar, verschluckt aber Fehler, und bei SIGINT oder `process::exit` läuft er gar nicht. + - **Lesefehler verwerfen.** `filter_map(Result::ok)` macht kaputte Eingaben unsichtbar. + - **Fachliche Validierung clap überlassen.** clap prüft Syntax und Werte, nicht ob die Kombination sinnvoll ist. + - **`set_var` in Tests.** Seit Edition 2024 `unsafe` und bei parallelen Tests unsound; Werte als Parameter übergeben. + - **Unbekannte Konfigurationsschlüssel still ignorieren.** `deny_unknown_fields` macht den Tippfehler zur Meldung mit Zeile. + - **Diagnosen auf stdout.** Sie verunreinigen den Ergebnisstrom; Meldungen gehören auf stderr. + + ## Zusammenfassung + + - Gültige, ignorierte und fehlerhafte Daten sind getrennt definiert; der Parser kennt weder Datei noch Konsole. + - `Box` vereint Datei und stdin (`-`); Tests übergeben einen `Cursor`. + - clap übernimmt Syntax, Hilfe und Nutzungsfehler (Status 2); fachliche Prüfung bleibt bei dir. + - Vorrang CLI > Umgebung > Datei > Standard als reine, getestete Funktion; TOML mit `deny_unknown_fields`. + - `BufWriter::new(io::stdout().lock())` mit explizitem `flush` statt `println!` in Schleifen. + - `BrokenPipe` ist für Filterwerkzeuge ein normales Ende, kein Panic. + - Strg+C setzt ein Flag, das Programm leert seine Puffer und endet mit 130. exercises: - id: teilbericht type: choice @@ -254,7 +442,8 @@ exercises: explanation: BTreeMap iteriert in Schlüsselreihenfolge. Dadurch bleibt die Ausgabe unabhängig von der Reihenfolge des ersten Auftretens stabil. resources: - - *id001 + - title: BTreeMap + url: https://doc.rust-lang.org/std/collections/struct.BTreeMap.html - id: zeilennummer type: output title: Menschen zählen ab eins @@ -274,7 +463,40 @@ exercises: explanation: enumerate zählt auch die später ignorierte Leerzeile mit. Die Addition von eins macht daraus physische Zeilennummern für Nutzer. resources: - - *id001 + - title: Iterator::enumerate + url: https://doc.rust-lang.org/std/iter/trait.Iterator.html#method.enumerate +- id: vorrang-regel + type: output + title: Wer gewinnt die Stufe? + prompt: | + `waehle` setzt die Vorrangregel Kommandozeile vor Umgebung vor Datei vor Standard um. Was gibt das Programm aus? Gib jede Ausgabezeile an. + lang: rust + code: |- + fn waehle<'a>(cli: Option<&'a str>, env: Option<&'a str>, datei: Option<&'a str>) -> &'a str { + cli.or(env).or(datei).unwrap_or("warn") + } + + fn main() { + println!("[{}]", waehle(None, Some("error"), Some("info"))); + println!("[{}]", waehle(Some("debug"), None, Some("info"))); + println!("[{}]", waehle(None, Some(""), Some("info"))); + println!("[{}]", waehle(None, None, None)); + } + expected: |- + [error] + [debug] + [] + [warn] + hints: + - "`a.or(b)` liefert `a`, wenn es `Some` ist, sonst `b`." + - Ist `Some("")` ein Wert oder keiner? + explanation: | + `Option::or` nimmt den ersten Wert, der `Some` ist, `unwrap_or` greift nur, wenn alle drei `None` sind. Die dritte Zeile ist die Falle: `Some("")` ist ein Wert, also schlägt die leere Umgebung die Datei, und die Ausgabe ist `[]`. Genau das passiert mit `LOGWERK_STUFE=` in der Shell, denn `env::var` liefert dafür `Ok("")`, nicht `NotPresent`. Soll ein leerer Wert nicht zählen, filtere ihn vorher: `env.filter(|s| !s.is_empty())`. Weil die Regel eine reine Funktion ist, prüfst du solche Fälle ohne Dateien und ohne `set_var`. + resources: + - title: Option::or + url: https://doc.rust-lang.org/std/option/enum.Option.html#method.or + - title: std::env::var + url: https://doc.rust-lang.org/std/env/fn.var.html - id: reader-fehler type: gap title: Lesefehler erhalten @@ -297,7 +519,8 @@ exercises: explanation: Jede gelesene Zeile kann fehlschlagen. Der Operator beendet die Auswertung mit der Ursache, statt den Datensatz wegzufiltern. resources: - - *id001 + - title: BufRead::lines + url: https://doc.rust-lang.org/std/io/trait.BufRead.html#method.lines - id: writer-bericht type: gap title: Ausgabe als überprüfbare Grenze @@ -316,7 +539,57 @@ exercises: hint: Die Variante mit Zeilenende lautet writeln!. explanation: Write erlaubt Datei, stdout oder einen Bytepuffer. Das Result bewahrt Fehler beim Schreiben. resources: - - *id001 + - title: std::writeln! + url: https://doc.rust-lang.org/std/macro.writeln.html +- id: clap-subcommand + type: gap + title: Zwei Befehle und eine Umgebungsvariable + prompt: | + `logwerk` soll die Subcommands `zaehlen` und `filtern` haben. Ohne `--stufe` liest `filtern` die Mindeststufe aus der Umgebungsvariable `LOGWERK_STUFE`, sonst gilt `warn`. Ergänze das Attribut am Feld, das Derive des Enums und die Angabe im `#[arg]`-Attribut. Die Abhängigkeit ist `clap = { version = "4.6", features = ["derive", "env"] }`. + lang: rust + code: |- + use std::path::PathBuf; + use clap::{Parser, Subcommand}; + + #[derive(Parser)] + #[command(name = "logwerk")] + struct Cli { + #[command(⟦subcommand⟧)] + befehl: Befehl, + } + + #[derive(⟦Subcommand⟧)] + enum Befehl { + /// Zählt die Zeilen je Stufe + Zaehlen { datei: PathBuf }, + /// Gibt nur Zeilen ab einer Mindeststufe aus + Filtern { + #[arg(long, ⟦env = "LOGWERK_STUFE"⟧, default_value = "warn")] + stufe: String, + datei: PathBuf, + }, + } + + fn main() { + match Cli::parse().befehl { + Befehl::Zaehlen { datei } => println!("zähle {}", datei.display()), + Befehl::Filtern { stufe, datei } => println!("{} ab {stufe}", datei.display()), + } + } + gaps: + - label: Feld für den Befehl + hint: Das Attribut sagt clap, dass hier ein Subcommand-Enum eingebettet ist. Es heißt wie das Konzept, kleingeschrieben. + - label: Derive des Enums + hint: Der Trait ist schon importiert. + - label: Umgebungsvariable + hint: "`env` allein würde den Namen aus dem Feld ableiten (`STUFE`). Gib den Namen ausdrücklich an." + explanation: | + `#[command(subcommand)]` bettet ein Enum ein, dessen Varianten `#[derive(Subcommand)]` zu Befehlen macht; clap schreibt sie in kebab-case, aus `Zaehlen` wird `zaehlen`. Die Doc-Kommentare der Varianten erscheinen in `logwerk --help`. `env = "LOGWERK_STUFE"` liest die Variable, wenn `--stufe` fehlt, und zeigt sie in der Hilfe als `[env: LOGWERK_STUFE=]`; das braucht das Feature `env`. Ein bloßes `env` hätte den Namen aus dem Feld abgeleitet, hier `STUFE`. Lokal ergab `LOGWERK_STUFE=error logwerk filtern app.log` die Ausgabe `app.log ab error`, ohne Variable `app.log ab warn`. + resources: + - title: clap Derive · Arg-Attribute + url: https://docs.rs/clap/latest/clap/_derive/index.html#arg-attributes + - title: clap Derive · Subcommand-Typen + url: https://docs.rs/clap/latest/clap/_derive/index.html#subcommand-types - id: release-start type: command title: Argumente an das Binary geben @@ -325,7 +598,8 @@ exercises: - cargo run --release -- app.log explanation: Der Trenner `--` beendet die Cargo-Optionen. Danach folgen Argumente für das ausgeführte Binary. resources: - - *id001 + - title: Cargo · cargo run + url: https://doc.rust-lang.org/cargo/commands/cargo-run.html - id: diagnose-strom type: bug title: Eine Pipe erhält Fehlertext als Nutzdaten @@ -345,7 +619,58 @@ exercises: explanation: Diagnosen gehören auf stderr. Das hält stdout für den dokumentierten Ergebnisstrom nutzbar; zusätzlich benötigt das echte Programm einen passenden Fehlerstatus. resources: - - *id001 + - title: std::eprintln! + url: https://doc.rust-lang.org/std/macro.eprintln.html +- id: broken-pipe + type: bug + title: Panik hinter head + prompt: | + `logwerk filtern gross.log | head -1` zeigt die erste Zeile und danach: + + ```text + thread 'main' (1427964) panicked at library/std/src/io/stdio.rs:1168:9: + failed printing to stdout: Broken pipe (os error 32) + ``` + + Der Exit-Status ist 101, obwohl `main` den Fall `BrokenPipe` ausdrücklich behandelt. Außerdem ist die Ausgabe an Pipes langsam. Finde die Zeile und korrigiere sie. + lang: rust + code: |- + use std::io::{self, BufWriter, ErrorKind, Write}; + + fn ausgeben(zeilen: &[String]) -> io::Result<()> { + let mut out = BufWriter::new(io::stdout().lock()); + for zeile in zeilen { + println!("{zeile}"); + } + out.flush() + } + + fn main() { + let zeilen: Vec = (1..=100_000).map(|n| format!("WARN disk {n}")).collect(); + match ausgeben(&zeilen) { + Ok(()) => {} + Err(e) if e.kind() == ErrorKind::BrokenPipe => {} + Err(e) => eprintln!("logwerk: {e}"), + } + } + lines: + - 6 + fix: + - line: 6 + answers: + - writeln!(out, "{zeile}")?; + - writeln!(out, "{}", zeile)?; + - writeln!(&mut out, "{zeile}")?; + hints: + - Wohin schreibt `println!`, und wofür wurde `out` angelegt? + - "`println!` panikt bei einem Schreibfehler. Welches Makro liefert den Fehler als `io::Result`?" + explanation: | + `println!` schreibt an `out` vorbei direkt auf stdout und panikt, sobald `head` die Pipe geschlossen hat; der `match` in `main` sieht den Fehler nie. Der `BufWriter` bleibt leer und bringt nichts, also läuft jede Zeile einzeln durch den zeilengepufferten stdout. `writeln!(out, "{zeile}")?` schreibt in den Puffer und reicht `ErrorKind::BrokenPipe` als `io::Error` nach oben, wo `main` es als normales Ende behandelt. Lokal endete die korrigierte Fassung hinter `head -1` mit Status 0, die fehlerhafte mit 101. + resources: + - title: std::print! · Panics und Sperre + url: https://doc.rust-lang.org/std/macro.print.html#panics + - title: ErrorKind::BrokenPipe + url: https://doc.rust-lang.org/std/io/enum.ErrorKind.html#variant.BrokenPipe - id: result-option type: explain title: Drei fachliche Ausgänge @@ -414,3 +739,114 @@ exercises: Der frühe Fehler verhindert einen scheinbar vollständigen Teilbericht. resources: - *id001 +- id: konfig-schichten + type: practice + title: Stufe aus Kommandozeile, Umgebung und Datei + prompt: | + Baue für `logwerk` die Auswahl der Mindeststufe. Quellen in dieser Reihenfolge: `--stufe`, die Umgebungsvariable `LOGWERK_STUFE`, das Feld `stufe` in `logwerk.toml` (Pfad per `--konfig` änderbar), sonst `warn`. + + - Erlaubt sind `info`, `warn`, `error`, auf der Kommandozeile wie in der Datei. + - Fehlt die Datei, gilt das als leere Konfiguration. Eine kaputte Datei oder ein unbekannter Schlüssel ist ein Fehler mit Dateiname und Position auf stderr und Status 1. + - Die Vorrangregel steckt in einer reinen Funktion mit Tests, die weder Dateien noch Umgebungsvariablen anfassen. + - Das Programm gibt `Stufe: Warn` (bzw. die gewählte Stufe) aus. + + Abhängigkeiten: `clap` 4.6 mit `derive` und `env`, `serde` 1 mit `derive`, `toml` 1.1. Probiere es mit und ohne Datei, mit `LOGWERK_STUFE=error` und mit einem Tippfehler in der Datei aus. + lang: rust + solution: |- + use std::path::{Path, PathBuf}; + use std::process::ExitCode; + + use clap::{Parser, ValueEnum}; + use serde::Deserialize; + + #[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, ValueEnum)] + #[serde(rename_all = "lowercase")] + enum Stufe { + Info, + Warn, + Error, + } + + /// Filtert Logzeilen ab einer Mindeststufe. + #[derive(Parser)] + #[command(name = "logwerk")] + struct Cli { + /// Mindeststufe, schlägt Umgebung und Datei + #[arg(long, value_enum, env = "LOGWERK_STUFE")] + stufe: Option, + /// Konfigurationsdatei + #[arg(long, default_value = "logwerk.toml")] + konfig: PathBuf, + } + + #[derive(Debug, Default, Deserialize)] + #[serde(default, deny_unknown_fields)] + struct Datei { + stufe: Option, + } + + fn stufe_waehlen(cli_oder_env: Option, datei: Option) -> Stufe { + cli_oder_env.or(datei).unwrap_or(Stufe::Warn) + } + + fn datei_lesen(pfad: &Path) -> Result { + match std::fs::read_to_string(pfad) { + Ok(text) => toml::from_str(&text).map_err(|e| format!("{}: {e}", pfad.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Datei::default()), + Err(e) => Err(format!("{}: {e}", pfad.display())), + } + } + + fn main() -> ExitCode { + let cli = Cli::parse(); + match datei_lesen(&cli.konfig) { + Ok(datei) => { + println!("Stufe: {:?}", stufe_waehlen(cli.stufe, datei.stufe)); + ExitCode::SUCCESS + } + Err(fehler) => { + eprintln!("logwerk: {fehler}"); + ExitCode::FAILURE + } + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn vorrang() { + assert_eq!(stufe_waehlen(Some(Stufe::Error), Some(Stufe::Info)), Stufe::Error); + assert_eq!(stufe_waehlen(None, Some(Stufe::Info)), Stufe::Info); + assert_eq!(stufe_waehlen(None, None), Stufe::Warn); + } + + #[test] + fn datei_streng() { + assert!(toml::from_str::("stuffe = \"info\"").is_err()); + let datei: Datei = toml::from_str("stufe = \"error\"").unwrap(); + assert_eq!(datei.stufe, Some(Stufe::Error)); + } + } + checklist: + - Ein Enum mit `ValueEnum` und `Deserialize` (`rename_all = "lowercase"`) beschreibt die drei Stufen für beide Quellen. + - "Das clap-Feld ist `Option` mit `env = \"LOGWERK_STUFE\"` und ohne Standardwert, damit die Datei noch greifen kann." + - "Die Dateistruktur nutzt `#[serde(default, deny_unknown_fields)]`." + - Eine fehlende Datei (`ErrorKind::NotFound`) ergibt die Standardkonfiguration, jeder andere Lese- oder Parsefehler einen Fehler mit Dateinamen. + - Die Vorrangfunktion ist rein und hat Tests für alle drei Stufen der Regel. + - "`main` gibt `ExitCode` zurück: 0 bei Erfolg, 1 bei Konfigurationsfehlern; Nutzungsfehler meldet clap mit 2." + verify: false + hints: + - Lässt du `default_value_t` weg und machst das Feld zu `Option`, erledigt clap die ersten beiden Quellen und liefert `None`, wenn beide fehlen. + - "`std::fs::read_to_string` liefert bei fehlender Datei einen Fehler mit `kind() == ErrorKind::NotFound`." + - "`Option::or` und `unwrap_or` reichen für die Vorrangfunktion." + explanation: | + clap legt Kommandozeile und Umgebung zusammen; mit `Option` und ohne Standardwert bleibt erkennbar, ob eine der beiden Quellen etwas geliefert hat. Datei und Standard verbindet die reine Funktion `stufe_waehlen`, die ohne Dateisystem und ohne `set_var` testbar ist. `deny_unknown_fields` macht aus `stuffe = "info"` die Meldung ``unknown field `stuffe`, expected `stufe` `` mit Zeile und Spalte, statt den Schlüssel zu ignorieren. Eine fehlende Datei ist der Normalfall bei frischen Installationen und darf keinen Fehler auslösen. Lokal ergaben die Läufe ohne Datei `Warn`, mit `stufe = "info"` `Info`, mit `LOGWERK_STUFE=error` `Error` und mit zusätzlichem `--stufe warn` wieder `Warn`; `--stufe laut` endete mit Status 2. + resources: + - title: serde · deny_unknown_fields + url: https://serde.rs/container-attrs.html#deny_unknown_fields + - title: toml::from_str + url: https://docs.rs/toml/latest/toml/fn.from_str.html + - title: clap Derive · Arg-Attribute + url: https://docs.rs/clap/latest/clap/_derive/index.html#arg-attributes diff --git a/content/rust/modules/testen-qualitaet.yaml b/content/rust/modules/testen-qualitaet.yaml index d5260b51..f2b596b2 100644 --- a/content/rust/modules/testen-qualitaet.yaml +++ b/content/rust/modules/testen-qualitaet.yaml @@ -1,13 +1,16 @@ id: testen-qualitaet title: Tests & Code-Qualität level: 3 -minutes: 170 +minutes: 240 summary: Fachliche Verträge, Fehlerpfade und Programmgrenzen mit aussagekräftigen Tests und reproduzierbaren Werkzeugprüfungen sichern. +place: pc goals: - Du leitest Testfälle aus Regeln und Grenzwerten ab. -- Du unterscheidest Unit-, Integrations- und Dokumentationstests. -- Du isolierst I/O und vermeidest reihenfolgeabhängige Tests. +- Du unterscheidest Unit-, Integrations- und Dokumentationstests und testest ein CLI über Exit-Status, stdout und stderr. +- Du isolierst I/O, gibst jedem Test mit `tempfile` ein eigenes Verzeichnis und vermeidest reihenfolgeabhängige Tests. +- Du formulierst Property-Tests mit proptest und liest ein geschrumpftes Gegenbeispiel. +- Du setzt Snapshot-Tests mit insta gezielt ein und filterst zufällige Werte. - Du setzt cargo test, rustfmt und Clippy mit klaren Aussagen ein. resources: - &id001 @@ -17,18 +20,18 @@ resources: url: https://doc.rust-lang.org/book/ch11-03-test-organization.html - title: Cargo · cargo test url: https://doc.rust-lang.org/cargo/commands/cargo-test.html -- title: Clippy · Usage - url: https://doc.rust-lang.org/clippy/usage.html -- title: rustdoc · Documentation tests - url: https://doc.rust-lang.org/rustdoc/write-documentation/documentation-tests.html +- title: assert_cmd + url: https://docs.rs/assert_cmd/latest/assert_cmd/ +- title: proptest-Buch + url: https://proptest-rs.github.io/proptest/intro.html lesson: | - Ein Parser kompiliert, Clippy meldet nichts und der normale Beispieldatensatz wird richtig verarbeitet. Trotzdem kann ein leerer Datensatz paniken oder eine Zahl am Rand des zulässigen Bereichs falsch behandelt werden. Der Compiler prüft viele strukturelle Eigenschaften. Er weiß aber nicht, welchen fachlichen Vertrag du deinem Nutzer versprochen hast. + Ein Parser kompiliert, Clippy meldet nichts, und der Beispieldatensatz wird richtig verarbeitet. Trotzdem kann ein leerer Datensatz paniken oder eine Zahl am Rand des zulässigen Bereichs falsch behandelt werden. Der Compiler prüft viele strukturelle Eigenschaften, aber er kennt den fachlichen Vertrag nicht, den du deinem Nutzer versprochen hast. - Tests beschreiben diesen Vertrag anhand überprüfbarer Fälle. Gute Tests erhöhen die Freiheit, die Implementierung später zu ändern, weil sie das beobachtbare Verhalten festhalten. Schlechte Tests bilden jede interne Schleife nach und werden bei jeder sinnvollen Änderung zu zusätzlicher Arbeit. Dieses Modul verbindet Rusts eingebaute Tests mit Fehlerdiagnose, reproduzierbaren Werkzeugprüfungen und einer kleinen CI-Strategie. + Tests beschreiben diesen Vertrag an überprüfbaren Fällen. Gute Tests halten beobachtbares Verhalten fest und geben dir die Freiheit, die Implementierung zu ändern. Schlechte Tests bilden jede interne Schleife nach und werden bei jeder sinnvollen Änderung zur Last. Dieses Modul geht von Rusts eingebauten Tests über CLI-Integrationstests bis zu Property- und Snapshot-Tests. ## Ein Test braucht eine überprüfbare Behauptung - `#[test]` markiert eine Testfunktion. `cargo test` baut einen Testrunner und führt die Tests aus. Eine fehlschlagende Assertion lässt den Test scheitern. Das [Testkapitel](https://doc.rust-lang.org/book/ch11-01-writing-tests.html) beschreibt Einrichtung, Aktion und Erwartung als die drei grundlegenden Schritte. + `#[test]` markiert eine Testfunktion, `cargo test` baut einen Testrunner und führt sie aus, eine fehlschlagende Assertion lässt den Test scheitern. Das [Testkapitel](https://doc.rust-lang.org/book/ch11-01-writing-tests.html) beschreibt Einrichtung, Aktion und Erwartung als Grundschritte. ```rust fn rabatt(preis: u32) -> u32 { @@ -50,13 +53,11 @@ lesson: | } ``` - Die Werte 99 und 100 sind gewählt, weil dort die Regel wechselt. Zehn beliebige Werte weit oberhalb der Grenze wären weniger aussagekräftig. Ein Testname erklärt die Fachregel, nicht nur „test_1“. `assert_eq!` zeigt bei einem Fehler beide verglichenen Werte; `assert!` eignet sich für eine Bedingung. Für Vergleiche eigener Typen sind `Debug` und meist `PartialEq` hilfreich. - - Eine Erwartung darf nicht denselben fehleranfälligen Algorithmus nachbauen. Wenn die Implementierung einen Rabatt falsch berechnet und der Test dieselbe Formel kopiert, bestätigen sich beide gegenseitig. Nimm unabhängige, von der Anforderung hergeleitete Beispiele. Bei einer komplexen Formel kann eine kleine, klar lesbare Referenzimplementierung für begrenzte Eingaben sinnvoll sein. + Die Werte 99 und 100 stehen da, weil dort die Regel wechselt; zehn Werte weit oberhalb der Grenze sagen weniger. Der Testname erklärt die Fachregel. `assert_eq!` zeigt bei einem Fehler beide Werte, `assert!` prüft eine Bedingung. Eine Erwartung darf nicht denselben fehleranfälligen Algorithmus nachbauen: Kopiert der Test die Formel der Implementierung, bestätigen sich beide gegenseitig. Nimm Beispiele, die du aus der Anforderung herleitest. ## Randfälle und Fehlerarten gezielt abdecken - Teile den Eingaberaum in sinnvolle Klassen: leer, gültig, Grenze, ungültige Syntax, unzulässiger Wertebereich. Bei Text kommen Unicode und Zeilenenden hinzu; bei Collections oft null, ein und mehrere Elemente. Nicht jede Kombination verdient einen eigenen Test, aber jede unterschiedliche Entscheidung sollte belegbar sein. + Teile den Eingaberaum in Klassen: leer, gültig, Grenze, ungültige Syntax, unzulässiger Wertebereich. Bei Text kommen Unicode und Zeilenenden dazu, bei Collections null, ein und viele Elemente. Nicht jede Kombination braucht einen Test, aber jede unterschiedliche Entscheidung sollte belegt sein. ```rust #[derive(Debug, PartialEq)] @@ -77,48 +78,49 @@ lesson: | fn main() { println!("{:?}", lesen("7")); } ``` - Ein bloßes `is_err()` würde hier nicht prüfen, ob Leerraum die fachlich richtige Variante erhält. Es ist ausreichend, wenn alle Fehler dieselbe Bedeutung haben; andernfalls teste die konkrete Variante. `#[should_panic]` ist für APIs gedacht, deren Vertrag einen Panic erwartet. Es ist kein Ersatz für die Fehlerprüfung einer `Result`-Funktion. Mit `expected` lässt sich die erwartete Panic-Meldung eingrenzen, damit nicht irgendein anderer Panic den Test zufällig grün macht. + Ein bloßes `is_err()` würde nicht prüfen, ob Leerraum die richtige Variante bekommt. `#[should_panic]` ist für APIs gedacht, deren Vertrag einen Panic verlangt, und kein Ersatz für die Fehlerprüfung einer `Result`-Funktion; mit `expected` grenzt du die Meldung ein, damit nicht irgendein anderer Panic den Test grün macht. > [!PRAXIS] - > Für einen gemeldeten Bug schreibst du zuerst den kleinsten Test, der den beobachteten Fehler zeigt. Prüfe, dass er vor der Reparatur scheitert. Erst danach zeigt ein grüner Lauf, dass die Reparatur den konkreten Fehler erfasst. + > Für einen gemeldeten Bug schreibst du zuerst den kleinsten Test, der den Fehler zeigt, und prüfst, dass er vor der Reparatur scheitert. Erst dann beweist ein grüner Lauf, dass die Reparatur genau diesen Fehler erfasst. ## Unit-, Integrations- und Dokumentationstests - Unit-Tests liegen häufig als `#[cfg(test)] mod tests` beim Code. Über `use super::*` können sie auch private Implementierungsteile erreichen. Das ist nützlich für kleine Parserregeln, aber kein Auftrag, jede private Hilfsfunktion direkt zu testen. - - Integrationstests unter `tests/` werden als externe Nutzer deiner Library gebaut. Sie erreichen deren öffentliche API. Deshalb lohnt sich bei einem CLI die Trennung: Fachlogik in `src/lib.rs`, Argumente und Prozessstatus in `src/main.rs`. Das [Kapitel zur Testorganisation](https://doc.rust-lang.org/book/ch11-03-test-organization.html) erklärt die unterschiedlichen Sichtbarkeiten. + Unit-Tests liegen als `#[cfg(test)] mod tests` beim Code und erreichen über `use super::*` auch private Teile. Integrationstests unter `tests/` baut Cargo als externe Nutzer deiner Library; sie sehen nur die öffentliche API. Bei einem CLI lohnt deshalb die Trennung: Fachlogik in `src/lib.rs`, Argumente und Prozessstatus in `src/main.rs` ([Testorganisation](https://doc.rust-lang.org/book/ch11-03-test-organization.html)). ```text src/ lib.rs # Parser und fachliche Operationen main.rs # CLI-Grenze - tests/ - cli.rs # Aufruf und sichtbares Verhalten des Programms + tests/ + cli.rs # Aufruf und sichtbares Verhalten des Binarys + props.rs # Property-Tests gegen die Library + snapshots/ # von insta verwaltete .snap-Dateien ``` - Ein CLI-Integrationstest prüft Exit-Status, Standardausgabe und Fehlerausgabe. Ein erfolgreiches Ergebnis auf stdout kann falsch sein, wenn der Prozess trotzdem mit Fehler endet. Umgekehrt kann eine Fehlermeldung korrekt sein, aber ein Erfolgsstatus die automatisierte Nutzung täuschen. - - Dokumentationstests prüfen Rust-Beispiele in `///`-Kommentaren. Sie sichern, dass ein öffentlich erklärter Aufruf noch übersetzt und funktioniert. Das [rustdoc-Testkapitel](https://doc.rust-lang.org/rustdoc/write-documentation/documentation-tests.html) beschreibt auch `compile_fail` für absichtlich unzulässige Beispiele. Verwechsle das nicht mit `ignore`: Ignorieren nimmt dir die Prüfung weg. + Dokumentationstests prüfen Rust-Beispiele in `///`-Kommentaren und sichern, dass ein öffentlich erklärter Aufruf noch funktioniert. `compile_fail` markiert absichtlich unzulässige Beispiele; `ignore` nimmt dir die Prüfung weg ([rustdoc](https://doc.rust-lang.org/rustdoc/write-documentation/documentation-tests.html)). ```rust - /// Liefert die doppelte Anzahl. + /// Liefert die doppelte Anzahl oder `None`, + /// wenn das Ergebnis nicht in `u32` passt. /// - /// Liefert None, wenn die Verdopplung nicht in u32 passt. + /// ``` + /// assert_eq!(logwerk::doppelt(3), Some(6)); + /// assert_eq!(logwerk::doppelt(u32::MAX), None); + /// ``` pub fn doppelt(n: u32) -> Option { n.checked_mul(2) } fn main() { assert_eq!(doppelt(3), Some(6)); - assert_eq!(doppelt(u32::MAX), None); } ``` - Für deine echte Library ergänzst du im Doc-Kommentar ein Beispiel mit ihrem Crate-Namen. Das Beispiel sollte außerdem den Rückgabevertrag korrekt erklären: Hier liefert ein Überlauf `None`, er ist kein vom Aufrufer unerlaubt herbeigeführter Zustand. + Das Beispiel zwischen den Zäunen im Doc-Kommentar ist der Doc-Test. `cargo test` übersetzt es als eigenes kleines Programm, das deine Library wie ein fremdes Crate benutzt – deshalb der volle Pfad `logwerk::doppelt`, und deshalb funktionieren Doc-Tests nur für Library-Crates, nicht für `src/main.rs`. Das Beispiel dokumentiert den Rückgabevertrag mit: Ein Überlauf ist hier ein erwarteter Fall und liefert `None`. - ## Determinismus entsteht durch passende Grenzen + ## Determinismus: Grenzen, Uhren und temporäre Dateien - Tests sollten nicht auf eine echte Uhr, einen zufälligen freien Port oder ein produktives Netzwerk angewiesen sein, wenn sie nur Fachlogik prüfen. Reiche Abhängigkeiten hinein: einen Reader, einen Writer, eine Uhrfunktion oder einen kleinen Trait. So bleibt der wichtige Mechanismus sichtbar, ohne eine große Mock-Infrastruktur aufzubauen. + Tests sollten nicht von einer echten Uhr, einem zufälligen Port oder einem produktiven Netz abhängen, wenn sie nur Fachlogik prüfen. Reiche Abhängigkeiten hinein: einen Reader, einen Writer, eine Uhrfunktion. ```rust use std::io::{self, BufRead}; @@ -140,13 +142,217 @@ lesson: | fn main() {} ``` - `Cursor` erfüllt die I/O-Schnittstelle, arbeitet aber vollständig mit lokalen Bytes. Ein zusätzlicher Integrationstest mit einer echten temporären Datei prüft die Dateigrenze. Diese beiden Ebenen ergänzen sich. Ein reiner Speichertest beweist nicht, dass Argumente und Pfade korrekt verdrahtet sind. + `Cursor` erfüllt die I/O-Schnittstelle mit Bytes im Speicher. Ob Pfade und Argumente richtig verdrahtet sind, beweist das nicht; dafür braucht es echte Dateien. Und hier beginnt das Problem: `cargo test` lässt Tests parallel in Threads laufen. Zwei Tests, die dieselbe feste Datei `/tmp/logwerk-test.log` schreiben und dann `logwerk` darauf starten, waren bei 50 von 50 Läufen rot, mit `--test-threads=1` bei 0 von 50. Die Option ist eine Diagnose, keine Reparatur. + + Die Reparatur ist ein eigenes Verzeichnis pro Test. [`tempfile::tempdir()`](https://docs.rs/tempfile/latest/tempfile/fn.tempdir.html) legt eins mit zufälligem Namen an, und der Drop des `TempDir` löscht es samt Inhalt; [`NamedTempFile`](https://docs.rs/tempfile/latest/tempfile/struct.NamedTempFile.html) macht dasselbe für eine einzelne Datei. Zwei Fallen: Lässt du das `TempDir` zu früh fallen, etwa weil eine Hilfsfunktion nur den Pfad zurückgibt, ist das Verzeichnis weg, bevor das Programm läuft ([Early drop pitfall](https://docs.rs/tempfile/latest/tempfile/#early-drop-pitfall)). Und bei SIGINT oder einem `process::exit` läuft kein Destruktor, das Verzeichnis bleibt liegen. Zur Fehlersuche hältst du es mit [`TempDir::keep()`](https://docs.rs/tempfile/latest/tempfile/struct.TempDir.html#method.keep) absichtlich fest; der alte Name `into_path` ist veraltet. Für Timer gilt dasselbe Prinzip: Tokio hält die Uhr in Tests an ([Async Rust](/rust/async-rust)), statt dass Tests echt warten. + + ## CLI-Integrationstests: das Binary von außen prüfen + + Ein CLI hat drei Ausgänge, und jeder gehört zum Vertrag: Exit-Status, stdout, stderr. Ein richtiger Bericht auf stdout mit Status 1 ist genauso falsch wie eine gute Fehlermeldung mit Status 0. Für Integrationstests setzt Cargo `CARGO_BIN_EXE_` auf den absoluten Pfad des gebauten Binarys und baut es vor dem Test ([Cargo: Umgebungsvariablen](https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-sets-for-crates)). Mit `env!` und `std::process::Command` reicht die Standardbibliothek: + + ```rust + use std::io::Write; + use std::process::{Command, Output, Stdio}; + + fn logwerk(args: &[&str], eingabe: &str) -> Output { + let mut kind = Command::new(env!("CARGO_BIN_EXE_logwerk")) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("logwerk startet"); + kind.stdin.take().unwrap().write_all(eingabe.as_bytes()).unwrap(); + kind.wait_with_output().expect("logwerk endet") + } + + #[test] + fn zaehlt_stufen_von_stdin() { + let aus = logwerk(&["zaehlen", "-"], "INFO start\nERROR db\nINFO ende\n"); + assert!(aus.status.success()); + assert_eq!(String::from_utf8_lossy(&aus.stdout), "INFO 2\nERROR 1\n"); + assert!(aus.stderr.is_empty()); + } + + #[test] + fn meldet_kaputte_zeile_mit_nummer() { + let aus = logwerk(&["zaehlen", "-"], "INFO start\n\nDEBUG x\n"); + assert_eq!(aus.status.code(), Some(1)); + assert!(aus.stdout.is_empty()); + let fehler = String::from_utf8_lossy(&aus.stderr); + assert!(fehler.contains("Zeile 3"), "stderr war: {fehler}"); + } + ``` + + `env!` liest die Variable beim Kompilieren: Ein Tippfehler im Namen ist ein Compilerfehler, kein roter Test. `take()` holt die stdin-Pipe aus dem `Child`. Am Ende der Anweisung wird sie gedroppt, die Pipe schließt, und `logwerk` sieht das Dateiende. + + [assert_cmd](https://docs.rs/assert_cmd/latest/assert_cmd/) 2.2 kürzt das ab und liefert lesbare Fehlermeldungen; [predicates](https://docs.rs/predicates/latest/predicates/str/index.html) beschreibt Erwartungen an Text. Das Makro [`cargo_bin_cmd!`](https://docs.rs/assert_cmd/latest/assert_cmd/cargo/macro.cargo_bin_cmd.html) baut auf demselben `env!` auf; `Command::cargo_bin("logwerk")` sucht den Pfad erst zur Laufzeit und liefert ein `Result`. Veraltet ist in 2.2 nur `crate_name!` (ersetzt durch `pkg_name!`). Die Abhängigkeiten gehören in `[dev-dependencies]`: + + ```toml + [dev-dependencies] + assert_cmd = "2.2" + predicates = "3.1" + tempfile = "3.27" + proptest = "1.12" + insta = { version = "1.49", features = ["filters"] } + ``` + + ```rust + use std::fs; + + use assert_cmd::cargo::cargo_bin_cmd; + use predicates::prelude::*; + + #[test] + fn zaehlt_datei() { + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join("app.log"); + fs::write(&log, "INFO start\nWARN platte\nINFO ende\n").unwrap(); + + cargo_bin_cmd!("logwerk") + .args(["zaehlen", log.to_str().unwrap()]) + .assert() + .success() + .stdout("INFO 2\nWARN 1\n") + .stderr(predicate::str::is_empty()); + } + + #[test] + fn fehlende_datei_ist_fehler() { + let dir = tempfile::tempdir().unwrap(); + cargo_bin_cmd!("logwerk") + .args(["zaehlen", "fehlt.log"]) + .current_dir(dir.path()) + .assert() + .code(1) + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::contains("fehlt.log").and(predicate::str::contains("os error 2"))); + } + ``` + + `dir` lebt bis zum Ende der Testfunktion, das Verzeichnis also auch. `current_dir` lässt das Programm im leeren Temp-Verzeichnis laufen, damit kein zufällig vorhandenes `fehlt.log` im Projekt den Test verfälscht. + + Endet `logwerk` bei einer kaputten Zeile versehentlich mit 0, meldet ein Test mit `.code(1)` (Pfad zum Binary gekürzt): + + ```text + Unexpected return code, failed var == 1 + └── var: 0 + + command=`"…/target/debug/logwerk" "zaehlen" "/tmp/.tmpy8M7jo/app.log"` + code=0 + stdout="" + stderr="logwerk: Zeile 2: unbekannte Stufe `DEBUG`\n" + ``` + + Die Meldung nennt die verletzte Erwartung, den vollständigen Aufruf und alle drei Ausgänge. Das reicht meist, um den Fehler ohne Debugger zu sehen. + + ## Property-Tests: Eigenschaften statt Beispiele + + Ein Beispieltest prüft die Fälle, an die du gedacht hast. Ein Property-Test beschreibt eine Eigenschaft, die für alle gültigen Eingaben gilt, und [proptest](https://docs.rs/proptest/latest/proptest/) erzeugt standardmäßig 256 zufällige Eingaben dafür ([Config::cases](https://docs.rs/proptest/latest/proptest/test_runner/struct.Config.html#structfield.cases)). Drei Arten von Eigenschaften tragen weit: ein Roundtrip (`parse(format(x)) == x`), eine Invariante (die Summe aller Zähler ist die Zahl der Zeilen) und der Vergleich mit einer einfachen Referenz (eigene Sortierung gegen `sort`). Ein erster Versuch für `logwerk`: + + ```rust + proptest! { + #[test] + fn format_und_parse_passen_zusammen(stufe in stufe(), meldung in "[a-zA-Z0-9 ]{1,30}") { + let zeile = format_zeile(stufe, &meldung); + prop_assert_eq!(parse_zeile(&zeile), Ok(Some((stufe, meldung)))); + } + } + ``` + + Ein String-Literal ist in proptest eine Strategie: Es erzeugt Texte, die auf den regulären Ausdruck passen ([string_regex](https://docs.rs/proptest/latest/proptest/string/fn.string_regex.html)). `cargo test` meldete (gekürzt): + + ```text + proptest: Saving this and future failures in …/testen/tests/props_roundtrip.proptest-regressions + Test failed: assertion failed: `(left == right)` + left: `Err(MeldungFehlt)`, + right: `Ok(Some((Info, " ")))` at testen/tests/props_roundtrip.rs:12. + minimal failing input: stufe = Info, meldung = " " + successes: 1 + ``` + + Das ist Shrinking: Proptest fand eine scheiternde Eingabe und verkleinerte sie, solange der Test weiter scheiterte – bis zu einer Meldung, die nur aus einem Leerzeichen besteht ([Shrinking](https://proptest-rs.github.io/proptest/proptest/tutorial/shrinking-basics.html)). Der Fund ist echt: `parse_zeile` trimmt, also überlebt eine Meldung mit Leerzeichen am Rand den Roundtrip nicht. Hier ist die Eigenschaft richtig und die Strategie zu weit; gültige Meldungen beginnen und enden mit einem Zeichen, das kein Leerzeichen ist. Die korrigierte Fassung samt Invariante: + + ```rust + use logwerk::{format_zeile, parse_zeile, zaehlen, Stufe}; + use proptest::prelude::*; + + fn stufe() -> impl Strategy { + prop_oneof![Just(Stufe::Info), Just(Stufe::Warn), Just(Stufe::Error)] + } + + proptest! { + // Roundtrip: Was format_zeile schreibt, liest parse_zeile unverändert zurück. + #[test] + fn format_und_parse_passen_zusammen( + stufe in stufe(), + meldung in "[a-zA-Z0-9äöü]([a-zA-Z0-9äöü ]{0,28}[a-zA-Z0-9äöü])?", + ) { + let zeile = format_zeile(stufe, &meldung); + prop_assert_eq!(parse_zeile(&zeile), Ok(Some((stufe, meldung)))); + } + + // Invariante: Jede gültige Zeile wird genau einmal gezählt. + #[test] + fn summe_ist_zeilenzahl(zeilen in prop::collection::vec((stufe(), "[a-z]{1,8}"), 0..50)) { + let text: String = zeilen.iter().map(|(s, m)| format_zeile(*s, m) + "\n").collect(); + let summe: usize = zaehlen(&text).unwrap().values().sum(); + prop_assert_eq!(summe, zeilen.len()); + } + } + ``` + + Weitere Strategien: `any::()` für den ganzen Wertebereich, `0u32..10` für einen Bereich, `prop::collection::vec` für Listen. Wähle den Bereich so, dass interessante Fälle oft vorkommen: Unter 50 zufälligen `u32` ist ein Duplikat fast ausgeschlossen, unter 50 Werten aus `0..10` die Regel. Den Seed jedes Fehlschlags speichert proptest in einer `proptest-regressions`-Datei (bei Integrationstests neben der Testdatei) und spielt ihn bei jedem Lauf zuerst ab. Committe sie, damit CI und Kollegen denselben Fall prüfen ([Failure Persistence](https://proptest-rs.github.io/proptest/proptest/failure-persistence.html)). + + ## Snapshot-Tests mit insta + + Manche Ausgaben sind groß und stabil: ein Bericht, ein Hilfetext, eine generierte Konfiguration. Sie Zeile für Zeile in `assert_eq!` abzutippen ist mühsam. [insta](https://docs.rs/insta/latest/insta/) speichert die Ausgabe beim ersten Lauf als `.snap`-Datei und vergleicht spätere Läufe damit; `assert_snapshot!` nimmt Text, `assert_debug_snapshot!` die `Debug`-Ausgabe eines Werts. + + ```rust + use std::fs; + + use insta::assert_snapshot; + + #[test] + fn bericht_fuer_beispieldatei() { + let dir = tempfile::tempdir().unwrap(); + let pfad = dir.path().join("app.log"); + fs::write(&pfad, "INFO start\nWARN platte\nERROR db\nINFO ende\n").unwrap(); + let text = fs::read_to_string(&pfad).unwrap(); + let bericht = logwerk::bericht(&pfad.display().to_string(), &text).unwrap(); + + // Der Pfad ist bei jedem Lauf anders: ersetzen, bevor insta vergleicht. + let temp = dir.path().display().to_string(); + insta::with_settings!({filters => vec![(temp.as_str(), "[TEMP]")]}, { + assert_snapshot!(bericht); + }); + } + ``` + + Der erste Lauf scheitert absichtlich und legt `tests/snapshots/snapshots__bericht_fuer_beispieldatei.snap.new` an. Du prüfst den Inhalt und nimmst ihn mit `cargo insta review` an (Werkzeug `cargo-insta`) oder ohne Werkzeug mit `INSTA_UPDATE=always cargo test` ([Updating snapshots](https://docs.rs/insta/latest/insta/#updating-snapshots)). Die angenommene Datei gehört ins Repository: - Tests können parallel laufen. Vermeide gemeinsame feste Dateinamen, globale veränderbare Zustände und Abhängigkeiten von Testreihenfolgen. `--test-threads=1` ist als Diagnose nützlich, behebt aber keine unsaubere Isolation. Tokios [Testdokumentation](https://tokio.rs/tokio/topics/testing) beschreibt für Timer außerdem kontrollierbare Zeit; pauschales langes Schlafen macht Tests langsam und trotzdem unzuverlässig. + ```text + --- + source: tests/snapshots.rs + expression: bericht + --- + Bericht für [TEMP]/app.log + INFO 2 + WARN 1 + ERROR 1 + Zeilen gesamt: 4 + ``` + + Ohne den Filter stünde dort `/tmp/.tmpXXXXXX/app.log`, und jeder Lauf wäre rot. Filter ersetzen per regulärem Ausdruck Pfade, Zeitstempel oder IDs ([Filters](https://insta.rs/docs/filters/)); für serialisierte Werte gibt es Redactions, die einzelne Felder ersetzen ([Redactions](https://insta.rs/docs/redactions/)). Den Pfad als Ausdruck zu nehmen geht hier, weil die Punkte darin auf jedes Zeichen passen, also auch auf sich selbst. Noch besser ist oft, gar keinen zufälligen Wert auszugeben: relative Pfade und `current_dir`. Ausprobiert: Aus `Zeilen gesamt` wurde im Code `Zeilen insgesamt`, und der nächste Lauf zeigte einen Diff mit `-Zeilen gesamt: 4` und `+Zeilen insgesamt: 4`. + + Snapshots helfen bei großen, stabilen Ausgaben, bei denen eine Änderung auffallen soll. Für eine einzelne Zahl oder einen kurzen String bleibt `assert_eq!` klarer. + + > [!WARNING] Blind angenommen ist nicht getestet + > Mit `INSTA_UPDATE=always` überschreibt insta jeden abweichenden Snapshot, und der Lauf endet grün – auch der, der gerade einen Fehler zeigt. Nutze es nur nach einer bewussten Prüfung, nie als Alias oder in CI, und lies im Review den Diff der `.snap`-Datei wie Code. - ## Werkzeugprüfungen haben verschiedene Aufgaben + ## Werkzeuge, CI und belastbare Aussagen - `cargo fmt` vereinheitlicht Formatierung. `cargo clippy` untersucht zusätzliche Fehler- und Qualitätsmuster. `cargo test` prüft die von dir formulierten Verträge. Ein Release-Build prüft noch eine andere Konfiguration. Keine einzelne dieser Stufen beweist, dass die Anwendung vollständig richtig ist. + `cargo fmt` vereinheitlicht Formatierung, `cargo clippy` sucht zusätzliche Fehler- und Qualitätsmuster, `cargo test` prüft deine Verträge, ein Release-Build eine weitere Konfiguration. Keine Stufe allein beweist, dass die Anwendung richtig ist. ```bash cargo fmt --all -- --check @@ -155,28 +361,32 @@ lesson: | cargo build --release --locked ``` - Die [Clippy-Anleitung](https://doc.rust-lang.org/clippy/usage.html) erklärt das Weiterreichen von Optionen nach `--`. `-D warnings` macht Warnungen in diesem Lauf zu Fehlern. Das ist eine Teamentscheidung und sollte für die festgelegte Toolchain reproduzierbar sein. Schalte nicht ohne Anlass sämtliche Pedantic-Lints ein, wenn daraus nur Ausnahmeattribute ohne Verständnis entstehen. + Die [Clippy-Anleitung](https://doc.rust-lang.org/clippy/usage.html) erklärt das Weiterreichen von Optionen nach `--`; `-D warnings` macht Warnungen in diesem Lauf zu Fehlern. [`cargo test --locked`](https://doc.rust-lang.org/cargo/commands/cargo-test.html) verhindert, dass sich die Auflösung im Lockfile unbemerkt ändert; bei Anwendungen gehört `Cargo.lock` ins Repository. `--all-features` passt nur, wenn alle Features zusammen funktionieren. - [`cargo test --locked`](https://doc.rust-lang.org/cargo/commands/cargo-test.html) verhindert eine unerwartete Änderung der Auflösung im Lockfile. Für ein ausführbares Anwendungsprojekt gehört das Lockfile deshalb in den nachvollziehbaren Stand. Eine festgelegte Toolchain und dokumentierte Feature-Kombinationen machen lokale und CI-Ergebnisse vergleichbarer. `--all-features` ist nur passend, wenn die Features zusammen unterstützt werden; manche Projekte haben sich ausschließende Optionen. - - ## Von grünem Test zu belastbarer Aussage - - Ein grüner Lauf sagt: Diese Tests haben mit dieser Konfiguration bestanden. Er sagt nicht: alle Fehlerfälle sind geprüft, alle Plattformen funktionieren oder die reale Nutzbarkeit ist abgenommen. Schreibe in einen PR, welche Verhaltenspfade du geprüft hast und welche Grenzen offen bleiben. Bei einer CLI gehören mindestens normaler Aufruf, Hilfe, ungültige Argumente, fehlende Datei und kaputte Eingabedaten dazu. - - Bei Performanceproblemen brauchst du Messungen unter vergleichbaren Bedingungen. Ein Unit-Test mit einer knappen Zeitgrenze ist kein verlässlicher Benchmark auf beliebiger CI-Hardware. Verwende Release-Builds, repräsentative Datensätze und wiederholte Messungen. Eine vermeintliche Optimierung lohnt sich erst, wenn sie ein gemessenes Problem verbessert und den Vertrag erhält. + Ein grüner Lauf sagt: Diese Tests haben mit dieser Konfiguration bestanden. Schreib in den PR, welche Pfade du geprüft hast und welche offen bleiben; bei einem CLI mindestens normaler Aufruf, Hilfe, ungültige Argumente, fehlende Datei und kaputte Eingabe. Performance gehört nicht in Unit-Tests mit knapper Zeitgrenze, die auf CI-Hardware zufällig scheitern. Dafür gibt es Benchmarks mit Release-Build und wiederholten Messungen ([Performance messen](/rust/performance-messen)). > [!INTERVIEW] - > Erkläre einen Testfall über das Risiko: „Dieser Grenzwert deckt den Wechsel von kostenlos zu kostenpflichtig ab.“ Das ist aussagekräftiger als die Anzahl der Tests oder eine hohe Coverage-Zahl ohne Erklärung. - - ## Häufige Fehler und Zusammenfassung - - - Tests prüfen beobachtbare Regeln statt die aktuelle Codeform. - - Grenzwerte und Fehlerarten liefern mehr als viele ähnliche Normalfälle. - - Integrationstests prüfen die öffentliche Grenze; Unit-Tests isolieren Logik. - - Gemeinsame Dateien und echte Wartezeiten erzeugen unnötige Instabilität. - - Formatierung, Lints, Tests und Release-Build haben unterschiedliche Aussagen. + > Erkläre einen Testfall über das Risiko: „Dieser Grenzwert deckt den Wechsel von kostenlos zu kostenpflichtig ab.“ Auf die Frage nach Property-Tests: „Ich beschreibe eine Eigenschaft wie den Roundtrip von Formatieren und Parsen, proptest sucht ein Gegenbeispiel und schrumpft es; den Seed committe ich.“ Das ist mehr wert als eine Coverage-Zahl ohne Erklärung. + + ## Häufige Fehler + + - **Test prüft die Implementierung gegen sich selbst.** Eine Erwartung, die aus dem Ergebnis berechnet wird, ist immer grün. + - **Feste Dateinamen in parallelen Tests.** Rot bei 50 von 50 Läufen; `tempdir()` pro Test. + - **`TempDir` zu früh gedroppt.** Hilfsfunktion gibt nur den Pfad zurück, das Verzeichnis ist weg, bevor das Programm läuft. + - **Nur stdout geprüft.** Exit-Status und stderr gehören zum Vertrag eines CLI. + - **Strategie verfehlt die interessanten Fälle.** `any::()` erzeugt kaum Duplikate; Bereiche passend wählen. + - **`proptest-regressions` nicht committet.** Der gefundene Fall verschwindet beim nächsten Klon. + - **Snapshots blind angenommen.** `.snap`-Diffs im Review lesen; zufällige Werte filtern. + + ## Zusammenfassung + + - Tests prüfen beobachtbare Regeln an Grenzwerten und Fehlerarten, nicht die aktuelle Codeform. + - Integrationstests sehen die öffentliche API; ein CLI testest du über `CARGO_BIN_EXE_` mit Status, stdout und stderr. + - `assert_cmd` mit `cargo_bin_cmd!` und `predicates` kürzt CLI-Tests ab, `tempfile` gibt jedem Test ein eigenes Verzeichnis. + - Property-Tests beschreiben Roundtrips, Invarianten und Referenzvergleiche; Shrinking liefert ein kleines Gegenbeispiel, der Seed wird committet. + - Snapshots passen zu großen, stabilen Ausgaben; Filter entfernen Pfade und Zeiten, Diffs werden gelesen. + - Formatierung, Lints, Tests und Release-Build machen unterschiedliche Aussagen; Performance misst ein Benchmark. - Eine nachweislich rote Regression vor der Reparatur erhöht die Aussagekraft. - - Dokumentiere überprüfte Pfade und verbleibende Grenzen ehrlich. exercises: - id: grenzwert type: choice @@ -243,7 +453,8 @@ exercises: explanation: checked_mul beschreibt Überlauf explizit als None. Das Verhalten hängt dadurch nicht von einer zufälligen Debug- oder Release-Annahme ab. resources: - - *id001 + - title: u8::checked_mul + url: https://doc.rust-lang.org/std/primitive.u8.html#method.checked_mul - id: test-markieren type: gap title: Eine Regression registrieren @@ -283,7 +494,49 @@ exercises: explanation: Die Assertion beschreibt den Rückgabevertrag einschließlich Fehlerart. is_err alleine wäre weniger genau. resources: - - *id001 + - title: Rust Book · assert_eq! + url: https://doc.rust-lang.org/book/ch11-01-writing-tests.html#testing-equality-with-the-assert_eq-and-assert_ne-macros +- id: bin-exe-pfad + type: gap + title: Das gebaute Binary finden + prompt: | + Diese Hilfsfunktionen stehen in `tests/cli.rs` und werden aus den Testfunktionen aufgerufen. Ergänze den Pfad zum Binary `logwerk`, den Cargo für Integrationstests bereitstellt, den Aufruf, der das Programm ausführt und stdout und stderr einsammelt, sowie die beiden Prüfungen: Exit-Status 1 und Fehlermeldung auf stderr. Nur die Standardbibliothek. + lang: rust + code: |- + use std::process::{Command, Output}; + + fn logwerk(args: &[&str]) -> Output { + Command::new(⟦env!("CARGO_BIN_EXE_logwerk")⟧) + .args(args) + .⟦output()⟧ + .expect("logwerk startet") + } + + // wird aus den Testfunktionen in tests/cli.rs aufgerufen + fn pruefe_fehlende_datei() { + let aus = logwerk(&["zaehlen", "fehlt.log"]); + assert_eq!(aus.status.⟦code()⟧, Some(1)); + assert!(aus.stdout.is_empty()); + assert!(String::from_utf8_lossy(&aus.⟦stderr⟧).contains("fehlt.log")); + } + gaps: + - label: Pfad zum Binary + hint: Cargo setzt beim Bauen von Integrationstests eine Umgebungsvariable `CARGO_BIN_EXE_`. Welches Makro liest sie beim Kompilieren? + - label: Ausführen und einsammeln + hint: "`status()` liefert nur den Status, `spawn()` ein laufendes Kind. Die dritte Methode wartet und sammelt beide Ausgaben." + - label: Exit-Status als Zahl + hint: "`ExitStatus` hat eine Methode, die `Option` liefert." + - label: Fehlerkanal + hint: "`Output` hat die Felder `status`, `stdout` und …" + explanation: | + Cargo baut für Integrationstests jedes Binary des Pakets und setzt `CARGO_BIN_EXE_logwerk` auf dessen absoluten Pfad; `env!` liest den Wert beim Kompilieren, ein falscher Name ist also ein Compilerfehler. `output()` startet den Prozess, wartet auf sein Ende und liefert `Output` mit `status`, `stdout` und `stderr` als Bytes. `status.code()` ist `Option`, weil ein Prozess unter Unix auch durch ein Signal enden kann und dann keinen Code hat. Drei getrennte Prüfungen halten den Vertrag fest: Status 1, nichts auf stdout, Meldung mit Dateinamen auf stderr. Der Code hat keine `main` und läuft nur als Integrationstest; lokal war der Test im Paket `logwerk` grün. Im echten Test setzt du zusätzlich `current_dir` auf ein leeres temporäres Verzeichnis, wie in der Lektion: Sonst hängt das Ergebnis davon ab, ob zufällig eine `fehlt.log` im Paketverzeichnis liegt. + resources: + - title: Cargo · CARGO_BIN_EXE_ + url: https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-sets-for-crates + - title: Command::output + url: https://doc.rust-lang.org/std/process/struct.Command.html#method.output + - title: ExitStatus::code + url: https://doc.rust-lang.org/std/process/struct.ExitStatus.html#method.code - id: cargo-format type: command title: Formatierung nur prüfen @@ -293,7 +546,8 @@ exercises: explanation: '`--check` wird nach dem Trenner an rustfmt weitergegeben. `--all` erweitert die Prüfung auf den Workspace.' resources: - - *id001 + - title: Cargo · cargo fmt + url: https://doc.rust-lang.org/cargo/commands/cargo-fmt.html - id: falsche-erwartung type: bug title: Der Test erlaubt den Bug @@ -318,6 +572,48 @@ exercises: gültigen Wert fest. resources: - *id001 +- id: property-tautologie + type: bug + title: Immer grün, nie hilfreich + prompt: | + Im Review fällt auf, dass `sortiere` doppelte Werte entfernt. Das soll es nicht: Die Funktion soll sortieren wie `Vec::sort`. Der Property-Test daneben lief trotzdem in jedem CI-Lauf grün, mit 256 Listen pro Lauf. Repariere nicht `sortiere`, sondern den Test: Finde die Zeile, die ihn wertlos macht, und korrigiere sie so, dass er `sortiere` mit der Standardsortierung vergleicht. + lang: rust + code: |- + use proptest::prelude::*; + + fn sortiere(mut werte: Vec) -> Vec { + werte.sort(); + werte.dedup(); + werte + } + + proptest! { + #[test] + fn sortiert_wie_std(werte in prop::collection::vec(0u32..10, 0..20)) { + let ergebnis = sortiere(werte.clone()); + let mut erwartet = ergebnis.clone(); + erwartet.sort(); + prop_assert_eq!(ergebnis, erwartet); + } + } + lines: + - 13 + fix: + - line: 13 + answers: + - let mut erwartet = werte.clone(); + - let mut erwartet = werte; + - let mut erwartet = werte.to_vec(); + hints: + - Woraus wird `erwartet` berechnet – aus der Eingabe oder aus dem Ergebnis? + - Ein sortiertes Ergebnis noch einmal zu sortieren ändert nichts. + explanation: | + Die Erwartung wird aus dem Ergebnis berechnet. Ein schon sortiertes `ergebnis` bleibt nach `sort` gleich, also prüft der Test nur „sortiert ist sortiert“ – auch eine Liste, aus der Werte verschwunden sind, ist sortiert. Mit `erwartet` aus der Eingabe `werte` wird der Test zum Vergleich mit einer Referenz, und genau den braucht die Eigenschaft. Lokal blieb die fehlerhafte Fassung grün, die korrigierte scheiterte sofort mit einem geschrumpften Gegenbeispiel wie `werte = [7, 7]`. Dass der Bereich `0u32..10` ist, hilft: Unter zufälligen `u32` aus dem ganzen Wertebereich kämen Duplikate fast nie vor. + resources: + - title: proptest-Buch · Shrinking + url: https://proptest-rs.github.io/proptest/proptest/tutorial/shrinking-basics.html + - title: prop::collection::vec + url: https://docs.rs/proptest/latest/proptest/collection/fn.vec.html - id: reader-test type: explain title: Warum Cursor statt Festplatte? @@ -341,7 +637,38 @@ exercises: explanation: Die I/O-Abstraktion trennt Verarbeitung vom Ursprung der Bytes. Der Test bleibt schnell und isoliert. Er ersetzt keinen Test der äußeren Dateigrenze. resources: - - *id001 + - title: std::io::Cursor + url: https://doc.rust-lang.org/std/io/struct.Cursor.html +- id: shrinking-lesen + type: explain + title: Was proptest gefunden hat + prompt: | + Nach der Korrektur aus der vorigen Übung meldet `cargo test` für `sortiert_wie_std` das Folgende (gekürzt, Pfad abgekürzt). Erkläre einer Kollegin, was die Meldung über `sortiere` sagt, wie das Gegenbeispiel zustande kam, warum es `[7, 7]` und nicht `[0, 0]` ist und was mit der Datei aus der ersten Zeile geschehen soll. + lang: text + code: |- + proptest: Saving this and future failures in …/tests/props_tautologie_fixed.proptest-regressions + cc dac5fb658dc44b9bb5bef54b88d5eb58ef2db4ecdfffb5efe54b053ad9c0da13 + + thread 'sortiert_wie_std' (1482343) panicked at testen/tests/props_tautologie_fixed.rs:9:1: + Test failed: assertion failed: `(left == right)` + left: `[7]`, + right: `[7, 7]` at testen/tests/props_tautologie_fixed.rs:15. + minimal failing input: werte = [ + 7, + 7, + ] + points: + - "`left` ist das Ergebnis von `sortiere`, `right` die Referenz aus `sort`: Aus `[7, 7]` wird `[7]`, ein Duplikat verschwindet." + - Proptest hat eine zufällige scheiternde Liste gefunden und sie verkleinert, solange der Test weiter scheiterte (Shrinking). + - Die Zahl 7 ist Zufall; proptest verkleinert Elemente einzeln, und jede einzelne Verkleinerung würde die beiden Werte ungleich und den Test grün machen. + - Die `cc …`-Zeile ist der gespeicherte Seed; proptest spielt ihn bei jedem Lauf zuerst ab, die `.proptest-regressions`-Datei gehört ins Repository. + explanation: | + Die Assertion vergleicht `ergebnis` (links) mit `erwartet` (rechts): `sortiere` macht aus `[7, 7]` ein `[7]`, entfernt also Duplikate. Proptest hat zuerst irgendeine Liste aus dem Bereich `0..10` gefunden, die scheitert, und sie dann geschrumpft: Elemente weglassen und einzelne Werte verkleinern, solange der Test rot bleibt. Bei zwei gleichen Werten endet das früh, denn jede Verkleinerung eines einzelnen Elements macht die Werte ungleich, und ohne Duplikat ist der Test grün. Deshalb steht da `[7, 7]` und in einem anderen Lauf vielleicht `[6, 6]` – minimal im Sinn von proptest, nicht das kleinste denkbare Beispiel. Die erste Zeile nennt die Datei, in der proptest den Seed (`cc …`) abgelegt hat. Spätere Läufe prüfen diesen Fall zuerst, bevor neue Eingaben entstehen; committet, prüfen CI und Kollegen ihn auch. + resources: + - title: proptest-Buch · Failure Persistence + url: https://proptest-rs.github.io/proptest/proptest/failure-persistence.html + - title: proptest-Buch · Shrinking + url: https://proptest-rs.github.io/proptest/proptest/tutorial/shrinking-basics.html - id: grenzen-testen type: practice title: Eine Validierung gegen Gegenbeispiele absichern @@ -375,3 +702,82 @@ exercises: zählen. Die Tests unterscheiden Off-by-one, vergessenen trim und Bytezählung. resources: - *id001 +- id: cli-test-tempdir + type: practice + title: logwerk von außen testen + prompt: | + `logwerk zaehlen DATEI` gibt für jede vorkommende Stufe eine Zeile `STUFE ANZAHL` aus (Reihenfolge INFO, WARN, ERROR). Eine ungültige Zeile ergibt `logwerk: Zeile N: …` auf stderr und Status 1; ein fehlendes Argument ergibt eine Zeile mit `Aufruf` auf stderr und Status 2. + + Schreibe `tests/cli.rs` mit assert_cmd 2.2, predicates und tempfile: + + - gültige Datei: Status 0, exakte Ausgabe auf stdout, stderr leer + - Datei mit einer ungültigen zweiten Zeile `DEBUG nein`: Status 1, stdout leer, stderr nennt Zeile 2 und die Stufe + - Aufruf ohne Datei: Status 2 + + Jeder Test bekommt sein eigenes Temp-Verzeichnis. Eine Hilfsfunktion legt die Logdatei an. Lass die Tests mit `cargo test` laufen und prüfe, dass sie auch scheitern, wenn du im Programm den Status 1 durch 0 ersetzt. + lang: rust + solution: |- + use std::fs; + + use assert_cmd::cargo::cargo_bin_cmd; + use predicates::prelude::*; + use tempfile::TempDir; + + fn mit_log(inhalt: &str) -> (TempDir, String) { + let dir = tempfile::tempdir().unwrap(); + let pfad = dir.path().join("app.log"); + fs::write(&pfad, inhalt).unwrap(); + let pfad = pfad.to_str().unwrap().to_owned(); + (dir, pfad) // dir zurückgeben, sonst löscht der Drop das Verzeichnis sofort + } + + #[test] + fn gueltige_datei() { + let (_dir, pfad) = mit_log("INFO start\nERROR db\n\nINFO ende\n"); + cargo_bin_cmd!("logwerk") + .args(["zaehlen", &pfad]) + .assert() + .success() + .stdout("INFO 2\nERROR 1\n") + .stderr(predicate::str::is_empty()); + } + + #[test] + fn kaputte_zeile() { + let (_dir, pfad) = mit_log("INFO start\nDEBUG nein\n"); + cargo_bin_cmd!("logwerk") + .args(["zaehlen", &pfad]) + .assert() + .code(1) + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::contains("Zeile 2").and(predicate::str::contains("DEBUG"))); + } + + #[test] + fn fehlendes_argument() { + cargo_bin_cmd!("logwerk") + .arg("zaehlen") + .assert() + .code(2) + .stderr(predicate::str::contains("Aufruf")); + } + checklist: + - "Die Tests nutzen `cargo_bin_cmd!(\"logwerk\")` oder `Command::new(env!(\"CARGO_BIN_EXE_logwerk\"))` statt eines festen Pfads." + - Jeder Test prüft Status, stdout und stderr getrennt. + - Jeder Test legt seine Datei in einem eigenen `tempdir()` an; kein fester Dateiname unter `/tmp`. + - Das `TempDir` lebt, bis der Prozess gelaufen ist (nicht in der Hilfsfunktion gedroppt). + - Der Fehlerfall prüft `code(1)` und den Inhalt von stderr, nicht nur `failure()`. + - Du hast gesehen, dass mindestens ein Test rot wird, wenn das Programm bei kaputter Zeile mit 0 endet. + verify: false + hints: + - "Die Hilfsfunktion kann `(TempDir, String)` zurückgeben; der Aufrufer bindet das Verzeichnis an `_dir`, nicht an `_`." + - "`predicate::str::contains(\"Zeile 2\").and(predicate::str::contains(\"DEBUG\"))` verknüpft zwei Erwartungen." + explanation: | + `cargo_bin_cmd!` liefert den Pfad, den Cargo für Integrationstests setzt, und `assert()` sammelt Status und Ausgaben ein. Jede Assertion schaut auf einen Kanal: `success()` oder `code(n)` auf den Status, `stdout(…)` und `stderr(…)` auf die Ströme. Die Hilfsfunktion gibt das `TempDir` mit zurück; bindet der Test es an `_dir`, lebt das Verzeichnis bis zum Ende der Testfunktion. Mit `let (_, pfad) = …` würde es am Ende der Anweisung gedroppt, und `logwerk` fände die Datei nicht – lokal ausprobiert endete das mit Status 1 und „No such file or directory“. Weil jeder Test sein eigenes Verzeichnis hat, laufen die Tests parallel, ohne sich Dateien zu überschreiben. Lokal liefen alle drei Tests grün; mit Status 0 bei kaputter Zeile scheiterte `kaputte_zeile`. + resources: + - title: assert_cmd · cargo_bin_cmd! + url: https://docs.rs/assert_cmd/latest/assert_cmd/cargo/macro.cargo_bin_cmd.html + - title: assert_cmd · Assert + url: https://docs.rs/assert_cmd/latest/assert_cmd/assert/struct.Assert.html + - title: tempfile · Early drop pitfall + url: https://docs.rs/tempfile/latest/tempfile/#early-drop-pitfall diff --git a/content/rust/modules/traits-generics.yaml b/content/rust/modules/traits-generics.yaml index b21dff37..1a75d316 100644 --- a/content/rust/modules/traits-generics.yaml +++ b/content/rust/modules/traits-generics.yaml @@ -1,7 +1,7 @@ id: traits-generics title: Traits & Generics level: 2 -minutes: 180 +minutes: 210 summary: Generischer Code ohne Laufzeitkosten, gemeinsames Verhalten über Traits und die Wahl zwischen statischer und dynamischer Dispatch – das Werkzeug, mit dem Rust-Bibliotheken ihre APIs bauen. goals: - Du schreibst generische Funktionen, Structs und `impl`-Blöcke und erklärst, was Monomorphisierung kostet und bringt. @@ -9,6 +9,7 @@ goals: - Du entscheidest begründet zwischen `impl Trait`, Generics und `dyn Trait` und erklärst, was eine vtable ist. - Du leitest die wichtigsten Standard-Traits per `derive` ab und implementierst `Display`, `From` und `Add` von Hand. - Du erklärst die Orphan Rule und umgehst sie mit dem Newtype-Muster. + - "Du entwirfst öffentliche APIs nach den Rust API Guidelines: `TryFrom`/`FromStr`, Builder, Typestate mit `PhantomData`, sealed traits, `#[non_exhaustive]` und `#[must_use]`." resources: - title: The Rust Book · Kapitel 10 – Generic Types, Traits, and Lifetimes url: https://doc.rust-lang.org/book/ch10-00-generics.html @@ -18,6 +19,9 @@ resources: url: https://doc.rust-lang.org/reference/items/traits.html - title: Rust by Example · Traits url: https://doc.rust-lang.org/rust-by-example/trait.html + - title: Rust API Guidelines · Checkliste + url: https://rust-lang.github.io/api-guidelines/checklist.html +place: pc lesson: | Dein Monitoring-Tool prüft Festplatten, Dienste und Zertifikate. Jede Prüfung hat einen Namen und ein Ergebnis, aber jede ermittelt es anders. Später sollen Benachrichtigungen per Mail, Slack oder in eine Datei gehen – je nach Konfiguration. In TypeScript würdest du dafür Interfaces schreiben, in Kotlin ebenfalls. In Rust heißen sie **Traits**. Dazu kommen **Generics**: Funktionen und Typen, die für viele konkrete Typen funktionieren, ohne dass du sie für jeden Typ kopierst. @@ -397,6 +401,283 @@ lesson: | > [!PRAXIS] Newtypes als Werkzeug für Korrektheit > In echten Codebasen sind Newtypes nicht nur ein Trick gegen die Orphan Rule. `struct Cent(u64)`, `struct UserId(u64)` und `struct OrderId(u64)` machen Verwechslungen zu Compile-Fehlern: Eine Funktion `fn storniere(id: OrderId)` nimmt keine `UserId` an. Zur Laufzeit kostet das nichts, der Newtype hat dieselbe Größe wie der eingepackte Wert. In Code-Reviews ist „nackter `u64` für Geld“ ein häufiger Kommentar. + ## API-Design: Typen, die falsche Benutzung verhindern + + Sobald andere deinen Code benutzen – ein Kollege, ein anderes Crate im Workspace, du selbst in einem halben Jahr –, wird die Signatur zur Dokumentation. Eine gute Rust-API macht falsche Benutzung zum Compile-Fehler statt zum Laufzeitfehler. Und sie lässt sich erweitern, ohne jeden Nutzer zu brechen. Die [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/) sammeln dafür Regeln mit Kürzeln wie C-BUILDER; ihre [Checkliste](https://rust-lang.github.io/api-guidelines/checklist.html) taugt als Vorlage im Code-Review. Die Werkzeuge kennst du schon aus diesem Modul: Traits, Generics und eigene Typen. + + ### Prüfend konvertieren: TryFrom und FromStr + + `From` darf nicht scheitern. Kann eine Umwandlung scheitern, etwa bei Port 0 oder einem unbekannten Namen aus einer Konfiguration, implementierst du [`TryFrom`](https://doc.rust-lang.org/std/convert/trait.TryFrom.html) mit einem eigenen Fehlertyp. Wie bei `From` und `Into` bekommst du `TryInto` dazu ([generische Implementierungen](https://doc.rust-lang.org/std/convert/trait.TryFrom.html#generic-implementations)). Für Text gibt es [`FromStr`](https://doc.rust-lang.org/std/str/trait.FromStr.html): Wer es implementiert, bekommt [`str::parse`](https://doc.rust-lang.org/std/primitive.str.html#method.parse) für seinen Typ. clap und viele Konfigurationsbibliotheken nutzen genau diesen Trait, um Argumente in deine Typen zu verwandeln. + + ```rust + use std::fmt; + use std::str::FromStr; + + #[derive(Debug, Clone, Copy, PartialEq)] + struct Port(u16); + + #[derive(Debug, PartialEq)] + struct PortNull; + + impl TryFrom for Port { + type Error = PortNull; + + fn try_from(n: u16) -> Result { + if n == 0 { Err(PortNull) } else { Ok(Port(n)) } + } + } + + #[derive(Debug, Clone, Copy, PartialEq)] + enum Stufe { Info, Warn, Error } + + #[derive(Debug, PartialEq)] + struct UnbekannteStufe(String); + + impl fmt::Display for UnbekannteStufe { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "unbekannte Stufe {:?}", self.0) + } + } + + impl FromStr for Stufe { + type Err = UnbekannteStufe; + + fn from_str(s: &str) -> Result { + match s.to_ascii_lowercase().as_str() { + "info" => Ok(Stufe::Info), + "warn" => Ok(Stufe::Warn), + "error" => Ok(Stufe::Error), + _ => Err(UnbekannteStufe(s.to_string())), + } + } + } + + fn main() { + println!("{:?}", Port::try_from(8080)); // Ok(Port(8080)) + println!("{:?}", Port::try_from(0)); // Err(PortNull) + let p: Result = 443_u16.try_into(); // TryInto gibt es gratis + println!("{p:?}"); + + let s: Stufe = "WARN".parse().unwrap(); // parse ruft FromStr auf + println!("{s:?}"); // Warn + match "debug".parse::() { + Ok(s) => println!("{s:?}"), + Err(e) => println!("{e}"), // unbekannte Stufe "debug" + } + } + ``` + + Der Gewinn: Ein `Port` ist im restlichen Programm nie 0. Die Prüfung steht an genau einer Stelle statt in jeder Funktion, die einen `u16` annimmt. Das ist die Newtype-Idee aus dem vorigen Abschnitt, ergänzt um Validierung ([C-NEWTYPE](https://rust-lang.github.io/api-guidelines/type-safety.html#c-newtype)). Dass Konvertierungen über diese Standard-Traits laufen statt über eigene Namen wie `port_von_zahl`, verlangt [C-CONV-TRAITS](https://rust-lang.github.io/api-guidelines/interoperability.html#c-conv-traits). + + ### Builder: viele Optionen, ein gültiges Ergebnis + + Rust kennt keine benannten oder optionalen Parameter. `Pruefung::neu(name, adresse, timeout, versuche)` ist schon mit vier Argumenten schwer zu lesen, und ein fünftes bricht jeden Aufrufer. Ein [Builder](https://rust-lang.github.io/api-guidelines/type-safety.html#c-builder) sammelt die Optionen schrittweise und prüft sie am Ende in `build`: + + ```rust + use std::time::Duration; + + #[derive(Debug)] + pub struct Pruefung { + name: String, + adresse: String, + timeout: Duration, + versuche: u32, + } + + #[derive(Debug)] + #[must_use = "ohne build() entsteht keine Prüfung"] + pub struct PruefungBuilder { + name: String, + adresse: Option, + timeout: Duration, + versuche: u32, + } + + impl Pruefung { + pub fn builder(name: impl Into) -> PruefungBuilder { + PruefungBuilder { + name: name.into(), + adresse: None, + timeout: Duration::from_secs(2), + versuche: 1, + } + } + } + + impl PruefungBuilder { + pub fn adresse(mut self, a: impl Into) -> Self { + self.adresse = Some(a.into()); + self + } + pub fn timeout(mut self, t: Duration) -> Self { + self.timeout = t; + self + } + pub fn versuche(mut self, n: u32) -> Self { + self.versuche = n; + self + } + pub fn build(self) -> Result { + let adresse = self.adresse.ok_or("Adresse fehlt")?; + if self.versuche == 0 { + return Err("mindestens ein Versuch".into()); + } + Ok(Pruefung { name: self.name, adresse, timeout: self.timeout, versuche: self.versuche }) + } + } + + fn main() { + let db = Pruefung::builder("datenbank") + .adresse("10.0.0.5:5432") + .timeout(Duration::from_millis(500)) + .build(); + println!("{db:?}"); + + let kaputt = Pruefung::builder("cache").versuche(0).build(); + println!("{kaputt:?}"); // Err("Adresse fehlt") + } + ``` + + Jede Setter-Methode nimmt `self` per Wert und gibt es zurück, so entsteht die Kette. `build` liefert ein `Result`, weil erst dort alle Angaben zusammen geprüft werden können. Das `#[must_use]` am Builder warnt, wenn jemand die Kette ohne `build()` stehen lässt. Eine neue Option ist später eine neue Methode mit Standardwert, und kein Aufrufer bricht. Das funktioniert nur, weil die Felder von `Pruefung` privat sind ([C-STRUCT-PRIVATE](https://rust-lang.github.io/api-guidelines/future-proofing.html#c-struct-private)). + + ### Typestate: den Zustand im Typ tragen + + Manche Methoden ergeben nur in einem bestimmten Zustand Sinn: Senden erst nach dem Verbinden, Ausrollen erst nach der Freigabe. Statt das zur Laufzeit mit einem `bool` und einem Fehler zu prüfen, machst du den Zustand zum Typparameter. Zustände sind leere Structs. [`PhantomData`](https://doc.rust-lang.org/std/marker/struct.PhantomData.html) sagt dem Compiler, dass der Typ den Parameter benutzt, obwohl kein Feld ihn speichert. Es belegt keinen Speicher. + + ```rust + use std::marker::PhantomData; + + struct Getrennt; + struct Verbunden; + + struct Verbindung { + adresse: String, + _zustand: PhantomData, + } + + impl Verbindung { + fn neu(adresse: &str) -> Self { + Verbindung { adresse: adresse.to_string(), _zustand: PhantomData } + } + + fn verbinden(self) -> Verbindung { + println!("verbinde mit {}", self.adresse); + Verbindung { adresse: self.adresse, _zustand: PhantomData } + } + } + + impl Verbindung { + fn senden(&self, zeile: &str) { + println!("→ {zeile}"); + } + } + + fn main() { + let v = Verbindung::neu("127.0.0.1:7000"); + // v.senden("PING"); // Fehler: senden gibt es nur für Verbindung + let v = v.verbinden(); + v.senden("PING"); + println!("{}", std::mem::size_of::>()); // 0 + } + ``` + + `verbinden` verbraucht die getrennte Verbindung (`self` per Wert) und liefert eine verbundene. Danach gibt es keinen Wert mehr, auf dem man fälschlich weiterarbeiten könnte; Ownership erledigt den Rest. Nimmst du den Kommentar vor `v.senden` weg, meldet rustc ([E0599](https://doc.rust-lang.org/error_codes/E0599.html)): + + ```text + error[E0599]: no method named `senden` found for struct `Verbindung` in the current scope + | + 30 | v.senden("PING"); + | ^^^^^^ method not found in `Verbindung` + | + = note: the method was found for `Verbindung` + ``` + + Typestate lohnt sich bei wenigen, klaren Zuständen und Abläufen, die immer gleich verlaufen. Steht ein Zustand erst zur Laufzeit fest (eine Verbindung kann jederzeit abbrechen), brauchst du weiterhin ein Enum und `Result`. Zu viele Zustände machen Signaturen unlesbar. + + ### Sealed und Extension Traits + + Ein öffentlicher Trait gibt ein stilles Versprechen: Jeder darf ihn implementieren. Fügst du später eine Pflichtmethode hinzu, bricht jede fremde Implementierung. Ein **sealed trait** verhindert fremde Implementierungen, indem er einen Supertrait aus einem privaten Modul verlangt ([C-SEALED](https://rust-lang.github.io/api-guidelines/future-proofing.html#c-sealed)). Nutzer können ihn verwenden, aber nicht implementieren. Damit darfst du ihn erweitern. + + Ein **extension trait** geht in die Gegenrichtung: Er gibt einem fremden Typ neue Methoden. Die Orphan Rule erlaubt das, weil der Trait aus deinem Crate stammt. Die Konvention ist das Suffix `Ext` ([RFC 445](https://rust-lang.github.io/rfcs/0445-extension-trait-conventions.html)); du triffst es als `StreamExt` oder `AsyncReadExt` im Async-Modul wieder. Die Methoden gibt es nur dort, wo der Trait importiert ist. + + ```rust + use std::time::Duration; + + mod privat { + pub trait Versiegelt {} + } + + /// Ein Ziel, das der Healthchecker prüfen kann. + /// Nur dieses Crate darf Ziele hinzufügen. + pub trait Ziel: privat::Versiegelt { + fn beschreibung(&self) -> String; + } + + pub struct Tcp(pub String); + pub struct Http(pub String); + + impl privat::Versiegelt for Tcp {} + impl privat::Versiegelt for Http {} + + impl Ziel for Tcp { + fn beschreibung(&self) -> String { format!("tcp://{}", self.0) } + } + impl Ziel for Http { + fn beschreibung(&self) -> String { format!("http://{}", self.0) } + } + + // Extension Trait: neue Methoden für einen fremden Typ + pub trait DauerExt { + fn als_ms_text(&self) -> String; + } + + impl DauerExt for Duration { + fn als_ms_text(&self) -> String { + format!("{} ms", self.as_millis()) + } + } + + fn main() { + let ziele: Vec> = vec![ + Box::new(Tcp("db:5432".into())), + Box::new(Http("api/health".into())), + ]; + for z in &ziele { + println!("{}", z.beschreibung()); + } + println!("{}", Duration::from_millis(1500).als_ms_text()); // 1500 ms + } + ``` + + Das Modul `privat` ist nicht öffentlich. Ein anderes Crate kann `privat::Versiegelt` deshalb nicht nennen und `Ziel` nicht implementieren. + + ### `#[non_exhaustive]` und `#[must_use]` + + Ein öffentliches Fehler-Enum mit zwei Varianten verspricht: Es gibt genau diese zwei. Eine dritte Variante in Version 1.3 bricht jedes `match` deiner Nutzer, nach SemVer wäre das ein Major-Release (SemVer und Sichtbarkeit mit `pub` vertieft das Modul [Module, Crates & Cargo](/rust/module-cargo)). Mit [`#[non_exhaustive]`](https://doc.rust-lang.org/reference/attributes/type_system.html#the-non_exhaustive-attribute) verlangt der Compiler von Nutzern **außerhalb** deines Crates einen `_`-Arm, und du darfst später Varianten ergänzen. Setze es beim ersten Release oder beim nächsten Major-Release: Das Attribut nachträglich an ein bestehendes Enum zu hängen, ist selbst ein Breaking Change, weil es jedes vorhandene `match` ohne `_` bricht ([Cargo: SemVer](https://doc.rust-lang.org/cargo/reference/semver.html#attr-adding-non-exhaustive)). + + ```rust + #[derive(Debug)] + #[non_exhaustive] + pub enum Fehler { + Timeout, + Verweigert, + } + ``` + + Ein anderes Crate, das in `match f { … }` nur `Timeout` und `Verweigert` behandelt, bekommt ([E0004](https://doc.rust-lang.org/error_codes/E0004.html)): + + ```text + error[E0004]: non-exhaustive patterns: `_` not covered + = note: `Fehler` is marked as non-exhaustive, so a wildcard `_` is necessary to match exhaustively + ``` + + Innerhalb deines eigenen Crates wirkt das Attribut nicht, dort bleibt das `match` vollständig prüfbar. An einem Struct verhindert es, dass fremde Crates ihn mit Literal-Syntax bauen; ein neues Feld bricht dann niemanden. + + [`#[must_use]`](https://doc.rust-lang.org/reference/attributes/diagnostics.html#the-must_use-attribute) löst die Warnung `unused_must_use` aus, wenn jemand einen Rückgabewert verwirft. `Result` trägt es schon, deshalb warnt ein verworfenes `Result` ohne dein Zutun. Sinnvoll ist es an Funktionen ohne Nebenwirkung, deren Ergebnis der ganze Zweck ist – die Standardbibliothek markiert etwa `str::trim` so –, und an Typen wie Buildern. Ein `#[must_use]` ohne Begründung an einer Funktion, die ohnehin `Result` liefert, ist doppelt; Clippy meldet das als [`double_must_use`](https://rust-lang.github.io/rust-clippy/master/index.html#double_must_use). + + > [!INTERVIEW] „Wie hältst du eine öffentliche API stabil?“ + > Von Anfang an: Felder privat, Konstruktion über Funktionen oder einen Builder, wachsende Enums mit `#[non_exhaustive]`, Traits, die nur meine Bibliothek implementiert, versiegelt. Nachrüsten lässt sich das nur mit einem Major-Release. Dazu Standard-Traits wie `From`, `TryFrom`, `FromStr`, `Display` und `Debug` statt eigener Namen. Dann kann ich Varianten, Felder und Methoden ergänzen, ohne ein Major-Release zu brauchen. Neue APIs prüfe ich gegen die Checkliste der Rust API Guidelines. + ## Häufige Fehler - **Bound vergessen.** `binary operation > cannot be applied to type &T` ([E0369](https://doc.rust-lang.org/error_codes/E0369.html)) oder `T doesn't implement Display` ([E0277](https://doc.rust-lang.org/error_codes/E0277.html)). Die Hilfe des Compilers schlägt den fehlenden Bound meist wörtlich vor. @@ -405,6 +686,8 @@ lesson: | - **Trait nicht dyn compatible.** [E0038](https://doc.rust-lang.org/error_codes/E0038.html). Die generische Methode in einen eigenen Trait auslagern oder mit `where Self: Sized` aus der vtable ausnehmen. - **Fremder Trait für fremden Typ.** [E0117](https://doc.rust-lang.org/error_codes/E0117.html). Newtype drumherum bauen. - **`Into` statt `From` implementieren.** Funktioniert, verschenkt aber die automatische Gegenrichtung. Immer `From` implementieren. + - **Öffentliche Felder und erschöpfende Enums in einer Bibliothek.** Jede Ergänzung wird zum Breaking Change. Felder privat halten, wachsende Enums mit `#[non_exhaustive]` markieren. + - **`Option` statt Fehlertyp beim Konvertieren.** `fn port_aus_text(s: &str) -> Option` verschweigt, was falsch war. `TryFrom` oder `FromStr` mit eigenem Fehler sagen es dem Aufrufer. - **Blind alles ableiten.** `Copy` auf einem großen Struct macht jede Übergabe zur Kopie; `Default` auf einem Typ ohne sinnvollen Standardwert lädt zu ungültigen Zuständen ein. ## Zusammenfassung @@ -417,6 +700,7 @@ lesson: | - `derive` liefert `Debug`, `Clone`, `Copy`, `PartialEq`, `Eq`, `PartialOrd`, `Ord`, `Hash`, `Default`; `Display` schreibst du selbst. - `From` implementieren, `Into` benutzen; `AsRef` für billige Sichten. - Orphan Rule: Trait oder Typ muss lokal sein. Newtypes lösen das und machen Code typsicherer. + - API-Design: `TryFrom`/`FromStr` prüfen beim Konvertieren, Builder sammeln Optionen, Typestate mit `PhantomData` erzwingt Abläufe. Private Felder, sealed traits und `#[non_exhaustive]` halten eine öffentliche API erweiterbar. exercises: - id: dispatch-aussagen type: choice @@ -786,6 +1070,60 @@ exercises: - title: Rust by Example · Operator Overloading url: https://doc.rust-lang.org/rust-by-example/trait/ops.html + - id: typestate-release + type: gap + title: Ausrollen erst nach der Freigabe + prompt: | + Ein Release soll sich nur ausrollen lassen, nachdem es freigegeben wurde – und zwar so, dass ein Aufruf von `ausrollen` auf einem Entwurf **nicht kompiliert**. Ergänze die drei Lücken. Erwartete Ausgabe: `rolle 2.4.0 aus`. + lang: rust + code: | + use std::marker::PhantomData; + + struct Entwurf; + struct Freigegeben; + + struct Release { + version: String, + _s: ⟦PhantomData⟧, + } + + impl Release { + fn neu(v: &str) -> Self { + Release { version: v.to_string(), _s: PhantomData } + } + + fn freigeben(⟦self⟧) -> Release { + Release { version: self.version, _s: PhantomData } + } + } + + impl ⟦Release⟧ { + fn ausrollen(&self) { + println!("rolle {} aus", self.version); + } + } + + fn main() { + let r = Release::neu("2.4.0").freigeben(); + r.ausrollen(); + } + gaps: + - label: Feldtyp für den Zustand + hint: Ein Marker aus `std::marker`, der den Typparameter „benutzt“, ohne Speicher zu belegen. + accept: ["std::marker::PhantomData"] + - label: Empfänger von freigeben + hint: Der Entwurf soll danach nicht mehr existieren. Wie übergibst du einen Wert, damit er verbraucht wird? + accept: ["mut self"] + - label: Für welchen Typ gilt dieser impl-Block? + hint: "`ausrollen` darf es nur im freigegebenen Zustand geben." + explanation: | + Der Zustand steckt im Typparameter `S`. Weil kein Feld einen Wert vom Typ `S` speichert, braucht der Struct `PhantomData` – sonst meldet der Compiler einen unbenutzten Parameter (E0392). `freigeben(self)` nimmt den Entwurf per Wert: Danach ist `r` vor der Freigabe verbraucht, niemand kann versehentlich mit dem alten Zustand weiterarbeiten. Der Block `impl Release` legt `ausrollen` nur für freigegebene Releases an. `Release::neu("2.4.0").ausrollen()` scheitert deshalb mit E0599 („the method was found for `Release`“). Zur Laufzeit kostet das nichts, `PhantomData` hat die Größe 0. + resources: + - title: std · PhantomData + url: https://doc.rust-lang.org/std/marker/struct.PhantomData.html + - title: Error-Index · E0392 (unbenutzter Typparameter) + url: https://doc.rust-lang.org/error_codes/E0392.html + - id: fehlender-bound type: bug title: Generisch, aber nicht vergleichbar @@ -929,6 +1267,47 @@ exercises: - title: Error-Index · E0119 url: https://doc.rust-lang.org/error_codes/E0119.html + - id: api-erweiterbar + type: explain + title: Was geht in 1.4, was braucht 2.0? + prompt: | + Du reviewst die öffentliche API einer Deploy-Bibliothek in Version 1.3, die drei andere Teams nutzen. Gewünscht sind: eine neue Fehlervariante `Netzwerk`, ein neues Feld `timeout` in `Konfig` und eine neue Methode `pruefen` im Trait `Ziel`. Erkläre, welche Änderung in 1.4 ohne Bruch geht und welche ein Major-Release 2.0 braucht – und warum. Wie baust du die API in 2.0, damit solche Ergänzungen danach Minor-Releases bleiben? Was hältst du von `port_aus_text`? + lang: rust + code: | + pub enum DeployFehler { + Timeout, + Abgelehnt, + } + + pub struct Konfig { + pub host: String, + pub port: u16, + } + + pub trait Ziel { + fn ausrollen(&self, k: &Konfig) -> Result<(), DeployFehler>; + } + + pub fn port_aus_text(s: &str) -> Option { + s.parse().ok() + } + points: + - "Die neue Variante bricht jedes vollständige `match` der Nutzer, also 2.0. `#[non_exhaustive]` nachzurüsten wäre selbst ein Bruch; es gehört deshalb in 2.0, danach sind neue Varianten Minor." + - "Das neue öffentliche Feld bricht jede Konstruktion `Konfig { host, port }`, also ebenfalls 2.0. In 2.0 werden die Felder privat, gebaut wird über Konstruktor oder Builder (oder `#[non_exhaustive]` am Struct)." + - "`pruefen` geht in 1.4, wenn die Methode eine Default-Implementierung bekommt (die Cargo-Referenz nennt das nur „possibly-breaking“). Ohne Default bricht sie jede fremde Implementierung; soll nur die Bibliothek `Ziel` implementieren, wird der Trait in 2.0 versiegelt." + - "`port_aus_text` verschweigt den Grund des Scheiterns und ist ein eigener Name für eine Standard-Konvertierung; besser ein `Port`-Newtype mit `FromStr` oder `TryFrom` und eigenem Fehlertyp." + explanation: | + Nutzer dürfen sich auf die vollständige Form einer öffentlichen API verlassen. Wer `match fehler { Timeout => …, Abgelehnt => … }` schreibt, bekommt mit `Netzwerk` einen Compile-Fehler; laut Cargo-SemVer-Referenz ist eine neue Variante in einem erschöpfenden Enum ein Major-Change. Dasselbe gilt für `#[non_exhaustive]` selbst, wenn man es nachträglich anbringt – es bricht dieselben `match`-Ausdrücke. Also: 2.0 mit `Netzwerk` und `#[non_exhaustive]`, danach darf das Enum wachsen. Öffentliche Felder erlauben `Konfig { host, port }`; ein weiteres Feld macht diesen Ausdruck ungültig. Private Felder plus `Konfig::new` oder ein Builder entkoppeln Nutzer von der Struktur, auch das ist der Schritt zu 2.0. Nur `pruefen` kann in 1.4 kommen, mit Default-Implementierung; ohne Default bricht sie jedes Team, das `Ziel` selbst implementiert hat. Soll das nie jemand außer der Bibliothek tun, versiegelt man den Trait in 2.0 mit einem privaten Supertrait. `port_aus_text` liefert bei `"80a"` und `"70000"` dasselbe `None`; ein `Port` mit `FromStr` nennt den Fehler und passt zu `str::parse`. Die Lehre: Erweiterbarkeit plant man beim ersten Release (C-STRUCT-PRIVATE, C-SEALED, C-CONV-TRAITS der Rust API Guidelines), nicht erst, wenn die erste Erweiterung ansteht. + resources: + - title: Rust API Guidelines · Future proofing + url: https://rust-lang.github.io/api-guidelines/future-proofing.html#c-sealed + - title: Rust Reference · non_exhaustive + url: https://doc.rust-lang.org/reference/attributes/type_system.html#the-non_exhaustive-attribute + - title: Cargo Book · SemVer Compatibility + url: https://doc.rust-lang.org/cargo/reference/semver.html#enum-variant-new + - title: Cargo Book · non_exhaustive nachträglich + url: https://doc.rust-lang.org/cargo/reference/semver.html#attr-adding-non-exhaustive + - id: rabatt-regeln type: practice title: Rabattregeln für einen Shop diff --git a/content/rust/projects.yaml b/content/rust/projects.yaml index ed303c38..fbc64d3d 100644 --- a/content/rust/projects.yaml +++ b/content/rust/projects.yaml @@ -148,6 +148,113 @@ projects: - Graceful Shutdown mit sauber dokumentierter Teilausgabe. portfolio: Zeige eine gemessene Abwägung zu Workerzahl und Datenträgerlast. Erkläre Ownership-Transfer und warum mehr Threads nicht unbegrenzt schneller sind. +- id: healthchecker + title: Healthchecker für Dienste + level: 3 + hours: 28 + summary: Dienste per TCP, HTTP und Prozessaufruf prüfen – begrenzt nebenläufig, abbrechbar, mit atomar geschriebenem Bericht und Messprotokoll. + capstone: false + brief: |- + Auftrag: Ein Plattformteam rollt jeden Dienstag um 6 Uhr aus. Vorher soll ein Werkzeug prüfen, ob alle Abhängigkeiten erreichbar sind: die Datenbank, zwei interne HTTP-APIs und ein lokales Prüfwerkzeug wie `pg_isready`. Heute klickt jemand die Liste von Hand ab. Das Team will einen Befehl, der in einer Pipeline läuft, einen klaren Exit-Status liefert und einen Bericht für das Deployment-Protokoll ablegt. + + Baue `healthcheck` mit Tokio. Die Ziele stehen in einer TOML-Datei; jedes hat einen Namen, eine Art (`tcp`, `http` oder `befehl`) und einen Timeout: + + ```toml + [[ziel]] + name = "datenbank" + art = "tcp" + adresse = "10.0.0.5:5432" + timeout_ms = 500 + + [[ziel]] + name = "api" + art = "http" + adresse = "10.0.0.7:8080" + pfad = "/health" + timeout_ms = 1000 + + [[ziel]] + name = "pg-bereit" + art = "befehl" + programm = "pg_isready" + argumente = ["-h", "10.0.0.5", "-p", "5432"] + timeout_ms = 2000 + ``` + + Pflicht: TCP-Prüfung über Verbindungsaufbau; HTTP-Prüfung mit einer selbst geschriebenen, minimalen HTTP/1.1-Anfrage über `tokio::net::TcpStream` (nur `http://`, nur die Statuszeile auswerten, 2xx gilt als gesund – die Grenzen stehen im README); Prozessprüfung über `tokio::process::Command` mit Argumentliste, ohne Shell. Höchstens `--parallel N` Prüfungen gleichzeitig, jede mit eigenem Timeout. Strg+C bricht laufende Prüfungen ab und hinterlässt keine Kindprozesse. Der Textbericht geht auf stdout, Diagnosen auf stderr, ein JSON-Bericht wird atomar in eine Datei geschrieben. Exit-Status: 0 alle gesund, 1 mindestens ein Ziel ungesund, 2 Konfigurations- oder Aufruffehler. Dazu Tests, ein Benchmark und ein Messprotokoll. + + Rahmen: Keine echten Firmenadressen im Repository, alle Tests laufen gegen lokale Fake-Dienste auf Port 0. Ein HTTP-Client wie reqwest wäre im Betrieb die bessere Wahl; hier schreibst du die Anfrage selbst, weil es um das Denkmodell Bytestrom, Framing und Timeout geht. KI darf dir Boilerplate wie das CI-YAML abnehmen. Parser, Nebenläufigkeitsmodell, Tests und Messprotokoll schreibst und begründest du selbst. + skills: + - Tokio + - select! und Timeouts + - Semaphore + - CancellationToken + - tokio::net + - tokio::process + - serde/toml + - clap + - Atomares Schreiben + - Integrationstests + - criterion + - hyperfine + steps: + - id: vertrag + title: Vertrag, Konfiguration und Fake-Dienste festlegen + detail: Schreibe das TOML-Format, die Exit-Status, das Berichtsformat (Text und JSON) und das Verhalten bei Strg+C auf, bevor du Code schreibst. Plane die lokalen Fake-Dienste für die Tests – gesund, langsam, HTTP 503, Port geschlossen – und welches Ergebnis jeder liefern muss. + - id: konfig-laden + title: Konfiguration laden und prüfen + detail: "`#[derive(Deserialize)]` mit `deny_unknown_fields` und einem Enum für die Art. Prüfe fachliche Regeln (Timeout größer 0, Programm nicht leer, Namen eindeutig) in einer `TryFrom`-Umwandlung in deine internen Typen. clap liefert `--konfig`, `--parallel` und `--bericht`; ein Fehler endet mit Status 2 und einer Meldung mit Datei und Ursache." + - id: tcp-pruefung + title: TCP-Prüfung mit Zeitlimit + detail: "`TcpStream::connect` innerhalb von `tokio::time::timeout`. Miss die Dauer mit `Instant` und unterscheide Zeitlimit, verweigerte Verbindung und Namensauflösung im Ergebnis. Teste gegen einen `TcpListener` auf `127.0.0.1:0` und gegen einen Port, der gerade frei geworden ist." + - id: http-pruefung + title: Minimale HTTP-Prüfung + detail: Sende `GET HTTP/1.1` mit `Host` und `Connection close`, lies bis zum Ende der ersten Zeile und parse die Statuszeile in einer reinen Funktion ohne I/O. Die Funktion bekommt einen Property-Test mit proptest. Dokumentiere, was du bewusst nicht kannst (TLS, Weiterleitungen, Chunked Encoding). + - id: befehl-pruefung + title: Prozessprüfung ohne Shell + detail: "`tokio::process::Command` mit `args`, `kill_on_drop(true)` und Timeout. Werte `ExitStatus` aus, nimm einen gekürzten stderr-Auszug in den Bericht und unterscheide „Programm nicht gefunden“ von „Programm meldet Fehler“." + - id: begrenzt-nebenlaeufig + title: Begrenzt nebenläufig prüfen + detail: Starte jede Prüfung in einem `JoinSet` und begrenze die gleichzeitigen mit einer `Semaphore` (Permit wandert in die Task). Trenne Task-Fehler (`JoinError`) von fachlichen Fehlschlägen und sortiere die Ergebnisse vor der Ausgabe nach Namen. + - id: abbruch + title: Strg+C und Abbruch + detail: "`tokio::signal::ctrl_c` löst ein `CancellationToken` aus; jede Prüfung wartet per `select!` auf Ergebnis oder Abbruch. Prüfe, dass nach Strg+C kein Kindprozess weiterläuft, und lege fest, ob ein abgebrochener Lauf einen markierten Teilbericht schreibt." + - id: bericht-atomar + title: Bericht schreiben, ohne Halbes zu hinterlassen + detail: Textbericht über `BufWriter` auf gesperrtem stdout, Broken Pipe beendet still. Den JSON-Bericht schreibst du in eine temporäre Datei im Zielverzeichnis, rufst `flush` und `sync_all` auf und ersetzt das Ziel per `rename`. + - id: tests + title: Tests gegen Fake-Dienste + detail: "Integrationstests mit lokalen Fake-Diensten, Timeouts mit `#[tokio::test(start_paused = true)]`, wo die Zeit simuliert werden kann. Ein CLI-Test mit assert_cmd und tempfile prüft Exit-Status, stdout und die Berichtsdatei. Ein insta-Snapshot hält den Textbericht fest; Dauern filterst du heraus, damit der Snapshot stabil bleibt." + - id: messen + title: Messen und Messprotokoll + detail: Ein criterion-Benchmark misst Statuszeilen-Parser und Berichtserzeugung. hyperfine misst einen ganzen Lauf gegen 50 lokale Fake-Dienste mit `--parallel 1`, `8` und `32`. Halte Hypothese, Werte (Release-Build), Streuung und deine Entscheidung für den Standardwert von `--parallel` im README fest. + acceptance: + - id: exit-status + text: Exit-Status 0, 1 und 2 verhalten sich wie im README beschrieben; je ein automatisierter Test belegt jeden Status. + - id: grenze-eingehalten + text: Nie laufen mehr als `--parallel` Prüfungen gleichzeitig; ein Test mit zählendem Fake-Dienst belegt das. + - id: timeout-greift + text: Ein hängender Dienst wird nach seinem Timeout als ungesund gemeldet, und der Lauf endet trotzdem. + - id: keine-shell + text: Prozessprüfungen laufen mit Argumentliste ohne Shell; nach Timeout oder Strg+C bleibt kein Kindprozess zurück. + - id: bericht-ganz-oder-gar-nicht + text: Die JSON-Datei ist nach jedem Lauf entweder die alte oder die neue vollständige Fassung; die temporäre Datei liegt im Zielverzeichnis. + - id: stdout-sauber + text: stdout enthält nur den Bericht, Diagnosen gehen auf stderr; `healthcheck … | head -1` endet ohne Panic. + - id: tests-ci + text: "`cargo fmt --check`, `cargo clippy --all-targets -- -D warnings` und alle Tests (Integration, Property, Snapshot) laufen in CI grün." + - id: messprotokoll + text: Das README enthält criterion- und hyperfine-Ergebnisse aus dem Release-Build mit Hypothese, Streuung und begründeter Entscheidung. + stretch: + - HTTPS über einen gepflegten Client (reqwest mit rustls) als zusätzliche Art `https`, die minimale Implementierung bleibt für `http`. + - Wiederholungen mit Backoff und Jitter, sichtbar im Bericht. + - Ausgabe im Prometheus-Textformat für einen Node-Exporter-Textfile-Collector. + - Konfiguration bei SIGHUP neu laden und über einen `watch`-Kanal an laufende Prüfungen verteilen. + portfolio: |- + Zeige eine kurze Demo gegen lokale Fake-Dienste: alles gesund, dann ein langsamer Dienst, dann Strg+C mitten im Lauf. Zeige den Exit-Status und den JSON-Bericht. + + Erkläre im Gespräch drei Entscheidungen: warum die Nebenläufigkeit mit einer Semaphore begrenzt ist und nicht mit der Zahl der Threads, wie Abbruch und Kindprozesse zusammenhängen, und welcher Messwert deinen Standard für `--parallel` begründet. Nenne die Grenzen der eigenen HTTP-Prüfung offen. + - id: abschluss-logwerk title: 'Abschlussprojekt: Logwerk für den Betrieb' level: 3