前文中我們聊了Rust如何管理線程以及如何利用Rust中的鎖進行編程。今天我們繼續學習併發編程,
原子類型
許多編程語言都會提供原子類型,Rust也不例外,在前文中我們聊了Rust中鎖的使用,有了鎖,就要小心死鎖的問題,Rust雖然聲稱是安全併發,但是仍然無法幫助我們解決死鎖的問題。原子類型就是編程語言爲我們提供的無鎖併發編程的最佳手段。熟悉Java的同學應該知道,Java的編譯器並不能保證代碼的執行順序,編譯器會對我們的代碼的執行順序進行優化,這一操作成爲指令重排。而Rust的多線程內存模型不會進行指令重排,它可以保證指令的執行順序。
通常來講原子類型會提供以下操作:
- Load:從原子類型讀取值
- Store:爲一個原子類型寫入值
- CAS(Compare-And-Swap):比較並交換
- Swap:交換
- Fetch-add(sub/and/or):表示一系列的原子的加減或邏輯運算
Ok,這些基礎的概念聊完以後,我們就來看看Rust爲我們提供了哪些原子類型。Rust的原子類型定義在標準庫std::sync::atomic
中,目前它提供了12種原子類型。
下面這段代碼是Rust演示瞭如何用原子類型實現一個自旋鎖。
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
fn main() {
let spinlock = Arc::new(AtomicUsize::new(1));
let spinlock_clone = spinlock.clone();
let thread = thread::spawn(move|| {
spinlock_clone.store(0, Ordering::SeqCst);
});
while spinlock.load(Ordering::SeqCst) != 0 {}
if let Err(panic) = thread.join() {
println!("Thread had an error: {:?}", panic);
}
}
我們利用AtomicUsize的store方法將它的值設置爲0,然後用load方法獲取到它的值,如果不是0,則程序一直空轉。在store和load方法中,我們都用到了一個參數:Ordering::SeqCst
,在聲明中能看出來它也是屬於atomic包。
我們在文檔中發現它是一個枚舉。其定義爲
pub enum Ordering {
Relaxed,
Release,
Acquire,
AcqRel,
SeqCst,
}
它的作用是將內存順序的控制權交給開發者,我們可以自己定義底層的內存排序。下面我們一起來看一下這5種排序分別代表什麼意思
- Relaxed:表示「沒有順序」,也就是開發者不會干預線程順序,線程只進行原子操作
- Release:對於使用Release的store操作,在它之前所有使用Acquire的load操作都是可見的
- Acquire:對於使用Acquire的load操作,在它之前的所有使用Release的store操作也都是可見的
- AcqRel:它代表讀時使用Acquire順序的load操作,寫時使用Release順序的store操作
- SeqCst:使用了SeqCst的原子操作都必須先存儲,再加載。
一般情況下建議使用SeqCst,而不推薦使用Relaxed。
線程間通信
Go語言文檔中有這樣一句話:不要使用共享內存來通信,應該使用通信實現共享內存。
Rust標準庫選擇了CSP併發模型,也就是依賴channel來進行線程間的通信。它的定義是在標準庫std::sync::mpsc
中,裏面定義了三種類型的CSP進程:
- Sender:發送異步消息
- SyncSender:發送同步消息
- Receiver:用於接收消息
我們通過一個栗子來看一下channel是如何創建並收發消息的。
use std::thread;
use std::sync::mpsc;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let val = String::from("hi");
tx.send(val).unwrap();
});
let received = rx.recv().unwrap();
println!("Got: {}", received);
}
首先,我們先是使用了channel()
函數來創建一個channel,它會返回一個(Sender, Receiver)元組。它的緩衝區是無界的。此外,我們還可以使用sync_channel()
來創建channel,它返回的則是(SyncSender, Receiver)元組,這樣的channel發送消息是同步的,並且可以設置緩衝區大小。
接着,在子線程中,我們定義了一個字符串變量,並使用send()
函數向channel中發送消息。這裏send返回的是一個Result類型,所以使用unwrap來傳播錯誤。
在main函數最後,我們又用recv()
函數來接收消息。
這裏需要注意的是,send()
函數會轉移所有權,所以,如果你在發送消息之後再使用val變量時,程序就會報錯。
現在我們已經掌握了使用Channel進行線程間通信的方法了,這裏還有一段代碼,感興趣的同學可以自己執行一下這段代碼看是否能夠順利執行。如果不能,應該怎麼修改這段代碼呢?
use std::thread;
use std::sync::mpsc;
fn main() {
let (tx, rx) = mpsc::channel();
for i in 0..5 {
let tx = tx.clone();
thread::spawn(move || {
tx.send(i).unwrap();
});
}
for rx in rx.iter() {
println!("{:?}", j);
}
}
線程池
在實際工作中,如果每次都要創建新的線程,每次創建、銷燬線程的開銷就會變得非常可觀,甚至會成爲系統性能的瓶頸。對於這種問題,我們通常使用線程池來解決。
Rust的標準庫中沒有現成的線程池給我們使用,不過還是有一些第三方庫來支持的。這裏我使用的是threadpool。
首先需要在Cargo.toml中增加依賴threadpool = "1.7.1"
。然後就可以使用use threadpool::ThreadPool;
將ThreadPool引入我們的程序中了。
use threadpool::ThreadPool;
use std::sync::mpsc::channel;
fn main() {
let n_workers = 4;
let n_jobs = 8;
let pool = ThreadPool::new(n_workers);
let (tx, rx) = channel();
for _ in 0..n_jobs {
let tx = tx.clone();
pool.execute(move|| {
tx.send(1).expect("channel will be there waiting for the pool");
});
}
assert_eq!(rx.iter().take(n_jobs).fold(0, |a, b| a + b), 8);
}
這裏我們使用ThreadPool::new()
來創建一個線程池,初始化4個工作線程。使用時用execute()
方法就可以拿出一個線程來進行具體的工作。
總結
今天我們介紹了Rust併發編程的三種特性:原子類型、線程間通信和線程池的使用。
原子類型是我們進行無鎖併發的重要手段,線程間通信和線程池也都是工作中所必須使用的。當然併發編程的知識遠不止於此,大家有興趣的可以自行學習也可以與我交流討論。