您好,登錄后才能下訂單哦!
本篇內容介紹了“C++11內存模型這么理解”的有關知識,在實際案例的操作過程中,不少人都會遇到這樣的困境,接下來就讓小編帶領大家學習一下如何處理這些情況吧!希望大家仔細閱讀,能夠學有所成!
簡要的歷史回顧
最初,開發者并沒有發布一個公開的處理器內存模型規范。然而依據一組規則,弱序列化的處理器便可很好的與內存進行工作。個人認為那會開發人員肯定希望在未來的某一天引入一些新的策略(為什么在架構開發中需遵循某些規范?)。然而厄運不斷,千兆周就足以讓開發者毛躁。開發者引入多核,最終導致多線程暴增。
最初驚慌的是操作系統開發人員,因為他們不得不維護多核CPU,然而那會并不存在弱的有序架構規則。此后其它的標準委員會才陸續參與進來,隨著程序越來越并行,語言內存模型的標準化就應運而生,為多線程并發執行提供某種保障,不過現在我們有了處理器內存模型規則。最終,幾乎所有的現代處理器架構都有開放的內存模型規范。
一直以來C++就以高級語言的方式編寫底層代碼的特性而著稱,在C++內存模型的開發中自然也是不能破壞這個特性,必然賦予程序員最大的靈活性。在分析JAVA等語言的內存模型,及典型同步原語的內部結構和無鎖算法案例之后,開發人員引入了三種內存模型:
序列一致性模型
獲取/釋放語義模型
寬松的內存序列化模型(relaxed)
所有這些內存模型定義在一個C++列表中– std::memory_order,包含以下六個常量:
memory_order_seq_cst 指向序列一致性模型
memory_order_acquire, memory_order_release, memory_order_acq_rel, memory_order_consume 指向基于獲取/釋放語義的模型
memory_order_relaxed 指向寬松的內存序列化模型
開始審視這些模型之前,應先確定程序中采用何種內存模型,再一次審視原子性運算。該運算原子性的文章中已有介紹,此運算與C++11中定義的運算并沒什么兩樣。因為都基于這樣一個準則:memory_order作為原子運算的參數。其原因有二:
語義:事實上,我們說的序列化(內存柵障)是指程序執行的原子運算。位于讀/寫方法中的柵障,其神奇之處就在于跟代碼沒有關聯,其實是柵障等價存在的指令。另外,讀/寫中的柵障位置取決于架構本身。
實際應用中:英特Itanium是一種特殊的、與眾不同的架構,該架構的序列化內存方式,適用于讀寫指令和RMW運算。而舊的Itanium版本則是一種可選的指令標簽:獲取、釋放或者寬松(relaxed)。但架構中不存在單獨的獲取/釋放語義指令,僅有一個重量級的內存柵障指令。
下面是真實的原子性運算,std::atomic<T> 類的每種規范至少應包含以下方法:
void store(T, memory_order = memory_order_seq_cst);
T load(memory_order = memory_order_seq_cst) const;
T exchange(T, memory_order = memory_order_seq_cst);
bool compare_exchange_weak(T&, T, memory_order = memory_order_seq_cst);
bool compare_exchange_strong(T&, T, memory_order = memory_order_seq_cst);
獨立的內存柵障
當然,在C++11同樣也為大家提供了兩個獨立的內存柵障方法:
void atomic_thread_fence(memory_order);
void atomic_signal_fence(memory_order);
atomic_thread_fence亦可采用獨立讀寫柵障的方式運行,而后者被告知已過時。盡管memory_order序列化方法atomic_signal_fence不提供讀柵障(load/load)或者寫柵障(Strore/Store),不過atomic_signal_fence可以用于信號處理器(signal handler);作為一個規則,該方法不產生任何代碼,僅是一個編譯器柵障。(譯者注:稱之為柵欄似乎更為妥當)。
正如你看到的,缺省狀態的C++11內存模型為序列一致模型,這正是我們要討論的,不過在這之前我們先簡要聊聊編譯器柵障。
編譯器柵障
誰會重排我們寫的代碼呢?處理器可以重排代碼,另外還有編譯器。而許多啟發式開發和優化開發方法,都是基于單線程執行這樣的假設。因此,要讓編譯器明白你的代碼是多線程的,那是相當困難的。因此它需要提示–柵障。諸如此類的柵障告知編譯器“別把柵障前面的代碼和柵障后面的代碼混在一起,反之亦然”,編譯器柵障不會產生任何代碼。
MS Visual С++的編譯器柵障是一個偽方法:_ReadWriteBarrier()。(過去我一直記不住它的名字:和讀寫內存柵障相關—重量級內存柵障)而對GCC和Clang而言,它是一個smart __asm__ __volatile__ ( “” ::: ?memory? )結構。
同樣值得注意的是,assembly __asm__ __volatile__ ( … ) insertions也是一種GCC和Clang柵障。編譯器沒有權利遺棄或者重排柵障前后的代碼。C++ memory_order常量,在某種程度上,支持編譯器對處理器施加影響。作為編譯器柵障,限制了代碼的重排(比如優化)。因此,無需再設置特定的編譯器柵障,當然,前提是編譯器完全支持這一新標準。
序列化一致性模型
假設,我們實現了一個無鎖棧,編譯后并正在進行測試。我們拿到一個核心文件,會問哪里出錯呢?開始查找尋找錯誤根源,腦袋飛快地在思索無鎖棧中一行行代碼實現(沒有一個調試器能幫到我們),試圖模擬多線程,并回答下列問題:
“線程1執行第k行的同時,線程2執行第N行,此時會有什么致命問題導致程序失敗呢?”或許,你會發現錯誤根源并處理掉這些錯誤,但無鎖棧依舊報錯,這是為何?
(譯者注:所謂核心文件,又叫核心轉儲,操作系統在進程收到某些信號而終止運行時,將此時進程地址空間的內容以及有關進程狀態的其他信息寫入該文件,此信息用來調試)
事實上,我們試圖尋找錯誤根源,在腦海中比較多線程并發執行下的程序不同行,其實就是序列化一致性。它是一種嚴格的內存模型,確保處理器按照程序本身既定的順序執行程序指令,例如,下面的代碼:
// Thread 1
atomic<int> a, b ;
a.store( 5 );
int vb = b.load();
// Thread 2
atomic<int> x,y ;
int vx = x.load() ;
y.store( 42 ) ;
任何一種執行情形都是序列化一致性模型允許的,除了對調換a.store / b.load或者x.load / y.store。注意,我并沒有顯式地給加載存儲設置memory_order參數,而是依賴缺省的參數值。
相同的規范擴展到編譯器:memory_order_seq_cst下面的運算不得遷移到此柵障上面,與此同時,在seq_cst-barrier上面的運算不得遷移到此柵障下面。
序列化一致性模型接近人腦思維,但它有個相當致命的缺陷,對現代處理器限制過多。這會產生極度重量級的內存柵障,很大程度上制約了處理器的啟發式執行,這也是新的C++標準為何有以下折中的原因:
有序化一致性模型由于其嚴格的特性,加之容易理解,因而被作為原子運算的缺省模型
同時C++引入一個弱內存柵障,以應對現代架構的更多可能
基于獲取/釋放語義的模型作為序列化一致性模型的一個很好補充。
獲取/釋放語義
正如你看到的標題那樣,在某種程度上,該語義與資源的獲取釋放有關。確實如此,資源的獲取就是將其從內存讀入寄存器,釋放就是將其從寄存器寫回內存中。
load memory, register ;
membar #LoadLoad | #LoadStore ; // acquire-барьер
// Operation within acquire/release-sections
...
membar #LoadStore | #StoreStore ; // release-barrier
store regiser, memory ;
正如你看到的,我們沒有用到#StoreLoad這樣重量級柵障應用。獲取柵障、釋放柵障就是半個柵障。獲取不會將前面的存儲運算與后續的加載、存儲進行重排,而釋放不會將前面加載與后續的加載進行重排,同樣,不會將前面的存儲與后續的加載進行重排。所有的這些適用于編譯器和處理器,獲取、釋放作為該區間所有代碼的柵障。而獲取柵障前面的某些運算(可以被處理器或編譯器重排)可以滲入到獲取/釋放模塊中。同樣釋放柵障后續的運算可以轉入上方進入獲取/釋放區間。但獲取/釋放里面的運算不會越出這個界。
我猜自旋鎖(spin lock)是獲取/釋放語義應用最簡單的例子。
無鎖和自旋鎖
或許你會感到奇怪,在無鎖算法系列文章中列舉一個鎖算法的例子似乎不妥,容我解釋一下。
我不是一個純無鎖粉,不過,純無鎖(特別是無等待)算法確實令我很開心。設法實現它我甚至會更開心。作為一個務實主義者:任何有效的事情就是好的。倘若使用鎖帶來益處,我也覺得挺好的。自旋鎖可以帶來比綜合互斥量(mutex)更多的收益,比如對一個小段程序進行保護–少量匯編指令。同樣,針對不同優化,自旋鎖是一種用之不竭的資源。
基于獲取/釋放的最簡易自旋鎖實現大致如此:(而C++專家認為應該用某個特定的atomic_flag來實現自旋鎖,但我更傾向于將自旋鎖建立在原子變量上,甚至不是boolean類型。從本文角度看,這樣看起來會更清晰。)
class spin_lock
{
atomic<unsigned int> m_spin ;
public:
spin_lock(): m_spin(0) {}
~spin_lock() { assert( m_spin.load(memory_order_relaxed) == 0);}
void lock()
{
unsigned int nCur;
do { nCur = 0; }
while ( !m_spin.compare_exchange_weak( nCur, 1, memory_order_acquire ));
}
void unlock()
{
m_spin.store( 0, memory_order_release );
}
};
本代碼中困惑我的是,倘若CAS未成功執行,compare_exchange方法,第一參數接收一個引用,并修改它。因此不得不采用一個帶非空體的do-while。
在lock方法中采用獲取-語義,在unlock方法中采用釋放語義(順便說一句,獲取/釋放語義來自同步原語,標準開發者細心地分析各種不同的同步原語實現,進而衍生出獲取/釋放模型)。正如早前提到的,本例中的柵障不允許lock和unlock之間的代碼溢出,這正是我們需要的。
原子性m_spin變量確保m_spin=1時,沒有人可以獲得該鎖,這也是我們所需要的!
大家看到算法中用到了compare_exchange_weak,但它是什么呢?
Weak and Strong CAS
正如你所記得那樣,處理器結構通常會選擇兩種類型中的一種,或者實現原子性CAS原語,或者實現LL/SC對((load-linked/store-conditional)。LL/SC對可以實現原子性CAS,但由于很多原因它并不具有原子性。其中一個原因就是,LL/SC中正在執行的代碼可以被操作系統中斷。例如,此刻OS決定將當前線程壓出;重新恢復之后,store-conditional不再響應。而CAS會返回false,錯誤的原因不是數據本身,而是外部事件-線程被中斷。
正是因為如此,促使開發人員在標準中添入兩個compare_exchange原語-弱的和強的。也因此這兩原語分別被命名為compare_exchange_weak和compare_exchange_strong。即使當前的變量值等于預期值,這個弱的版本也可能失敗,比如返回false。可見任何weak CAS都能破壞CAS語義,并返回false,而它本應返回true。而Strong CAS會嚴格遵循CAS語義。當然,這是值得的。
何種情形下使用Weak CAS,何種情形下使用Strong CAS呢?我做了如下變通:倘若CAS在循環中(這是一種基本的CAS應用模式),循環中不存在成千上萬的運算(比如循環體是輕量級和簡單的),我會使用compare_exchange_weak。否則,采用強類型的compare_exchange_strong。
針對獲取/釋放語義的內存序列
正如上文所述,獲取/釋放語義下的memory_order定義:
memory_order_acquire
memory_order_consume
memory_order_release
memory_order_acq_rel
針對讀(加載),可選memory_order_acquire和 memory_order_consume。針對寫(存儲),僅能選memory_order_release。Memory_order_acq_rel是唯一可以用來做RMW運算,比如compare_exchange, exchange, fetch_xxx。事實上,原子性RMW原語擁有獲取語義memory_order_acquire, 釋放語義memory_order_release 或者 memory_order_acq_rel.
這些常量決定了RMW運算語義,因為RMW原語可以并發執行原子性讀寫。RMW運算語義上被認為擁有獲取-加載,或者釋放-存儲,或者兩者皆有。
只在算法中定義RMW運算語義是可行的,在某種程度上與自旋鎖相似的部分,在無鎖算法中顯得很特別。首先,獲取資源,做一些運算,比如計算新值;最后,釋放掉新的資源值。倘若資源獲取由RMW運算(通常為CAS)執行,諸如此類的運算很有可能擁有獲取語義。倘若某個新值由RMW原語來執行,此類型很有可能擁有釋放語義。用“很有可能”描述不是沒有目的,對算法的具體細節進行分析是必須的,這樣才能明白什么樣的語義匹配什么樣的RMW運算。
倘若RMW原語分開執行,獲取/釋放模式是做不到的,不過有三種可能的語義變體:
memory_order_seq_cst 是算法的核心, RMW運算中,代碼的重排,加載和存儲的上下遷移都會報錯。
memory_order_acq_rel 和memory_order_seq_cst有些相似, 但RMW運算位于獲取/釋放內部。
memory_order_relaxed RMW運算可以上下遷移,不會引發錯誤。(比如:運算就在獲取/釋放區間)
以上這些細枝末節都應很好地被理解,然后再試著采用一些基本的原則,在RMW原語上采用這樣的,或那樣的語義。完了之后,必須針對每個算法做出細致地分析。
消費語義(COnsume-Semantic)
這是一個獨立的,更弱類型的獲取語義,一個讀消費語義。此語義作為一個“內存的禮物”被引入DECAlpha處理器中。Alpha架構與其它現代架構有很大的不同,它會破壞數據依賴。下面的代碼就是一個例子:
struct foo {
int x;
int y;
} ;
atomic<foo *> pFoo ;
foo * p = pFoo.load( memory_order_relaxed );
int x = p->x;
重排p->x讀取和p獲取(別問我這怎么可能呢!這就是Alpha的特點之一,我沒有用過Alpha,所以也不能確定這對與不對)。為了阻止此種重排,引入了消費語義,用于struct指針的原子讀,以及struct字段讀取。下面的例子中pFoo指針便是如此:
foo * p = pFoo.load( memory_order_consume );
int x = p->x;
消費語義介于讀取的寬松語義和獲取語義之間,現今大多數架構都基于讀取的寬松語義。
再談CAS
我已經介紹了兩個CAS原子性接口-weak和Strong,但不止兩個CAS變體,其它CAS,多了一個memory_order參數:
bool compare_exchange_weak(T&, T, memory_order successOrder, memory_order failedOrder );
bool compare_exchange_strong(T&, T, memory_order successOrder, memory_order failedOrder );
不過failedOrder是什么樣的參數呢?
記住CAS是RMW原語,即便失敗,也會執行原子性讀。CAS失敗,failedOrder參數會決定本次讀運算語義。普通讀相應的相同值也是支持的,在實際應用中,“針對失敗語義”是極其少有的,當然,這取決于算法。
寬松語義
最后,來說說第三種原子性模型,寬松語義適用于所有的原子性原語-加載、存儲、所有RMW-幾乎沒有任何限制。因此,它允許處理器最大程度上的指令重排,這是它最大的優勢。為何是幾乎呢?
首先,該標準需要保證寬松運算的原子性。這意味著即使是寬松運算也應該是原子性的,不存在部分效應(partial effects)。
其次,啟發式寫在原子性寬松寫中是被禁止的。
這些要求會嚴格地應用于一些弱序列化架構的原子性寬松運算中。比如,原子性變量的寬松加載在Intel Itanium中由load.acq實現(acquire-read, 切勿把Itanium acquire和C++ acquire混為一體)。
Itanium之安魂曲
我在文中多次提到英特爾Itanium,搞得我好像就是Intel架構粉;其實該架構在慢慢逝去,當然我不是英特爾的粉絲。Itanium VLIW 架構不同于其它架構地方,是其命令系統的構建規則。內存序列化由加載、存儲、RMW指令的前綴完成。而在現代架構體系中你不會找到這些的,這些獲取和釋放術語,讓我想到,C++11或許就是從Itanium拷貝過來的。
過去,我們一直在用Itanium或者它的子架構,直到AMD引入AMD64—將x86擴展到64位。那時Intel正慢悠悠地開發一款64位計算架構。這個架構潛藏著一些細枝末節,透過它,你會了解到臺式機Itanium原本是為我們準備的。另外,針對Itanium架構的微軟Windows操作系統端口和Visual C++編譯器也間接地證明這一點(還有人看到其它運行在Itanium上的Windows操作系統嗎?)。顯然AMD打亂了Intel的計劃,而Intel必須迎頭趕上,將64位整合進x86。最后,Itanium停留在服務器片段中,因拿不到合適的開發資源,而慢慢消失了。
不過,Itanium的一組VLIW指令卻是很有趣,并已取得突破性進展。現代處理器執行的這些指令(加載執行塊,重排運算)曾經被植入Itanium 的編譯器中。但該編譯器不能處理任務,也不能產生完備的優化代碼。結果,Itanium性能數次跌入谷底,因此Itanium 是我們不可以實現的未來。
但有誰知道呢,或許現在寫夢之安魂曲為時尚早?
熟悉C++11標準的人肯定會問:“關系(relations)在何處決定原子性運算語義:happened before, synchronized with ,還是其它?”我會說“在標準里”。
Anthony Williams在其書《C++ Concurrency in Action》第五章對此有詳盡的描述,你可以找到很多詳盡的例子。
標準開發者有一項重要的任務,對C++內存模型規則做一些變動。該規則不是用來描述內存柵障的位置,而是用來保障線程之間通信的。
結果,一個簡潔明了的C++內存模型規范就此產生了。
不幸的是,在實際應用中,此關系使用起來太過困難;不論是在復雜或是簡易的無鎖算法中,大量的變量需要考慮,才能保證memory_order的正確性。
這就是為何缺省模型為序列化一致性模型,它無需針對原子性運算設置任何特殊的memory_order參數。前面已經提到,該模型處于一種減速狀態,應用弱模型—比如獲取/釋放 或者寬松—均需要算法驗證。
補充說明:讀了一些文章發現最后的論述不夠準確。事實上,序列一致性模型本身不保證任何事情,即使有它的幫助,你也能把代碼寫的一團糟。因此不論何種內存模型,無鎖算法驗證都是必須的。只不過在弱模型中,特別有必要。
無鎖算法驗證
我知道的第一個驗證方式,是Dmitriy Vyukov寫的 relacy 庫。不幸的是,該方式需要建立一個特殊模型。第一步,簡化的無鎖模型應該以relacy library方式來構建;而且該模型應該經過調試(為何是簡化的呢?在建模的時候,通常你要深思熟慮摒棄掉跟算法無關的東西);只有這樣,你才能寫出一個算法產品。該方式特別適合從事無鎖算法和無鎖數據結構開發的軟件工程師,事實上也確實如此。
但通常很難做到兩步,或許是人惰性的天性,他們即可馬上就需出東西。
我猜relacy的作者也意識到這個缺陷(不是嘲諷,在這個小領域也算是一個突破性的項目)。作者將一個驗證方法作為標準庫的一部分,這也意味著你無須做任何額外的模型。這個看起來有些像STL中的safe iterators概念。
最近一個新工具ThreadSanitizer由Dmitriy和他谷歌的同事一起開發的,這個工具可以用來檢測程序中存在的數據競爭;因此在原子性運算的重排中非常有用。更重要的是,該工具不是構建進了STL,而是更底層的編譯器中(比如Clang3.2、GCC4.8)。
ThreadSanitizer的使用方式特別簡單,編譯某個程序時僅僅需要特定的按鍵,運行單元測試,接著就可以看到豐富的日志分析結構。因此,我也將本工具應用于我的libcds庫中,確保libsds沒有問題。
“我不是很明白”—批判標準
我斗膽批判C++標準,只是不明白為何標準將該語義設置為原子性運算的參數。不過,邏輯上應該使用模板,這么做才對嘛:
template <typename T>
class atomic {
template <memory_order Order = memory_order_seq_cst>
T load() const ;
template <memory_order Order = memory_order_seq_cst>
void store( T val ) ;
template <memory_order SuccessOrder = memory_order_seq_cst>
bool compare_exchange_weak( T& expected, T desired ) ;
// and so forth, and so on
};
我來談談為何我的想法更正確呢。
前面不止一次提到,原子性運算語義不僅作用于處理器,也作用于編譯器。語義是編譯器的優化(半)柵障。除此之外,編譯器應該監控原子性運算是否被賦予恰當語義(比如,釋放語義應用于讀運算)。那該語義在編譯期就應該確定下來,但在下面的代碼中,我很難想象編譯器如何做到這一點:
從形式上看,該代碼并不違反C++11標準,不過編譯器唯一能做的也就只有下面這些了:
extern std::memory_order currentOrder ;
std::Atomic<unsigned int> atomicInt ;
atomicInt.store( 42, currentOrder ) ;
要么報錯,但為何允許原子性運算接口拋出錯誤?
要么應用序列化一致性語義,總之是“不會太糟”。但變量currentOrder會被忽略掉,程序會遇到很多我們原本想避免的問題。
要么產生一個針對所有currentOrder可能值的switch/case語句。但這樣,我們會得到很多低效的代碼,而非一兩個匯編指令。恰當語義問題還未解決,你可以調用釋放讀或者獲取寫。
然而模板方式卻沒有此缺陷,在模板函數中,memory_order列表中定義編譯期常量。的確,原子性運算調用確實有些繁瑣。
std::Atomic<int> atomicInt ;
atomicInt.store<std::memory_order_release>( 42 ) ;
// or even like that:
atomicInt.template store<std::memory_order_release>( 42 ) ;
但這些繁瑣可以借由模板方式抵消,在編譯期運算語義就可以明白無誤地顯示出來。而C++采用非模板的方式唯一的解釋就是為了兼容C語言。除了std::atomic類,C++11標準還引入了諸如 atomic_load, atomic_store等C原子性函數。
“C++11內存模型這么理解”的內容就介紹到這里了,感謝大家的閱讀。如果想了解更多行業相關的知識可以關注億速云網站,小編將為大家輸出更多高質量的實用文章!
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。