并行設(shè)計:如何高效解決同步互斥問題?
我曾經(jīng)負(fù)責(zé)主導(dǎo)了一個性能優(yōu)化的項目,此項目的主要業(yè)務(wù)邏輯在于在線搶貨并購買。在起初的設(shè)計方案里,鑒于要保證庫存數(shù)據(jù)的一致性,后端服務(wù)于請求處理時運(yùn)用了 Redis 互斥鎖,然而這致使系統(tǒng)的吞吐量被限制在 30TPS,無法通過彈性擴(kuò)展來增強(qiáng)性能。那這個問題是如何解決的呢?后來,我們通過采用無鎖化來達(dá)成性能的拓展,系統(tǒng)吞吐量一下子提高到了 1000TPS,相較原來提升了足足 30 倍。
由此可見,同步互斥屬于影響并發(fā)系統(tǒng)性能的關(guān)鍵要素之一,倘若處理不當(dāng),甚至可能引發(fā)死鎖或者導(dǎo)致系統(tǒng)崩潰的危險。在這節(jié)課中,我將會帶領(lǐng)你去探尋并發(fā)系統(tǒng)里存在的同步互斥問題,一同思考、剖析引發(fā)這些問題的根源究竟是什么,隨后我還會介紹各種同步互斥手段的內(nèi)部實現(xiàn)詳情,助力你理解運(yùn)用同步互斥的具體原理以及解決的思路。如此一來,在你深入領(lǐng)會同步互斥問題的本質(zhì)模型以后,就能夠更為精確地設(shè)計并發(fā)系統(tǒng)中的同步互斥策略,進(jìn)而有助于提升系統(tǒng)的關(guān)鍵性能。好啦,接下來,咱們就從并發(fā)系統(tǒng)中現(xiàn)存的同步互斥問題著手,一起來瞧瞧引起同步互斥問題的內(nèi)在根源是什么吧。
并行執(zhí)行的核心問題
從計算機(jī)早期的圖靈機(jī)模型,直至面向過程、面向?qū)ο蟮能浖幊棠P?,軟件工程師向來都傾向于運(yùn)用串行思維來思考與解決問題。伴隨著多核時代的到來,受限于硬件層面并發(fā)技術(shù)的進(jìn)步,為了更充分地發(fā)揮 CPU 的價值,必須依靠軟件層的并行設(shè)計來進(jìn)一步提高系統(tǒng)性能。然而,當(dāng)下大多數(shù)軟件工程師依舊習(xí)慣以串行思維來處理問題,這便會致使所設(shè)計實現(xiàn)的軟件系統(tǒng)不但性能極差,而且容易出現(xiàn)故障。比方說,我們不妨來看看這個并發(fā)程序,探尋一下它在執(zhí)行過程中可能會存在哪些問題。
int number_1 = 0;
int number_2 = 0;
void atom_increase_call()
{
for (int i = 0; i < 10000; i++)
{
number_1++;
number_2++;
}
}
void atom_read_call()
{
int inorder_count = 0;
for (int i = 0; i < 10000; i++)
{
if (number_2 > number_1)
{
inorder_count++;
}
}
std::cout << "thread:3 read inorder_number is " << inorder_count
<< std::endl;
}
int main()
{
std::thread threadA(atom_increase_call);
std::thread threadB(atom_increase_call);
std::thread threadC(atom_read_call);
threadA.join();
threadB.join();
threadC.join();
std::cout << "thread:main read number is " << number_1 << std::endl;
return 0;
}
運(yùn)行之后你會發(fā)現(xiàn),由于代碼在三個線程上并行執(zhí)行,導(dǎo)致這個程序每次的運(yùn)行結(jié)果可能都不相同,這種現(xiàn)象就被叫做程序運(yùn)行結(jié)果不確定性,而這通常是業(yè)務(wù)所不能接受的。這里我列舉了其中兩次執(zhí)?結(jié)果,如下:
| 第?次:
thread:3 read inorder_number is 1
thread:main read number_1 is 15379
thread:main read number_2 is 15378
| 第?次:
thread:3 read inorder_number is 13
thread:main read number_1 is 15822
thread:main read number_2 is 15821
通過對這段代碼的兩次執(zhí)行結(jié)果加以分析,我們能夠看到該并發(fā)程序呈現(xiàn)出了兩種現(xiàn)象:在線程 A 和線程 B 中,number_1++、number_2++ 累計執(zhí)行了 20000 次,照理說結(jié)果應(yīng)當(dāng)是 20000 ,但實際運(yùn)行的結(jié)果卻與 20000 存在較大差距。
在線程 A 和線程 B 中,都是先進(jìn)行 number_1++ 的操作,然后再執(zhí)行 number_2++ ,所以 inorder_number 的統(tǒng)計按理應(yīng)當(dāng)是 0 才合理,然而最終的結(jié)果并非 0 。這表明,number_1++ 與 number_2++ 執(zhí)行結(jié)果的生效,在跨線程的情況下順序并非一致。
那么此刻,我們可以先思考一下:為何在現(xiàn)象 1 中,number_1 的值并非 20000 呢?我覺得可能存在兩個原因:number_1 在不同線程間的緩存失效,致使大量寫入操作與預(yù)期不符,進(jìn)而導(dǎo)致與實際值的偏差較大;number_1++ 的操作包含了讀取、修改這兩個階段,期間有可能被中斷,因而不具備原子特性,這樣一來兩個線程中的 number_1++ 操作相互干擾,也就無法確保結(jié)果的正確性。而致使 inorder_number 值不為 0 的原因眾多,比如:變量 number_1 和 number_2 在線程間的緩存不一致;由于編譯器的指令重排序優(yōu)化,導(dǎo)致 number_1++ 和 number_2++ 生成指令的順序被打亂;由于 CPU 級的指令級并發(fā)技術(shù),使得 number_1++ 和 number_2++ 并發(fā)執(zhí)行,從而無法保證執(zhí)行順序。
如此一來,我們將以上所有問題進(jìn)行匯總梳理之后,實際上能夠發(fā)現(xiàn)導(dǎo)致并發(fā)系統(tǒng)執(zhí)行結(jié)果不確定性的根源問題主要有三個,分別是原子性破壞問題、緩存一致性問題、順序一致性問題。那么我們應(yīng)當(dāng)如何去解決并發(fā)系統(tǒng)中存在的這三個根源問題呢?想必您肯定會想到,運(yùn)用互斥鎖呀!確實,互斥鎖能夠有效地解決上述三個問題。
下面,我們就一起來了解下互斥鎖是如何解決上面描述的三個問題的,同時在此過程中,我們也來看看由于使用了互斥鎖,都會引入什么樣的性能開銷。
圖片
如圖所示,在 Lock 加鎖后進(jìn)入臨界區(qū)之前,以及退出臨界區(qū)后并執(zhí)行 Unlock 之前,這兩個地方都增添了內(nèi)存屏障指令(不同的 CPU 架構(gòu)與 OS 上的實現(xiàn)存在一些差異,不過其基本原理是相似的)。
如此一來,在編譯期間通過這兩個內(nèi)存屏障,實現(xiàn)了以下的功能:對臨界區(qū)與非臨界區(qū)之間的指令重排序進(jìn)行了限制;確保在釋放鎖之前,臨界區(qū)中的共享數(shù)據(jù)已經(jīng)寫入到內(nèi)存中,以此保障多線程間的緩存一致性。
由于臨界區(qū)是互斥訪問的,所以您可以認(rèn)為臨界區(qū)的業(yè)務(wù)邏輯整體上是原子性且緩存一致的,并且跨線程間數(shù)據(jù)順序的一致性約束,也統(tǒng)一在臨界區(qū)內(nèi)得以實現(xiàn)。
雖然臨界區(qū)間內(nèi)的代碼是亂序優(yōu)化執(zhí)行的,還存在非原子性操作等情況,不過這都不會對程序執(zhí)行最終結(jié)果的確定性造成影響。
另外,從圖中您還能夠看到,當(dāng)互斥鎖加鎖失敗后,執(zhí)行線程會進(jìn)入休眠狀態(tài),一直到互斥鎖資源被釋放,才會被動地等待內(nèi)核態(tài)重新調(diào)度來激活。很明顯,線程長時間的休眠會造成業(yè)務(wù)阻塞,進(jìn)而影響到軟件系統(tǒng)的性能。
所以,在并發(fā)程序中使用互斥鎖時,一個重要的性能優(yōu)化手段就是減小臨界區(qū)的大小,以此來減少線程可能的阻塞時間。比如說,通過刪掉一些非沖突的業(yè)務(wù)邏輯,來縮短臨界區(qū)的執(zhí)行代碼時間。
不過在這里,請您再思考一個問題:在通過減少臨界區(qū)代碼來優(yōu)化性能的過程中,如果您發(fā)現(xiàn)臨界區(qū)的執(zhí)行時間,已經(jīng)小于線程休眠切換的時間開銷(通常線程休眠切換的開銷大概在 2us 左右,不同機(jī)器在性能上會有一定的差別,需要以實際機(jī)器的測試結(jié)果為準(zhǔn)),那您還會選擇互斥鎖這種方式嗎?實際上,這時候您應(yīng)該考慮更換一種鎖,以減少線程休眠切換所消耗的時間。接下來我要為您介紹的自旋鎖(SpinLock),就能夠幫助達(dá)成這個目的。自旋鎖在 Linux 源碼中被廣泛使用,下面我來給您介紹一下它的基本原理與性能表現(xiàn)吧。
自旋鎖的原理與性能
首先,我們還是來了解下自旋鎖的實現(xiàn)原理,看看它的處理邏輯是怎么樣的,如下圖所示:
圖片
對比前面互斥鎖的工作過程示意圖,您能夠發(fā)現(xiàn),自旋鎖和互斥鎖的邏輯差別主要在于:當(dāng)加鎖失敗時,當(dāng)前線程不會進(jìn)入休眠狀態(tài)。所以,如果您采用自旋鎖這種實現(xiàn)方式,倘若臨界區(qū)執(zhí)行的開銷較小,那么就能夠獲取等待時間開銷小于線程休眠切換開銷所帶來的額外收益。
在自旋鎖中,臨界區(qū)的實現(xiàn)機(jī)制和互斥鎖基本相同,所以它也能夠解決前面所提及的并發(fā)系統(tǒng)中的三個根源問題。
另外,和互斥鎖一樣,為了進(jìn)一步提高軟件的性能,您也需要進(jìn)一步降低線程間的數(shù)據(jù)依賴。這樣,經(jīng)過您設(shè)計優(yōu)化之后,當(dāng)把線程之間的依賴數(shù)據(jù)減少到只有幾個變量時,執(zhí)行的開銷可能僅需要幾個指令周期就能完成。但是在這種情況下使用鎖機(jī)制,您還需要在每次數(shù)據(jù)操作的過程中進(jìn)行加鎖和解鎖,如此一來,額外開銷的占比就會過高,實際上是不太劃算的。
那么既然這樣,還有其他更為高效的解決辦法嗎?當(dāng)然有!請牢記,鎖只是我們解決問題的方式,而非我們需要解決的問題。
現(xiàn)在讓我們再次回到問題本身,再來強(qiáng)化記憶一下并發(fā)系統(tǒng)內(nèi)的三個本質(zhì)問題:原子性破壞問題、緩存一致性問題、順序一致性問題。在這里您需要明白,在具體的并發(fā)業(yè)務(wù)場景中,可能并不需要您同時去解決這三個問題。例如在多線程場景下的統(tǒng)計變量,兩個線程同時更新一個變量,在這里根本就不存在順序一致性的問題。因此,您首先需要學(xué)會的是辨別并發(fā)系統(tǒng)中有待解決的問題,然后再去精確地尋找解決辦法,這才是進(jìn)一步提升系統(tǒng)性能的關(guān)鍵所在。
那么,在實際的業(yè)務(wù)場景中,最常見的導(dǎo)致并發(fā)系統(tǒng)執(zhí)行結(jié)果不確定性的問題,實際上是緩存一致性問題,比如典型的生產(chǎn)者消費(fèi)者問題。不過在嵌入式系統(tǒng)的業(yè)務(wù)場景中,C 語言已經(jīng)通過引入 volatile 變量解決了這個問題。接下來,我們就通過使用 volatile 來解決問題的工作流程,來分析、了解一下 volatile 是怎樣解決同步互斥中存在的問題的。
volatile 的原理與性能
volatile 是一種特殊變量類型,它主要是為了解決并發(fā)系統(tǒng)中的緩存一致性問題。定義為 volatile 類型的變量,會被默認(rèn)為是緩存失效狀態(tài),針對這個變量的讀取、設(shè)置操作,都可以通過直接操作內(nèi)存來實現(xiàn),從而就規(guī)避了緩存一致性問題。在 C/C++ 語言中,volatile 一直在沿用這種方式,但這種實現(xiàn)機(jī)制并沒有完全解決并發(fā)系統(tǒng)中的原子性破壞和順序一致性的問題。而在 Java 語言中,JVM 會在 volatile 變量的過程中添加內(nèi)存屏障機(jī)制,從而可以部分解決順序一致性的問題。其具體機(jī)制如下圖所示:
圖片
圖中,變量 x、y 屬于 volatile 類型變量,初始值分別是 1 和 2,Load 表示的是對內(nèi)存直接進(jìn)行讀取操作,而 Store 代表了對內(nèi)存直接進(jìn)行寫入操作。在線程 1 內(nèi)部,當(dāng) volatile 變量 y 進(jìn)行寫入操作時,會在生成的操作指令前面添加寫屏障指令;而線程 2 在執(zhí)行 volatile 變量 y 的讀取操作時,在生成的代碼指令后面添加了讀屏障指令。
這樣一來,通過寫屏障就對線程 1 的執(zhí)行過程進(jìn)行了限制,使得 Store x 與 Store y 的寫操作不能亂序;讀屏障則對線程 2 的執(zhí)行過程進(jìn)行了限制,讓 Load y 和 Load x 不能亂序。
因此,對于線程 2 而言,只可能看到線程 1 執(zhí)行過程中的 3 個時間點的狀態(tài),分別是:State A :初始化狀態(tài),y=2,x =1。State B :x 剛設(shè)置完的中間狀態(tài),y=2,x =5。State C :x、y 都設(shè)置完的狀態(tài),y=8,x=5。
而要是線程 1 和線程 2 中的任何一方?jīng)]有使用內(nèi)存屏障指令,就有可能致使線程 2 讀到的數(shù)據(jù)順序不一致,比如獲取到混亂的狀態(tài),y=8,x=2。
實際上,這也是無鎖編程(也就是不使用操作系統(tǒng)中鎖資源的程序,而互斥鎖需要使用操作系統(tǒng)的鎖資源)中的一個典型的問題解決方式。
但在這里,您還需要留意:volatile 并沒有完全達(dá)成原子性。比如說,在以下兩種情況下,就不滿足原子性:類似 i++ 這種針對數(shù)據(jù)的更新操作,在 CPU 層面無法通過一條指令就完成更新,所以使用 volatile 也無法保證原子性;對于 32 位的 CPU 架構(gòu)來說,64 位的長整型變量的讀取和寫入操作無法在一條指令內(nèi)完成,所以同樣無法保證原子性。
對于因 32 位與 64 位 CPU 架構(gòu)之間的差異而導(dǎo)致的原子性問題,我們只能在使用過程中盡量去避開;而針對 i++ 這種更新操作,大部分 CPU 架構(gòu)都實現(xiàn)了一條特殊的 CPU 指令,來專門解決這個問題。
這個特殊指令就是 CAS 指令,它的實現(xiàn)語義如下:
bool CAS(T* addr, T expected, T newValue)
{
if( *addr == expected )
{
*addr = newValue;
return true;
}
else
return false;
}
該函數(shù)所實現(xiàn)的功能為:倘若當(dāng)前值與 expect 相等,那么就將值更新為 newValue,否則不進(jìn)行更新;要是更新成功則返回 true,否則返回 false。這條指令是滿足原子性的。
好了,現(xiàn)在我為您總結(jié)一下前面的分析過程:在并發(fā)系統(tǒng)的同步互斥當(dāng)中,運(yùn)用 volatile 能夠?qū)崿F(xiàn)讀取和寫入操作的原子性,使用 CAS 指令可以實現(xiàn)更新操作的原子性,接著借助內(nèi)存屏障達(dá)成跨線程的順序一致性。在 Java 語言里,正是基于 volatile + CAS + 內(nèi)存屏障的組合,實現(xiàn)了 Atomic 類型(如果想要更深入地理解 Java 的 Atomic 類型的原理與機(jī)制,可以參考閱讀這個文檔),進(jìn)而支撐解決了并發(fā)中的三個本質(zhì)問題。C++ 在 Atmoic 實現(xiàn)的原理和 Java Atomic 是類似的,不過在 C++ 語言中,它定義了更為豐富的一致性內(nèi)存模型,可供我們靈活選擇