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

























