Rust入坑指南:齊頭並進(上)

我們知道,如今CPU的計算能力已經非常強大,其速度比內存要高出許多個數量級。爲了充分利用CPU資源,多數編程語言都提供了併發編程的能力,Rust也不例外。

聊到併發,就離不開多進程和多線程這兩個概念。其中,進程是資源分配的最小單位,而線程是程序運行的最小單位。線程必須依託於進程,多個線程之間是共享進程的內存空間的。進程間的切換複雜,CPU利用率低等缺點讓我們在做併發編程時更加傾向於使用多線程的方式。

當然,多線程也有缺點。其一是程序運行順序不能確定,因爲這是由內核來控制的,其二就是多線程編程對開發者要求比較高,如果不充分了解多線程機制的話,寫出的程序就非常容易出Bug。

多線程編程的主要難點在於如何保證線程安全。什麼是線程安全呢?因爲多個線程之間是共享內存空間的,因此就會存在同時對相同的內存進行寫操作,那就會出現寫入數據互相覆蓋的問題。如果多個線程對內存只有讀操作,沒有任何寫操作,那麼也就不會存在安全問題,我們可以稱之爲線程安全。

常見的併發安全問題有競態條件數據競爭兩種,競態條件是指多個線程對相同的內存區域(我們稱之爲臨界區)進行了“讀取-修改-寫入”這樣的操作。而數據競爭則是指一個線程寫一個變量,而另一個線程需要讀這個變量,此時兩者就是數據競爭的關係。這麼說可能不太容易理解,不過不要緊,待會兒我會舉兩個具體的例子幫助大家理解。不過在此之前,我想先介紹一下Rust中是如何進行併發編程的。

管理線程

在Rust標準庫中,提供了兩個包來進行多線程編程:

  • std::thread,定義一些管理線程的函數和一些底層同步原語
  • std::sync,定義了鎖、Channel、條件變量和屏障

我們使用std::thread中的spawn函數來創建線程,它的使用非常簡單,其參數是一個閉包,傳入創建的線程需要執行的程序。

use std::thread;
use std::time::Duration;

fn main() {
    thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {} from the spawned thread!", i);
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {} from the main thread!", i);
        thread::sleep(Duration::from_millis(1));
    }
}

這段代碼中,我們有兩個線程,一個主線程,一個是用spawn創建出來的線程,兩個線程都執行了一個循環。循環中打印了一句話,然後讓線程休眠1毫秒。它的執行結果是這樣的:

在這裏插入圖片描述

從結果中我們能看出兩件事:第一,兩個線程是交替執行的,但是並沒有嚴格的順序,第二,當主線程結束時,它並沒有等子線程運行完。

那我們有沒有辦法讓主線程等子線程執行結束呢?答案當然是有的。Rust中提供了join函數來解決這個問題。

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {} from the spawned thread!", i);
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {} from the main thread!", i);
        thread::sleep(Duration::from_millis(1));
    }

    handle.join().unwrap();
}

這樣主線程就必須要等待子線程執行完畢。

在某些情況下,我們需要將一些變量在線程間進行傳遞,正常來講,閉包需要捕獲變量的引用,這裏就涉及到了生命週期問題,而子線程的閉包的存活週期有可能長於當前的函數,這樣就會造成懸垂指針,這在Rust中是絕對不允許的。因此我們需要使用move關鍵字將所有權轉移到閉包中。

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        println!("Here's a vector: {:?}", v);
    });

    handle.join().unwrap();
}

使用thread::spawn創建線程是不是非常簡單。但是也是因爲它的簡單,所以可能無法滿足我們一些定製化的需求。例如制定線程的棧大小,線程名稱等。這時我們可以使用thread::Builder來創建線程。

use std::thread::{Builder, current};

fn main() {
    let mut v = vec![];
    for id in 0..5 {
        let thread_name = format!("child-{}", id);
        let size: usize = 3 * 1024;
        let builder = Builder::new().name(thread_name).stack_size(size);
        let child = builder.spawn(move || {
            println!("in child:{}", current().name().unwrap());
        }).unwrap();
        v.push(child);
    }

    for child in v {
        child.join().unwrap_or_default();
    }
}

我們使用thread::spawn創建的線程返回的類型是JoinHandle<T>,而使用builder.spawn返回的是Result<JoinHandle<T>>,因此這裏需要加上unwrap方法。

除了剛纔提到了這些函數和結構體,std::thread還提供了一些底層同步原語,包括park、unpark和yield_now函數。其中park提供了阻塞線程的能力,unpark用來恢復被阻塞的線程。yield_now函數則可以讓線程放棄時間片,讓給其他線程執行。

Send和Sync

聊完了線程管理,我們再回到線程安全的話題,Rust提供的這些線程管理工具看起來和其他沒有什麼區別,那Rust又是如何保證線程安全的呢?

祕密就在SendSync這兩個trait中。它們的作用是:

  • Send:實現Send的類型可以安全的在線程間傳遞所有權。
  • Sync:實現Sync的類型可以安全的在線程間傳遞不可變借用。

現在我們可以看一下spawn函數的源碼

#[stable(feature = "rust1", since = "1.0.0")]
pub fn spawn<F, T>(f: F) -> JoinHandle<T> where
    F: FnOnce() -> T, F: Send + 'static, T: Send + 'static
{
    Builder::new().spawn(f).expect("failed to spawn thread")
}

其參數F和返回值類型T都加上了Send + 'static限定,Send表示閉包必須實現Send,這樣纔可以在線程間傳遞。而'static表示T只能是非引用類型,因爲使用引用類型則無法保證生命週期。

Rust入坑指南:智能指針一文中,我們介紹了共享所有權的指針Rc<T>,但在多線程之間共享變量時,就不能使用Rc<T>,因爲它的內部不是原子操作。不過不要緊,Rust爲我們提供了線程安全版本:Arc<T>

下面我們一起來驗證一下。

use std::thread;
use std::rc::Rc;

fn main() {
    let mut s = Rc::new("Hello".to_string());
    for _ in 0..3 {
        let mut s_clone = s.clone();
        thread::spawn(move || {
            s_clone.push_str(" world!");
        });
    }
}

這個程序會報如下錯誤

在這裏插入圖片描述

那我們把Rc替換爲Arc試一下。

use std::sync::Arc;
...
let mut s = Arc::new("Hello".to_string());

很遺憾,程序還是報錯。

在這裏插入圖片描述

這是因爲,Arc默認是不可變的,我們還需要提供內部可變性。這時你可能想到來RefCell,但是它也是線程不安全的。所以這裏我們需要使用Mutex<T>類型。它是Rust實現的互斥鎖。

互斥鎖

Rust中使用Mutex<T>實現互斥鎖,從而保證線程安全。如果類型T實現了Send,那麼Mutex<T>會自動實現Send和Sync。它的使用方法也比較簡單,在使用之前需要通過locktry_lock方法來獲取鎖,然後再進行操作。那麼現在我們就可以對前面的代碼進行修復了。

use std::thread;
use std::sync::{Arc, Mutex};

fn main() {
    let mut s = Arc::new(Mutex::new("Hello".to_string()));
    let mut v = vec![];
    for _ in 0..3 {
        let s_clone = s.clone();
        let child = thread::spawn(move || {
            let mut s_clone = s_clone.lock().unwrap();
            s_clone.push_str(" world!");
        });
        v.push(child);
    }

    for child in v {
        child.join().unwrap();
    }
}

讀寫鎖

介紹完了互斥鎖之後,我們再來了解一下Rust中提供的另外一種鎖——讀寫鎖RwLock<T>。互斥鎖用來獨佔線程,而讀寫鎖則可以支持多個讀線程和一個寫線程。

在使用讀寫鎖時要注意,讀鎖和寫鎖是不能同時存在的,在使用時必須要使用顯式作用域把讀鎖和寫鎖隔離開。

總結

本文我們先是介紹了Rust管理線程的兩個函數:spawnjoin。並且知道了可以使用Builder結構體定製化創建線程。然後又學習了Rust提供線程安全的兩個trait,Send和Sync。最後我們一起學習了Rust提供的兩種鎖的實現:互斥鎖和讀寫鎖。

關於Rust併發編程坑還沒有到底,接下來還有條件變量、原子類型這些坑等着我們來挖。今天就暫時歇業了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章