Channels in Rust: Nachrichten zwischen Threads senden
Auf der Seite Nebenläufigkeit haben sich Threads Daten geteilt, indem sie sie mit Arc<Mutex<T>> gesperrt haben. Das funktioniert. Es gibt aber noch einen anderen Weg, der oft einfacher ist: Statt Daten zu teilen, schicken sich Threads Nachrichten.
Stell dir einen Channel (Kanal) wie ein Rohr mit zwei Enden vor. Ein Thread wirft Werte in das Ende des Senders. Ein anderer Thread holt sie am Ende des Empfängers wieder heraus, in derselben Reihenfolge. Niemand teilt etwas: Jeder Wert wandert von einem Thread zum anderen. Aus der Go-Community stammt ein Spruch, den Rust sich hier ausleiht: „Kommuniziere nicht, indem du Speicher teilst – teile Speicher, indem du kommunizierst.“
Dein erster Channel
Abschnitt betitelt „Dein erster Channel“Der Channel der Standardbibliothek liegt in std::sync::mpsc. Der Name steht für multiple producer, single consumer (mehrere Erzeuger, ein Verbraucher): viele Sender, ein Empfänger.
use std::sync::mpsc;use std::thread;
fn main() { let (tx, rx) = mpsc::channel();
thread::spawn(move || { let message = String::from("Hallo vom Worker"); tx.send(message).unwrap(); });
let received = rx.recv().unwrap(); println!("main hat bekommen: {received}");}Ausgabe:
main hat bekommen: Hallo vom WorkerSchritt für Schritt:
mpsc::channel()gibt ein Paar zurück: einenSenderund einenReceiver. Traditionell heißen sietx(transmit, senden) undrx(receive, empfangen).moveübergibttxan den neuen Thread – genauso, wie du auf der Seite Nebenläufigkeit Daten in Threads verschoben hast.tx.send(value)legt einen Wert in den Channel. Die Methode gibt ein Result zurück, denn sie schlägt fehl, wenn der Empfänger schon weg ist.rx.recv()wartet, bis ein Wert ankommt. EinErrgibt es nur, wenn alle Sender weg sind und der Channel leer ist.
Wir haben hier kein join() gebraucht. recv() wartet ja schon auf die Nachricht, also kann main nicht zu früh enden.
Viele Werte empfangen
Abschnitt betitelt „Viele Werte empfangen“Ein Receiver funktioniert als Iterator. Eine for-Schleife darüber ruft für dich immer wieder recv() auf. Und die Schleife endet von selbst, sobald alle Sender gedroppt (freigegeben) wurden:
use std::sync::mpsc;use std::thread;
fn main() { let (tx, rx) = mpsc::channel();
thread::spawn(move || { for i in 1..=3 { tx.send(i * 10).unwrap(); } // tx wird hier gedroppt, wenn der Thread endet });
for value in rx { println!("erhalten: {value}"); } println!("Channel geschlossen, alles erledigt");}Ausgabe:
erhalten: 10erhalten: 20erhalten: 30Channel geschlossen, alles erledigtHier gibt es nur einen Sender, also kommen die Werte genau in der Reihenfolge an, in der sie gesendet wurden. Die Regel „die Schleife hört auf, wenn die Sender weg sind“ ist wichtig. Merk sie dir für den nächsten Abschnitt.
Mehrere Erzeuger: den Sender klonen
Abschnitt betitelt „Mehrere Erzeuger: den Sender klonen“Damit mehrere Threads senden können, gibst du jedem mit tx.clone() eine eigene Kopie des Senders. Alle Kopien speisen denselben Channel:
use std::sync::mpsc;use std::thread;
fn main() { let (tx, rx) = mpsc::channel();
for id in 1..=3 { let tx = tx.clone(); thread::spawn(move || { tx.send(format!("Worker {id} meldet sich")).unwrap(); }); } drop(tx); // das Original droppen, sonst endet die Schleife unten nie
let mut messages: Vec<String> = rx.iter().collect(); messages.sort(); for m in messages { println!("{m}"); }}Ausgabe:
Worker 1 meldet sichWorker 2 meldet sichWorker 3 meldet sichZwei Dinge fallen auf:
drop(tx)ist wichtig. Jeder Thread droppt seinen Klon, wenn er fertig ist, abermainhält noch das Original. Solange es noch einen Sender gibt, wartet der Empfänger weiter – und das Programm würde für immer hängen. Mitdropsagst du: „main sendet nichts mehr.“- Die Threads werden in beliebiger Reihenfolge fertig. Die Nachrichten können als Worker 2, Worker 1, Worker 3 ankommen. Wir sammeln sie und sortieren sie, damit die Ausgabe immer gleich ist. Nimm das
sort()heraus und führ das Programm ein paarmal aus, dann siehst du, wie sich die Reihenfolge ändert.
Nicht ewig warten: try_recv und recv_timeout
Abschnitt betitelt „Nicht ewig warten: try_recv und recv_timeout“recv() blockiert, bis etwas ankommt. Manchmal willst du aber nicht warten. Dabei helfen zwei Verwandte:
try_recv()kehrt sofort zurück: mit einem Wert, mitEmpty(noch nichts da) oder mitDisconnected(alle Sender sind weg).recv_timeout(duration)wartet, aber nur bis zu einer Zeitgrenze. Kommt bis dahin nichts an, bekommst duTimeout.
use std::sync::mpsc;use std::thread;use std::time::Duration;
fn main() { let (tx, rx) = mpsc::channel::<i32>();
// Noch nichts gesendet: try_recv kehrt sofort zurück. println!("{:?}", rx.try_recv());
thread::spawn(move || tx.send(7).unwrap()).join().unwrap();
// Ein Wert wartet schon, also kommt er ohne Wartezeit zurück. println!("{:?}", rx.recv_timeout(Duration::from_secs(1)));
// Der Sender ist weg, und der Channel ist leer. println!("{:?}", rx.try_recv());}Ausgabe:
Err(Empty)Ok(7)Err(Disconnected)Wir rufen join() für den Thread auf, bevor wir empfangen. So wartet der Wert schon, und die Ausgabe ist immer dieselbe.
try_recv() ist praktisch in einer Schleife, die noch andere Arbeit zu erledigen hat – zum Beispiel in einer Spielschleife, die einmal pro Frame nach neuen Nachrichten schaut. recv_timeout() passt gut zu „gib auf, wenn der Worker zu lange braucht“.
Begrenzte Channels mit sync_channel
Abschnitt betitelt „Begrenzte Channels mit sync_channel“Ein normaler channel() hat keine Größenbegrenzung. Ist der Sender viel schneller als der Empfänger, stapeln sich die Nachrichten im Speicher. mpsc::sync_channel(n) erzeugt einen Channel, der höchstens n Nachrichten aufnimmt. Ist er voll, wartet send(), bis der Empfänger eine herausholt. Das nennt man Backpressure (Gegendruck): Ein langsamer Verbraucher bremst einen schnellen Erzeuger automatisch aus.
use std::sync::mpsc;use std::thread;
fn main() { // Platz für nur 2 Nachrichten gleichzeitig. let (tx, rx) = mpsc::sync_channel(2);
let producer = thread::spawn(move || { for i in 1..=5 { tx.send(i).unwrap(); // blockiert, solange der Puffer voll ist } });
let total: i32 = rx.iter().sum(); producer.join().unwrap(); println!("Summe = {total}");}Ausgabe:
Summe = 15Der Erzeuger kann main nie um mehr als zwei Zahlen voraus sein. Mit sync_channel(0) gibt es gar keinen Puffer: Jedes send() wartet, bis der Empfänger bereit ist, den Wert anzunehmen – wie bei einem Paket, das du persönlich übergibst.
Ein kleiner Worker-Pool
Abschnitt betitelt „Ein kleiner Worker-Pool“Jetzt setzen wir alles zusammen. Ein Worker-Pool besteht aus ein paar Threads, die Jobs aus einer gemeinsamen Warteschlange holen und Ergebnisse zurückschicken. Er nutzt zwei Channels: einen für die Jobs und einen für die Ergebnisse.
Es gibt einen Haken: Einen Receiver kannst du nicht klonen (es gibt ja nur einen Verbraucher). Deshalb teilen sich die Worker den Job-Empfänger hinter Arc<Mutex<_>> und holen abwechselnd Jobs heraus:
use std::sync::mpsc;use std::sync::{Arc, Mutex};use std::thread;
fn main() { let (job_tx, job_rx) = mpsc::channel::<u64>(); let (result_tx, result_rx) = mpsc::channel::<(u64, u64)>();
// Receiver lassen sich nicht klonen, also teilen sich die Worker einen hinter Arc<Mutex<_>>. let job_rx = Arc::new(Mutex::new(job_rx));
let mut workers = Vec::new(); for _ in 0..3 { let job_rx = Arc::clone(&job_rx); let result_tx = result_tx.clone(); workers.push(thread::spawn(move || loop { let job = job_rx.lock().unwrap().recv(); match job { Ok(n) => result_tx.send((n, n * n)).unwrap(), Err(_) => break, // keine Jobs mehr } })); } drop(result_tx);
for n in 1..=6 { job_tx.send(n).unwrap(); } drop(job_tx); // sagt den Workern, dass keine Arbeit mehr kommt
let mut results: Vec<(u64, u64)> = result_rx.iter().collect(); for w in workers { w.join().unwrap(); } results.sort(); for (n, square) in results { println!("{n} zum Quadrat ist {square}"); }}Ausgabe:
1 zum Quadrat ist 12 zum Quadrat ist 43 zum Quadrat ist 94 zum Quadrat ist 165 zum Quadrat ist 256 zum Quadrat ist 36So fährt der Pool sauber herunter:
drop(job_tx)schließt den Job-Channel. Dasrecv()jedes Workers gibt dannErrzurück, und der Worker verlässt seine Schleife mitbreak.- Wenn ein Worker endet, wird sein Klon von
result_txgedroppt. Das Original haben wir schon gedroppt. Sobald also alle drei Worker fertig sind, schließt sich der Ergebnis-Channel. - Dann hört
result_rx.iter()auf, und wir sortieren die Ergebnisse, weil die Worker in beliebiger Reihenfolge fertig werden.
Ein kleines Detail: job_rx.lock().unwrap().recv() steht mit Absicht in einer einzigen let-Anweisung. Die Sperre wird am Ende dieser Anweisung freigegeben. So können sich die anderen Worker schon den nächsten Job holen, während dieser hier noch seine Zahl quadriert.
Channels oder Arc<Mutex<T>>?
Abschnitt betitelt „Channels oder Arc<Mutex<T>>?“Beides ist sicher. Die beiden passen nur zu unterschiedlichen Arten von Problemen:
| Nimm einen Channel, wenn … | Nimm Arc<Mutex<T>>, wenn … |
|---|---|
| die Arbeit in eine Richtung fließt: Erzeuger stellen etwas her, ein Verbraucher kümmert sich darum | viele Threads denselben Wert lesen und ändern müssen (einen Zähler, einen Cache) |
| du eine Warteschlange für Jobs, Ereignisse oder Logzeilen willst | die Daten groß sind und Kopieren oder Verschieben Verschwendung wäre |
| jeder Wert immer nur einen Besitzer hat | Threads den aktuellen Zustand sehen müssen, keinen Strom von Änderungen |
Eine gute Faustregel: Wenn du dein Programm mit „diese Threads sagen jenem Thread etwas“ beschreiben kannst, nimm einen Channel. Heißt es eher „diese Threads bearbeiten alle dieses eine Ding“, nimm einen Mutex. Viele echte Programme nutzen beides – wie unser Worker-Pool.
Häufige Fehler
Abschnitt betitelt „Häufige Fehler“Einen Wert nach dem Senden weiterbenutzen. send() übernimmt die Ownership (Eigentum), genau wie wenn du einen Wert an eine Funktion übergibst. Nach dem Senden gehört der Wert dem Empfänger:
use std::sync::mpsc;use std::thread;
fn main() { let (tx, rx) = mpsc::channel();
thread::spawn(move || { let message = String::from("hallo"); tx.send(message).unwrap(); println!("gesendet: {message}"); });
println!("{}", rx.recv().unwrap());}error[E0382]: borrow of moved value: `message` --> src/main.rs:10:30 | 8 | let message = String::from("hallo"); | ------- move occurs because `message` has type `String`, which does not implement the `Copy` trait 9 | tx.send(message).unwrap(); | ------- value moved here10 | println!("gesendet: {message}"); | ^^^^^^^ value borrowed here after moveDer Compiler sagt: message wurde verschoben („moved“) und wird danach trotzdem noch ausgeliehen. Damit schützt dich Rust: Könnte der Sender message noch verändern, würden beide Threads auf dieselben Daten zugreifen. Gib den Wert vor dem Senden aus, oder sende ein .clone().
Den Sender in eine Schleife von Threads verschieben. Der erste Thread nimmt sich tx, und für den zweiten bleibt nichts übrig:
use std::sync::mpsc;use std::thread;
fn main() { let (tx, rx) = mpsc::channel();
for id in 1..=3 { thread::spawn(move || { tx.send(id).unwrap(); }); }
for value in rx { println!("{value}"); }}error[E0382]: use of moved value: `tx` --> src/main.rs:8:23 | 5 | let (tx, rx) = mpsc::channel(); | -- move occurs because `tx` has type `std::sync::mpsc::Sender<i32>`, which does not implement the `Copy` trait... 8 | thread::spawn(move || { | ^^^^^^^ value moved into closure here, in previous iteration of loopDie Meldung sagt: tx wurde schon im vorherigen Schleifendurchlauf in das Closure verschoben. Die Lösung ist let tx = tx.clone(); ganz oben in der Schleife, wie im Beispiel mit mehreren Erzeugern.
Ein Programm, das nie endet. Hier gibt es keinen Compilerfehler, und genau das macht diesen Fehler so tückisch. Wenn du über einen Empfänger iterierst (for x in rx), während noch irgendein Sender lebt – oft das ursprüngliche tx in main –, wartet die Schleife für immer. Wenn dein Programm „einfach hängt“, prüf, ob wirklich jeder Sender gedroppt wird.
Wie es weitergeht
Abschnitt betitelt „Wie es weitergeht“std::sync::mpsc deckt schon viel ab, aber dir werden zwei beliebte Alternativen begegnen:
- crossbeam-channel (auf Englisch) bietet Channels mit mehreren Verbrauchern (Empfänger, die du klonen kannst) und ein
select!-Makro, mit dem du auf mehrere Channels gleichzeitig wartest. tokio::sync::mpscist der Channel für Async-Code. Wenn duasync/.awaitbenutzt (siehe Async/Await), nimm ihn statt des Channels aus der Standardbibliothek, denn seinrecv().awaitblockiert nicht den ganzen Thread.
Zusammenfassung
Abschnitt betitelt „Zusammenfassung“mpsc::channel()gibt dir ein Paar(Sender, Receiver).send()schiebt einen Wert hinein,recv()wartet auf den nächsten.- Klon den Sender, um mehrere Erzeuger zu haben. Einen Empfänger gibt es immer nur einmal.
- Eine Schleife über einen Empfänger endet, wenn alle Sender gedroppt sind. Denk also daran, das Original mit
drop()freizugeben. - Mit
try_recv()undrecv_timeout()kannst du nachschauen, ohne ewig zu warten.sync_channel(n)begrenzt die Warteschlange aufnNachrichten. - Nachrichten von mehreren Threads kommen in beliebiger Reihenfolge an: Sortiere oder summiere sie, wenn du eine stabile Ausgabe brauchst.
- Nimm Channels, wenn Daten von Thread zu Thread fließen. Nimm
Arc<Mutex<T>>, wenn Threads sich einen Wert teilen und ihn bearbeiten.
Kurzer Check
Abschnitt betitelt „Kurzer Check“Kurzer Check
1. Wofür steht „mpsc“?
2. Wie lässt du drei Threads in denselben Channel senden?
3. Eine Schleife for value in rx endet nie. Was ist die wahrscheinlichste Ursache?
4. Was macht send(), wenn ein sync_channel(2) schon 2 Nachrichten enthält?
Punkte: 0 / 4
Weiterlesen
Abschnitt betitelt „Weiterlesen“- Nachrichtenaustausch zwischen Threads – Das Rust-Buch: die offizielle Einführung in Channels, mit weiteren Beispielen.
std::sync::mpsc– Doku der Standardbibliothek (auf Englisch): alle Methoden vonSender,SyncSenderundReceiver.- Channels – Rust by Example (auf Englisch): ein kurzes, ausführbares Beispiel mit mehreren Erzeuger-Threads.
- Von einem single-threaded zu einem multi-threaded Webserver – Das Rust-Buch: baut Schritt für Schritt einen kompletten Thread-Pool mit Channels und
Arc<Mutex<Receiver>>. tokio::sync::mpsc– docs.rs (auf Englisch): der Async-Channel, den du mit Tokio benutzt, und wie er sich vom Channel der Standardbibliothek unterscheidet.