
譯者 | 盧鑫旺
審校 | 云昭
將Rust比作C++的小弟的話,相信大家都不會有異議。Rust借鑒了許多C++的設計思想。并發特性亦是如此。
Rust標準庫的并發特性與C++ 11中的特性非常相似:線程、原子操作、鎖和互斥量、條件變量等等。然而,在過去的幾年中,隨著C++ 17和C++ 20發布,C++已經獲得了相當多新的與并發相關的特性,未來的版本還會有更多的可借鑒之處。
讓我們花點時間來回顧一下C++的并發特性,討論一下這些特性在Rust下會是什么樣子的,以及要達到這個效果需要做些什么。
atomic_ref
P0019R8引入了std::atomic_ref到C++ 中。它是一種允許你將非原子對象用作原子對象的類型。例如,你可以創建一個atomic_ref<int>,它引用一個常規的int類型的變量,這時你可以使用與原子類型atomic<int>相同的功能,就跟它是atomic<int>一樣。
在C++中,這需要一個復制大部分原子接口的全新類型,而等效的Rust特性是一行函數:atomic*::from_mut。例如,該函數允許你將&mut u32轉換為&AtomicU32,這是一種在Rust中完全正確的別名形式。C++ atomic_ref類型附帶了需要手動維護的安全要求。只要你使用atomic_ref來訪問對象,那么對該對象的所有訪問都必須通過atomic_ ref。當仍然存在atomic_ref時直接訪問它會導致未定義的行為。然而,在Rust中,這已經由借用檢查器完全處理。編譯器理解,通過可變地借用u32,在借用結束之前,不允許任何東西直接訪問該u32。進入from_mut函數的&mut u32的生命周期將作為從中得到的&AtomicU32的一部分保留。你可以根據需要復制任意數量的&AtomicU32副本,但只有在該引用的所有副本都消失后,原始借用才會結束。
from_mut函數目前不太穩定,但也許是時候穩定它了。
泛型原子類型
在C++中,std::atomic是泛型的:你可以有一個atomic<int>,也可以有atomic<myownstuct>。另一方面,在Rust中,我們只有特定的原子類型:AtomicU32、AtomicBool、AtomicUsize等。
C++的原子類型支持任何大小的對象,無論平臺是否支持。對于平臺本機原子操作不支持的大小的對象,它會自動返回到基于鎖的實現。Rust則只提供平臺本機支持的類型。如果你正在用沒有64位原子的平臺進行編譯,則AtomicU64不存在。
這有優點也有缺點。這意味著使用AtomicU64的Rust代碼可能無法在某些平臺上編譯,但也意味著當某些類型默默地返回到一個非常不同的實現時,不會出現與性能相關的意外。這也意味著我們可以假設一個AtomicU64與內存中的u64完全相同,允許使用類似AtomicU64::from_mut的函數。在Rust中使用一個泛型原子類型atomic<T>來處理任何大小的類型可能會很棘手。沒有專門化,我們無法使automic<LargeThing>包含Mutex,而不將其包含在automic<SmallThing>中。然而,我們可以做的是將互斥量存儲在一個全局HashMap中,由內存地址索引。然后,automic<T>的大小可以與T相同,并在必要時使用此全局HashMap中的互斥量。這就是流行的atomic所做的事情。在Rust標準庫中添加這樣一個通用的范型automic<T>類型的建議需要討論它是否應該在no_std程序中使用。常規哈希映射需要分配,這在no_std程序中是不可能的。固定大小的表可能適用于no_std程序,但由于各種原因可能不受歡迎。
Compare-exchange與填充
P0528R3更改了compare_exchange處理填充的方式。atomic<TypeWithPadding>上的比較交換操作也用于比較填充位,但結果證明這是一個壞主意。如今,填充位不再包括在比較中。
由于Rust目前只為整數提供原子類型,沒有任何填充,因此此更改與Rust無關。然而,使用compare_exchange方法的atomic<T>方案需要討論如何處理填充,并且可能需要從該方案中獲取輸入。
Compare-exchange內存排序
在C++11中,compare_exchange函數要求成功內存排序至少與失敗排序一樣強。不接受compare_exchange(…,…,memory_order_release,memory_ order_ acquire)。該要求被逐字復制到Rust的compare_exchange函數中。P0418R2認為應取消此限制,這是C++17的一部分。作為Rust 1.64和Rust lang/Rust#98383的一部分,解除了相同的限制。
Constexpr互斥量構造函數
C++的std::mutex有一個constexpr構造函數,這意味著它可以在編譯時作為常量求值的一部分進行構造。然而,并非所有的實現都真正提供了這一點。例如,微軟的std::mutex實現不包括constexpr構造函數。因此,依賴這一點對于可移植代碼來說是個壞主意。
另外,有趣的是,C++的std:: condition_variable和std:: shared_mutex根本不提供constexpr構造函數。在Rust 1.0中,Rust的原始互斥不包括常量fn new。再加上Rust對靜態初始化的嚴格要求,這使得在靜態變量中使用互斥非常煩人。這在Rust 1.63.0中作為Rust lang/Rust#93740的一部分得到了解決,所有:?
- Mutex:: new
- rBlock:: new
- Condvar:: new
現在都是常量函數。
Latches與barriers
P1135R6在C++20中引入了std::ltatch和std::barriers,這兩種類型都允許等待多個線程到達某一點。latch基本上只是一個計數器,它由每個線程遞減,并允許你等待它達到零。它只能使用一次。barrier是這種思想的更高級版本,可以重復使用,并接受計數器達到零時自動執行的“完成函數”。Rust從1.0開始就有了類似的barrier類型。它是受pthread(pthrea_Barrier_t)而不是C++的啟發。Rust的(和pthread的)barrier不如C++中現在包含的靈活。它只有一個“遞減和等待”操作(稱為等待),并且缺少C++的std::barrier附帶的“僅等待”、“僅遞減”和“遞減和刪除”函數。另一方面,與C++不同,Rust(和pthread)的“遞減和等待”操作將一個線程指定為組長。這是完成函數的一種(可能更靈活)替代方法。
Rust版本上缺失的操作可以在任何時候輕松添加。我們所需要的只是這些新方法的名稱的一個好建議。
信號量
同樣的,P1135R6還向C++20添加了信號量:
- std::counting_semaphore
- std::binary_semaphore
Rust沒有通用的信號量類型,盡管它確實通過thread::park和unpark為每個線程提供了有效的二進制信號量。
使用Mutex<u32>和Condvar可以輕松地手動構建信號量,但大多數操作系統允許使用單個AtomicU32實現更高效、更小的實現。例如,通過Linux上的futex()和Windows上的waitoAddress()??梢杂糜谶@些操作的原子大小取決于操作系統及其版本。C++的counting_semaphore是一個模板,它以一個整數作為參數來指示我們希望能夠計數到什么程度。例如,counting_semaphore<1000>可以計數到至少1000,因此將是16位或更大。binary_semaphore類型只是counting_Sema phore<1>的別名,在某些平臺上可以是單個字節。在Rust中,我們可能還沒有很快為這種泛型類型做好準備。Rust的泛型強制了某種一致性,這對我們可以將常量作為泛型參數進行處理帶來了一些限制。
我們可以有單獨的信號量32、信號量64等等,但這似乎有點過分了。擁有信號量<u32>和信號量<u64>甚至信號量<bool>都是可能的,但這是我們以前在標準庫中沒有做過的事情。我們的原子類型簡單地是AtomicU32、AtomicU64等等。如上所述,對于我們的原子類型,我們只提供你正在編譯的平臺本機支持的類型。如果我們將同樣的理念應用于信號量,它將不存在于沒有futex或WaitoAddress功能的平臺上,例如macOS。如果我們有不同大小的單獨信號量類型,某些大小在(某些版本的)Linux和各種BSD上是不存在的。如果我們想在Rust中使用標準信號量類型,我們首先需要一些輸入,說明我們是否確實需要不同大小的信號量,以及需要何種形式的靈活性和可移植性才能使它們有用。也許我們應該只使用一種始終可用的32位信號量類型(使用基于鎖的回退),但任何此類建議都必須包括對用例和限制的詳細解釋。
原子等待和通知
P1135R6添加到C++20的其余新功能是原子等待和通知函數。
這些函數通過標準接口有效地直接公開Linux的futex()和Windows的waitoAddress()。
然而,無論操作系統支持什么,它們都可以在所有大小的原子上、所有平臺上使用。Linux Futex(在FUTEX2之前)始終是32位的,但C++也允許atomic<uint64_t>:wait。
一種方法是使用類似于“停車場”的東西:有效地將內存地址映射到鎖和隊列的全局哈希映射。這意味著Linux上的32位等待操作可以使用非??焖俚幕趂utex的實現,而其他大小的操作將使用非常不同的實現。如果我們遵循只提供本機支持的類型和函數的理念(就像我們對原子類型所做的那樣),我們就不會提供這樣的回退實現。這意味著我們在Linux上只有AtomicU32::wait(和AtomicI32::wait),而在Windows上,所有的原子類型都包括這個wait方法。在Rust中使用Atomic*::wait和Atomic*::notify需要討論回退到全局表在Rust中是否合適。
jthread和stop_token
P0660R10將std::jthread和std::stop_token添加到了C ++20中。
如果我們暫時忽略stop_token,jthread基本上只是一個在銷毀時自動獲取join()方法的的常規std::thread。這避免了意外地分離線程并使其運行的時間比預期的長,這在常規線程中可能會發生。然而,它也引入了一個潛在的新陷阱:立即銷毀jthread對象將立即加入線程,有效地消除了任何潛在的并行性。從Rust 1.63.0開始,提供了范圍線程(Rust lang/Rust#93203)。與jthread一樣,作用域線程也會自動加入。然而,它們的連接點是明確的,并且保證安全可靠。借用檢查器甚至可以理解這一保證,允許你安全地借用作用域線程中的局部變量,只要這些變量超出作用域。除了自動加入之外,jthreads的一個主要特性是其stop_token和相應的stop_ source。可以在stop_source上調用request_stop(),使stop_ token上相應的stopUrequest()方法返回true。這可以很好地要求線程停止,并在加入之前在jthread的析構函數中自動完成。由線程的代碼來實際檢查令牌,并在設置時停止。到目前為止,它看起來幾乎像一個普通的AtomicBool。不同的是stop_callback類型。這種類型允許用停止令牌注冊回調函數,即“停止函數”。使用相應的停止源請求停止將執行此功能。實際上,線程可以使用它來讓其他線程知道如何停止或取消其工作。
在Rust中,我們可以很容易地將類似atomicboolean的功能添加到thread:: Scope的Scope對象中。簡單的is_finished(&self) -> bool或stop_requested(&self) -> bool指示主作用域函數是否已完成可能就夠了??梢越Y合request_stop(&self)方法從任何地方請求它。
stop_callback特性更加復雜,任何Rust的等價功能都可能需要詳細的提議來討論它的接口、用例和限制。
原子浮點數
P0020R6在C++ 20中增加了對原子浮點加法和減法的支持。在Rust中添加AtomicF32或AtomicF64也很容易,但吊詭的是,似乎目前原生支持原子浮點運算的平臺往往是GPU廠商,而Rust現在好像并沒有提供對這些平臺的支持。關于向Rust添加這些類型方面,強烈建議提供一些實用的用例。
字節原子內存
目前,在Rust或C++中不可能有效地實現遵循內存模型所有規則的序列鎖。
P1478R7建議在未來的C++版本中添加atomic_load_per_byte_memcpy和atomic_store_per_byte_memcpy來解決這個問題。
對于Rust,這里給出一個想法,就是可以通過AtomicPerByte<T>類型:RFC 3301來公開功能。
原子shared_ptr
P0718R2為C++20添加了atomic<shared_ptr>和atomic<weak_ptr>的專門化。
引用計數指針(C++中的shared_ptr,Rust中的Arc)通常用于并發無鎖數據結構。通過正確處理引用計數,原子<shared_ptr>專門化使正確執行此操作更加容易。
在Rust中,我們可以添加等效的AtomicArc<T>和AtomicWeak<T>類型。(雖然AtomicArc聽起來有點奇怪,但考慮到Arc的A已經代表“原子”了。)
然而,C++的shared_ptr<T>是可為空的,而在Rust中,它需要一個選項<Arc<T>。目前還不清楚AtomicArc<T>是否應該為空,或者我們是否也應該有一個AtomicOptionArc<T>。
流行的arc-swap已經在Rust中提供了所有這些變體,但據我所知,目前還沒有任何類似于標準庫的建議。
synchronized_value
盡管P0290R2沒有被接受,但提出了一種稱為synchronized_value<T>的類型,它將互斥鎖與數據類型T組合在一起。盡管它當時沒有被C++接受,但這是一個有趣的建議,因為synchronize_value<T>與Rust中的Mutex<T>幾乎完全相同。
在C++中,std::mutex不包含它保護的數據,甚至根本不知道它保護的是什么。這意味著,需要由用戶來記住哪些數據受保護以及由哪個互斥鎖保護,并確保每次訪問“受保護”數據時鎖定正確的互斥鎖。Rust的Mutex設計,使用了一個類似于(可變的)T引用的MutexGuard,這使得安全性更高,同時在只需要一個互斥鎖而不需要任何數據的情況下,仍然允許使用Mutex<()>。synchronized_value的提議試圖將此模式添加到C++中,但是使用閉包而不是互斥鎖,因為C++不跟蹤生命期。
結語
在筆者看來,C++可以繼續成為Rust的靈感來源,盡管“直接復制粘貼”的想法并不值得提倡,但好的思想還是要學習和繼承的。正如我們看到的Mutex,作用域線程,Atomic*::from_mut等,在Rust中提供相同功能的同時,事情往往會變得非常不同。
當然,提供與C++完全相同的功能不應該是主要目標。目標應該是準確地提供Rust生態系統從語言和標準庫中需要的東西,這可能與C++用戶從他們的語言中需要的東西不同。如果你有來自Rust標準庫的并發需求,而目前還沒有滿足,歡迎把它留在評論區,不管它是否已經用另一種語言解決了。
原文鏈接:
https://blog.m-ou.se/rust-cpp-concurrency/
譯者介紹
盧鑫旺,51CTO社區編輯,編程語言愛好者,對數據庫,架構,云原生有濃厚興趣,目前就職某跨境電商出海營銷公司,擔任后端開發工作。
































