Erreichen von Zero-Copy-Datenparsing in Rust-Webdiensten für verbesserte Leistung
Lukas Schneider
DevOps Engineer · Leapcell

Einleitung
In der Welt der Hochleistungs-Webdienste zählt jede Millisekunde. Wenn Anwendungen skalieren und die Datenvolumen steigen, können die Gemeinkosten traditioneller Datenverarbeitungstechniken, insbesondere Datenkopien und -zuweisungen, zu einem erheblichen Engpass werden. Dies gilt insbesondere für Dienste, die große Mengen an Anfrage- oder Antwortkörpern verarbeiten, wie z. B. JSON-APIs, Datei-Uploads oder Streaming-Daten. Rust bietet mit seinem Fokus auf Leistung, Speichersicherheit und feingranularer Kontrolle eine ideale Umgebung, um diese Herausforderungen zu meistern. Eine leistungsstarke Optimierungstechnik, die perfekt zur Philosophie von Rust passt, ist das Zero-Copy-Datenparsing. Durch die Minimierung oder vollständige Eliminierung von Datenkopien kann Zero-Copy-Parsing den Durchsatz Ihrer Webdienste dramatisch verbessern und die Latenz reduzieren. Dieser Artikel befasst sich mit dem Konzept des Zero-Copy-Datenparsings im Kontext von Rust-Webdiensten, erklärt seine Vorteile und zeigt, wie es effektiv implementiert werden kann.
Verstehen von Zero-Copy und seinen Auswirkungen
Bevor wir uns mit den Implementierungsdetails befassen, wollen wir ein klares Verständnis der beteiligten Kernkonzepte gewinnen.
Kernterminologie
- Zero-Copy: Im Wesentlichen bezieht sich Zero-Copy auf Operationen, bei denen die CPU keine Datenkopien zwischen verschiedenen Speicherorten durchführt. Anstatt Daten zu duplizieren, zeigen Zero-Copy-Techniken typischerweise auf vorhandene Daten oder remappen Speicher. Dies steht im Gegensatz zu traditionellen Ansätzen, bei denen Daten von einem Netzwerkpuffer in einen Anwendungspuffer kopiert und dann möglicherweise zum Parsen oder Verarbeiten erneut kopiert werden.
- Speicherzuweisung: Der Prozess der Reservierung eines Speicherblocks zur Verwendung durch ein Programm. Häufige oder große Zuweisungen können teuer sein in Bezug auf CPU-Zyklen und können auch zu Speicherfragmentierung führen.
- Deserialisierung: Der Prozess der Konvertierung eines strukturierten Formats (wie JSON, XML oder Binärprotokolle) in eine In-Memory-Datenstruktur (wie eine Rust-Struktur).
&[u8]
(Byte-Slice): Ein grundlegender Rust-Typ, der einen Verweis (Reference) auf eine zusammenhängende Sequenz von vorzeichenlosen 8-Bit-Ganzzahlen (Bytes) darstellt. Es ist eine Sicht auf vorhandenen Speicher, was ihn ideal für Zero-Copy-Vorgänge macht.Cow<'a, T>
(Clone on Write): Ein intelligenter Zeiger in Rust, der einen besessenen (T
) oder geliehenen (&'a T
) Wert ermöglicht. Er ist besonders nützlich für Zero-Copy-Szenarien, in denen Sie Daten meistens leihen müssen, aber gelegentlich besitzen und ändern müssen.
Das Problem mit traditionellem Parsing
Betrachten Sie ein typisches Szenario in einem Webdienst: Der eingehende HTTP-Anfragekörper enthält eine JSON-Nutzlast. Ein konventioneller Parsing-Ablauf könnte wie folgt aussehen:
- Netzwerkpuffer zu Anwendungspuffer: Der Webserver empfängt Bytes aus dem Netzwerk und kopiert sie in einen internen Puffer.
- Anwendungspuffer zu String/Vektor: Die Bytes im Puffer werden dann möglicherweise in einen
String
(für UTF-8-Validierung) oder einenVec<u8>
konvertiert. Dies beinhaltet eine weitere Kopie und Zuweisung. - Parsing/Deserialisierung: Ein Deserialisierer liest aus diesem
String
oderVec<u8>
und erstellt anwendungsspezifische Datenstrukturen. Abhängig vom Deserialisierer und den Daten können Teile der Daten erneut kopiert werden (z. B. beim Erstellen neuerString
-Instanzen für String-Felder).
Jeder dieser Kopiervorgänge verursacht CPU-Overhead und erhöht potenziell den Druck auf die Speicherzuweisung, was zu häufigerer Garbage Collection (bei Verwendung einer GC-Sprache) oder einfach zu langsamerer Ausführung in Rust aufgrund des Zuweisungs-Overheads führen kann.
Die Zero-Copy-Lösung
Zero-Copy-Parsing zielt darauf ab, diese redundanten Kopien zu umgehen. Anstatt Daten zu kopieren, arbeitet es so weit wie möglich mit Verweisen auf den ursprünglichen Datenpuffer. Wenn Sie beispielsweise einen JSON-String erhalten, würde ein Zero-Copy-Parser die String-Feldwerte als Verweise (z. B. &str
) zurück in den ursprünglichen Bytepuffer parsen, anstatt für jedes Feld neue String
-Instanzen zuzuweisen.
Die Hauptvorteile sind:
- Reduzierte CPU-Zyklen: Weniger
memcpy
-Operationen bedeuten weniger CPU-Zeit für Datenbewegungen. - Geringere Speicherzuweisung: Weniger neue Objekte bedeuten weniger Druck auf den Speicherzuweiser, was zu einer potenziell besseren Cache-Lokalität und reduzierten Speicherfragmentierung führt.
- Verbesserter Durchsatz: Ihr Dienst kann mehr Anfragen pro Sekunde verarbeiten, da weniger Zeit für nicht unbedingt notwendige Datenmanipulation aufgewendet wird.
- Geringere Latenz: Die Verarbeitungszeiten einzelner Anfragen werden reduziert.
Implementierung von Zero-Copy in Rust-Webdiensten
Rusts Ownership- und Borrowing-System, kombiniert mit leistungsstarken Serialisierungs-/Deserialisierungs-Crates, macht Zero-Copy-Parsing bemerkenswert erreichbar. Der Schlüssel liegt darin, nach Möglichkeit in geliehene Typen (borrowed types) zu deserialisieren.
Lassen Sie uns dies mit den beliebten Crates serde
und serde_json
veranschaulichen, die häufig mit Web-Frameworks wie Axum
oder Actix-web
verwendet werden.
Betrachten Sie eine einfache JSON-Nutzlast:
{ "name": "Jane Doe", "age": 30, "hobbies": ["reading", "hiking"] }
Traditionelle (besessene) Deserialisierung
Eine typische Rust-Struktur dafür könnte so aussehen:
use serde::Deserialize; #[derive(Debug, Deserialize)] struct UserOwned { name: String, age: u8, hobbies: Vec<String>, }
Beim Deserialisieren in UserOwned
werden für jeden String
und Vec<String>
neue Speicher zugewiesen und die Daten aus dem Eingabepuffer kopiert.
Zero-Copy (geliehene) Deserialisierung
Um Zero-Copy für String- und Byte-Slice-Felder zu erreichen, können wir geliehene Typen mit Lifetimes verwenden:
use serde::Deserialize; #[derive(Debug, Deserialize)] struct UserBorrowed<'a> { name: &'a str, // Leiht einen Slice aus dem Eingabe-Bytepuffer age: u8, hobbies: Vec<&'a str>, // Leiht Slices für jeden Hobby-String }
Hier ist name
ein &'a str
und hobbies
ein Vec<&'a str>
. Das bedeutet, dass serde_json
diese Felder parsen wird, indem es direkt String-Slices (&str
) erstellt, die auf den ursprünglichen Byte-Array zurückverweisen, der den JSON enthielt. Für diese Felder werden keine neuen String
-Zuweisungen vorgenommen.
Praktisches Beispiel mit Axum
Integrieren wir dies in einen Axum-Webdienst. Wir simulieren ein Szenario, in dem der Anfragekörper eine JSON-Nutzlast ist.
Fügen Sie zunächst die notwendigen Abhängigkeiten hinzu:
# Cargo.toml [dependencies] tokio = { version = "1", features = ["full"] } axum = "0.7" serde = { version = "1", features = ["derive"] } serde_json = "1"
Nun der Axum-Handler:
use axum::{ body::Bytes, extract::State, http::StatusCode, response::IntoResponse, routing::post, Router, }; use serde::Deserialize; use std::sync::Arc; // Unsere Zero-Copy-freundliche Struktur. // Beachten Sie das Attribut `#[serde(borrow)]`, das entscheidend ist, um in geliehene Typen zu deserialisieren, // wenn die Eingabe-Bytes nicht 'static sind. // Für `Vec<&'a str>` ist `serde(borrow)` nicht streng notwendig für das `Vec` selbst, aber // die Elemente innerhalb des Vec profitieren davon, und es zeigt generell eine geliehene Deserialisierungsstrategie an. #[derive(Debug, Deserialize)] #[serde(borrow)] // Wichtig, damit `serde_json` korrekt geliehene Typen deserialisieren kann struct User<'a> { name: &'a str, age: u8, hobbies: Vec<&'a str>, } #[tokio::main] async fn main() { let app = Router::new() .route("/users", post(create_user)); let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") .await .unwrap(); println!("Listening on http://127.0.0.1:3000"); axum::serve(listener, app).await.unwrap(); } async fn create_user( // Axums `Bytes`-Extraktor gibt uns einen unveränderlichen Verweis auf die Anfragekörper-Bytes. // Dies ist die ideale Eingabe für Zero-Copy-Parsing. body: Bytes, ) -> impl IntoResponse { // Versuchen, die Bytes in unsere geliehene User-Struktur zu deserialisieren. // Der `&body`-Slice wird an `serde_json::from_slice` übergeben. match serde_json::from_slice::<User<'_>>(&body) { Ok(user) => { println!("Empfangener Benutzer: {:?}", user); // In einer realen Anwendung würden Sie `user` hier verarbeiten. // Hinweis: Wenn Sie `user` über den Geltungsbereich von `body` hinaus speichern müssen, // müssen Sie möglicherweise geliehene Felder in besessene `String`s konvertieren, // oder `Cow` verwenden, um die Kopie aufzuschieben. (StatusCode::CREATED, "Benutzer erfolgreich erstellt (Zero-Copy geparst)".into_response()) } Err(e) => { eprintln!("Fehler beim Parsen des Benutzers: {:?}", e); (StatusCode::BAD_REQUEST, format!("Ungültiger Anfragekörper: {}", e).into_response()) } } }
In diesem Beispiel:
axum::body::Bytes
ist einbytes::Bytes
-Typ, ein effizient geteilter, unveränderlicher Bytepuffer. Wenn Axum den Anfragekörper empfängt, kopiert er ihn nicht sofort in einenString
oderVec<u8>
. Stattdessen stellt erBytes
bereit, eine günstige Sicht oder einen Verweis auf die rohen Netzwerkdaten.- Wir übergeben
&body
(einen&[u8]
) direkt anserde_json::from_slice
. - Das Attribut
#[serde(borrow)]
aufUser
ist entscheidend. Es weistserde
an, beim Deserialisieren zu versuchen, String- und Bytezellen-ähnliche Daten aus dem Eingabe-Slice auszuleihen, anstatt neue besessene Typen zuzuweisen. - Folglich sind
user.name
und die Elemente inuser.hobbies
&str
-Slices, die direkt in denbody
-Bytepuffer zeigen. Solangebody
gültig ist, sind diese Verweise gültig.
Umgang mit Daten, die den Request überdauern müssen
Was ist, wenn Sie die User
-Daten in einer Datenbank oder einem langlebigen Cache speichern müssen, wo der body'-Bytepuffer (und damit die geliehenen Daten) nicht mehr gültig wären? Hier kommt
Cow<'a, str>` ins Spiel.
use serde::Deserialize; use std::borrow::Cow; #[derive(Debug, Deserialize)] #[serde(borrow)] struct UserCow<'a> { name: Cow<'a, str>, // Kann geliehen oder besessen sein age: u8, hobbies: Vec<Cow<'a, str>>, // Jedes Element kann geliehen oder besessen sein }
Mit Cow
versucht serde_json
zunächst, die Daten zu leihen, wenn möglich. Wenn dies aus irgendeinem Grund nicht möglich ist (z. B. wenn die Daten eine Dekodierung oder Validierung erfordern, die eine Kopie erzwingt), weist es dann Speicher zu und übernimmt den Besitz der Daten. Dies bietet das Beste aus beiden Welten: Zero-Copy-Verhalten, wenn machbar, mit einem reibungslosen Fallback auf besessene Daten, wenn nötig.
Sie können dann explizit besessene Daten beim Speichern konvertieren:
fn process_and_store_user(user: UserCow<'_>) { let owned_user = UserOwned { name: user.name.into_owned(), // Erstellt einen besessenen String age: user.age, hobbies: user.hobbies.into_iter().map(|s| s.into_owned()).collect(), // Erstellt besessene Strings }; // Speichern von owned_user }
Anwendungsszenarien
Zero-Copy-Parsing ist besonders vorteilhaft in:
- Hochdurchsatz-APIs: Dienste, die eine große Menge an JSON-, XML- oder Protobuf-Nutzlasten verarbeiten.
- Proxy-Dienste: Wo Daten minimal verarbeitet werden, bevor sie weitergeleitet werden.
- Protokollverarbeitung: Strukturierte Protokollzeilen ohne unnötige String-Zuweisungen parsen.
- Medien-Streaming: Effizientes Verarbeiten von Binärdatenblöcken.
- Datenerfassung: Große Datenströme, bei denen die Leistung entscheidend ist.
Wichtige Überlegungen:
- Lifetimes: Die Verwendung von Lifetimes (
'a
) ist fundamental für Zero-Copy in Rust. Sie müssen sicherstellen, dass der Eingabe-Bytepuffer die geparsten geliehenen Daten überdauert. Web-Frameworks handhaben dies normalerweise korrekt für die Dauer eines Request-Handlers. #[serde(borrow)]
: Denken Sie daran, dieses Attribut zu Ihren Strukturen hinzuzufügen, damitserde
das Ausleihen versucht.- Datenunveränderlichkeit: Geliehene Daten sind unveränderlich. Wenn Sie geparste String-Felder ändern müssen, müssen Sie sie schließlich in besessene
String
s konvertieren. Cow
für Flexibilität: Verwenden SieCow
für Felder, die manchmal besessen oder geändert werden müssen, und bieten Sie so ein flexibles Gleichgewicht zwischen Zero-Copy und Änderbarkeit.
Fazit
Zero-Copy-Datenparsing ist eine leistungsstarke Optimierungstechnik, die die Leistung von Rust-Webdiensten erheblich verbessern kann, indem sie Speicherzuweisungen und Datenkopien minimiert. Durch die Nutzung des robusten Typsystems, der Lifetimes von Rust und der Fähigkeiten von Crates wie serde
und serde_json
können Entwickler effizient riesige Datenmengen verarbeiten, indem sie mit geliehenen Verweisen arbeiten, anstatt Daten ständig zu duplizieren. Die Implementierung von Zero-Copy-Parsing führt zu schnellerer Anfrageverarbeitung, geringerem Speicherbedarf und letztendlich zu skalierbareren und reaktionsfreudigeren Webanwendungen. Nutzen Sie Zero-Copy, um das volle Leistungspotenzial Ihrer Rust-Webdienste auszuschöpfen.