面向非CC++開發者的Rust併發

馬克西姆-扎韋爾辛斯基

12分鐘閱讀

最低限度。

大多數來到Rust的人都有C/C++的背景,這使得他們可以輕鬆地過渡到Rust的並行性,因為它是如此相似。然而,對於許多來自其他語言的人來說,這是一個挑戰。在這篇文章中,我們將介紹標準的Rust並行工具,以及它們背後的動機。這需要在開始時對硬體進行深入研究,然後解釋低階工具,如atomics,最後解釋高階工具,如Mutex。最後,我們將解釋Rust如何保證多執行緒應用的安全。

在Rust中,當你聽到人們談論並行性和併發性時,主要是指框架,因為Rust作為一種語言並不贊成任何特定的並行性或併發性抽象,而是提供最低限度的,如標準執行緒和幾個同步原語。這種最低限度的東西就是我們在這篇文章中要探討的。

為什麼併發是困難的

首先,我們需要了解為什麼並行化很難,原因是硬體、作業系統和編譯器都太複雜。由於1970年的處理器核心並不直接與記憶體一起工作,而是使用複雜的快取和寫緩衝器的層次結構。

面向非C/C++開發者的Rust併發

摘自《原子<>武器》。

我們甚至不需要回顧整個層次結構就能理解為什麼它很難。讓我們刪除所有的快取,只考慮寫緩衝區。寫入緩衝區對於處理器的效能來說是絕對必要的,因為向記憶體的寫入是很昂貴的,我們希望儘可能地批次寫入。

面向非C/C++開發者的Rust併發

摘自《原子<>武器》。

考慮以下程式,我們在兩個核心上執行。該程式有一個關鍵部分,我們不希望它同時在兩個核心上執行。確保這一點的方法之一是使用intent標誌。intent標誌被核心用來宣告它們進入關鍵部分的意圖。從邏輯上講,如果其中一個核心已經進入了關鍵部分,那麼他們的intent標誌就是非零的,另一個核心就不會進入。然而,如果兩個核心都寫了它們的intent標誌而沒有Flush緩衝區,那麼它們都將進入臨界區,因為它們將從記憶體中讀取意圖標誌的0值。

另一種思考問題的方式是認為寫緩衝區透過在flag1 = 1之前執行flag2 != 0來

重新安排操作的順序

。同樣的,我們可以認為快取也重新安排了操作的順序。

操作也會被進行最佳化的編譯器重新排序,比如子表示式的消除,以及被進行預取(

prefetching

)和推測(

speculation

)的處理器重新排序,以及其他事情。因此,原始碼中的操作順序將與特定核心執行的順序不同。事實上,同樣的程式碼在兩個獨立的核心上並行執行時,會有不同的操作順序。

如果我們不使用執行在不同核心上的執行緒來相互協作,那麼操作的順序就不會成為問題。協作的執行緒要求我們論證,操作X發生線上程A上,然後才是執行緒B上的操作Y,就像上面的例子。多執行緒要求我們能夠談論跨執行緒操作之間的因果關係。如果沒有特殊的工具,那是不可能的。

低級別的原語

原子是低級別的同步原語,它允許我們透過限制操作的順序來獲得因果關係。這些原語需要是處理器級的,因為除了限制編譯器之外,我們還想限制處理器的快取級重排和其他事情。原語給出了兩個保證。

我們可以對它們進行讀/寫操作,而不必擔心讀或寫的分裂。

原子操作對它們的執行順序有一定的保證,相對於其他操作,甚至跨執行緒執行。事實上,原子操作甚至強制執行非原子操作的順序,我們將在後面看到。

對原子變數的每個操作都需要有一個排序型別。

Ordering::Relaxed

Ordering::Acquire

Ordering::Release

(或它們的聯合替代品

Ordering::AcqRel

Ordering::SeqCst

- 順序一致性的簡稱

你幾乎總是會使用SeqCst,它應用了最強的約束,也是最容易推理的。Relaxed應用了最弱的約束,而且非常不直觀,所以除非你在開發低級別的高效能程式碼,否則應該遠離它。就認知的複雜性而言,Acquire/Release是一箇中間地帶,但你仍然幾乎不會喜歡它而不是SeqCst。然而,理解Acquire/Release對於理解Mutex和RwLock等高階同步原語非常有幫助。

Acquire/Release

正如我們之前所說,

Acquire

/

Release

是硬體級的操作,因為它們為硬體生成了特殊指令。我們可以透過以下方式使用

Acquire

/

Release

let x = AtomicUsize::new(0);let mut result = x。load(Ordering::Acquire);result += 1;x。store(result, Ordering::Release); // The value is now 1。

Acquire只能用於載入操作,而Release只能用於儲存操作。Acquire和Release有以下規則。

Acquire——程式碼中所有發生在它之後的記憶體訪問都留在它之後,所有執行緒都可以看到(記住,執行緒B和C可以感知到執行緒A對記憶體的操作順序是不同的)。

Release——程式碼中發生在它之前的所有記憶體訪問都留在它之前,為所有執行緒所看到。

所以在下面的情況下,如果執行緒A執行左邊的程式碼,執行緒B和C可以在裡面對a,b,c的所有操作進行交換,見下文。然而,他們不能看到c = “Bye ”發生在Acquire之前。

面向非C/C++開發者的Rust併發

取自Atomics <> Weapons

透過Acquire/Release,我們可以建立執行緒之間的因果關係。例如,在下面的程式碼中,我們可以假設,如果b為真,那麼a一定也被設定為真。

let x = Arc::new(AtomicBool::new(false));let y = Arc::new(AtomicBool::new(false));{ let x = x。clone(); let y = y。clone(); thread::spawn(move || { x。store(true, Ordering::Release); y。store(true, Ordering::Release); });}{ let x = x。clone(); let y = y。clone(); thread::spawn(move || { let b = y。load(Ordering::Acquire); let a = x。load(Ordering::Acquire); if b { assert!(a); } });}

使用原子化的 Acquire/Release,我們可以實現一個全功能的自旋鎖,保護程式碼的某個區域不被幾個執行緒同時訪問。

while(locked。compare_exchange(false, true, Ordering::Acquire, Ordering::Acquire)) {}// Do important stuff that only one thread can execute at a time。locked。store(false, Ordering::Release);

注意,除了上面的限制,Acquire/Release操作還有一個比較隱晦的規則,可以防止像上面那樣的自旋鎖的干擾。Rust從C11記憶體模型中繼承了它。

順序的一致性

不幸的是,在許多情況下,獲取/釋放仍然是難以爭辯的。考慮一下下面的程式碼。

面向非C/C++開發者的Rust併發

摘自《原子<>武器》。

在這段程式碼中,兩個訊息都有可能被打印出來,這意味著執行緒

C

D

對哪個事件先發生有不一致的看法。換句話說,有了Acquire/Release,就沒有了全域性的操作順序。Acquire/Release只是創造了橫向因果關係,SeqCst則建立了一個全域性的操作順序。如果我們用SeqCst代替上述Acquire/Release,那麼最多隻能列印一條資訊。更正式地說,SeqCst遵循以下規則。

所有發生在SeqCst操作之前/之後的原子操作在所有執行緒上都保持在它之前/之後。普通的非原子讀和寫可以在一個原子讀中向下移動,或者在一個原子寫中向上移動。

在Rust中,SeqCst涉及發射一個記憶體barrier(不要與記憶體fence混淆),以防止不良的重新排序。不幸的是,SeqCst比純粹的Acquire/Release更昂貴,然而,從全域性來看,它仍然可以忽略不計,因此,強烈建議儘可能使用SeqCst。

高階原語

在上面的程式碼中,我們使用了執行緒而沒有解釋它們是什麼。一般來說,有三種東西被人們稱為執行緒。

硬體執行緒,又稱超執行緒。

作業系統執行緒。

綠色執行緒 Green Thread。

超執行緒是指處理器實際上將每個物理核心分割成兩個虛擬核心,從而實現更好的負載分配。作業系統執行緒是由作業系統內部建立和管理的,每個執行緒都執行自己的程式碼,它們在虛擬核心上輪流執行。大多數作業系統使執行緒的數量實際上是無限的,不幸的是,啟動它們是昂貴的,因為它需要分配一個堆疊。綠色執行緒是由使用者軟體實現的,它們在作業系統執行緒的基礎上執行。綠色執行緒的優點是:它們甚至可以在沒有作業系統執行緒支援的環境中工作;它們的啟動速度比普通執行緒快得多。

不幸的是,Rust已經刪除了綠色執行緒,現在只允許裸露的作業系統執行緒。這樣做是因為綠色執行緒不是一個零成本的抽象,而這是Rust區別於其他語言的一個基本規則。

零成本的抽象概念。你不使用的東西,你不需要付錢。

更進一步。你使用的東西,你不可能用手寫程式碼來寫得更好。

- Bjarne Stroustrup

綠色執行緒需要有一個沉重的執行時間,每個程式都必須為此付費,即使它不使用它們。

然而,如果知道如何正確使用作業系統執行緒,就不會那麼昂貴。考慮一下前面的自旋鎖的例子。在等待鎖被釋放的過程中,該迴圈將燃燒CPU。我們可以透過使用 yield_now 來解決這個問題。

while(locked。compare_exchange(false, true, Ordering::Acquire, Ordering::Acquire) { std::thread::yield_now();}// Do important stuff。locked。store(false, Ordering::Release);

由於作業系統的執行緒可能多於虛擬核心,可能還有另一個執行緒在等待被安排。 yield_now告訴作業系統,它可以嘗試在該虛擬核心上執行另一個執行緒,而第一個執行緒在等待其鎖。

Mutex和RwLock

在上一節中,我們談到了atomics,它是在硬體層面上執行的低階同步原語。Mutex和RwLock是高層次的同步原語,它們在作業系統層面上執行。在這篇文章中,我們將不涉及Channel、CondVar和Barrier,因為我們提供了足夠的背景,能夠從它們的文件中瞭解到它們。

Mutex和RwLock類似於我們之前看過的spinlock,但有一個主要區別——spinlock會消耗CPU來等待鎖,而Mutex和RwLock則是釋放當前的作業系統執行緒,並不消耗CPU。因此,它們必須在作業系統層面而不是純硬體層面進行操作,類似於我們對產生執行緒的自旋鎖的修改。然而,產生自旋鎖和Mutex之間的主要區別是,在Mutex中,一旦鎖被釋放,作業系統知道何時喚醒等待的執行緒,而在產生自旋鎖中,作業系統將零星地喚醒等待的執行緒,希望鎖被釋放。另外,Mutex的實現是針對平臺的。

RwLocks與Mutexes類似,因為它們保護程式碼的某個區域不被同時訪問,但也有一些權衡。

Mutex鎖定了程式碼的讀和寫,而RwLock允許併發的讀,只要沒有寫,模仿借用檢查器。

Mutex是一個

同步製造者

。在下一節中,我們將看到什麼是傳送和同步的特徵,並將重新審視Mutex和RwLock。

透過Send和Sync實現執行緒安全

在上一篇文章中,我們看到了Rust是如何透過借用規則和生命期提供單執行緒安全的。Send和Sync特性將這種安全性擴充套件到多執行緒應用程式中。

關於Rust安全,最重要的一點是,它只能防止資料競爭,而不能防止其他。資料競爭發生在一個執行緒向記憶體區域寫東西的同時,另一個執行緒從該區域讀出或寫進該區域,這就導致了讀和寫的分裂。

資料競爭是特別討厭的,因為它們會導致未定義的行為。然而,它們的原因是明確的,因此可以自動檢測或在語言層面上防止,就像Rust那樣。

另一方面,競爭條件是語義錯誤。例如,我們可以錯誤地認為一個事件總是在另一個事件之前發生。競爭條件破壞了領域邏輯的不變性,通常是不正確的同步或缺乏同步的標誌。Rust不能使我們免於犯語義層面的錯誤。事實上,設計這樣一種語言是不可行的。死鎖和活鎖也是語義上的錯誤,是領域邏輯不變數被破壞的結果,例如,我們假設鎖A總是在持有鎖B時發生,但在我們程式碼的某個地方,我們以相反的方式實現了它,造成了死鎖。因此,我們唯一要討論的是Rust如何防止資料競爭。

Send和Sync是為了防止資料競爭。Send和Sync是

自動派生的

不安全的

標記性的特徵

自動特質

不是由工程師明確實現的,而是由編譯器自動得出的。傳送特質標誌著可以線上程之間安全傳送的結構,同步特質標誌著可以線上程之間安全共享的結構。如果一個結構的所有欄位都是Send/Sync,編譯器就會決定該結構是Send/Sync。

不安全特性

需要不安全關鍵字來實現。

標記性特徵

沒有方法,僅用於表達實現這些特徵的結構的某些屬性;例如,Eq特徵是標記性特徵的另一個例子。Eq告訴我們,一個已經實現了平等操作的結構可以被當作是反射性、對稱性和傳遞性操作來使用。

大多數原語都是Send/Sync的,因此幾乎所有的型別都是Send/Sync的,除了Rc、Cell和RefCell。Rc、Cell和RefCell不是同步的,因為它們實現了內部可變性,這意味著對它們的操作,如果同時執行,會引起資料競爭。另外,Rc不是Send,因為它複製了指向相同資料的指標,所以執行緒不需要共享相同的副本就能引起資料競賽;因此Rust完全禁止跨執行緒傳送Rc。有趣的是,Cell和RefCell告訴編譯器它們不安全的方式是用UnsafeCell包裝它們的內部欄位,UnsafeCell的全部目的是為了防止自動衍生出Sync特性。Rc沒有使用UnsafeCell,而是明確地宣告自己是!“Send”和 “Sync”。

一個實現了Sync的物件的引用就是Send,反之亦然。換句話說,&T: Send意味著T: Sync,T: Sync意味著&T: Send。線上程之間傳送一個物件是很常見的,而共享則不太常見。通常,當我們想讓執行緒訪問同一個物件時,我們會把它包裝成一個智慧指標,比如Arc,這導致我們

Send

它的副本,而不是真正地共享它。要共享一個物件,我們需要共享它的引用,像這樣。

fn main() { let x = 42; thread::spawn(|| { println!(“{}”, x); })。join。unwrap();}

這在大多數情況下是行不通的,因為std::thread::spwn只接受具有靜態壽命的閉包。從堆疊中實際借用變數的唯一方法是使用第三方庫(如crossbeam)中的一個範圍執行緒。

現在,讓我們再談談Mutex與RwLock。從形式上看,它們的實現是這樣的。

impl Send for Muteximpl Sync for Mutex

impl Send for RwLockimpl Sync for RwLock

這意味著我們可以將一個只實現Send而不實現Sync的物件T包裹到Mutex中,Mutex將同時成為Send和Sync。不過RwLock不是一個

同步器

。因為幾個執行緒可以同時對底層物件進行讀取訪問,所以它應該是同步的。Mutex阻止了任何形式的同時訪問,因此我們可以認為該物件被髮送到

持有鎖的執行緒。

引用

編撰該帖時使用了以下資源。

Rustonomicon

中的併發性

章節

Without Boats的Zero-Cost Async IO講座,講述了Rust併發性的過去和未來。

Atomic <> Weapons

是Herb Sutter的一個偉大的演講,涵蓋了C++併發性的許多細枝末節。

C++ Concurrency in Actio》是Anthony Williams的一本好書,它給出了許多有用的例子,我沒能在這篇文章中包括。

安東尼-威廉姆斯(Anthony Williams)的 stackoverflow 併發回答

包含了他書中的一些內容。

最後,感謝Gail Hernandez;Alex Skidanov,Bowen Wang和我們Near Protocol團隊的其他所有人。對於那些對Near Protocol感興趣的人來說:我們建立了一個分片的通用區塊鏈,非常強調實用性。