多線程 | Rust學(xué)習(xí)筆記

作者:謝敬偉,江湖人稱“刀哥”,20年IT老兵,數(shù)據(jù)通信網(wǎng)絡(luò)專家,電信網(wǎng)絡(luò)架構(gòu)師,目前任Netwarps開(kāi)發(fā)總監(jiān)。刀哥在操作系統(tǒng)、網(wǎng)絡(luò)編程、高并發(fā)、高吞吐、高可用性等領(lǐng)域有多年的實(shí)踐經(jīng)驗(yàn),并對(duì)網(wǎng)絡(luò)及編程等方面的新技術(shù)有濃厚的興趣。

現(xiàn)代的CPU基本都是多核結(jié)構(gòu),為了充分利用多核的能力,多線程都是繞不開(kāi)的話題。無(wú)論是同步或是異步編程,與多線程相關(guān)的問(wèn)題一直都是困難并且容易出錯(cuò)的,本質(zhì)上是因?yàn)槎嗑€程程序的復(fù)雜性,特別是競(jìng)爭(zhēng)條件的錯(cuò)誤,使得錯(cuò)誤發(fā)生具備一定的隨機(jī)性,而隨著程序的規(guī)模越來(lái)越大,解決問(wèn)題的難度也隨之越來(lái)越高。

其他語(yǔ)言的做法

C/C++將同步互斥,以及線程通信的問(wèn)題全部交給了程序員。關(guān)鍵的共享資源一般需要通過(guò)Mutex/Semaphone/CondVariable之類的同步原語(yǔ)保證安全。簡(jiǎn)單地說(shuō),就是需要加鎖。然而怎么加,在哪兒加,怎么釋放,都是程序員的自由。不加也能跑,絕大多數(shù)時(shí)候,也不會(huì)出問(wèn)題。當(dāng)程序的負(fù)載上來(lái)之后,不經(jīng)意間程序崩潰了,然后就是痛苦地尋找問(wèn)題的過(guò)程。

Go提供了通過(guò)channel的消息機(jī)制來(lái)規(guī)范化協(xié)程之間的通信,但是對(duì)于共享資源,做法與C/C++沒(méi)有什么不同。當(dāng)然,遇到的問(wèn)題也是類似。

Rust 做法

Go類似,Rust 也提出了channel機(jī)制用于線程之間的通信。因?yàn)?code>Rust 所有權(quán)的關(guān)系,無(wú)法同時(shí)持有多個(gè)可變引用,因此channel被分成了rxtx兩部分,使用起來(lái)沒(méi)有Go的那么直觀和順手。事實(shí)上,channel的內(nèi)部實(shí)現(xiàn)也是使用原子操作、同步原語(yǔ)對(duì)于共享資源的封裝。所以,問(wèn)題的根源依然在于Rust如何操作共享資源。
?
Rust 通過(guò)所有權(quán)以及Type系統(tǒng)給出了解決問(wèn)題的一個(gè)不同的思路,共享資源的同步與互斥不再是程序員的選項(xiàng),Rust代碼中同步及互斥相關(guān)的并發(fā)錯(cuò)誤都是編譯時(shí)錯(cuò)誤,強(qiáng)迫程序員在開(kāi)發(fā)時(shí)就寫(xiě)出正確的代碼,這樣遠(yuǎn)遠(yuǎn)好過(guò)面對(duì)在生產(chǎn)環(huán)境中頂著壓力排查問(wèn)題的窘境。我們來(lái)看一看這一切是如何做到的。

Send,Sync 究竟是什么

Rust語(yǔ)言層面通過(guò) std::marker 提供了 SendSync 兩個(gè)Trait。一般地說(shuō)法,Send標(biāo)記表明類型的所有權(quán)可以在線程間傳遞,Sync標(biāo)記表明一個(gè)實(shí)現(xiàn)了Sync 的類型可以安全地在多個(gè)線程中擁有其值的引用。這段話很費(fèi)解,為了更好地理解SendSync,需要看一看這兩個(gè)約束究竟是怎樣被使用的。以下是標(biāo)準(zhǔn)庫(kù)中std::thread::spawn()的實(shí)現(xiàn):

    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) }
    }

可以看到,創(chuàng)建一個(gè)線程,需要提供一個(gè)閉包,而這個(gè)閉包的約束是 Send ,也就是需要能轉(zhuǎn)移到線程中,閉包返回值T的約束也是 Send(這個(gè)不難理解,線程運(yùn)行后返回值需要轉(zhuǎn)移回去) 。舉例說(shuō)明,以下代碼無(wú)法通過(guò)編譯。

    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。原因在于,閉包的實(shí)現(xiàn)在內(nèi)部是由編譯器創(chuàng)建一個(gè)匿名結(jié)構(gòu),將捕獲的變量存入此結(jié)構(gòu)。以上代碼閉包大致被翻譯成:

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

Rc<T>是不支持 Send 的數(shù)據(jù)類型,因此該匿名結(jié)構(gòu),即這個(gè)閉包,也不支持 Send ,無(wú)法滿足std::thread::spawn()關(guān)于F的約束。

上面代碼改用Arc<T>,則編譯通過(guò),因?yàn)?code>Arc<T>是一種支持 Send的數(shù)據(jù)類型。但是Arc<T>不允許共享可變引用,如果想實(shí)現(xiàn)多線程之間修改共享資源,則需要使用Mutex<T>來(lái)包裹數(shù)據(jù)。代碼會(huì)改為這個(gè)樣子:

    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>可以做到這一點(diǎn),能否改用RefCell<T>完成相同功能?答案是否定的。我們來(lái)看一下這幾個(gè)數(shù)據(jù)類型的限定:

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,當(dāng)其包裹的T同時(shí)支持SendSync。很明顯Arc<RefCell<T>>不滿足此條件,因?yàn)?code>RefCell<T>不支持Sync。而Mutex<T>在其包裹的T支持Send的前提下,滿足同時(shí)支持SendSync。實(shí)際上,Mutex<T>的作用就是將一個(gè)支持Send的普通數(shù)據(jù)結(jié)構(gòu)轉(zhuǎn)化為支持Sync,進(jìn)而可以通過(guò)Arc<T>傳入線程中。我們知道,多線程下訪問(wèn)共享資源需要加鎖,所以Mutex::lock()正是這樣一個(gè)操作,lock()之后便獲取到內(nèi)部數(shù)據(jù)的可變引用。
?
通過(guò)上述分析,我們看到Rust另辟蹊徑,利用所有權(quán)以及Type系統(tǒng)在編譯時(shí)刻解決了多線程共享資源的問(wèn)題,的確是一個(gè)巧妙的設(shè)計(jì)。

異步代碼,協(xié)程

異步代碼同步互斥問(wèn)題與同步多線程代碼沒(méi)有本質(zhì)不同。異步運(yùn)行庫(kù)一般提供類似于std::thread::spawn()的方式來(lái)創(chuàng)建協(xié)程/任務(wù),以下是async-std創(chuàng)建一個(gè)協(xié)程/任務(wù)的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約束。這意味著參數(shù)future必須可以Send。我們知道,async語(yǔ)法通過(guò)generaror生成了一個(gè)狀態(tài)機(jī)驅(qū)動(dòng)的Future,而generaror與閉包類似,捕獲變量,放入一個(gè)匿名數(shù)據(jù)結(jié)構(gòu)。所以這里變量必須也是Send才能滿足FutureSend約束條件。試圖轉(zhuǎn)移一個(gè)Rc<T>進(jìn)入async block依然會(huì)被編譯器拒絕。以下代碼無(wú)法通過(guò)編譯:

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

此外,在異步代碼中,原則上應(yīng)當(dāng)避免使用同步的操作從而影響異步代碼的運(yùn)行效率。試想一下,如果Future中調(diào)用了std::mutex::lock,則當(dāng)前線程被掛起,Executor將不再有機(jī)會(huì)執(zhí)行其他任務(wù)。為此,異步運(yùn)行庫(kù)一般提供了類似于標(biāo)準(zhǔn)庫(kù)的各種同步原語(yǔ)。這些同步原語(yǔ)不會(huì)掛起線程,而是當(dāng)無(wú)法獲取資源時(shí)返回Poll::PendingExecutor將當(dāng)前任務(wù)掛起,執(zhí)行其他任務(wù)。

完美了么?死鎖問(wèn)題

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


深圳星鏈網(wǎng)科科技有限公司(Netwarps),專注于互聯(lián)網(wǎng)安全存儲(chǔ)領(lǐng)域技術(shù)的研發(fā)與應(yīng)用,是先進(jìn)的安全存儲(chǔ)基礎(chǔ)設(shè)施提供商,主要產(chǎn)品有去中心化文件系統(tǒng)(DFS)、區(qū)塊鏈基礎(chǔ)平臺(tái)(SNC)、區(qū)塊鏈操作系統(tǒng)(BOS)。
微信公眾號(hào):Netwarps

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容