原文連結:https://dev.to/imaculate3/fearless-concurrency-5fk8 >
原文標題:That's so Rusty! Fearless concurrency
公眾號:Rust 碎碎念
翻譯: Praying
併發程式是指執行多個任務的程式(或看上去是多工),即兩個及以上的任務在重疊的時間跨度內執行。這些任務由執行緒——最小的處理單元執行。在其背後,並不完全是多工(並行)處理,而是執行緒之間以普通人無法感知的速度進行上下文快速切換。很多現代應用程式都依賴於這種錯覺,比如伺服器可以在處理請求的同時等待其他請求。當執行緒間共享資料時可能會出很多問題,最常見的兩種 bug 是:競態條件和死鎖。
競態條件常發生於多個執行緒以不一致的順序訪問/修改共享資料。這對於那些必須以原子性執行的事務會造成很嚴重的影響。每次只能有一個執行緒訪問共享資料是必要的,並且無論執行緒以何種順序執行,程式都應該工作良好。
死鎖發生於兩個及以上的執行緒互相等待對方採取行動,由於資源的鎖定,沒有執行緒能夠獲取所需資源,從而導致無限掛起。比如,執行緒 1 需要兩個資源,它已經獲取了資源 1 的鎖並在等待執行緒 2 釋放資源 2,但是此時,執行緒 2 也在等待執行緒 1 釋放資源 1,這個時候就會發生死鎖。這種情況被稱作迴圈等待。如果這些執行緒可以在等待獲取鎖的同時並持有資源,且不支援搶佔,死鎖就無法恢復。
在任何一門程式語言中要避免這些問題都需要大量的練習。當這些問題真的滲透到專案中,它們很難被檢測出來。最好的情況下,這些 bug 表現為崩潰和掛起,最壞的情況下,程式將以不可預測的方式執行。換句話說,無畏併發的保證不可輕視。恰好,Rust 宣稱可以提供這種保證,不是麼?
Rust 併發(Rust Concurrency)
Rust 併發的主要構成是執行緒和閉包。
1. 閉包(Closures)
閉包是指能夠訪問在其所被定義的作用域內的變數的匿名函式。它們是 Rust 的函式式特性之一。它們可以被賦予變數,作為引數傳遞以及從函式中返回。它們的作用域僅限於區域性變數,因此,不能暴露在 crate 之外。在語法上,除了沒有名字之外,它們和函式非常相似,引數在豎線括號(||)中傳遞,型別標註是可選的。和函式不同的是,閉包能夠從其被定義的作用域內訪問變數。這些被捕獲的變數可以以借用(borrow)或移動(move)的方式進入閉包,具體取決於變數的型別以及該變數如何在閉包中被使用。下面是一個閉包(被賦於print_greeting
)的例子,該閉包接收一個引數,並且捕獲變數generic_greeting
。
fn main() {
let generic_greeting = String::from("Good day,");
let print_greeting = |name| println!("{} {}!", generic_greeting, name);
let person = String::from("Crab");
print_greeting(person);
// println!("Can I use generic greeting? {}", generic_greeting);
// println!("Can I use person {}", person);
}
在上面的例子中,變數person
和generic_greeting
都被移動(move)到閉包當中,因此,在呼叫閉包之後,它們就不能再被使用了。取消最後兩行列印語句的註釋,程式將無法編譯。
2. 執行緒(Threads)
Rust 併發是通過生成(spawn)多個執行緒來實現的,這些執行緒執行處於無引數閉包中的不同任務。當一個執行緒被生成(spawn)時,返回型別時JoinHandle
型別,除非JoinHandle
被 joined,否則主執行緒不會等待它完成。因此,傳遞給執行緒的閉包必須拿到被捕獲變數的所有權以確保線上程最終執行的時候,這些被捕獲變數依然時有效的。這一點在下面的例子中有所體現。
use std::thread;
use std::time::Duration;
fn main() {
let t1 = thread::spawn(|| {
for i in 1..10 {
println!("Greeting {} from other thread!", i);
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..5 {
println!("Greeting {} from main thread!", i);
thread::sleep(Duration::from_millis(1));
}
t1.join().unwrap();
}
我們得到了來自兩個執行緒的完整輸出,來自生成(spawn)執行緒的 9 個 greetings 和來自主執行緒的 4 個 greetings。
當註釋掉t1.join().unwrap()
時,程式會在主執行緒執行完成後退出。你可以在Rust playground[1]上進行嘗試。
需要注意的是,多次執行程式得到的結果可能有所不同。這樣的不確定性也是併發的特點之一,也正如我們將要看到的那樣,這也是很多 bug 的根源。
就多工處理而言,執行緒之間共享資訊非常關鍵。在標準庫中,Rust 支援了兩種通訊方式:訊息傳遞(Message Passing)和共享狀態(Shared-State)。
1. 訊息傳遞(Message Passing)
Rust 支援 channel,執行緒可以通過 channel 來傳送和接收訊息。一個例子就是多生產者單消費者(縮寫為mpsc
)channel。這種 channel 允許多個傳送方和單個接收方通訊,這些傳送方和接收方可能處於不同的執行緒中。channel 在傳送方結尾處獲取變數的所有權,並在接收方結尾處將其丟棄。下面的例子展示了訊息是如何在兩個傳送方和一個接收方之間的 channel 傳遞的。
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx1, rx) = mpsc::channel();
let tx2 = mpsc::Sender::clone(&tx1);
thread::spawn(move || {
println!("Sending message from thread 1");
tx1.send(String::from("Greeting from thread 1")).unwrap();
});
thread::spawn(move || {
println!("Sending message from thread 2");
tx2.send(String::from("Greeting from thread 2")).unwrap();
});
for recvd in rx {
println!("Received: {}", recvd);
}
}
第二個傳送方通過對第一個傳送方克隆(clone)產生。這裡不需要 join 這些執行緒,因為接收方會阻塞直到收到訊息,在那裡等待執行緒執行完成。
2. 共享狀態(Shared state)
另一種通訊方式是共享記憶體。訊息傳遞固然很好,但是它受單個所有許可權制。物件必須被移動(move)/克隆(clone)才能傳送到另一個執行緒,如果物件被移動(move),它就變為不可用,如果它被克隆(clone),在該物件上的任何更新都必須要通過訊息傳遞來通訊。解決這個問題的方案是多所有權( multiple ownership),你可以回顧smartpointers post[2]這篇文章(譯註: smartpointers 這篇文章已翻譯,譯文為Rust與智慧指標
),多所有權( multiple ownership)通過引用計數智慧指標Rc<T>
來實現。我們看到,通過和RefCell<T>
組合使用,我們可以建立可變的共享指標。為什麼不在一個多執行緒程式中使用它們呢?下面是一個嘗試:
fn main() {
let counter = Rc::new(RefCell::new(0));
let mut threads = vec![];
for _ in 0..5 {
let counter = Rc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = (*counter).borrow_mut();
*num += 1;
});
threads.push(handle);
}
for thread in threads {
thread.join().unwrap();
}
println!("Result: {}", *counter.borrow());
}
counter
的共享指標( shared pointer )被髮送到 5 個執行緒,每個執行緒都將其增加 1,線上程結束後列印出這個數字。上面這段程式碼能執行麼?
這段程式碼無法編譯,但是錯誤資訊給出了提示。Rc<RefCell<T>>
不能線上程間安全地被傳遞。(上圖中)再往下就是原因了:它沒有實現Send
trait。看起來我們的意圖已經被正確地表達了,但是我們需要這些指標的執行緒安全版本。安全版本的替代選項是否存在?確實,Rc<T>
的替代選項是原子引用計數型別Arc<T>
。除了Rc<T>
的屬性(共享所有權)外,它還可以通過實現了Send
trait 線上程間安全地共享。對上面的程式碼進行替換,我們應該可以能夠更接近正確的執行狀態。下面的程式碼可以編譯嘛?
use std::cell::RefCell;
use std::thread;
use std::sync::Arc;
fn main() {
let counter = Arc::new(RefCell::new(0));
let mut threads = vec![];
for _ in 0..5 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = (*counter).borrow_mut();
*num += 1;
});
threads.push(handle);
}
for thread in threads {
thread.join().unwrap();
}
println!("Result: {}", *counter.borrow());
}
還是不行,現在我們有了一個和之前類似但是略微不同的錯誤。RefCell<T>
不能線上程間共享,因為它沒有實現Send
和Sync
trait。
如果RefCell<T>
不適用於併發,那就必須要有一個執行緒安全的替代選項。事實上,Mutex(Mutex<T>
)就是這個替代選項。除了提供內部可變性之外,mutex 還可以線上程間共享。執行緒在訪問 mutex 物件之前必須先獲取一個鎖,以確保一次只有一個執行緒訪問。鎖定一個 mutex 會返回一個LockResult<MutexGuard<T>>
型別的智慧指標。LockResult
是一個列舉(enum),可以是Ok<T>
或Error
。簡單起見,我們通過呼叫unwrap()
將其匯出,如果是Ok
,unwrap()
會返回其內部物件(這裡是MutexGuard
),如果是Error
則會 panic。MutexGuard
是另一個智慧指標,它可以被解引用以獲取其內部物件,當LockResult
離開作用域時,鎖會被釋放。在我們的程式碼中,我們將RefCell<T>
替換為Mutex<T>
並更新其操作方式,更新後的程式碼如下:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut threads = vec![];
for _ in 0..5 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num_guard = (*counter).lock().unwrap();
*num_guard += 1;
});
threads.push(handle);
}
for thread in threads {
thread.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());
}
讓我們高興的是,它奏效了!
值得注意的是,Arc<T>
和Mutex<T>
的原子性是有效能開銷的。在單執行緒程式中也可以使用它們但這並非明智之舉。
瞭解了上述情況後,Rust 併發是否如其所聲稱的那樣無須畏懼(fearless)呢?答案就在於它處理大多數常見併發陷阱的方式,其中的一些上面已經介紹過了。
1. 競態條件(Race conditions)
從Mutable[3]這篇文章我們知道,資料競爭/不一致發生於兩個或更多的指標同時訪問相同的資料,其中至少有一個指標被用於寫入資料並且對資料的訪問沒有得到同步。通過保證一個 mutex 總是和一個物件關聯,進而保證對物件的訪問總是同步的(synchronized)。這一點和 C++不同,在 C++中,mutex 是一個單獨分開的實體,程式設計師必須人為地保證在訪問一個資源之前要獲取一個鎖。下面是一個關於不正確使用 mutex 如何引發不一致性的例子:
#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>
using namespace std;
mutex mtx;
int counter = 0;
void increment_to_5(int id)
{
cout << "Executing from thread: " << id << endl;
if (counter < 5)
{
lock_guard<mutex> lck(mtx);
counter++;
}
}
int main()
{
thread threads[10];
for (int i = 0; i < 10; ++i)
threads[i] = thread(increment_to_5, i);
for (auto &thread : threads)
thread.join();
cout << "Final result: " << counter << endl;
return 0;
}
函式increment_to_5()
在不同的執行緒中被呼叫,這段程式碼的期望是增加counter
變數直到它達到 5。
儘管只在臨界區(增加 counter)之前鎖定 mutex 是合理的,但是這個鎖應該在將counter
和 5 進行比較之前就應該獲取。否則,多執行緒可能會讀取一個過期的值,並且將其增加從而導致超過預期的 5。在獲取鎖之前新增一個小的延遲,這個結果就能很容易地復現出來,如下所示:
cout << "Executing from thread: " << id << endl;
if (counter < 5)
{
this_thread::sleep_for(chrono::milliseconds(1));
lock_guard<mutex> lck(mtx);
counter++;
}
}
counter
最終的值在每次執行之後都會不同。如果把這段程式碼用 Rust 來寫就不會出現這種問題,因為counter
是一個 mutex 型別,並且在任何訪問之前都要先獲取鎖。下面的程式碼執行多次之後將會產生相同的結果:
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut threads = vec![];
for i in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
println!("Executing from thread: {}", i);
let mut num_guard = (*counter).lock().unwrap();
if *num_guard < 5 {
thread::sleep(Duration::from_millis(1));
*num_guard += 1;
}
});
threads.push(handle);
}
for thread in threads {
thread.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());
}
2. 死鎖
雖然 Rust 減輕了一些併發風險,但是它還不能夠在編譯期檢測死鎖。下面的例子展示了兩個執行緒如何在兩個 mutex 上發生死鎖:
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
fn main() {
let v1 = Arc::new(Mutex::new(0));
let v2 = Arc::new(Mutex::new(0));
let v11 = Arc::clone(&v1);
let v21 = Arc::clone(&v2);
let t1 = thread::spawn(move ||
{
println!("t1 attempting to lock v1");
let v1_guard = (*v11).lock().unwrap();
println!("t1 acquired v1");
println!("t1 waiting ...");
thread::sleep(Duration::from_secs(5));
println!("t1 attempting to lock v2");
let v2_guard = (*v21).lock().unwrap();
println!("t1 acquired both locks");
}
);
let v12 = Arc::clone(&v1);
let v22 = Arc::clone(&v2);
let t2 = thread::spawn(move ||
{
println!("t2 attempting to lock v2");
let v1_guard = (*v22).lock().unwrap();
println!("t2 acquired v2");
println!("t2 waiting ...");
thread::sleep(Duration::from_secs(5));
println!("t2 attempting to lock v1");
let v2_guard = (*v12).lock().unwrap();
println!("t2 acquired both locks");
}
);
t1.join().unwrap();
t2.join().unwrap();
}
從輸出結果中可以看出,在每個執行緒各自獲取第一個鎖後,程式被掛起了。
類似的方式,在訊息傳遞時也會發生死鎖,如下面程式碼所示:
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx1, rx1) = mpsc::channel();
let (tx2, rx2) = mpsc::channel();
thread::spawn(move || {
println!("Waiting for item 1");
rx1.recv().unwrap();
println!("Sending item 2");
tx2.send(()).unwrap();
});
println!("Waiting for item 2");
rx2.recv().unwrap();
println!("Sending item 1");
tx1.send(()).unwrap();
}
因為每個 channel 在傳送之前都要等待接收一個 item,所以兩個 channel 都會一直等待下去。
3. 迴圈引用(Reference cycles)
通過smart pointers post[4]這篇文章,我們知道,Rc
指標可能引發迴圈引用。Arc
指標對此也不能倖免,但是類似的,它可以通過 Atomic Weak 指標減少這種情況。
通過上面的觀察,可以說 Rust 的併發並非 100%的完美無暇。它在編譯期避免了競態條件(race conditions),但是無法避免死鎖和迴圈引用。 回過頭來看,這些問題並不是 Rust 獨有的,與大多數語言相比,Rust 的表現要好得多。Rust 中的併發不一定是無須畏懼(fearless)的,但它不那麼可怕。
致謝
感謝太陽快遞員
、惰性氣體
、沒得
三位同學對fearless一詞翻譯提供的建議。
參考資料
Rust playground: https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=0879538b56c60c89c4d083308424c701
[2]smartpointers post: https://dev.to/imaculate3/that-s-so-rusty-smart-pointers-245l
[3]Mutable: https://dev.to/imaculate3/that-s-so-rusty-mutables-5b40
[4]smart pointers post: https://dev.to/imaculate3/that-s-so-rusty-smart-pointers-245l