背景
?
C/C++語言的并發(fā)程序(Concurrent Programming)設(shè)計(jì),一直是一個比較困難的話題。很多朋友都會嘗試使用多線程編程,但是卻很難保證自己所寫的多線程程序的正確性。多線程程序,如果涉及到對共享資源的并發(fā)讀寫,就會產(chǎn)生資源爭用(Data Race)。解決資源爭用,最直接的想法是引入鎖,對并發(fā)讀寫的數(shù)據(jù)進(jìn)行保護(hù)(更高級的則包括無鎖編程—— Lock Free Programming)。但是,鎖又有很多種類,例如:自旋鎖(Spinlock)、互斥鎖(Mutex)、讀寫鎖(Read-Write-Lock)等等。這么多的鎖,每種鎖有什么特點(diǎn)?對應(yīng)哪些不同的使用場景?使用過程中需要注意哪些事項(xiàng)?各自分別有哪些不足之處?都是困擾程序員的一個個問題。
?
甚至,一個最基本的問題:為什么鎖就能夠用來保護(hù)共享資源?鎖真正蘊(yùn)含的意義有哪些?我相信很多使用過各種鎖的程序員,都不一定能夠完全正確的回答出來。
?
有鑒于此,本人希望將自己近10年數(shù)據(jù)庫內(nèi)核研發(fā),所積累下的并發(fā)編程的經(jīng)驗(yàn)記錄下來,形成一個系列的文章,分享給大家。這個系列,個人打算對其命名為 #并發(fā)編程系列# ,作為此系列開篇的文章,本文將從一個簡單的并發(fā)編程的例子出發(fā),引出鎖真正蘊(yùn)含的意義。
?
一個測試用例
?
并發(fā)程序處理中,面臨的一個最簡單,也是最常見的共享資源的爭用,就是針對一個全局變量進(jìn)行并發(fā)的更新和讀取。這個全局變量,可以是一個全局計(jì)數(shù)器,統(tǒng)計(jì)某個事件在多線程中發(fā)生的次數(shù);或者是一個全局遞增序列,每個線程需要獲取屬于其的唯一標(biāo)識。諸如此類,多個線程,針對一個全局變量的并發(fā)讀寫,是十分常見的。如下圖所示:
此用例中,N個線程,并發(fā)更新一個全局變量。讓我們先來看一個簡單的測試,全局變量global_count沒有任何保護(hù),此時會發(fā)生什么?
?
測試場景:500個線程,每個線程做10000次global_count++操作,主線程首先將global_count初始化為0,然后等待這500線程運(yùn)行結(jié)束。待500個線程運(yùn)行結(jié)束之后,由于每個線程都做了10000次global_count++,那么可以確定,最后的global_count取值應(yīng)該是5000000。事實(shí)是這樣嗎?根據(jù)此測試場景,撰寫測試代碼,每個線程做的都是同樣的事,代碼很簡單:
主線程等待所有500個線程結(jié)束之后,進(jìn)行判斷,若global_count不等于5000000,則打印出當(dāng)前global_count的取值。運(yùn)行結(jié)果如下:
通過上圖,可以發(fā)現(xiàn),global_count并不是每次都等于5000000,很大的幾率,global_count要小于5000000。多線程對一個全局變量進(jìn)行++操作,并不能保證最終得到的結(jié)果的正確性。究其內(nèi)部原因,是因?yàn)?+操作并不是一個原子操作(Atomic Operation),而是對應(yīng)至少3條匯編語句,考慮如下兩個線程的 ++ 操作并發(fā):
線程1,2,分別讀取了global_count的當(dāng)前值,分別加1后寫出。線程2的寫覆蓋了線程1的寫,最后導(dǎo)致兩次 ++ 操作,實(shí)際上卻對global_count只加了1次。
?
如何解決此問題,相信大家都有很多方法,例如:將global_count聲明為原子變量(C++ 11標(biāo)準(zhǔn)支持)。但是此文,并不打算使用原子變量,而是將global_count的++操作,通過Spinlock保護(hù)起來。一個全局的Spinlock,500個線程,在++操作前,需要獲取Spinlock,然后進(jìn)行g(shù)lobal_count的++操作,完成后釋放Spinlock。對應(yīng)的每個線程代碼修改如下:
主線程,仍舊是同樣的邏輯,等待所有的500個線程執(zhí)行結(jié)束之后,判斷global_count取值是否等于5000000,如果不相等,則打印出來。此時,同樣執(zhí)行此測試程序,沒有任何一條數(shù)據(jù)打印出來,每一個循環(huán),都滿足global_count等于5000000。通過引入了Spinlock,完美了解決上面的問題。
?
為什么引入了Spinlock保護(hù)之后,多線程針對全局變量的并發(fā)讀寫所帶來的問題就解決了?此問題,恰好引入了鎖意義的剖析。
?
鎖的意義
?
在分析鎖的意義前,先來簡單看看Spinlock的功能:Spinlock是一把互斥鎖,同一時間,只能有一個線程持有Spinlock鎖,而所有其他的線程,處于等待Spinlock鎖。當(dāng)持有Spinlock的線程放鎖之后,所有等待獲取Spinlock的線程一起爭搶,一個Lucky的線程,搶到這把鎖,大部分Unlucky的線程,只能繼續(xù)等待下一次搶鎖的機(jī)會。
?
由此來說,在spinlock鎖保護(hù)下的代碼片段,同一時間只能有一個線程(獲得Spinlock的線程)能夠執(zhí)行,而其他的線程,在獲取spinlock之前,不可進(jìn)入spinlock鎖保護(hù)下的代碼片段進(jìn)行執(zhí)行。前面的測試用例,由于spinlock保護(hù)了global_count++的代碼,因此global_count++操作,同時只能有一個線程執(zhí)行,不可能出現(xiàn)前面提到的兩線程并發(fā)修改global_count變量出現(xiàn)的問題。How Perfect?。。。ㄗⅲ涸趕pinlock加鎖之前,以及spinlock放鎖之后的代碼段,可以由多線程并發(fā)執(zhí)行。)
但是,故事到此就完了嗎?我相信對于大部分程序員來說,或者是之前的我來說,認(rèn)為故事到此就結(jié)束了。已經(jīng)成功的使用了一個Spinlock,來保護(hù)全局變量的并發(fā)讀寫,保證了并發(fā)訪問的正確性。
?
但是(又是這個該死的但是),故事并未結(jié)束,這個案子也還沒有了結(jié)。有一定經(jīng)驗(yàn)的C/C++程序員,或者是曾經(jīng)看過我寫過的一個PPT:《CPU Cache and Memory Ordering——并發(fā)程序設(shè)計(jì)入門》,以及一篇博客:《C/C++ Volatile關(guān)鍵詞深度剖析》,的朋友來說,應(yīng)該都知道這個故事還有一個點(diǎn)沒有挖掘:內(nèi)存模型(Memory Model),無論是程序語言(如:C/C++,Java),或者是CPU(如:Intel X86,Power PC,ARM),都有所謂的內(nèi)存模型。
?
簡單來說,內(nèi)存模型規(guī)定了一種內(nèi)存操作可見的順序。為了提高程序運(yùn)行的效率,編譯器可能會對你寫的程序進(jìn)行重寫,執(zhí)行順序調(diào)整等等,同樣,CPU也會對其執(zhí)行的匯編執(zhí)行進(jìn)行順序的調(diào)整,這就是所謂的亂序執(zhí)行。最基本的四種亂序行為,包含:LoadLoad亂序;LoadStore亂序;StoreLoad亂序;StoreStore亂序,分別對應(yīng)于讀讀亂序,讀寫亂序,寫讀亂序,寫寫亂序。關(guān)于這四種亂序行為更為詳細(xì)的介紹,可參考Preshing的博客:《Memory Reordering Caught in the Act》,《Memory Barriers Are Like Source Control Operations》,或者是《SMP Primer for Android》這篇文章。本文接下來的部分,假設(shè)讀者已經(jīng)知道了無論是編譯器,還是CPU,都會存在編譯亂序與指令執(zhí)行亂序的現(xiàn)象。
?
編譯亂序與指令執(zhí)行亂序,跟本文討論的鎖的意義有何關(guān)系?可以說,不僅有關(guān)系,還有很大的關(guān)系,關(guān)系到鎖之所以能夠稱之為鎖,能夠用來保護(hù)共享資源的關(guān)鍵。
?
一個簡單的問題:在存在編譯亂序與指令執(zhí)行亂序的情況下,怎么保證鎖所保護(hù)的代碼片段,不會被提前到加鎖之前,或者是放鎖之后執(zhí)行?如果編譯器將鎖保護(hù)下的代碼,通過編譯優(yōu)化,放到了加鎖之前運(yùn)行?又如果CPU在執(zhí)行指令時,將鎖保護(hù)下的匯編代碼,延遲到了放鎖之后執(zhí)行?如下圖所示:
如上所示,如果編譯器做了它不該做的優(yōu)化,或者CPU做了其不該做的亂序,那么spinlock保護(hù)下的代碼片段,同一時刻,一定只有一個線程能夠執(zhí)行的假設(shè)被打破了。此時,雖然spinlock仍舊只能有一個線程持有,但是spinlock保護(hù)下的代碼,被提到了spinlock保護(hù)之外執(zhí)行,spinlock哪怕功能再強(qiáng)大,也不能保護(hù)鎖之外的代碼,提取到spinlock鎖之外的代碼,能夠并發(fā)執(zhí)行。
?
但是上面的測試說明,spinlock保護(hù)下的global_count++操作,在多線程下能夠正確執(zhí)行。也就說明,無論是編譯器,還是CPU,并沒有不合時宜的做上面的這些優(yōu)化。而分析其原因,剛好引出了鎖(Spinlock、Mutex、RWLock等)的第二層意義:Lock Acquire和Unlock Release。
?
什么是Lock Acquire,Unlock Release又意味著什么?在此之前,需要先看看什么是Acquire和Release。Acquire和Release語義(Semantics)是程序語言和CPU內(nèi)存模型(Memory Model)中的一個概念。以下,是截取自Preshing博客《Acquire and Release Semantics》一文中,對Acquire與Release Semantics的定義:
?
Acquire semantics is a property which can only apply to operations which read from shared memory, whether they are read-modify-write operations or plain loads. The operation is then considered a read-acquire. Acquire semantics
prevent memory reordering of the read-acquire with any read or write operation which follows it in program order. (注:Acquire語義是一個作用于內(nèi)存讀操作上的特性,此內(nèi)存讀操作即被視為read-acquire。Acquire語義禁止read-acquire之后所有的內(nèi)存讀寫操作,被提前到read-acquire操作之前進(jìn)行。)
?
Release semantics is a property which can only apply to operations which write to shared memory, whether they are read-modify-write operations or plain stores. The operation is then considered a write-release. Release semantics
prevent memory reordering of the write-release with any read or write operation which precedes it in program order.(注:Release語義作用于內(nèi)存寫操作之上的特性,此內(nèi)存寫操作即被視為write-release。Release語義禁止write-release之前所有的內(nèi)存讀寫操作,被推遲到write-release操作之后進(jìn)行。)
?
從Acquire與Release語義的定義可以看出,兩個語義對編譯器優(yōu)化、CPU亂序分別做了一個限制條件:
?
Acquire語義限制了編譯器優(yōu)化、CPU亂序,不能將含有Acquire語義的操作之后的代碼,提到含有Acquire語義的操作代碼之前執(zhí)行;
Release語義限制了編譯器優(yōu)化、CPU亂序,不能將含有Release語義的操作之前的代碼,推遲到含有Release語義的操作代碼之后執(zhí)行;
有了明確的Acquire和Release語義的定義,再回過頭來看前面提到的鎖的第二層含義:Lock Acquire和Unlock Release。加鎖操作自帶Acquire語義,解鎖操作自帶Release語義。將加鎖、解鎖的兩個語義結(jié)合起來,就構(gòu)成了以下的完整的鎖的含義圖:
spinlock,只有帶有了Acquire和Release語義,才算是一個真正完整可用的鎖——Acquire與Release語義間,構(gòu)成了一個臨界區(qū)。獲取spinlock后的線程,可以大膽的運(yùn)行全局變量的讀寫,而不必?fù)?dān)心其他并發(fā)線程對于此變量的并發(fā)訪問。
?
好消息是,pthread lib所提供的spinlock、mutex,其加鎖操作都自帶了acquire語義,解鎖操作都自帶了release語義。因此,哪怕我們在使用的過程中,不知道有這兩個語義的存在,也能夠正確的使用這些鎖。但是,讀者需要實(shí)現(xiàn)自己的spinlock、mutex(注:實(shí)際情況下,確實(shí)有這個必要,數(shù)據(jù)庫系統(tǒng)如Oracle/PostgreSQL/InnoDB,都有自己實(shí)現(xiàn)的Spinlock、Mutex等),那么對于鎖的了解,到這個層次,是必不可少的。
?
總結(jié)
?
本文,作為 #并發(fā)編程系列# 的開篇,首先跟大家分析了鎖(Spinlock、Mutex、RWLock等)所代表的真正意義。首先,這些鎖要么保證同一時刻只能由一個線程持有(如:Spinlock、Mutex),要么保證同一時刻只能有一個寫鎖(如:RWLock);其次,鎖的加鎖操作帶有Acquire語義,解鎖操作帶有Release語義。通過這兩個條件,保證了加鎖/解鎖之間,構(gòu)成了一個完整的臨界區(qū),全局資源的更新操作,可以在臨界區(qū)內(nèi)完成,而不必?fù)?dān)心并發(fā)讀寫沖突。而這正是并發(fā)程序設(shè)計(jì)的基礎(chǔ):構(gòu)建一個Data-Race-Free的多線程系統(tǒng)。