併發編程系列之一:鎖的意義

http://hedengcheng.com/?p=803

背景

 

C/C++語言的併發程序(Concurrent Programming)設計,一直是一個比較困難的話題。很多朋友都會嘗試使用多線程編程,但是卻很難保證自己所寫的多線程程序的正確性。多線程程序,如果涉及到對共享資源的併發讀寫,就會產生資源爭用(Data Race)。解決資源爭用,最直接的想法是引入鎖,對併發讀寫的數據進行保護(更高級的則包括無鎖編程—— Lock Free Programming)。但是,鎖又有很多種類,例如:自旋鎖(Spinlock)、互斥鎖(Mutex)、讀寫鎖(Read-Write-Lock)等等。這麼多的鎖,每種鎖有什麼特點?對應哪些不同的使用場景?使用過程中需要注意哪些事項?各自分別有哪些不足之處?都是困擾程序員的一個個問題。

 

甚至,一個最基本的問題:爲什麼鎖就能夠用來保護共享資源?鎖真正蘊含的意義有哪些?我相信很多使用過各種鎖的程序員,都不一定能夠完全正確的回答出來。

 

有鑑於此,本人希望將自己近10年數據庫內核研發,所積累下的併發編程的經驗記錄下來,形成一個系列的文章,分享給大家。這個系列,個人打算對其命名爲 #併發編程系列# ,作爲此係列開篇的文章,本文將從一個簡單的併發編程的例子出發,引出鎖真正蘊含的意義

 

一個測試用例

 

併發程序處理中,面臨的一個最簡單,也是最常見的共享資源的爭用,就是針對一個全局變量進行併發的更新和讀取。這個全局變量,可以是一個全局計數器,統計某個事件在多線程中發生的次數;或者是一個全局遞增序列,每個線程需要獲取屬於其的唯一標識。諸如此類,多個線程,針對一個全局變量的併發讀寫,是十分常見的。如下圖所示:

global count ++

此用例中,N個線程,併發更新一個全局變量。讓我們先來看一個簡單的測試,全局變量global_count沒有任何保護,此時會發生什麼?

 

測試場景:500個線程,每個線程做10000次global_count++操作,主線程首先將global_count初始化爲0,然後等待這500線程運行結束。待500個線程運行結束之後,由於每個線程都做了10000次global_count++,那麼可以確定,最後的global_count取值應該是5000000。事實是這樣嗎?根據此測試場景,撰寫測試代碼,每個線程做的都是同樣的事,代碼很簡單:

threadaddfunc

主線程等待所有500個線程結束之後,進行判斷,若global_count不等於5000000,則打印出當前global_count的取值。運行結果如下:

通過上圖,可以發現,global_count並不是每次都等於5000000,很大的機率,global_count要小於5000000。多線程對一個全局變量進行++操作,並不能保證最終得到的結果的正確性。究其內部原因,是因爲++操作並不是一個原子操作(Atomic Operation),而是對應至少3條彙編語句,考慮如下兩個線程的 ++ 操作併發:

線程1,2,分別讀取了global_count的當前值,分別加1後寫出。線程2的寫覆蓋了線程1的寫,最後導致兩次 ++ 操作,實際上卻對global_count只加了1次。

 

如何解決此問題,相信大家都有很多方法,例如:將global_count聲明爲原子變量(C++ 11標準支持)。但是此文,並不打算使用原子變量,而是將global_count的++操作,通過Spinlock保護起來。一個全局的Spinlock,500個線程,在++操作前,需要獲取Spinlock,然後進行global_count的++操作,完成後釋放Spinlock。對應的每個線程代碼修改如下:

global_count++ with spinlock

主線程,仍舊是同樣的邏輯,等待所有的500個線程執行結束之後,判斷global_count取值是否等於5000000,如果不相等,則打印出來。此時,同樣執行此測試程序,沒有任何一條數據打印出來,每一個循環,都滿足global_count等於5000000。通過引入了Spinlock,完美瞭解決上面的問題。

 

爲什麼引入了Spinlock保護之後,多線程針對全局變量的併發讀寫所帶來的問題就解決了?此問題,恰好引入了鎖意義的剖析。

 

鎖的意義

 

在分析鎖的意義前,先來簡單看看Spinlock的功能:Spinlock是一把互斥鎖,同一時間,只能有一個線程持有Spinlock鎖,而所有其他的線程,處於等待Spinlock鎖。當持有Spinlock的線程放鎖之後,所有等待獲取Spinlock的線程一起爭搶,一個Lucky的線程,搶到這把鎖,大部分Unlucky的線程,只能繼續等待下一次搶鎖的機會。

 

由此來說,在spinlock鎖保護下的代碼片段,同一時間只能有一個線程(獲得Spinlock的線程)能夠執行,而其他的線程,在獲取spinlock之前,不可進入spinlock鎖保護下的代碼片段進行執行。前面的測試用例,由於spinlock保護了global_count++的代碼,因此global_count++操作,同時只能有一個線程執行,不可能出現前面提到的兩線程併發修改global_count變量出現的問題。How Perfect!!!注:在spinlock加鎖之前,以及spinlock放鎖之後的代碼段,可以由多線程併發執行。)

spinlock

但是,故事到此就完了嗎?我相信對於大部分程序員來說,或者是之前的我來說,認爲故事到此就結束了。已經成功的使用了一個Spinlock,來保護全局變量的併發讀寫,保證了併發訪問的正確性。

 

但是(又是這個該死的但是),故事並未結束,這個案子也還沒有了結。有一定經驗的C/C++程序員,或者是曾經看過我寫過的一個PPT:《CPU Cache and Memory Ordering——併發程序設計入門》,以及一篇博客:《C/C++ Volatile關鍵詞深度剖析》,的朋友來說,應該都知道這個故事還有一個點沒有挖掘:內存模型(Memory Model),無論是程序語言(如:C/C++,Java),或者是CPU(如:Intel X86,Power PC,ARM),都有所謂的內存模型。

 

簡單來說,內存模型規定了一種內存操作可見的順序。爲了提高程序運行的效率,編譯器可能會對你寫的程序進行重寫,執行順序調整等等,同樣,CPU也會對其執行的彙編執行進行順序的調整,這就是所謂的亂序執行。最基本的四種亂序行爲,包含:LoadLoad亂序;LoadStore亂序;StoreLoad亂序;StoreStore亂序,分別對應於讀讀亂序,讀寫亂序,寫讀亂序,寫寫亂序。關於這四種亂序行爲更爲詳細的介紹,可參考Preshing的博客:《Memory Reordering Caught in the Act》,《Memory Barriers Are Like Source Control Operations》。本文接下來的部分,假設讀者已經知道了無論是編譯器,還是CPU,都會存在編譯亂序與指令執行亂序的現象。

 

編譯亂序與指令執行亂序,跟本文討論的鎖的意義有何關係?可以說,不僅有關係,還有很大的關係,關係到鎖之所以能夠稱之爲鎖,能夠用來保護共享資源的關鍵。

 

一個簡單的問題:在存在編譯亂序與指令執行亂序的情況下,怎麼保證鎖所保護的代碼片段,不會被提前到加鎖之前,或者是放鎖之後執行?如果編譯器將鎖保護下的代碼,通過編譯優化,放到了加鎖之前運行?又如果CPU在執行指令時,將鎖保護下的彙編代碼,延遲到了放鎖之後執行?如下圖所示:

如上所示,如果編譯器做了它不該做的優化,或者CPU做了其不該做的亂序,那麼spinlock保護下的代碼片段,同一時刻,一定只有一個線程能夠執行的假設被打破了。此時,雖然spinlock仍舊只能有一個線程持有,但是spinlock保護下的代碼,被提到了spinlock保護之外執行,spinlock哪怕功能再強大,也不能保護鎖之外的代碼,提取到spinlock鎖之外的代碼,能夠併發執行。

 

但是上面的測試說明,spinlock保護下的global_count++操作,在多線程下能夠正確執行。也就說明,無論是編譯器,還是CPU,並沒有不合時宜的做上面的這些優化。而分析其原因,剛好引出了鎖(Spinlock、Mutex、RWLock等)的第二層意義:Lock AcquireUnlock Release

 

什麼是Lock Acquire,Unlock Release又意味着什麼?在此之前,需要先看看什麼是Acquire和Release。Acquire和Release語義(Semantics)是程序語言和CPU內存模型(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語義是一個作用於內存讀操作上的特性,此內存讀操作即被視爲read-acquire。Acquire語義禁止read-acquire之後所有的內存讀寫操作,被提前到read-acquire操作之前進行。

 

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語義作用於內存寫操作之上的特性,此內存寫操作即被視爲write-release。Release語義禁止write-release之前所有的內存讀寫操作,被推遲到write-release操作之後進行。

 

從Acquire與Release語義的定義可以看出,兩個語義對編譯器優化、CPU亂序分別做了一個限制條件:

 

  • Acquire語義限制了編譯器優化、CPU亂序,不能將含有Acquire語義的操作之後的代碼,提到含有Acquire語義的操作代碼之前執行;

    acquire sematics

  • Release語義限制了編譯器優化、CPU亂序,不能將含有Release語義的操作之前的代碼,推遲到含有Release語義的操作代碼之後執行;

release sematics

有了明確的Acquire和Release語義的定義,再回過頭來看前面提到的鎖的第二層含義:Lock Acquire和Unlock Release。加鎖操作自帶Acquire語義,解鎖操作自帶Release語義。將加鎖、解鎖的兩個語義結合起來,就構成了以下的完整的鎖的含義圖:

鎖含義

spinlock,只有帶有了Acquire和Release語義,纔算是一個真正完整可用的鎖——Acquire與Release語義間,構成了一個臨界區。獲取spinlock後的線程,可以大膽的運行全局變量的讀寫,而不必擔心其他併發線程對於此變量的併發訪問。

 

好消息是,pthread lib所提供的spinlock、mutex,其加鎖操作都自帶了acquire語義,解鎖操作都自帶了release語義。因此,哪怕我們在使用的過程中,不知道有這兩個語義的存在,也能夠正確的使用這些鎖。但是,讀者需要實現自己的spinlock、mutex(注:實際情況下,確實有這個必要,數據庫系統如Oracle/PostgreSQL/InnoDB,都有自己實現的Spinlock、Mutex等),那麼對於鎖的瞭解,到這個層次,是必不可少的。

 

總結

 

本文,作爲 #併發編程系列# 的開篇,首先跟大家分析了鎖(Spinlock、Mutex、RWLock等)所代表的真正意義。首先,這些鎖要麼保證同一時刻只能由一個線程持有(如:Spinlock、Mutex),要麼保證同一時刻只能有一個寫鎖(如:RWLock);其次,鎖的加鎖操作帶有Acquire語義,解鎖操作帶有Release語義。通過這兩個條件,保證了加鎖/解鎖之間,構成了一個完整的臨界區,全局資源的更新操作,可以在臨界區內完成,而不必擔心併發讀寫衝突。而這正是併發程序設計的基礎:構建一個Data-Race-Free的多線程系統。

 

接下來,作爲 #併發編程系列# 的後續文章,將會就如何實現自己的Spinlock、Mutex、RWLock?各種鎖之間的區別及應用場景?以及各種鎖使用過程中的注意事項,逐個展開討論,敬請期待!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章