Rust最近非常火,作為coder要早學早享受,本篇作為該博客第一篇學習Rust語言的文章,將通過一個在其他語言都比較常見的例子作為線索,引出Rust的一些重要理念或者說特性,這些特性都是令人心馳神往的,相信我,當你讀到最后,一定會有同樣的感覺(除非你是天選之子,從未受過語言的苦 ^ ^ ),
本文題目之所以使用“最強肉坦”來形容Rust,就是為了凸顯該語言的一種防御能力,是讓人很放心的存在,
關鍵字:Rust,變數,所有權,不可變性,無畏并發,閉包,多執行緒,智能指標
問題:多執行緒修改共享變數
這是幾乎每種編程語言都會遇到的實作場景,通過對比Java和Rust的實作與運行表現,我們可以清晰地看出Rust的不同或者說Rust的良苦用心,以及為了實作這一切所帶來的語言特性,我們首先來看Java的實作方法,
java實作方法
package com.evswards.multihandle;
import java.util.ArrayList;
import java.util.List;
public class TestJavaMulti001 {
public static void main(String[] args) throws InterruptedException {
class Point {
int x;
int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
}
Point p = new Point(1, 2);
List<Thread> handles = new ArrayList<>();
for (int i = 0; i < 10; i++) {
Thread t = new Thread(new Runnable() {
@Override
public void run() {
System.out.println(this + ": " + p.x);
p.x++;
}
});
handles.add(t);
t.start();
}
for (Thread t : handles) {
t.join();
}
System.out.println("total: " + p.x);
}
}
下面對以上代碼進行簡要的說明:
1、直接看main方法體,首先定義了一個類Point,是一個坐標點,它有x和y兩個成員都是int型別,并且有一個x和y共同參與的構造方法,
2、接下來,通過Point構造方法我創建了一個坐標點的實體p,它的值是(1,2),
3、然后是一個Thread的串列,用來保存多執行緒實體,作用是可以保證主執行緒對其的一個等待,而不是主執行緒在多執行緒執行完以前就執行完了,
4、一個10次的回圈,回圈體中是創建一個執行緒,首先列印p的x坐標,然后對其執行自增操作,然后將當前執行緒實體加入前面定義的Thread串列,并啟動該執行緒執行,
5、對多執行緒進行一個join的操作,用來保證主執行緒對其的一個等待,
6、最后列印出p的x坐標的值,
接下來,我們看一下它的輸出:
/Library/Java/JavaVirtualMachines/adoptopenjdk-8.jdk/Contents/Home/bin/java ...
com.evswards.multihandle.TestJavaMulti001$1@2586b45a: 1
com.evswards.multihandle.TestJavaMulti001$1@20cc06fb: 1
com.evswards.multihandle.TestJavaMulti001$1@3f1d0da9: 1
com.evswards.multihandle.TestJavaMulti001$1@28817d5f: 1
com.evswards.multihandle.TestJavaMulti001$1@2f7aa756: 3
com.evswards.multihandle.TestJavaMulti001$1@25d849fd: 6
com.evswards.multihandle.TestJavaMulti001$1@4df93c85: 7
com.evswards.multihandle.TestJavaMulti001$1@2e14a730: 8
com.evswards.multihandle.TestJavaMulti001$1@26795870: 8
com.evswards.multihandle.TestJavaMulti001$1@54359f35: 10
total: 11
Process finished with exit code 0
可以看出多執行緒執行的一個隨機性(前幾個執行緒在執行時的速度最快,當他們各自達到x坐標的時候,基本上還沒有被修改太多次,因此有很多的1被列印出來),然后在join方法的作用下,最終total的值是我們預想的11,即1被自增了10次的正確結果,
這段Java實作的多執行緒修改共享變數的代碼就介紹到這里,暫且先不去談它的一個健壯性以及代碼撰寫的合理性,但至少可以證明,這個問題對于Java的撰寫來講,不是特別麻煩,只要稍微懂一些JavaSE的知識就可以寫出來,下面,仿照這段Java語言對于這個問題的寫法,我們來寫Rust,看看它是如何處理的以及最終的實作版本是什么樣子,
Rust的實作方法
1、Rust helloworld
我們這篇Rust的文章是一個入門學習材料,因此要從頭說起,但我不準備介紹Rust的下載和IDE的方式,這部分內容可以直接參考https://doc.rust-lang.org/book/ch01-00-getting-started.html,另外,作為Rust的包管理工具,Cargo是一個重要知識點,但我也不準備在此仔細研究,作為入門材料,只要知道如何使用即可,那么讓我們直接到IDE里面完成Hello_World的撰寫并運行成功,
fn main() {
println!("Hello World!")
}
在IDE默認生成的rust工程中,main.rs檔案是入口原始碼,其中的main方法是入口方法,
語法:用fn宣告一個函式;列印函式是println!(),它是靜態內部方法可以直接呼叫,
執行后列印的內容:
/Users/liuwenbin24/.cargo/bin/cargo run --color=always --package prosecutor_core_rt --bin prosecutor_core_rt
Finished dev [unoptimized + debuginfo] target(s) in 0.01s
Runningtarget/debug/prosecutor_core_rt
Hello World!Process finished with exit code 0
這里正確列印出來了字串"Hello World!“,但它的前后有很多debug日志,這些內容并不是經常有用,我們在此約定:后面出現的列印結果中,不再粘貼無用的debug日志,而一些警告、錯誤的日志會被粘貼出來的進行分析,因為這些警告和錯誤日志恰恰是rust編譯器為程式員提供的最為精華的部分,
2、結構體struct
結構體struct是rust的一個復合資料型別,結構體的使用與其他語言類似,關鍵字是struct,相當于Java的Class,Java的坐標點類的寫法:
class Point {
int x;
int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
}
2.1 整型
前面學會了struct可以替換Class,但是Point的x和y坐標的整型資料結構該如何在rust中表現呢?
rust的整型關鍵字可分為有符號和無符號兩種:
1、i8, i16, i32, i64, i128 屬于有符號,可以表示正負數,i后面的數字代表空間占據固定的二進制位數,
2、u8, u16, u32, u64, u128 屬于無符號,只能表示正數,所以同等二進制位數下,無符號可表示的正數的最大值是有符號的兩倍,同樣的,u后面的數字代表空間占據固定的二進制位數,
rust在定義變數的時候,正好是與java反過來的,即變數名放前面,資料型別放后面,例如 num: i32
那么到這里,我們就能夠使用Rust寫出Point的結構體了,代碼如下:
struct Point {
x: i32,
y: i32,
}
2.2 變數
下面,我們希望在main方法中創建Point的實體并完成初始化賦值,這里就要使用到變數,
rust的變數的修飾符是let,這與java的資料型別不同,let僅有宣告變數的作用,至于資料型別要在變數名的后面,正如2.1講解的整型的例子那樣,
fn main() {
let p = Point { x: 1, y: 2 };
println!("{},{}", p.x, p.y)
}
我們在main方法中定義了變數p,給它賦值了Point的實體,該實體直接初始化了x=1, y=2,
這里有一個不同之處在于,java的main方法是由靜態修飾符static修飾的,因此若Point類寫在main方法的外面,main方法體還要使用Point的話,就需要顯式指定Point類也未static靜態類,然而,rust是沒有這個限制的,struct寫在哪里都可以,這里我們與java做點區分,還是放在main函式的外面比較合理,
下面,看一下列印輸出結果:
1,2
3、可變變數
2.2講過了變數,為什么可變變數要使用二級標題單獨講?因為這是rust一個比較重要的防御性設計,我們現在回顧一下本文的問題啊,其中有關鍵字是要修改變數,
rust的一個變數若想在后續被修改,必須顯式地被關鍵字mut所修飾,例如: let mut num: i32 = 10 ;
因此,接著前面的rust代碼,我們若想修改p的坐標值,需要mut宣告,
fn main() {
let mut p = Point { x: 1, y: 2 };
p.x += 1;
println!("{},{}", p.x, p.y)
}
列印結果:
2,2
4、借用變數
本文的問題在java的實作程序中需要將p傳到Thread類的Runnable介面的run方法中,這在java中是無需多慮的,然而在rust中,變數在作用域之間的傳遞會出現問題,我們仍舊繼續在前面的rust代碼基礎上去撰寫,
fn f2(_a: Point) {}
fn main() {
let mut p = Point { x: 1, y: 2 };
p.x += 1;
f2(p);
println!("{},{}", p.x, p.y);
}
我們增加了一個f2函式,引數是一個Point型別的內部變數a,同時在第6行增加了對于f2函式的呼叫,這段代碼看上去沒有執行什么有效邏輯,但是運行一下會報錯如下:
error[E0382]: borrow of moved value:
p
--> src/main.rs:14:28
|
11 | let mut p = Point { x: 1, y: 2 };
| ----- move occurs becausephas typePoint, which does not implement theCopytrait
12 | p.x += 1;
13 | f2(p);
| - value moved here
14 | println!("{},{}", p.x, p.y);
| ^^^ value borrowed here after move
|
= note: this error originates in the macro$crate::format_args_nl(in Nightly builds, run with -Z macro-backtrace for more info)
前面說到了rust程式執行時的報錯日志是非常精華的部分,讓程式員仿佛永遠在一個耐心的大神旁邊編程,這里的結果中最重要的一句是:error[E0382]: borrow of moved value: p,就是說這個p首先它已經被moved了,然后不能被借出,
4.1 rust的基礎型別
rust有四種基礎資料型別:整型(見2.1)、浮點型(f32\f64)、布爾(true/false)、字符(char,默認占4個位元組)
4.2 指標復習
與C語言的指標概念一致,基礎資料型別不需要指標,它的變數直接指向記憶體中的值,而參考型別是需要指標的,參考型別的變數指向一個指標,然后指標再指向記憶體中實際的值,所以指標是一個記憶體地址,由于參考型別的變數不像基礎型別的那樣在創建的時候就確定了分配記憶體的長度,所以有了指標,指標會指向該變數在記憶體中存盤的首個位元組單元的地址,例如0x69,然后參考型別的變數同時還默認包含了size或者length這種記錄長度的屬性,一個變數的資料在記憶體中的存盤是連續的,因此通過首個記憶體單元地址和長度這兩個屬性,就可以從記憶體中獲取到完整的資料,
4.3 野指標
C和C++語言往往會出現野指標的情況,即實際記憶體存盤單元已經被銷毀或修改,而原來的指標卻仍舊存在,這時候該指標就被稱為野指標,野指標一般是由于多個指標指向了同一個記憶體地址,而記憶體地址在銷毀或者變化時也會同時銷毀掉相關的指標,但它不能保證全部銷毀掉,一旦形成漏網之魚,指標就進化為野指標潛藏在你的系統中準備作妖,野指標在不被呼叫的時候不會出問題,系統穩定運行,但一旦被觸發,就會報錯,報錯的情況依據最新記憶體的資料情況而定,所以報錯日志并不可靠,再加上復雜的代碼邏輯,除錯起來那是相當麻煩,
4.4 參考所有權
為避免野指標的情況發生,如果由我來設計的話,也會想得到有兩個方面來解決:
第一、要保證在指標與記憶體單元的一對一關系,如果非得有一對多的情況,要嚴加管理,至少要顯式宣告,寫入邏輯明確指標的數量,
第二、在第一步的基礎上,當記憶體單元發生變化,指標需要被銷毀時,一定要確保所有關聯的指標全都被銷毀,杜絕漏網之魚,
俗話說得好“想起來容易,做起來難”,但rust語言就真的是實作了,這里就引出了rust的參考所有權的設定,所有權就是對指標的所有權,每個記憶體單元只能由一個變數的指標所指向,如果其他變數的指標也要指向這個記憶體單元,則必須原來的“主人“要將所有權出借,
Rust變數出借關鍵字&,用來形容一個變數的參考,我們將創建一個參考的行為稱為 借用(borrowing),
繼續寫代碼:
fn f2(_a: &Point) {}
fn main() {
let mut p = Point { x: 1, y: 2 };
p.x += 1;
f2(&p);
println!("{},{}", p.x, p.y);
}
我們在第6行給引數p增加了變數參考&,同時重新定義了f2函式的引數型別為Point,main函式的變數p被借用給了f2函式作為入參,當f2函式執行完畢,就會還給main函式,這樣修改完以后,執行成功了,
接著來研究rust所有權問題,我們知道不同編程語言對于記憶體管理的策略有所不同,
1、java有自己大名鼎鼎的GC,即垃圾回收器,程式員可以對記憶體的情況完全不管,所以java程式員的作業系統知識遠不如其他編程語言從業者來的扎實,這是一方面的劣勢,另一方面,GC也不是完全可靠的,java系統在運行程序中,至少有30%的錯誤來自于記憶體層面的問題,對于強于業務代碼而弱于系統知識的java程式員來說,這種問題無疑是棘手的,
2、C++看上去靈活許多,可以自己申請記憶體、分配記憶體,以及手動執行記憶體銷毀等,但是,程式員擁有了越高的權利意味著他承擔的責任也就越大,造成的劣勢首先是程式員的作業系統知識要很過硬,這就使得C++的門檻要遠高于java,接著,為了避免記憶體錯誤,程式員需要在安全方面撰寫大量的代碼對記憶體進行管理,這無疑是耗時耗力的,而前面講到的野指標問題,往往也是在這個階段出的問題,因為你永遠無法對自己撰寫的C++記憶體管理代碼完全自信,
那么,rust語言在這方面就考慮了很多,畢竟作為后來者,它能夠立足的根本就是吸取教訓,開拓進取嘛,因此,所有權機制就誕生了,它就是Rust語言對于自身記憶體管理的一個別稱,
Rust所有權的規則:
- 程式中每一個值都歸屬于一個變數,稱作該變數擁有此值的所有權,
- 值的所有權在同一時間只能歸屬于一個變數,當吧這個值賦予一個新變數時,新變數獲得所有權,舊的變數失去該值的所有權,無法再對其訪問和使用,
- 每個變數只能在自己的作用域中使用,程式執行完,該變數即作廢,變數的值被自動垃圾回收,
所有權轉移的三種情況:
- 一個變數賦值給另一個變數,
- 一個變數作為引數傳入一個函式,其所有權轉移給了形參,
- 一個函式把某變數作為回傳值回傳時,所有權轉移給接識訓傳值的變數,
5、Vec集合
接著使用Rust來解決我們的目標問題,對應前面java的實作,接下來要搞定的是:
List<Thread> handles = new ArrayList<>();
這行java的常用的串列集合的寫法,在rust中該如何實作?
rust有一個集合容器,關鍵字Vec,
這里有幾點要說明:
1、Vec在rust中的功能和實作原理與java的List很相似,可以新增元素,都是長度可變的,當順序排列到記憶體末尾不夠使用時,會把整個Vector的內容復制一份到一個新的記憶體足夠的連續的記憶體空間上,所以在長度變化的時候,會有一個記憶體空間的切換,也就是說Vec的記憶體空間地址不是一成不變的,
2、Vec只能存盤同一個資料型別的資料,可以在初始化的時候使用泛型來指定,
Vec的寫法:
struct Point {
x: i32,
y: i32,
}
fn main() {
let mut p = Point { x: 1, y: 2 };
p.x += 1;
let mut v: Vec<Point> = Vec::new();
v.push(p);
let a = v.get(0).expect("沒找到");
println!("{},{}", a.x, a.y);
}
這段rust代碼執行成功,輸出2,2,下面來分析一波:
1、先要夸一波,rust編譯器真的聰明,幾乎可以不去參考官方檔案,只依靠編譯器的報錯資訊和指導即可以完成編程,所以學習rust最簡單的辦法就是多寫,
2、回到原始碼,首先學習一下Vec的初始化:let mut v: Vec<Point> = Vec::new();泛型中指定了集合中存盤的元素型別是我們創建的結構體Point型別,等號右邊是Vec類對于new()方法的呼叫,注意是使用"::"兩個冒號來代表”誰的方法“,這里的new函式相當于是類的構造器,但它是靜態的,可以直接呼叫,
3、為Vec插入元素,即v.push(p);這個用法看起來差不多,只是要注意方法名不是add,而是push,不過也沒關系,編碼的時候都會有方法提示 (=_=!)
4、讀取Vec的元素內容,注意與指定泛型的默認轉換,let a = v.get(0).expect("沒找到");注意這里的a默認已經是&Point型別了,也就是我們在使用Vec的時候不必單獨考慮參考出借的問題,expect("")方法就是萬一找不到,用這個提示來代替,這種錯誤屬于資料錯誤,但是rust也會提前想到讓我們自己去定義錯誤日志,從而快速排查,
5、最后,就是驗證列印成功,
下面,我們換一種寫法,在集合創建的時候就把Point實體初始化進去,我們知道這種場景在java中是很容易實作的,那么我們來看rust是如何撰寫,以下僅粘貼不同的部分,
let v = vec![p];
這代碼直接把p初始化到了集合中,然后賦值給變數v,目前v就是一個Vec集合結構,它只有一個元素,就是Point型別的實體p,
5.1 宏
我在撰寫上面的rust代碼時,把vec!寫成了Vec!,程式執行時報錯,我才發現宏的概念,因為報錯的時候顯示error: cannot find macro "Vec" in this scope,這里的macro,我們如果在使用Excel的時候可能會注意到,由此可得到幾個結論:
1、宏的關鍵字是小寫加半角嘆號,就像vec!那樣,
2、宏的引數可以是括號修飾的入參(),也可以是方括號修飾的陣列[],
3、前面常用到的println!()也是宏,而不是函式,從這里才會注意到這一點,注意區分,
對于宏的解釋:
1、它是指Rust中的一系列功能,可以定義宣告宏和程序宏,
2、通過關鍵字macro_rules! 宣告宏,我們也可以撰寫宏并使用到它,
3、宏與函式的區別,宏是一種為寫其他代碼而寫代碼的方式,即元編程,宏會以展開的方式來生成比手寫更多的代碼,
4、宏在編譯器翻譯代碼時被展開,而方法是在運行時被呼叫,
5、宏的定義會比函式更復雜,
下面是vec!宏的定義原始碼:
#[macro_export]
macro_rules! vec {
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
\(( temp_vec.push(\)x);
)*
temp_vec
}
};
}
6、回圈
接著去看java的實作,我們剛剛解決了java List對應的rust寫法問題,繼續往下看是一段for回圈,那么rust中是如何實作的呢?
rust有loop、while、for三種回圈,其中while和for回圈與java的使用方法差不多,而獨有的loop回圈是一個死回圈,沒有限定條件,要配合一個break關鍵字進行使用,另外loop也可以有回傳值被接收,
下面寫一個10次的回圈:
for i in 0..10 {
println!("{}",i);
p.x += 1;
}
1、通過第2行的列印,我發現0..10代表的是10次,而1..10代表的是9次,所以這個范圍應該是[0,10),終止值是閉區間,也即不包含終止值,
2、做完1的實驗,我們可以把第2行的列印代碼洗掉,那么這個變數i就沒有人使用了,這時候也可以用單下劃線_代替,代表被丟棄的名稱,因為沒人用,那么最終的代碼就變為:
for _ in 0..10 {
p.x += 1;
}
7、執行緒
繼續看前面java的原始碼,剛剛我們解決了rust回圈的陳述句,下面要進入到回圈體中來了,回圈體中首先遇到的就是對執行緒的使用,在這一章,我們可以查看到官方檔案中對應的是16章,名字叫Fearless Concurrency,
”無畏并發“!
有點霸氣,其實前面學習到的rust的所有權、出借、可變變數等所有這些特性,都是為了執行緒安全而設計的,因此到了執行緒這一趴, rust真可以大聲喊一句,”我是無畏并發!“,有一種”該我上場表演了“的感覺,
下面看一下rust是如何創建執行緒的,
7.1 包參考
就像C++那樣,rust的包參考很相似:
use std::thread;
這樣就把包參考到當前類中來了,要注意的是這里參考的包都是在cargo的管理下,都能夠找得到的,當然了,它并不是針對thread這種在std標準庫中就有的包,而是第三方包或者我們自己開發的包這種比較難找的包,需要手動加載,
7.2 閉包
Rust 的 閉包(closures)是可以保存進變數或作為引數傳遞給其他函式的匿名函式,
閉包的定義以一對豎線(|)開始,在豎線中指定閉包的引數,如果有多于一個引數,可以使用逗號分隔,比如 |param1, param2|,
let closure = ||{
println!("{}",p.x);
};
closure();
雙豎線中間沒引數,后面直接跟大括號修飾的閉包方法體,是列印p的x坐標,別忘了在外面要主動呼叫一下該方法,即第4行的作用,
閉包的使用要注意變數的作用域,這里要結合rust的所有權概念一起使用,下面我們嘗試在閉包中增加引數,如下:
let closure = |Point|{
println!("{}",p.x);
};
closure(&p);
這里我們給閉包增加了一個引數,是Point型別,然后在第4行呼叫該函式的時候,傳入了p的參考,這里是從main函式作用域下的變數p借用給了閉包closure作為它的入參使用,當閉包執行完畢,還需要還回,
move語意
前面學習到了變數借用的機制,那么如果函式間呼叫,借走變數的函式執行完畢要歸還的時候發現被借的函式早已執行完畢記憶體被銷毀掉了,這時候怎么辦?從所有權機制上來分析,變數在這個時間點,它的所有權只有且必須是借走變數的函式所擁有,那么這種情況就不再使用借用機制,而是轉移機制,關鍵字move,
let closure = move |Point|{
println!("{}",p.x);
};
closure(p);
回到剛才的閉包代碼,在閉包的雙豎線之前增加關鍵字move,同時去掉第4行呼叫閉包函式時引數的參考&,這樣執行也是成功的,但是p的所有權永久地轉移給了閉包里,
7.3 spawn
Rust中創建一個新執行緒,可以通過thread::spawn函式并傳遞一個閉包,在其中包含執行緒要運行的方法體,
spawn這個單詞不常用,它是產卵的意思,其實就是一個new,但是作者不甘寂寞,對我們來說也算是加強印象,
use std::thread;
struct Point {
x: i32,
y: i32,
}
fn main() {
let mut p = Point { x: 1, y: 2 };
let mut handles = vec![];
p.x += 1;
for _ in 0..10 {
let handle = thread::spawn(|| {
println!("hello");
});
handles.push(handle);
}
println!("{},{}", p.x, p.y);
}
以上代碼實作了創造10個執行緒的程序,但是執行緒內部的執行邏輯卻比較簡單,并不涉及變數的內容,輸出的結果:
hello
hello
hello
hello
hello
hello
hello
hello
2,2
hello
hello
可以看到輸出的結果中,2,2的結果并不在最后,說明main執行緒是在我們spawn出來的執行緒之前就執行完了,因此,我們要加上join方法的呼叫,用來保證主函式的最后執行,
for handle in handles{
handle.join().expect("TODO: panic message");
}
println!("{},{}", p.x, p.y);
我們在main函式最后的列印代碼之前增加了對所有spawn出來的執行緒的遍歷,并把他們逐一join到主執行緒中,這樣一來,無論執行多少次,都能保證變數p的x和y坐標的列印永遠在最后一行,
hello
hello
hello
hello
hello
hello
hello
hello
hello
hello
2,2
8、一個錯誤版本
到此,看上去為了解決本文最上面的那個問題,我們的rust知識儲備已足夠,下面我們嘗試完成一個版本的實作,它看上去與java的實作很相似,
use std::thread;
struct Point {
x: i32,
y: i32,
}
fn main() {
let mut p = Point { x: 1, y: 2 };
let mut handles = vec![];
for i in 0..10 {
let handle = thread::spawn(move || {
println!("{},{}", i, p.x);
p.x += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().expect("TODO: panic message");
}
println!("{},{}", p.x, p.y);
}
首先我們定義了結構體Point,然后在main函式中,我們設定了可變變數p并賦值Point型別分別x=1,y=2,然后我們創建了一個空集合,接下來是一個for回圈,然后是執行緒的創建,這里用到了閉包,閉包首先設定變數的所有權被轉移,然后是一個空參閉包,內容首先列印執行緒的標號和轉移進來的變數p的x坐標的值,然后對x的坐標值加1,最后將當前執行緒添加到空集合中,接著,遍歷集合,保證每個子執行緒都join到主執行緒之前執行,最后,列印p的x和y坐標,這段代碼與最上面的java實作邏輯很類似,只是語言語法不同,下面來看一下執行結果:
3,1
8,1
6,1
1,1
4,1
5,1
2,1
0,1
9,1
7,1
1,2
這個結果明顯是不對的,首先,每個執行緒進來讀到的p的x坐標值都是1,然后最后main函式列印的p的值也沒有改變,這說明我們的多執行緒改變共享變數的目的失敗了,
我們回頭分析一下,應該是p變數再轉移進來以后,其他執行緒包括主執行緒都有一個自己的p,這是保存在執行緒堆疊中的值,而我們希望的是多執行緒修改同一個共享變數,這就需要把這個p放到堆里,讓所有執行緒都訪問同一個變數,
9、智能指標
指標 (pointer)是一個包含記憶體地址的變數的通用概念,
Rust 中最常見的指標是前面介紹的 參考(reference),參考以
&符號為標志并借用了他們所指向的值,智能指標(smart pointers)是一類資料結構,他們的表現類似指標,但是也擁有額外的元資料和功能,
在 Rust 中,普通參考和智能指標的一個額外的區別是參考是一類只借用資料的指標;相反,在大部分情況下,智能指標 擁有 他們指向的資料,Rust現存的智能指標很多,這里會研究其中4種智能指標:
- Box<T>,用于在堆上分配值
- Rc<T>,(reference counter)一個參考計數型別,其資料可以有多個所有者,
- Arc<T>,(atomic reference counter)可被多執行緒操作,但只能只讀,
- Mutex<T>,互斥指標,能保證修改的時候只有一個執行緒參與,
9.1 Box指標
第8章給出了一個錯誤版本,其中比較重要的部分是因為我們的變數p在多執行緒環境下被分配到了每個執行緒的堆疊記憶體中,根據rust所有權的機制,它在執行緒間不斷的move,這樣的變數是無法滿足我們的要求的,因此,我們希望變數能夠被儲存在堆上,
定義一個Box包裝變數:
let mut p = Box::new(Point { x: 1, y: 2 });
解參考
前面一直說參考&,那么如何讀出參考的值,就需要解參考*,因此,讀取Box變數的寫法:
println!("{}", (*p).x);
執行成功,這里要注意解參考時要加括號,否則會作用到x上面引發報錯,
Box變數雖然被強制分配在堆上,但它只能有一個所有權,所以還不是真正的共享,
9.2 Rc指標
Box指標修飾的變數只能保證強制被分配到堆上,但同一時間仍舊只能有一個所有權,不算真的共享,下面來學習Rc指標,Rc是一個參考計數智能指標,首先它修飾的變數也會分配在堆上,可以被多個變數所參考,智能指標會記錄每個變數的參考,這就是參考計數的概念,下面看一下如何撰寫使用Rc智能指標,
use std::rc::Rc;
fn main() {
let mut p = Rc::new(Point { x: 1, y: 2 });
let p1 = Rc::clone(&p);
let p2 = Rc::clone(&p);
println!("{},{},{}", p.x, p1.x, p2.x);
}
1、首先變數p被指定由Rc所包裝,
2、接著,p1和p2都是由p的參考克隆而來,所以他們都指向p的記憶體,
3、嘗試列印p和p1,p2的x坐標的值,我們用Box指標的話,這樣是不行的,一定會報錯,但是Rc指標是可以的,
執行成功,列印出1,1,1,
Rc智能指標學習到這里,看上去是可以滿足我們的多執行緒修改共享變數的目的,那我們撿起來之前的rust代碼,并將p修改為Rc智能指標所修飾,再去執行一下做個試驗,
error[E0277]:
Rc<Point>cannot be sent between threads safely
結果是不行的,報錯提示了,說明Rc指標不能保證執行緒安全,因此只能在單執行緒中使用,看來Rc指標是不能滿足我們的需求了,下面我們繼續來學習Arc指標,
9.3 Arc指標
Arc指標是比Rc多了一個Atomic的限定詞語,這是原子的意思,熟悉多執行緒的朋友應該了解,原子性代表了一種執行緒安全的特性,那么它該如何使用,是否能滿足我們的要求呢?我們來撰寫一下,
let mut p = Arc::new(Point { x: 1, y: 2 });
let mut handles = vec![];
for i in 0..10 {
let p1 = Arc::clone(&p);
let handle = thread::spawn(move || {
println!("{},{}", i, p1.x);
// p.x += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().expect("TODO: panic message");
}
println!("{}", p.x);
1、我們修改了第1行p為Arc的修飾,
2、然后第4行增加了對p的參考的克隆,這是在回圈體內執行的,保證每個執行緒都能有單獨的變數使用,同時借由Arc的特性,這些變數都共同指向了同一個記憶體值,
3、我們注釋掉了第7行對于共享變數的修改操作,否則會報錯:error[E0594]: cannot assign to data in an Arc
總結一下,Arc智能指標繼承了Rc的能力,同時又能夠滿足多執行緒下的操作,使得變數真正成為共享變數,然而Arc不能被修改,是只讀權限,這就無法滿足我們要修改的需求,我們距離目標越來越近了,
9.4 Mutex指標
下面來介紹Mutex指標,它是專門為修改共享變數而生的,Mutex指標能夠保證同一時間下,只有一個執行緒可以對變數進行修改,其他執行緒必須等待當前執行緒修改完畢方可進行修改,
Mutex指標的功能描述,與java的多執行緒上鎖的程序很相似,可變不共享,共享不可變,
下面我們在之前的基礎上嘗試修改:
fn main() {
let mut p = Mutex::new(Point { x: 1, y: 2 });
let mut handles = vec![];
for i in 0..10 {
// let p1 = Arc::clone(&p);
let handle = thread::spawn(move || {
let mut p0 = p.lock().unwrap();
println!("{},{}", i, p0.x);
p0.x += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().expect("TODO: panic message");
}
println!("{}", p.lock().unwrap().x);
}
1、首先第2行我們將p改為用Mutex指標修飾,
2、第7行要注意,Mutex之所以能夠是互斥,因為它內部是通過鎖機制來實作了多執行緒下的執行緒安全,所以這里要先得到p的鎖即p.lock(),然后在解包裝,就能得到里面的值,我們將它復制給p0,
3、最后列印的時候也要注意同樣的寫法,
那么這段代碼的執行仍舊是失敗,報錯提示error[E0382]: use of moved value: p,
其實問題還是出在了共享變數上,Mutex單獨修飾的變數并不是共享變數,因為它的所有權在同一時間仍舊是只有一個,也就是說這里其實缺少了Rc的能力,
10、終版
前面我們學習了4種智能指標,Box和Rc首先被淘汰,因為他們距離我們的需求都比較遙遠,但是他們兩個的學習可以很有效地幫助我們學習其他的智能指標,而Arc和Mutex這兩個智能指標在撰寫代碼的時候,總是感覺跟我們的目標擦肩而過,那么我們可以想一想,如果使用Arc來包裝Mutex指標,然后Mutex指標再包裝一層變數,這樣我們就可以既滿足多執行緒下修改的執行緒安全,同時又能夠克隆出來多個變數的參考,共同指向同一記憶體,下面就來實作一下本文題目的最終版本,
use std::sync::{Arc, Mutex};
use std::thread;
struct Point {
x: i32,
y: i32,
}
fn main() {
let mut p = Arc::new(Mutex::new(Point { x: 1, y: 2 }));
let mut handles = vec![];
for i in 0..10 {
let pp = Arc::clone(&p);
let handle = thread::spawn(move || {
let mut p0 = pp.lock().unwrap();
println!("thread-{}::{}", i, p0.x);
p0.x += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().expect("TODO: panic message");
}
println!("total: {}", p.lock().unwrap().x);
}
正如前面分析的,
1、我們在第10行將變數p先用Mutex包裝一層,然后在外層再使用Arc智能指標包裝一層,
2、第13行,我們在回圈體內,子執行緒外,給變數p克隆出一個pp,
3、第15行,我們使用pp.lock().unwrap()得到Mutex包裝的變數值,
4、后面就是對于p0在子執行緒中的操作,
最后列印出來p的x坐標,執行結果:
thread-0::1
thread-1::2
thread-4::3
thread-3::4
thread-2::5
thread-5::6
thread-6::7
thread-7::8
thread-8::9
thread-9::10
total: 11
共享變數p的x坐標值被10個執行緒所修改,每個執行緒都對其進行了加1操作,最終該共享變數p的x坐標變為了11,結果符合預期,
11、后記
Rust語言在完成多執行緒修改共享變數這件事上面,撰寫難度是遠大于java的,但Rust版本一旦執行成功,它的穩定性是要遠高于java,目前為止,還沒有出現過運行一段時間后記憶體溢位、指標例外等java版本常見的錯誤,這其實就突出了Rust語言的編程思想,它是希望各種編碼語法以及類別庫的配合,將錯誤例外封殺在編碼階段,通過復雜的撰寫方式來換取安全優質的執行環境,
語言的本質是對作業系統應用的更優策略,
本篇還有很多瑕疵,例如java實作的版本沒有鎖的控制,后面會單獨出java多執行緒精進的博文,例如Rust更多更豐富的語法沒有被覆寫到,
參考資料
- Rust官方檔案英文版
- Rust官方檔案中文版
- 《Rust基礎入門到應用》
更多文章請轉到一面千人的博客園
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/485575.html
標籤:其他
