最強肉坦:RUST多執行緒

2022-06-02 06:00:32

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
Running target/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 because p has type Point, which does not implement the Copy trait
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更多更豐富的語法沒有被覆蓋到。

參考資料

更多文章請轉到一面千人的部落格園