最強肉坦:RUST多線程

来源:https://www.cnblogs.com/Evsward/archive/2022/06/01/rust-one.html
-Advertisement-
Play Games

近幾年國內外聲名鵲起的Rust編程語言,聲名遠播,影響力巨大,到底是什麼讓它如此強大?本文適合作為一篇初級入門的文章。本文的優勢是通過一個常見的例子作為線索,引出Rust的一些重要理念或者說特性,通過這些特性深刻體會Rust的魅力。 ...


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更多更豐富的語法沒有被覆蓋到。

參考資料

更多文章請轉到一面千人的博客園


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 國際知名調研機構沙利文聯合頭豹研究院發佈了《2021年中國分散式資料庫市場報告》,報告顯示:**騰訊雲資料庫入選Frost Radar (弗若斯特雷達)領導者象限,增長指數排名第一。**為幫助廣大資料庫愛好者進一步瞭解報告內容,洞察資料庫發展趨勢,騰訊雲資料庫邀請沙利文中國高級分析師胡竣傑解讀最新報 ...
  • 相關示例代碼 WITH sqlt AS (SELECT a.PlaceCode, b.AcupointName FROM ITEM_Place_Correlation a LEFT JOIN ITEM_Acupoint b ON a.AcupointCode = b.AcupointCode AND ...
  • 事務隔離級別 事務併發可能出現的問題 臟寫 事務之間對增刪改互相影響 臟讀 事務之間讀取其他未提交事務的數據 不可重覆讀 一個事務在多次執行一個select讀到的數據前後不相同。因為被別的未提交事務修改,刪除數據或數據被更新被當前事務讀取到了。 幻讀 一個事務在第一次讀取正常數據,第二次讀取到其他未 ...
  • undo日誌 前面學習了redo日誌,redo日誌保證的是崩潰時事務持久性。我們可以從redo日誌恢復到系統崩潰以前。 undo日誌就是為了保證事務回滾時事務所作所為都能回到事務執行前。保證了事務的原子性。redo把我們做增刪改之前的狀態記錄下來,幫助MySQL回滾到事務執行之前的樣子。 這篇文章了 ...
  • 隨著短視頻時代的到來,音視頻剪輯應用不斷增加,市場競爭愈發激烈,如何為用戶提供差異化剪輯功能和優質的音頻處理體驗,已成為行業新的挑戰。 “音頻音樂剪輯“是武漢網冪科技開發的一款手機音頻剪輯應用,支持音樂剪輯、音頻提取、伴奏人聲提取、格式轉換、手機鈴聲製作、拼接、變速、混音、錄音、降噪等功能。為了給用 ...
  • 該項目是學習Nest.js框架所得,前端基於Vue.js + Vuex + VueRouter + ElementUI + SCSS,後端基於Node.js + TypeScript + Nest.js + MySQL + TypeORM。 ...
  • 思路:通過註冊表註冊自定義URL協議執行bat腳本,將文件路徑作為參數傳入 環境:win10 前置問題與條件 問題1:可以從瀏覽器直接打開可執行文件嗎? 答:不能。其實可以通過 ActiveXObject 實現軟體直接打開,但是它是不安全的,並且現在被大多數現代瀏覽器禁止,只能在 ie 使用。而通過 ...
  • 簡單介紹一下,我的 web 前端開發技術選擇。我更偏向於使用 jQuery 及其插件、CSS3、HTML5。不喜歡 mvvm 之類的技術。 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...