我們知道,如今CPU的計算能力已經非常強大,其速度比內存要高出許多個數量級。為了充分利用CPU資源,多數編程語言都提供了並發編程的能力,Rust也不例外。
聊到並發,就離不開多進程和多線程這兩個概念。其中,進程是資源分配的最小單位,而線程是程序運行的最小單位。線程必須依托於進程,多個線程之間是共享進程的內存空間的。進程間的切換復雜,CPU利用率低等缺點讓我們在做並發編程時更加傾向於使用多線程的方式。
當然,多線程也有缺點。其一是程序運行順序不能確定,因為這是由內核來控制的,其二就是多線程編程對開發者要求比較高,如果不充分了解多線程機制的話,寫出的程序就非常容易出Bug。
多線程編程的主要難點在於如何保證線程安全。什么是線程安全呢?因為多個線程之間是共享內存空間的,因此就會存在同時對相同的內存進行寫操作,那就會出現寫入數據互相覆蓋的問題。如果多個線程對內存只有讀操作,沒有任何寫操作,那么也就不會存在安全問題,我們可以稱之為線程安全。
常見的並發安全問題有競態條件和數據競爭兩種,競態條件是指多個線程對相同的內存區域(我們稱之為臨界區)進行了“讀取-修改-寫入”這樣的操作。而數據競爭則是指一個線程寫一個變量,而另一個線程需要讀這個變量,此時兩者就是數據競爭的關系。這么說可能不太容易理解,不過不要緊,待會兒我會舉兩個具體的例子幫助大家理解。不過在此之前,我想先介紹一下Rust中是如何進行並發編程的。
管理線程
在Rust標准庫中,提供了兩個包來進行多線程編程:
- std::thread,定義一些管理線程的函數和一些底層同步原語
- std::sync,定義了鎖、Channel、條件變量和屏障
我們使用std::thread中的spawn
函數來創建線程,它的使用非常簡單,其參數是一個閉包,傳入創建的線程需要執行的程序。
use std::thread;
use std::time::Duration;
fn main() {
thread::spawn(|| {
for i in 1..10 {
println!("hi number {} from the spawned thread!", i);
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..5 {
println!("hi number {} from the main thread!", i);
thread::sleep(Duration::from_millis(1));
}
}
這段代碼中,我們有兩個線程,一個主線程,一個是用spawn
創建出來的線程,兩個線程都執行了一個循環。循環中打印了一句話,然后讓線程休眠1毫秒。它的執行結果是這樣的:
從結果中我們能看出兩件事:第一,兩個線程是交替執行的,但是並沒有嚴格的順序,第二,當主線程結束時,它並沒有等子線程運行完。
那我們有沒有辦法讓主線程等子線程執行結束呢?答案當然是有的。Rust中提供了join
函數來解決這個問題。
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..10 {
println!("hi number {} from the spawned thread!", i);
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..5 {
println!("hi number {} from the main thread!", i);
thread::sleep(Duration::from_millis(1));
}
handle.join().unwrap();
}
這樣主線程就必須要等待子線程執行完畢。
在某些情況下,我們需要將一些變量在線程間進行傳遞,正常來講,閉包需要捕獲變量的引用,這里就涉及到了生命周期問題,而子線程的閉包的存活周期有可能長於當前的函數,這樣就會造成懸垂指針,這在Rust中是絕對不允許的。因此我們需要使用move
關鍵字將所有權轉移到閉包中。
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("Here's a vector: {:?}", v);
});
handle.join().unwrap();
}
使用thread::spawn
創建線程是不是非常簡單。但是也是因為它的簡單,所以可能無法滿足我們一些定制化的需求。例如制定線程的棧大小,線程名稱等。這時我們可以使用thread::Builder
來創建線程。
use std::thread::{Builder, current};
fn main() {
let mut v = vec![];
for id in 0..5 {
let thread_name = format!("child-{}", id);
let size: usize = 3 * 1024;
let builder = Builder::new().name(thread_name).stack_size(size);
let child = builder.spawn(move || {
println!("in child:{}", current().name().unwrap());
}).unwrap();
v.push(child);
}
for child in v {
child.join().unwrap_or_default();
}
}
我們使用thread::spawn
創建的線程返回的類型是JoinHandle<T>
,而使用builder.spawn
返回的是Result<JoinHandle<T>>
,因此這里需要加上unwrap
方法。
除了剛才提到了這些函數和結構體,std::thread
還提供了一些底層同步原語,包括park、unpark和yield_now函數。其中park提供了阻塞線程的能力,unpark用來恢復被阻塞的線程。yield_now函數則可以讓線程放棄時間片,讓給其他線程執行。
Send和Sync
聊完了線程管理,我們再回到線程安全的話題,Rust提供的這些線程管理工具看起來和其他沒有什么區別,那Rust又是如何保證線程安全的呢?
秘密就在Send
和Sync
這兩個trait中。它們的作用是:
- Send:實現Send的類型可以安全的在線程間傳遞所有權。
- Sync:實現Sync的類型可以安全的在線程間傳遞不可變借用。
現在我們可以看一下spawn
函數的源碼
#[stable(feature = "rust1", since = "1.0.0")]
pub fn spawn<F, T>(f: F) -> JoinHandle<T> where
F: FnOnce() -> T, F: Send + 'static, T: Send + 'static
{
Builder::new().spawn(f).expect("failed to spawn thread")
}
其參數F和返回值類型T都加上了Send + 'static
限定,Send表示閉包必須實現Send,這樣才可以在線程間傳遞。而'static
表示T只能是非引用類型,因為使用引用類型則無法保證生命周期。
在Rust入坑指南:智能指針一文中,我們介紹了共享所有權的指針Rc<T>
,但在多線程之間共享變量時,就不能使用Rc<T>
,因為它的內部不是原子操作。不過不要緊,Rust為我們提供了線程安全版本:Arc<T>
。
下面我們一起來驗證一下。
use std::thread;
use std::rc::Rc;
fn main() {
let mut s = Rc::new("Hello".to_string());
for _ in 0..3 {
let mut s_clone = s.clone();
thread::spawn(move || {
s_clone.push_str(" world!");
});
}
}
這個程序會報如下錯誤
那我們把Rc
替換為Arc
試一下。
use std::sync::Arc;
...
let mut s = Arc::new("Hello".to_string());
很遺憾,程序還是報錯。
這是因為,Arc默認是不可變的,我們還需要提供內部可變性。這時你可能想到來RefCell,但是它也是線程不安全的。所以這里我們需要使用Mutex<T>
類型。它是Rust實現的互斥鎖。
互斥鎖
Rust中使用Mutex<T>
實現互斥鎖,從而保證線程安全。如果類型T實現了Send,那么Mutex<T>
會自動實現Send和Sync。它的使用方法也比較簡單,在使用之前需要通過lock
或try_lock
方法來獲取鎖,然后再進行操作。那么現在我們就可以對前面的代碼進行修復了。
use std::thread;
use std::sync::{Arc, Mutex};
fn main() {
let mut s = Arc::new(Mutex::new("Hello".to_string()));
let mut v = vec![];
for _ in 0..3 {
let s_clone = s.clone();
let child = thread::spawn(move || {
let mut s_clone = s_clone.lock().unwrap();
s_clone.push_str(" world!");
});
v.push(child);
}
for child in v {
child.join().unwrap();
}
}
讀寫鎖
介紹完了互斥鎖之后,我們再來了解一下Rust中提供的另外一種鎖——讀寫鎖RwLock<T>
。互斥鎖用來獨占線程,而讀寫鎖則可以支持多個讀線程和一個寫線程。
在使用讀寫鎖時要注意,讀鎖和寫鎖是不能同時存在的,在使用時必須要使用顯式作用域把讀鎖和寫鎖隔離開。
總結
本文我們先是介紹了Rust管理線程的兩個函數:spawn
、join
。並且知道了可以使用Builder結構體定制化創建線程。然后又學習了Rust提供線程安全的兩個trait,Send和Sync。最后我們一起學習了Rust提供的兩種鎖的實現:互斥鎖和讀寫鎖。
關於Rust並發編程坑還沒有到底,接下來還有條件變量、原子類型這些坑等着我們來挖。今天就暫時歇業了。