對于我們的第二個項目,讓我們來看一個典型的并發(fā)性問題。這就是“哲學(xué)家就餐問題”。這最初是由迪杰斯特拉在 1965 年提出的,但我們將要使用的版本出自托尼?霍爾在 1985 年發(fā)表的一篇論文。
在古代,一個富有的慈善家捐贈了一所學(xué)院來安排五個著名的哲學(xué)家。每個哲學(xué)家都有一個房間,他可以在其中從事他自己專業(yè)的思考活動;學(xué)校里有一個公共的餐廳,這里配備有一個圓形的桌子,桌子周邊有五個椅子,每個椅子用坐在上面的哲學(xué)家的名字來標(biāo)記。他們以逆時針的方向來圍著桌子坐。每個哲學(xué)家的左側(cè)放了一個金叉,桌子中間有一大碗不斷添滿的意大利面。哲學(xué)家將花費大部分時間去思考;但是當(dāng)他餓了,他就會去餐廳,坐在自己的椅子上,拿起在自己左邊的叉子來吃意大利面。但是意大利面條比較難吃到,需要第二個叉子才能把面條送到嘴里。因此,哲學(xué)家也要拿起他右邊的叉子。當(dāng)哲學(xué)家吃完面條后,他會把兩個叉子都放下,離開自己的椅子,并繼續(xù)思考。當(dāng)然了,一個叉子一次只能有一個哲學(xué)家來使用。如果其他哲學(xué)家想要使用你的叉子,那么他僅僅需要等到這個叉子沒人用了即可。
這個經(jīng)典問題展示了一些不同的并發(fā)性的元素。原因在于,其實際實現(xiàn)起來比較復(fù)雜:一個簡單的實現(xiàn)可能導(dǎo)致死鎖。舉一個例子,讓我們先想一個簡單的算法來解決這個問題:
現(xiàn)在,讓我們來想象一下這一系列的事件:
有不同的方法來解決這個問題。在本教程中我們將用我們的方法來解決它?,F(xiàn)在,讓我們開始從問題的本身開始建模。我們先從哲學(xué)家身上開始:
struct Philosopher {
name: String,
}
impl Philosopher {
fn new(name: &str) -> Philosopher {
Philosopher {
name: name.to_string(),
}
}
}
fn main() {
let p1 = Philosopher::new("Baruch Spinoza");
let p2 = Philosopher::new("Gilles Deleuze");
let p3 = Philosopher::new("Karl Marx");
let p4 = Philosopher::new("Friedrich Nietzsche");
let p5 = Philosopher::new("Michel Foucault");
}
在這里,我們創(chuàng)建了一個結(jié)構(gòu)體來代表一個哲學(xué)家。在結(jié)構(gòu)體里,我們現(xiàn)在所需要的僅僅是一個名字。我們將名字的類型選擇成 String 型,而不是 &str。一般來說,使用一種自身擁有數(shù)據(jù)的類型比使用一個利用引用的類型容易的多。
我們繼續(xù)看下面的部分:
impl Philosopher {
fn new(name: &str) -> Philosopher {
Philosopher {
name: name.to_string(),
}
}
}
這里的 impl 塊能讓我們?yōu)?Philosopher 結(jié)構(gòu)體定義一些內(nèi)容。在本例中,我們定義了一個名叫 new 的關(guān)聯(lián)函數(shù)。它的第一行如下所示:
fn new(name: &str) -> Philosopher {
這里我們需要輸入一個參數(shù) name,類型是 &str。這是另一個字符串的一個引用。此函數(shù)會返回一個 Philosopher 結(jié)構(gòu)體的實例。
Philosopher {
name: name.to_string(),
}
這將會創(chuàng)建一個新的 Philosopher,并將它的 name 值設(shè)置成前面 name 的參數(shù)值。但這不是參數(shù)的自身,因為我們對其調(diào)用了 .to_string()
。這將創(chuàng)建一個 &str
所指向字符串的副本,此副本的類型是 Philosopher 的 name 字段的 String 類型。
那么為什么不直接用 String 類型的參數(shù)?這樣更容易調(diào)用。如果我們采用了 String 類型參數(shù),但是我們的調(diào)用者所有的是 &str,那么他們必須自己調(diào)用這個 .to_string()
的方法。這種靈活性的缺點是,它總是產(chǎn)生一個副本。對于這個小程序,這并不是特別重要,因為我們知道,我們僅僅使用的是短字符串。
最后一件你可能會注意到的事:我們只定義了一個 Philosopher,似乎用它什么事情也沒有做。Rust 是一種基于表達式的語言,這意味著 Rust 中幾乎所有的內(nèi)容都是返回一個值的表達式。這個說法對函數(shù)亦是如此,因為函數(shù)的最后一個表達式將自動返回。由于這個函數(shù)的最后一個表達式是我們創(chuàng)建了一個新的 Philosopher,我們最終返回這個 Philosopher。
對于 Rust,這個名字,new(),沒有什么特別,但是它是創(chuàng)建新的結(jié)構(gòu)體實例的函數(shù)的約定俗成的叫法。在我們討論為什么是這樣之前,讓我們先再看看 main():
fn main() {
let p1 = Philosopher::new("Baruch Spinoza");
let p2 = Philosopher::new("Gilles Deleuze");
let p3 = Philosopher::new("Karl Marx");
let p4 = Philosopher::new("Friedrich Nietzsche");
let p5 = Philosopher::new("Michel Foucault");
}
在這里,我們創(chuàng)建了五個 Philosopher 的變量綁定。這五個名字都是我最喜歡的,你也可以用任何你想要的名字來替換他們。如果我們沒有定義 new() 函數(shù),它會看起來會是這樣的:
fn main() {
let p1 = Philosopher { name: "Baruch Spinoza".to_string() };
let p2 = Philosopher { name: "Gilles Deleuze".to_string() };
let p3 = Philosopher { name: "Karl Marx".to_string() };
let p4 = Philosopher { name: "Friedrich Nietzche".to_string() };
let p5 = Philosopher { name: "Michel Foucault".to_string() };
}
這看起來就比較麻煩了。當(dāng)然使用 new 也有其他的一些優(yōu)勢,而在我這個簡單的例子中,使用它也使我們的代碼簡潔了不少。
現(xiàn)在我們在這里已經(jīng)寫好了基本的內(nèi)容,同時會有很多方法可以解決上述問題。我想從問題的結(jié)束端先開始:讓我們建立一個每個哲學(xué)家吃完的方案。這是很小的一步,我們先創(chuàng)建一個方法,然后遍歷所有的哲學(xué)家,如下所示:
struct Philosopher {
name: String,
}
impl Philosopher {
fn new(name: &str) -> Philosopher {
Philosopher {
name: name.to_string(),
}
}
fn eat(&self) {
println!("{} is done eating.", self.name);
}
}
fn main() {
let philosophers = vec![
Philosopher::new("Baruch Spinoza"),
Philosopher::new("Gilles Deleuze"),
Philosopher::new("Karl Marx"),
Philosopher::new("Friedrich Nietzsche"),
Philosopher::new("Michel Foucault"),
];
for p in &philosophers {
p.eat();
}
}
讓我們首先來看 main()。我們沒有采用有五個哲學(xué)家 Philosopher 的變量綁定,相反我們創(chuàng)建了一個哲學(xué)家的 Vec<T>
。 Vec<T>
也被稱為“向量”,這是一個可增長的數(shù)組類型。然后,我們使用一個 for 循環(huán)迭代向量體,也就是會輪流的獲取到每個哲學(xué)家的引用。
在循環(huán)體內(nèi),我們調(diào)用的是 p.eat()
,下面是關(guān)于其函數(shù)定義:
fn eat(&self) {
println!("{} is done eating.", self.name);
}
在 Rust 中,方法可以用一個明確的 self 參數(shù)。這就是為什么 eat() 是一個方法,而 new 是一個關(guān)聯(lián)函數(shù): new() 中沒有 self。我們 eat() 的第一個版本,只打印出哲學(xué)家的名字,并且提及他們正在吃?,F(xiàn)在運行這個程序,你可以得到以下的輸出:
Baruch Spinoza is done eating.
Gilles Deleuze is done eating.
Karl Marx is done eating.
Friedrich Nietzsche is done eating.
Michel Foucault is done eating.
夠簡單吧,程序運行完全沒有問題!然而我們還沒有實現(xiàn)真正的問題,所以我們還沒有完成整個程序!
接下來,我們想想做的是:我們的哲學(xué)家不僅吃完,而且實際上他們也是在吃的。下面是程序的下一個版本:
use std::thread;
struct Philosopher {
name: String,
}
impl Philosopher {
fn new(name: &str) -> Philosopher {
Philosopher {
name: name.to_string(),
}
}
fn eat(&self) {
println!("{} is eating.", self.name);
thread::sleep_ms(1000);
println!("{} is done eating.", self.name);
}
}
fn main() {
let philosophers = vec![
Philosopher::new("Baruch Spinoza"),
Philosopher::new("Gilles Deleuze"),
Philosopher::new("Karl Marx"),
Philosopher::new("Friedrich Nietzsche"),
Philosopher::new("Michel Foucault"),
];
for p in &philosophers {
p.eat();
}
}
僅有幾個變化。讓我們一個一個的來講。
use std::thread;
use 可以使后面的庫加載在作用域內(nèi)。我們稍后會使用標(biāo)準(zhǔn)庫中的 thread 模塊,所以我們需要 use 它。
fn eat(&self) {
println!("{} is eating.", self.name);
thread::sleep_ms(1000);
println!("{} is done eating.", self.name);
}
我們現(xiàn)在打印了兩個消息,其中間有一個 sleep_ms()
將其分隔開。sleep_ms()
將模擬哲學(xué)家吃的時間。
如果現(xiàn)在你運行這個程序,你會看到每個哲學(xué)家輪流吃:
Baruch Spinoza is eating.
Baruch Spinoza is done eating.
Gilles Deleuze is eating.
Gilles Deleuze is done eating.
Karl Marx is eating.
Karl Marx is done eating.
Friedrich Nietzsche is eating.
Friedrich Nietzsche is done eating.
Michel Foucault is eating.
Michel Foucault is done eating.
太好了!我們又前進了一步。這里還有一個問題:我們實際上并沒有以并行的方式操作,并行才是就餐問題的核心部分!
為了能讓我們的哲學(xué)家并發(fā)的吃,我們需要做一個小改變。下面是下一個版本:
use std::thread;
struct Philosopher {
name: String,
}
impl Philosopher {
fn new(name: &str) -> Philosopher {
Philosopher {
name: name.to_string(),
}
}
fn eat(&self) {
println!("{} is eating.", self.name);
thread::sleep_ms(1000);
println!("{} is done eating.", self.name);
}
}
fn main() {
let philosophers = vec![
Philosopher::new("Baruch Spinoza"),
Philosopher::new("Gilles Deleuze"),
Philosopher::new("Karl Marx"),
Philosopher::new("Friedrich Nietzsche"),
Philosopher::new("Michel Foucault"),
];
let handles: Vec<_> = philosophers.into_iter().map(|p| {
thread::spawn(move || {
p.eat();
})
}).collect();
for h in handles {
h.join().unwrap();
}
}
我們所要做的就是改變 main() 里面的循環(huán),在其中添加上第二個循環(huán)體!下面是第一個改變:
let handles: Vec<_> = philosophers.into_iter().map(|p| {
thread::spawn(move || {
p.eat();
})
}).collect();
雖然這里只有五行,但這五行代碼確很密集。讓我們一個一個的來講。
let handles: Vec<_> =
我們這里引入了一個名叫 handles 新的綁定。我們這樣叫它,是因為我們要創(chuàng)建一些新的線程,而這些線程會返回一些能讓我們控制此些線程操作的 handles。由于一個我們過后要講到的問題,這里我們要明確解釋這個類型。_
是一個類型的占位符。我們說“handles 是一個某項的向量,在Rust 中,你可以找出到這項到底是什么?!?/p>
philosophers.into_iter().map(|p| {
我們的哲學(xué)家 philosophers 調(diào)用了 into_iter()。這將創(chuàng)建一個迭代器,它將擁有每個哲學(xué)家的所有權(quán)。我們要這樣才能將哲學(xué)家傳遞到線程中。我們用迭代器調(diào)用 map,它以一個閉包作為參數(shù),并且依次調(diào)用閉包中的每個元素。
thread::spawn(move || {
p.eat();
})
這里就是發(fā)生并發(fā)的地方。thread::spawn
函數(shù)以一個閉包作為參數(shù),并會在一個新線程里執(zhí)行這個閉包。關(guān)于這個閉包的一些額外的解釋, move,表明閉包要獲取它的捕獲值的所有權(quán)。主體函數(shù)上,p 是 map 函數(shù)的變量。
在線程內(nèi)部,我們所做的就是用 p 調(diào)用 eat()。
}).collect();
最后,我們獲取了所有的 map 調(diào)用的結(jié)果并把它們收集起來。collect()
會將它們生成某種集合,這就是為什么我們要解釋返回類型的原因:因為我們想要一個 Vec<T>
。集合的元素就是 thread::spawn
調(diào)用的返回值,也就是這些線程的 handles。
for h in handles {
h.join().unwrap();
}
main() 函數(shù)的結(jié)尾部分,我們對 handles 進行循環(huán)處理,對其調(diào)用 join(),這可以阻塞其它線程的執(zhí)行,直到本線程執(zhí)行完成。這確保了線程在程序?qū)⑵渫顺銮皶瓿伤鼈兊墓ぷ鳌?/p>
如果你運行這個程序,你會發(fā)現(xiàn)哲學(xué)家可以自由的吃啦!到這兒,我們已經(jīng)有多線程程序啦!
Gilles Deleuze is eating.
Gilles Deleuze is done eating.
Friedrich Nietzsche is eating.
Friedrich Nietzsche is done eating.
Michel Foucault is eating.
Baruch Spinoza is eating.
Baruch Spinoza is done eating.
Karl Marx is eating.
Karl Marx is done eating.
Michel Foucault is done eating.
但是叉子呢?我們還沒有為它們建模。
要做到這一點,讓我們寫一個新的 struct:
use std::sync::Mutex;
struct Table {
forks: Vec<Mutex<()>>,
}
這桌子 Table 有一個互斥元(Mutex)的向量?;コ庠强刂撇l(fā)性的一種方法:一次只能有一個線程可以訪問內(nèi)容。這正是我們叉子所需要的屬性。在互斥元中我們用了一個空的元組,(),因為我們并不打算使用其中的值,僅僅獲取到它就可以。
讓我們使用 Table 來修改這個程序:
use std::thread;
use std::sync::{Mutex, Arc};
struct Philosopher {
name: String,
left: usize,
right: usize,
}
impl Philosopher {
fn new(name: &str, left: usize, right: usize) -> Philosopher {
Philosopher {
name: name.to_string(),
left: left,
right: right,
}
}
fn eat(&self, table: &Table) {
let _left = table.forks[self.left].lock().unwrap();
let _right = table.forks[self.right].lock().unwrap();
println!("{} is eating.", self.name);
thread::sleep_ms(1000);
println!("{} is done eating.", self.name);
}
}
struct Table {
forks: Vec<Mutex<()>>,
}
fn main() {
let table = Arc::new(Table { forks: vec![
Mutex::new(()),
Mutex::new(()),
Mutex::new(()),
Mutex::new(()),
Mutex::new(()),
]});
let philosophers = vec![
Philosopher::new("Baruch Spinoza", 0, 1),
Philosopher::new("Gilles Deleuze", 1, 2),
Philosopher::new("Karl Marx", 2, 3),
Philosopher::new("Friedrich Nietzsche", 3, 4),
Philosopher::new("Michel Foucault", 0, 4),
];
let handles: Vec<_> = philosophers.into_iter().map(|p| {
let table = table.clone();
thread::spawn(move || {
p.eat(&table);
})
}).collect();
for h in handles {
h.join().unwrap();
}
}
又有了許多的變化!這一次我們有了最終可運行的版本。我們看下細(xì)節(jié)內(nèi)容:
use std::sync::{Mutex, Arc};
我們將使用 std::sync
包中的另一個結(jié)構(gòu):Arc<T>
。當(dāng)我們使用它時,會做進一步的講解。
struct Philosopher {
name: String,
left: usize,
right: usize,
}
我們需要為 Philosopher 添加兩個字段。每個哲學(xué)家都有兩個叉子:一個在左,一個在右。我們用 usize 類型來表示它們,因為這類型是索引向量。這兩個值將被索引到我們 Table 中的 forks 了。
fn new(name: &str, left: usize, right: usize) -> Philosopher {
Philosopher {
name: name.to_string(),
left: left,
right: right,
}
}
我們現(xiàn)在需要構(gòu)造 left 和 right 的值了,所以我們將它們添加到了 new() 中。
fn eat(&self, table: &Table) {
let _left = table.forks[self.left].lock().unwrap();
let _right = table.forks[self.right].lock().unwrap();
println!("{} is eating.", self.name);
thread::sleep_ms(1000);
println!("{} is done eating.", self.name);
}
我們又添加了兩行新代碼。我們還添加了一個參數(shù),table。這樣我們就可以訪問 Table 的 fork 列表,然后可以用 self.left 和 self.right 訪問特定索引上的 fork。讓我們可以訪問那個索引上的互斥元 Mutex,并且用其調(diào)用 lock()。如果互斥元目前正在被別人訪問,那么我們的線程將被阻塞,直到互斥元變得可用。
lock() 的調(diào)用可能會失敗,如果是這樣,程序會崩潰。在這種情況下,錯誤的發(fā)生可能是因為互斥元“中毒”了,這是由于當(dāng)鎖已經(jīng)被持有時會引起線程應(yīng)急。因為這樣的錯誤不該發(fā)生,所以我們使用 unwrap() 來解決它。
這些行另一個奇怪的現(xiàn)象是:我們將結(jié)果命名為 _left
和 _right
。這些下劃線有什么用?嗯,因為我們不打算使用鎖內(nèi)的值。我們只是想持有鎖。因此,Rust 會警告我們,我們從來沒有使用過鎖內(nèi)值。通過使用下劃線,我們就會告訴 Rust,這就是我們想要的,這樣的話 Rust 就不會拋出警告。
那么如何釋放鎖呢?嗯,當(dāng) _left
和 _right
超出作用域后,鎖會自動釋放。
let table = Arc::new(Table { forks: vec![
Mutex::new(()),
Mutex::new(()),
Mutex::new(()),
Mutex::new(()),
Mutex::new(()),
]});
接下來,在 main() 中,我們創(chuàng)建了一個新的 Table 并定義成 Arc<T>
類型?!癮rc” 是 “atomic reference count” 的縮寫,我們要通過多線程來共享我們的 Table。當(dāng)我們要共享它時,引用數(shù)就會上升,當(dāng)每個線程結(jié)束時,引用數(shù)就會減少。
let philosophers = vec![
Philosopher::new("Baruch Spinoza", 0, 1),
Philosopher::new("Gilles Deleuze", 1, 2),
Philosopher::new("Karl Marx", 2, 3),
Philosopher::new("Friedrich Nietzsche", 3, 4),
Philosopher::new("Michel Foucault", 0, 4),
];
我們要將我們的 left 和 right 值傳遞到 Philosopher 的構(gòu)造函數(shù)中。這里有一個非常重要的細(xì)節(jié)。如果你查看他們的樣式,開始直到最后都是一致的。Monsieur Foucault 本應(yīng)該用 4,0 作為參數(shù),但相反的,用 0,4 作了參數(shù)。這是其實是防止死鎖:我們的一個哲學(xué)家是左撇子!這是解決問題的一種方法,在我看來,這是最簡單的。
let handles: Vec<_> = philosophers.into_iter().map(|p| {
let table = table.clone();
thread::spawn(move || {
p.eat(&table);
})
}).collect();
最后,在 map()/collect()
循環(huán)中,我們調(diào)用了 table.clone()
。Arc<T>
調(diào)用的 clone()
方法會增加引用的計數(shù),但是當(dāng)它超出作用域之后,引用的計數(shù)會相應(yīng)的減少。你會注意到,我們可以在這里引入一個新的 table 的綁定,它將覆蓋舊的那一個。這個方法經(jīng)常使用。這樣可以使你不需要命名兩個唯一的名字。
到這里,我們的程序就可以工作啦!只有兩個哲學(xué)家可以在同一時間吃,所以你會得到一些如下的輸出:
Gilles Deleuze is eating.
Friedrich Nietzsche is eating.
Friedrich Nietzsche is done eating.
Gilles Deleuze is done eating.
Baruch Spinoza is eating.
Karl Marx is eating.
Baruch Spinoza is done eating.
Michel Foucault is eating.
Karl Marx is done eating.
Michel Foucault is done eating.
恭喜你!你已經(jīng)用 Rust 實現(xiàn)了一個典型的并發(fā)性問題。