12.2 使用訊息傳遞(Channels)進行通訊#
從共享狀態到訊息傳遞#
在多執行緒程式的設計中,兩種典範貫穿所有決策:共用狀態與訊息傳遞。共用狀態是指多個執行緒存取同一份記憶體,必須用鎖或原子操作保護;訊息傳遞則是執行緒透過通道互相送資料,所有權隨著訊息一起轉移。前者的風險是資料競爭與死鎖,後者的風險則主要是通道本身的設計與阻塞行為。
Rust 標準函式庫在 std::sync::mpsc 提供了通道(channel)。它的設計哲學非常明確:每一份資料在任何時刻只有一個擁有者。你把資料送進通道,資料的所有權就從你手上移交给通道,再由通道移交给接收端。因為所有權全程唯一,就不存在「兩個執行緒同時修改同一份資料」的可能——這是 Rust 用型別系統換來的免費安全保證,也是與 Go 的 chan、C# 的 Channel<T> 最根本的差異:那些語言的通道是複製或參考語意的,Rust 的通道是移動語意的。
建立通道#
mpsc::channel() 回傳一組 (Sender<T>, Receiver<T>)。T 是通道所傳遞的資料型別,編譯器會要求發送端與接收端使用同一個 T:
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
tx.send(String::from("你好")).unwrap();
});
let received = rx.recv().unwrap();
println!("收到:{}", received);
}
這個範例裡有兩個關鍵的所有權動作。tx 被 move 進閉包,因為通道的發送端必須存活到訊息送完為止;而 String 在 send 時被移動進通道,send 之後 tx 那一側原本的 String 就不存在了。這與 Arc<Mutex<Vec<String>>> 的共用模型形成鮮明對比——後者要手動加鎖、前者完全不需要。
Sender 與 Receiver 的型別關係#
Sender<T> 可以 clone(),產生多個發送端,這就是 mpsc 這個縮寫的由來:Multiple Producer, Single Consumer(多生產者,單消費者)。相對地,Receiver<T> 不實作 Clone,因為一個通道只能有一個消費者端;如果有兩個執行緒同時呼叫 recv(),它們會競爭同一個佇列,而 Rust 的所有權語意要求接收端是唯一的。
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel::<u32>();
for id in 0..3u32 {
let tx = tx.clone();
thread::spawn(move || {
for i in 0..2u32 {
tx.send(id * 10 + i).unwrap();
}
});
}
drop(tx);
let mut results: Vec<u32> = rx.iter().collect();
results.sort_unstable();
println!("結果:{:?}", results);
}
注意 drop(tx) 這一行。tx 在主執行緒中原本還有一份,如果主執行緒不主動丟棄它,rx.iter() 這個迭代器就永遠不會結束——因為迭代器只有在所有 Sender 都被丟棄後才會回傳 None。這個「以擁有者數決定生命週期」的設計非常 Rust:通道的結束不是靠一個顯式的 close() 呼叫,而是靠型別系統裡的擁有者計數自然導出的。Python 的 queue.Queue 需要呼叫 task_done() 或設定 sentinel 值來標示結束,Go 需要關閉 channel 並處理 ok 布林值,Rust 則是「沒有人還能發送,就是結束了」。
這個設計帶來一個實務上的必然推論:Sender 必須被 drop 才能讓迭代終止,而 drop 必須發生在正確的時機。若你把 tx 存在一個 Arc<Mutex<>> 裡,或是被某個函式當作參數傳入而沒有及時釋放,接收端的 iter() 就會一直等下去。良好的做法是讓 Sender 的作用域盡可能短,讓它在不需要時自然離開作用域。
另一個推論是:所有權就是最好的生命週期管理。當一個工作執行緒結束時,它持有的 Sender 副本自動被釋放,通道的參考計數隨之下降;當最後一個 Sender 消失,接收端立刻收到 Err。你不需要寫任何手動的清理程式,也不可能忘記清理。
通道的封裝與型別設計#
一個成熟的設計會把通道隱藏在一個自訂型別之後,而不是讓 Sender 與 Receiver 散落在程式各處。這樣做的好處是可以在單一位置處理結束協定、錯誤轉換與容量設定:
use std::sync::mpsc::{self, Receiver, Sender};
pub struct TaskQueue {
sender: Sender<Job>,
receiver: Receiver<Job>,
}
#[derive(Debug)]
pub struct Job {
pub id: u64,
pub payload: String,
}
impl TaskQueue {
pub fn new() -> Self {
let (sender, receiver) = mpsc::channel();
TaskQueue { sender, receiver }
}
pub fn submit(&self, id: u64, payload: &str) -> Result<(), mpsc::SendError<Job>> {
self.sender.send(Job {
id,
payload: payload.to_string(),
})
}
pub fn next(&self) -> Option<Job> {
self.receiver.try_recv().ok()
}
}
fn main() {
let queue = TaskQueue::new();
queue.submit(1, "壓縮圖片").unwrap();
queue.submit(2, "產生縮圖").unwrap();
while let Some(job) = queue.next() {
println!("處理 #{}:{}", job.id, job.payload);
}
}
這種封裝讓「通道是這個模組的內部實作細節」成為可能。呼叫端只看到 submit 與 next 兩個方法,不需要知道通道的存在,日後要從 mpsc 換成 crossbeam-channel 或 flume 也只需修改內部。
平行求和:通道的另一種典型應用#
除了工作佇列,通道也很適合「分派子任務、回收結果」的模式:
use std::sync::mpsc;
use std::thread;
fn main() {
let data: Vec<u64> = (1..=1000).collect();
let chunk = data.len() / 4;
let (tx, rx) = mpsc::channel::<u64>();
let mut handles = vec![];
for i in 0..4 {
let tx = tx.clone();
let part = data[i * chunk..(i + 1) * chunk].to_vec();
handles.push(thread::spawn(move || {
let sum: u64 = part.iter().sum();
tx.send(sum).unwrap();
}));
}
drop(tx);
let total: u64 = rx.iter().sum();
for h in handles {
h.join().unwrap();
}
println!("總和 = {}", total);
}
這個範例裡,rx.iter().sum() 能夠結束是因為 drop(tx) 讓主執行緒放棄了最後一份發送端的所有權。這個 drop 是刻意的——它把「我已經派完所有工作了」這個事實編碼進型別系統。
傳送與接收的四種形式#
send 回傳 Result<(), SendError<T>>,因為接收端有可能已經被丟棄——這時訊息無處可去,SendError 會把被還原的 T 一併帶回來,讓你不損失資料:
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
let (tx, rx) = mpsc::channel::<u32>();
let consumer = thread::spawn(move || {
let mut total = 0;
while let Ok(msg) = rx.recv_timeout(Duration::from_millis(100)) {
total += msg;
}
total
});
for i in 1..=5 {
if tx.send(i).is_err() {
println!("接收端已離開,停止傳送");
break;
}
}
drop(tx);
println!("總和 = {}", consumer.join().unwrap());
}
四個主要操作的語意如下:
| 操作 | 行為 | 失敗情形 |
|---|---|---|
tx.send(v) | 移入 v 並傳送 | 回傳 Err(SendError(v)),表示接收端已消失 |
rx.recv() | 阻塞直到有一則訊息 | 回傳 Err(RecvError),表示所有發送端已丟棄 |
rx.try_recv() | 立刻取回,不阻塞 | Empty 表示暫時無資料,Disconnected 表示通道已關閉 |
rx.recv_timeout(d) | 最多等待 d | 逾時回傳 Timeout |
try_recv 與 recv_timeout 回傳的是 TryRecvError 與 RecvTimeoutError 這類帶有分類標記的列舉型別,可以用 match 精準區分「現在沒東西」與「永遠不會再有東西」兩種截然不同的狀況。這個區分在實務上極為重要:前者只需重試,後者應該結束迴圈。
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
let (tx, rx) = mpsc::channel::<u32>();
let producer = thread::spawn(move || {
thread::sleep(Duration::from_millis(50));
tx.send(42).unwrap();
});
let mut attempt = 0;
loop {
match rx.try_recv() {
Ok(v) => {
println!("取得 {}", v);
break;
}
Err(mpsc::TryRecvError::Empty) => {
attempt += 1;
if attempt > 100 {
println!("放棄等待");
break;
}
thread::sleep(Duration::from_millis(1));
}
Err(mpsc::TryRecvError::Disconnected) => {
println!("通道已關閉");
break;
}
}
}
producer.join().unwrap();
}
通道的所有權語意#
上面所有範例都沒有出現 Arc 與 Mutex,這是因為 send 吃掉了資料。送出 String 之後,發送端手裡那份就不存在了;資料的所有權沿著通道一路移動到接收端,全程沒有任何兩個擁有者並存的瞬間。
這個語意帶來一個實用的推論:通道天然避免重複處理。若你用 Arc<Mutex<Vec<u32>>> 收集資料並用一個標記陣列記錄「已處理過」,那個標記陣列本身就需要另一把鎖,而且可能出現「標記已寫入但資料未處理」的順序不一致。用通道時,工作項目本身就是唯一的存在憑證,取出即代表歸屬唯一。
另一個推論是通道的兩端各自是 Send 的。Sender<T> 與 Receiver<T> 都實作 Send,只要 T 也是 Send,你就能把它們自由地送到不同的執行緒。這與 Rc 形成對比:Rc 不是 Send,所以無法作為執行緒間傳遞的載體。你可以把 Sender 放進 Arc 讓多個執行緒共用同一個發送端,但那個 Arc 裡的 Sender 依然是 Send 的。
值得留意的是 Sender<T> 不是 Sync,這意味著你無法把 &Sender<T> 直接送到另一個執行緒使用;需要的是 &Sender<T>: Send,而這要求 Sender<T>: Sync,不成立。實務上的解法是 clone() 出新的 Sender 副本,而不是共用同一個參考。這個設計避免了「多個執行緒同時對同一個通道佇列呼叫 send」時內部計數更新的競爭。
多消費者需要 Arc<Mutex>#
有時候你確實想要多個消費者。此時的標準做法是把 Receiver 包進 Arc<Mutex<>>,讓多個執行輪流鎖住接收端,從中取出一則訊息:
use std::sync::{mpsc, Arc, Mutex};
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel::<u32>();
let rx = Arc::new(Mutex::new(rx));
let mut handles = vec![];
for _ in 0..3 {
let rx = Arc::clone(&rx);
handles.push(thread::spawn(move || {
let mut count = 0;
loop {
let msg = {
let guard = rx.lock().unwrap();
guard.recv()
};
match msg {
Ok(_) => count += 1,
Err(_) => break,
}
}
count
}));
}
for i in 0..9 {
tx.send(i).unwrap();
}
drop(tx);
let total: usize = handles.into_iter().map(|h| h.join().unwrap()).sum();
println!("共處理 {} 則訊息", total);
}
這裡有個技巧值得注意:鎖的守衛對象(MutexGuard)被限制在一個區塊之內,這樣 recv() 的阻塞等待發生在未持有鎖的狀態下。如果直接寫 rx.lock().unwrap().recv(),那個執行緒會在阻塞時一直占著鎖,其他消費者將完全無法前進——這是一種隱性死鎖的溫床。這個「取鎖、取資料、放鎖」的最小化臨界區原則,是第 12.3 節的核心主題之一。
完整範例:工作佇列#
把上述概念組合起來,可以做出一個標準的執行緒池/工作佇列模式。這個範例示範「以通道協調多執行緒」的完整結構:主執行緒派發任務,工作執行緒取回任務並回報結果。
use std::sync::{mpsc, Arc, Mutex};
use std::thread;
enum Task {
Square(u64),
Cube(u64),
Shutdown,
}
fn main() {
let (task_tx, task_rx) = mpsc::channel::<Task>();
let (result_tx, result_rx) = mpsc::channel::<u64>();
let task_rx = Arc::new(Mutex::new(task_rx));
let mut workers = vec![];
for id in 0..4 {
let task_rx = Arc::clone(&task_rx);
let result_tx = result_tx.clone();
workers.push(thread::spawn(move || {
loop {
let task = {
let guard = task_rx.lock().unwrap();
guard.recv()
};
match task {
Ok(Task::Square(n)) => result_tx.send(n * n).unwrap(),
Ok(Task::Cube(n)) => result_tx.send(n * n * n).unwrap(),
Ok(Task::Shutdown) | Err(_) => break,
}
}
id
}));
}
drop(result_tx);
for n in 1u64..=10 {
task_tx.send(Task::Square(n)).unwrap();
task_tx.send(Task::Cube(n)).unwrap();
}
for _ in 0..4 {
task_tx.send(Task::Shutdown).unwrap();
}
drop(task_tx);
let mut results: Vec<u64> = result_rx.iter().collect();
results.sort_unstable();
println!("結果:{:?}", results);
let ids: Vec<i32> = workers.into_iter().map(|h| h.join().unwrap()).collect();
println!("工作執行緒編號:{:?}", ids);
}
這個設計的優點在於結構上的對稱性:任務佇列與結果佇列都用通道,而通道的所有權規則自動處理了結束訊號。當 task_tx 的所有副本被丟棄時,工作執行緒的 recv() 會回傳 Err,於是它們自然退出;不需要額外的計數器或特殊的結束值。
flowchart TD
P["主執行緒(產生者)"] -->|"task_tx.send(Task)"| C1["任務通道 mpsc::channel<Task>"]
W1["工作執行緒 1"] -->|"Arc<Mutex<Receiver>>"| C1
W2["工作執行緒 2"] -->|"Arc<Mutex<Receiver>>"| C1
W3["工作執行緒 3"] -->|"Arc<Mutex<Receiver>>"| C1
C1 -->|"recv()"| W1
C1 -->|"recv()"| W2
C1 -->|"recv()"| W3
W1 -->|"result_tx.send(r)"| R["結果通道"]
W2 -->|"result_tx.send(r)"| R
W3 -->|"result_tx.send(r)"| R
R -->|"iter() 直到所有 Sender 丟棄"| P
C1 -.->|"所有 Sender 丟棄後 recv() 回傳 Err"| W1通道帶來的編譯期保證#
通道提供的保證可以濃縮成一句話:編譯器保證不會有兩個執行緒同時擁有同一份資料。理由是所有權唯一,而 send 與 recv 都是所有權的交接點。
這項保證的實際效果是,一個使用通道的 Rust 程式在資料競爭這個維度上幾乎是免疫的。剩下的風險轉移到了別處:邏輯錯誤(例如把任務指派給錯誤的執行緒)、資源耗盡,以及阻塞引起的停滯。把難以證明的問題從「記憶體正確性」轉移到「邏輯正確性」,是 Rust 整體安全策略的核心。
順帶一提,通道並不能保證訊息的順序。多個生產者各自送出的訊息,在接收端看到的順序是未定義的;即使只有一個生產者,mpsc::channel 的內部佇列也是先到先服務,但多執行緒交錯送出時順序仍取決於排程。如果你的演算法需要嚴格順序,就必須在應用層面自行編號並排序。
死鎖的可能性#
通道本身不死鎖,但使用方式可能死鎖。最經典的案例是兩個執行緒互相等待對方的資料:
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx_a, rx_a) = mpsc::channel::<u32>();
let (tx_b, rx_b) = mpsc::channel::<u32>();
let h1 = thread::spawn(move || {
let v = rx_b.recv().unwrap();
tx_a.send(v + 1).unwrap();
});
let h2 = thread::spawn(move || {
let v = rx_a.recv().unwrap();
tx_b.send(v + 1).unwrap();
});
h1.join().unwrap();
h2.join().unwrap();
println!("(這段程式實際上會永久阻塞)");
}
這段程式會永久卡住:兩個執行緒各自先等對方的通道,而對方的訊息要等自己送出之後才會產生。這是循環等待(circular wait),與鎖的死鎖條件同源。
更普遍的情況是容量為零的通道造成的隱性互斥。mpsc::channel() 是無界的,容量其實是無限大;但若你改用 sync_channel(0),發送方在接收方取走訊息之前會一直阻塞。此時若發送方還持有某把鎖,就可能形成「持有鎖並等待通道」與「持有通道等待鎖」的交叉。
防範原則有幾條:避免跨越兩個通道互相等待的拓撲;不要在持有鎖的狀態下呼叫可能阻塞的 recv();對任何等待都設定逾時,用 recv_timeout 把永久阻塞轉成可處理的錯誤。
另一個常見的陷阱是迴圈中的隱性阻塞。假設你寫了一個處理佇列的迴圈,每輪 recv_timeout 都用一秒的逾時,當佇列空了之後,主執行緒每秒會醒來一次確認「還沒東西」再繼續睡。若你在這個迴圈裡還要做心跳或健康檢查,就要注意逾時長度與反應速度之間的取捨——太短會浪費 CPU,太長則讓關閉訊號變得遲緩。
還有一個容易被忽略的點:通道的容量與生產者數量會共同決定記憶體上限。sync_channel(100) 在四個生產者的情況下,實際上可能同時存在 104 筆待處理資料。若這批資料每筆是一個大型緩衝區,峰值記憶體用量會遠超你的預期。設計容量時要把生產者數量與單筆大小一起算進去。
有界通道與背壓#
sync_channel(bound) 建立容量有限的通道。當佇列已滿時,Sender::send 會阻塞直到有空間,這就是背壓(backpressure):生產者的速度被消費者拉住了。
Rust 的 sync_channel 是以一個固定容量的環形緩衝區實作的,send 在緩衝區滿時會等待通知。它與多數語言的佇列有一個關鍵差異:Rust 的 send 在滿的情況下會阻塞,而不是丟棄最舊的項目或回傳錯誤。這讓「資料不丟失」成為預設行為,但也意味著設計者必須思考:當消費者永久停擺時,生產者會被卡在哪裡。
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
let (tx, rx) = mpsc::sync_channel::<u32>(2);
let consumer = thread::spawn(move || {
let mut sum = 0;
while let Ok(v) = rx.recv() {
sum += v;
thread::sleep(Duration::from_millis(10));
}
sum
});
for i in 1..=5 {
tx.send(i).unwrap();
println!("已送 {}", i);
}
drop(tx);
println!("總和 = {}", consumer.join().unwrap());
}
在這個範例中,主執行緒送完第 3 筆之後就會被卡住,因為容量為 2 的佇列已滿,而消費者每 10 毫秒才取走一筆。這個「阻塞」正是流控(flow control)的機制:它保證記憶體用量不會隨著生產速度無限膨脹。在處理來自網路的資料時,這一點尤其重要——沒有背壓的系統會在流量尖峰時耗盡記憶體。
有界通道還有另一個好處:它讓錯誤更容易被發現。當 send 永久阻塞時,往往代表下游已經停止運作,而無界通道只會默默堆積記憶體直到程序被 OOM killer 殺掉。
需要補充的是,Rust 標準函式庫的 mpsc 沒有提供 try_send 這類非阻塞發送的 API。有界通道要實作非阻塞語意,只能搭配 recv_timeout 與計時邏輯,或改用 crossbeam-channel 等第三方套件。若你的系統需要「塞不進去就丟棄或降級」的策略,第三方通道庫通常更合適。
與 async 通道的對照#
std::sync::mpsc 是阻塞式的。在一個執行緒中呼叫 recv(),會把整個 OS 執行緒停在那裡。如果一個執行緒同時要處理多條連線,這種阻塞模型就不合適——你會需要一個連線一個執行緒,成本很高。
非同步世界裡有對應的工具:tokio::sync::mpsc 是 async 版本的通道,send 與 recv 都是 async 方法,它們不會阻塞 OS 執行緒,而是在「沒有資料可取」時讓出控制權給執行環境的排程器。tokio 的通道同樣提供 bounded 與 unbounded 兩種容量模式,背壓語意與同步版本一致。
兩者的取捨很清楚:std::sync::mpsc 適合「少量執行緒、每個都在做重 CPU 或阻塞式 I/O」的場景;tokio::sync::mpsc 適合「大量連線、I/O 密集且等待時間長」的場景。第 13 章會完整討論 tokio 的執行模型與非同步通道的細節,那裡也會說明為什麼在 async 環境中使用阻塞式的 std::sync::mpsc::Receiver::recv 是一個嚴重錯誤——它會卡住整個執行環境。
至此我們完成了兩種通訊典範中的第一種。訊息傳遞的優點是所有權清晰、安全性由編譯器保證;缺點是資料必須透過通道序列化一次,且無法直接共享複雜的可變狀態。
在選擇通訊方式時,有一個簡單的判準值得牢記:若資料的所有權可以自然地從一方「交給」另一方,就用通道;若多個執行緒必須「同時看到同一份資料的變化」,才用共用狀態。前者是前一個執行緒用完就不再需要的資料,後者是所有參與者都要持續觀察的共享儀表板。硬要對第一種情況使用鎖,往往只是換來更多複雜度而沒有實益。