Umgang mit synchronem Blockieren in asynchronen Rust-Webdiensten
Emily Parker
Product Engineer · Leapcell

Einleitung
In der Welt der modernen Webentwicklung sind Geschwindigkeit und Reaktionsfähigkeit von größter Bedeutung. Benutzer erwarten, dass Anwendungen schnell sind und zahlreiche Anfragen gleichzeitig bearbeiten, ohne spürbare Verzögerungen. Rust hat sich mit seinen leistungsstarken asynchronen Funktionen zu einem starken Kandidaten für die Erstellung hochperformanter Webdienste entwickelt. Frameworks wie Actix-web und Tokio ermöglichen es Entwicklern, hochgradig nebenläufigen Code zu schreiben, der Systemressourcen effizient nutzt.
Allerdings können und sollten nicht alle Operationen asynchron sein. Einige Aufgaben wie kryptographisches Hashing (z. B. Passwort-Hashing mit Argon2 oder Bcrypt), komplexe Datenverarbeitung oder die Interaktion mit alten synchronen Bibliotheken sind von Natur aus blockierend. Wenn diese blockierenden Operationen direkt in einem asynchronen Kontext ausgeführt werden, blockieren sie den gesamten Thread, unterbrechen den Fortschritt aller anderen gleichzeitigen Aufgaben und verschlechtern die Leistung des Dienstes erheblich. Dieser Artikel befasst sich mit der kritischen Herausforderung, diese synchronen, blockierenden Operationen korrekt in Ihren asynchronen Rust-Webdienst zu integrieren, um dessen Reaktionsfähigkeit und Effizienz zu erhalten.
Grundlegende Konzepte verstehen
Bevor wir uns mit Lösungen befassen, wollen wir ein klares Verständnis der beteiligten Schlüsselkonzepte schaffen:
- Asynchrone Programmierung: In Rust (und vielen anderen Sprachen) ermöglicht die asynchrone Programmierung einem Programm, viele Aufgaben zu initiieren, ohne darauf zu warten, dass jede abgeschlossen ist, bevor eine neue beginnt. Wenn eine asynchrone Aufgabe auf eine I/O-Operation (wie eine Netzwerkanforderung oder einen Festplattenlesevorgang) stößt, gibt sie anstelle des Blockierens des Threads die Kontrolle an die Laufzeit zurück, sodass andere Aufgaben ausgeführt werden können. Sobald die I/O-Operation abgeschlossen ist, kann die Aufgabe fortgesetzt werden. Dies wird durch die
async/await-Syntax und einenExecutor(wie Tokio) erreicht, der die Aufgabenplanung verwaltet. - Blockierende Operationen: Eine blockierende Operation ist eine, die bei Ausführung die Kontrolle erst dann an den Aufrufer zurückgibt, wenn sie vollständig abgeschlossen ist. Während dieser Zeit ist der Thread, der die Operation ausführt, "blockiert" und kann keine andere Arbeit verrichten. Beispiele hierfür sind CPU-gebundene Berechnungen (wie Passwort-Hashing), synchrone Datei-I/O oder blockierende Datenbankaufrufe.
- Tokio Runtime: Tokio ist die beliebteste asynchrone Laufzeit für Rust. Es stellt alle notwendigen Komponenten für die Erstellung asynchroner Anwendungen bereit, einschließlich einer Ereignisschleife, eines Task-Planers und Werkzeugen für kooperatives Multitasking. Es verwendet typischerweise eine feste Anzahl von Worker-Threads (oft einen pro CPU-Kern), um
async-Aufgaben auszuführen. - Thread-Pools: Ein Thread-Pool ist eine Sammlung von vorab gestarteten Threads, die zur Ausführung von Aufgaben verwendet werden können. Anstatt für jede Aufgabe einen neuen Thread zu starten, werden Aufgaben an den Pool übermittelt, und ein verfügbarer Thread nimmt sie auf. Dies reduziert den Overhead der Thread-Erstellung und -Zerstörung.
Das Problem tritt auf, wenn eine blockierende Operation direkt auf einem der Worker-Threads von Tokio ausgeführt wird. Da der Worker-Thread blockiert ist, kann er keine anderen async-Aufgaben ausführen, was einen Teil der Parallelität Ihres Dienstes effektiv zum Stillstand bringt.
Strategien zur Behandlung von blockierendem Code
Die grundlegende Lösung besteht darin, blockierende Operationen von den Worker-Threads der Haupt-Laufzeitumgebung zu verschieben. Dies stellt sicher, dass die primäre Ereignisschleife frei bleibt, um nicht-blockierende Aufgaben zu planen und auszuführen.
1. Verwendung von tokio::task::spawn_blocking
Der einfachste und empfohlene Weg, blockierende Operationen innerhalb einer Tokio-basierten Anwendung zu verarbeiten, ist die Verwendung von tokio::task::spawn_blocking. Diese Funktion verschiebt die bereitgestellte blockierende Future oder Closure auf einen dedizierten, dynamisch dimensionierten Thread-Pool, der von Tokio speziell für blockierende Aufgaben verwaltet wird.
Hier ist, wie es in der Praxis funktioniert, unter Verwendung eines Passwort-Hashing-Beispiels:
use actix_web::{web, App, HttpServer, HttpResponse, Responder}; use tokio::time::{sleep, Duration}; use argon2::{password_hash::SaltString, Argon2, PasswordHasher}; use rand_core::OsRng; // kryptographischer Zufallszahlengenerator async fn hash_password_handler(password: web::Path<String>) -> impl Responder { let password_str = password.into_inner(); // Stellen Sie sich vor, dies ist eine CPU-intensive Operation wie Argon2 Passwort-Hashing. // Wenn sie direkt ausgeführt wird, würde sie den Actix-web Worker-Thread blockieren. let hashed_password = tokio::task::spawn_blocking(move || { let salt = SaltString::generate(&mut OsRng); // Argon2 braucht Zeit, besonders mit starken Parametern let argon2 = Argon2::default(); argon2.hash_password(password_str.as_bytes(), &salt) .map(|hash| hash.to_string()) .expect("Failed to hash password") }) .await; match hashed_password { Ok(hash) => HttpResponse::Ok().body(format!("Hashed password: {}", hash)), Err(e) => { eprintln!("Failed to hash password in blocking thread: {:?}", e); HttpResponse::InternalServerError().body("Failed to process password") } } } async fn hello() -> impl Responder { // Dies ist eine nicht-blockierende Operation, kann gleichzeitig ausgeführt werden sleep(Duration::from_millis(100)).await; HttpResponse::Ok().body("Hello world!") } #[actix_web::main] async fn main() -> std::io::Result<()> { HttpServer::new(|| { App::new() .route("/", web::get().to(hello)) .route("/hash/{password}", web::get().to(hash_password_handler)) }) .bind(("127.0.0.1", 8080))? .run() .await }
In diesem Beispiel:
hash_password_handlerist eineasync-Funktion, aber die eigentliche Passwort-Hashing-Logik ist in einer Closure platziert, die antokio::task::spawn_blockingübergeben wird.spawn_blockinggibt einenJoinHandlezurück, der abgewartet wird. Dieserawait-Punkt ist entscheidend: Derhash_password_handlerselbst ist nicht blockierend, während er auf den Abschluss des Hashing auf einem anderen Thread wartet.- Die Hashing-Closure wird auf einem dedizierten Thread aus Tokios Blocking-Thread-Pool ausgeführt. Dieser Pool ist von den Kern-Worker-Threads der asynchronen Laufzeitumgebung getrennt.
- Der
hello-Endpunkt, der rein asynchron ist, kann weiterhin schnell antworten, auch wenn mehrere Passwort-Hashing-Anfragen in Bearbeitung sind.
Wann spawn_blocking verwenden:
- CPU-gebundene Berechnungen: Passwort-Hashing, Bildverarbeitung, schwere Datentransformationen.
- Synchrone I/O: Interaktion mit älteren Bibliotheken oder Dateien ohne asynchrone APIs.
- Jeglicher Code, der viel Zeit in Anspruch nimmt und nicht explizit die Kontrolle abgibt.
2. Dedizierte Thread-Pools (z. B. rayon)
Für komplexere oder sehr allgemeine CPU-gebundene Workloads könnten Sie die Verwendung einer dedizierten Thread-Pool-Bibliothek wie rayon in Betracht ziehen. Rayon bietet ein Framework für Datenparallelität, das sich hervorragend zur Parallelisierung von CPU-gebundenen Aufgaben eignet und oft besser ist als benutzerdefinierte Thread-Verwaltung.
Während rayon selbst nicht direkt auf die gleiche Weise wie tokio::task::spawn_blocking in async/await integriert ist, können Sie die beiden dennoch verbinden:
use actix_web::{web, App, HttpServer, HttpResponse, Responder}; use tokio::time::{sleep, Duration}; use argon2::{password_hash::SaltString, Argon2, PasswordHasher}; use rand_core::OsRng; use once_cell::sync::Lazy; // Für Lazy Static Initialisierung des Thread Pools use rayon::ThreadPoolBuilder; // Erstellen Sie einen globalen Rayon Thread Pool, speziell für intensive CPU-Aufgaben. // Passen Sie die Anzahl der Threads an die Bedürfnisse Ihrer Anwendung und die CPU-Kerne des Servers an. static CPU_POOL: Lazy<rayon::ThreadPool> = Lazy::new(|| { ThreadPoolBuilder::new() .num_threads(num_cpus::get()) // Typischerweise alle CPU-Kerne verwenden .build() .expect("Failed to build Rayon thread pool") }); async fn hash_password_rayon_handler(password: web::Path<String>) -> impl Responder { let password_str = password.into_inner(); let hashed_password = tokio::task::spawn_blocking(move || { // Jetzt, innerhalb des blockierenden Tokio Threads, können wir an Rayons Pool übermitteln CPU_POOL.install(move || { let salt = SaltString::generate(&mut OsRng); let argon2 = Argon2::default(); argon2.hash_password(password_str.as_bytes(), &salt) .map(|hash| hash.to_string()) .expect("Failed to hash password") }) }) .await; match hashed_password { Ok(hash) => HttpResponse::Ok().body(format!("Hashed password (Rayon): {}", hash)), Err(e) => { eprintln!("Failed to hash password with Rayon: {:?}", e); HttpResponse::InternalServerError().body("Failed to process password") } } } // ... async fn hello () und main Funktion wie zuvor, fügen Sie die neue Route hinzu ... #[actix_web::main] async fn main() -> std::io::Result<()> { HttpServer::new(|| { App::new() .route("/", web::get().to(hello)) .route("/hash/{password}", web::get().to(hash_password_handler)) // Verwendet spawn_blocking .route("/hash_rayon/{password}", web::get().to(hash_password_rayon_handler)) // Verwendet rayon über spawn_blocking }) .bind(("127.0.0.1", 8080))? .run() .await }
In diesem erweiterten Beispiel:
- Ein
rayon::ThreadPoolwird als Lazy Static Global erstellt, um sicherzustellen, dass er einmal initialisiert wird. hash_password_rayon_handlerverwendet immer nochtokio::task::spawn_blocking. Dies ist entscheidend: Derrayon::ThreadPoolläuft auf seinen eigenen konfigurierten Threads. Wenn wirCPU_POOL.installdirekt in einerasync-Funktion aufrufen würden, würde dies immer noch den Tokio Async-Worker-Thread blockieren.CPU_POOL.installnimmt eine Closure und stellt sicher, dass sie auf einem der Rayon-Threads ausgeführt wird. Hier findet die eigentliche CPU-intensive Arbeit statt.
Wann Rayon wie ein dedizierter Thread-Pool verwenden:
- Zur Parallelisierung hochgradig CPU-gebundener, datenintensiver Aufgaben, die in kleinere, unabhängige Einheiten zerlegt werden können.
- Wenn Sie eine feinere Kontrolle über die Anzahl der Threads benötigen, die für bestimmte CPU-intensive Workloads reserviert sind, getrennt vom Tokio-Blocking-Pool.
- Es wird oft in Verbindung mit
spawn_blockingverwendet, um Rayon's parallele Berechnungen sicher außerhalb der asynchronen Laufzeit auszuführen.
3. Externe Bibliotheken asynchron machen
Manchmal kommt die blockierende Operation von einer externen Bibliothek (z. B. einem Datenbanktreiber, der nur synchrone APIs anbietet).
-
Wrapper-Bibliotheken: Suchen Sie nach
async-Wrappern oder Forks der Bibliothek. Zum Beispiel istsqlxein asynchroner ORM für Rust, der speziell dafür entwickelt wurde, nicht blockierend zu sein. Der Wechsel von einer synchronendiesel-Verbindung zusqlxwürde Ihre Datenbankoperationen wirklich asynchron machen. -
Manuelles Auslagern: Wenn keine asynchrone Alternative existiert, müssen Sie auf
tokio::task::spawn_blockingzurückgreifen, um die blockierenden Aufrufe zu wrappen:// Beispiel: Blockierender Datenbankaufruf (hypothetisch) async fn fetch_user_blocking(user_id: u32) -> Result<String, String> { let user_data = tokio::task::spawn_blocking(move || { // Simuliert einen blockierenden Datenbankaufruf std::thread::sleep(Duration::from_secs(1)); if user_id == 1 { Ok(format!("User data for ID {}", user_id)) } else { Err("User not found".to_string()) } }).await; user_data.expect("Blocking task failed").map_err(|e| e.to_string()) }
Schlussfolgerung
Die Integration von synchronem, blockierendem Code in einen asynchronen Rust-Webdienst erfordert sorgfältige Überlegungen, um Reaktionsfähigkeit und Leistung aufrechtzuerhalten. Die goldene Regel lautet: Lassen Sie niemals eine blockierende Operation auf den Kern-Worker-Threads Ihrer asynchronen Laufzeitumgebung laufen. Durch die Nutzung von tokio::task::spawn_blocking können Sie diese CPU-gebundenen oder blockierenden I/O-Operationen effektiv an einen separaten Thread-Pool auslagern, der von Tokio verwaltet wird. Für hochgradig parallelisierbare CPU-gebundene Aufgaben bietet die Kombination von spawn_blocking mit einer dedizierten Bibliothek wie rayon noch feinere Kontrolle. Durch die Einhaltung dieser Praktiken können Sie robuste und performante asynchrone Rust-Anwendungen erstellen, die alle Arten von Workloads problemlos bewältigen, ohne die Benutzererfahrung zu beeinträchtigen. Lagern Sie blockierende Arbeiten immer aus, um die Agilität des Dienstes zu erhalten.