多線程 | Rust學習筆記

作者:謝敬偉,江湖人稱“刀哥”,20年IT老兵,數據通信網絡專家,電信網絡架構師,目前任Netwarps開發總監。刀哥在操作系統、網絡編程、高并發、高吞吐、高可用性等領域有多年的實踐經驗,并對網絡及編程等方面的新技術有濃厚的興趣。

現代的CPU基本都是多核結構,為了充分利用多核的能力,多線程都是繞不開的話題。無論是同步或是異步編程,與多線程相關的問題一直都是困難并且容易出錯的,本質上是因為多線程程序的復雜性,特別是競爭條件的錯誤,使得錯誤發生具備一定的隨機性,而隨著程序的規模越來越大,解決問題的難度也隨之越來越高。

其他語言的做法

C/C++將同步互斥,以及線程通信的問題全部交給了程序員。關鍵的共享資源一般需要通過Mutex/Semaphone/CondVariable之類的同步原語保證安全。簡單地說,就是需要加鎖。然而怎么加,在哪兒加,怎么釋放,都是程序員的自由。不加也能跑,絕大多數時候,也不會出問題。當程序的負載上來之后,不經意間程序崩潰了,然后就是痛苦地尋找問題的過程。

Go提供了通過channel的消息機制來規范化協程之間的通信,但是對于共享資源,做法與C/C++沒有什么不同。當然,遇到的問題也是類似。

Rust 做法

Go類似,Rust 也提出了channel機制用于線程之間的通信。因為Rust 所有權的關系,無法同時持有多個可變引用,因此channel被分成了rxtx兩部分,使用起來沒有Go的那么直觀和順手。事實上,channel的內部實現也是使用原子操作、同步原語對于共享資源的封裝。所以,問題的根源依然在于Rust如何操作共享資源。
?
Rust 通過所有權以及Type系統給出了解決問題的一個不同的思路,共享資源的同步與互斥不再是程序員的選項,Rust代碼中同步及互斥相關的并發錯誤都是編譯時錯誤,強迫程序員在開發時就寫出正確的代碼,這樣遠遠好過面對在生產環境中頂著壓力排查問題的窘境。我們來看一看這一切是如何做到的。

Send,Sync 究竟是什么

Rust語言層面通過 std::marker 提供了 SendSync 兩個Trait。一般地說法,Send標記表明類型的所有權可以在線程間傳遞,Sync標記表明一個實現了Sync 的類型可以安全地在多個線程中擁有其值的引用。這段話很費解,為了更好地理解SendSync,需要看一看這兩個約束究竟是怎樣被使用的。以下是標準庫中std::thread::spawn()的實現:

    pub fn spawn<F, T>(self, f: F) -> io::Result<JoinHandle<T>>
    where
        F: FnOnce() -> T,
        F: Send + 'static,
        T: Send + 'static,
    {
        unsafe { self.spawn_unchecked(f) }
    }

可以看到,創建一個線程,需要提供一個閉包,而這個閉包的約束是 Send ,也就是需要能轉移到線程中,閉包返回值T的約束也是 Send(這個不難理解,線程運行后返回值需要轉移回去) 。舉例說明,以下代碼無法通過編譯。

    let a = Rc::new(100);
    let h = thread::spawn(move|| {
        let b = *a+1;

    });

    h.join();

編譯器指出,std::rc::Rc<i32> cannot be sent between threads safely。原因在于,閉包的實現在內部是由編譯器創建一個匿名結構,將捕獲的變量存入此結構。以上代碼閉包大致被翻譯成:

struct {
    a: Rc::new(100),
    ...
}

Rc<T>是不支持 Send 的數據類型,因此該匿名結構,即這個閉包,也不支持 Send ,無法滿足std::thread::spawn()關于F的約束。

上面代碼改用Arc<T>,則編譯通過,因為Arc<T>是一種支持 Send的數據類型。但是Arc<T>不允許共享可變引用,如果想實現多線程之間修改共享資源,則需要使用Mutex<T>來包裹數據。代碼會改為這個樣子:

    let mut a = Arc::new(Mutex::new(100));
    let h = thread::spawn(move|| {
        let mut shared = a.lock().unwrap();
        *shared = 101;

    });
    h.join();

為什么Mutex<T>可以做到這一點,能否改用RefCell<T>完成相同功能?答案是否定的。我們來看一下這幾個數據類型的限定:

unsafe impl<T: ?Sized + Sync + Send> Send for Arc<T> {}
unsafe impl<T: ?Sized + Sync + Send> Sync for Arc<T> {}

unsafe impl<T: ?Sized> Send for RefCell<T> where T: Send {}
impl<T: ?Sized> !Sync for RefCell<T> {}

unsafe impl<T: ?Sized + Send> Send for Mutex<T> {}
unsafe impl<T: ?Sized + Send> Sync for Mutex<T> {}

Arc<T>可以Send,當其包裹的T同時支持SendSync。很明顯Arc<RefCell<T>>不滿足此條件,因為RefCell<T>不支持Sync。而Mutex<T>在其包裹的T支持Send的前提下,滿足同時支持SendSync。實際上,Mutex<T>的作用就是將一個支持Send的普通數據結構轉化為支持Sync,進而可以通過Arc<T>傳入線程中。我們知道,多線程下訪問共享資源需要加鎖,所以Mutex::lock()正是這樣一個操作,lock()之后便獲取到內部數據的可變引用。
?
通過上述分析,我們看到Rust另辟蹊徑,利用所有權以及Type系統在編譯時刻解決了多線程共享資源的問題,的確是一個巧妙的設計。

異步代碼,協程

異步代碼同步互斥問題與同步多線程代碼沒有本質不同。異步運行庫一般提供類似于std::thread::spawn()的方式來創建協程/任務,以下是async-std創建一個協程/任務的API

pub fn spawn<F, T>(future: F) -> JoinHandle<T>
where
    F: Future<Output = T> + Send + 'static,
    T: Send + 'static,
{
    Builder::new().spawn(future).expect("cannot spawn task")
}

可以看到,與std::thread::spawn()非常相似,閉包換成了Future,而Future要求Send約束。這意味著參數future必須可以Send。我們知道,async語法通過generaror生成了一個狀態機驅動的Future,而generaror與閉包類似,捕獲變量,放入一個匿名數據結構。所以這里變量必須也是Send才能滿足FutureSend約束條件。試圖轉移一個Rc<T>進入async block依然會被編譯器拒絕。以下代碼無法通過編譯:

    let a = Rc::new(100);
    let h = task::spawn(async move {
        let b = a;
    });

此外,在異步代碼中,原則上應當避免使用同步的操作從而影響異步代碼的運行效率。試想一下,如果Future中調用了std::mutex::lock,則當前線程被掛起,Executor將不再有機會執行其他任務。為此,異步運行庫一般提供了類似于標準庫的各種同步原語。這些同步原語不會掛起線程,而是當無法獲取資源時返回Poll::PendingExecutor將當前任務掛起,執行其他任務。

完美了么?死鎖問題

Rust雖然用一種優雅的方式解決了多線程同步互斥的問題,但這并不能解決程序的邏輯錯誤。因此,多線程程序最令人頭痛的死鎖問題依然會存在于Rust的代碼中。所以說,所謂Rust“無懼并發”是有前提的。至少在目前,看不到編譯器可以智能到分析并解決人類邏輯錯誤的水平。當然,屆時程序員這個崗位應該也就不存在了...


深圳星鏈網科科技有限公司(Netwarps),專注于互聯網安全存儲領域技術的研發與應用,是先進的安全存儲基礎設施提供商,主要產品有去中心化文件系統(DFS)、區塊鏈基礎平臺(SNC)、區塊鏈操作系統(BOS)。
微信公眾號:Netwarps

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。