MySQL 引擎特性:InnoDB 同步機制

發表於2017-12-12

前言

現代作業系統以及硬體基本都支援併發程式,而在併發程式設計中,各個程式或者執行緒需要對公共變數的訪問加以制約,此外,不同的程式或者執行緒需要協同工作以完成特徵的任務,這就需要一套完善的同步機制,在Linux核心中有相應的技術實現,包括原子操作,訊號量,互斥鎖,自旋鎖,讀寫鎖等。InnoDB考慮到效率和監控兩方面的原因,實現了一套獨有的同步機制,提供給其他模組呼叫。本文的分析預設基於MySQL 5.6,CentOS 6,gcc 4.8,其他版本的資訊會另行指出。

基礎知識

同步機制對於其他資料庫模組來說相對獨立,但是需要比較多的作業系統以及硬體知識,這裡簡單介紹一下幾個有用的概念,便於讀者理解後續概念。
記憶體模型 :主要分為語言級別的記憶體模型和硬體級別的記憶體模型。語言級別的記憶體模型,C/C++屬於weak memory model,簡單的說就是編譯器在進行編譯優化的時候,可以對指令進行重排,只需要保證在單執行緒的環境下,優化前和優化後執行結果一致即可,執行中間過程不保證跟程式碼的語義順序一致。所以在多執行緒的環境下,如果依賴程式碼中間過程的執行順序,程式就會出現問題。硬體級別的記憶體模型,我們常用的cpu,也屬於弱記憶體模型,即cpu在執行指令的時候,為了提升執行效率,也會對某些執行進行亂序執行(按照wiki提供的資料,在x86 64環境下,只會發生讀寫亂序,即讀操作可能會被亂序到寫操作之前),如果在程式設計的時候不做一些措施,同樣容易造成錯誤。
記憶體屏障 :為了解決弱記憶體模型造成的問題,需要一種能控制指令重排或者亂序執行程式的手段,這種技術就叫做記憶體屏障,程式設計師只需要在程式碼中插入特定的函式,就能控制弱記憶體模型帶來的負面影響,當然,由於影響了亂序和重排這類的優化,對程式碼的執行效率有一定的影響。具體實現上,記憶體屏障技術分三種,一種是full memory barrier,即barrier之前的操作不能亂序或重排到barrier之後,同時barrier之後的操作不能亂序或重排到barrier之前,當然這種full barrier對效能影響最大,為了提高效率才有了另外兩種:acquire barrier和release barrier,前者只保證barrier後面的操作不能移到之前,後者只保證barrier前面的操作不移到之後。
互斥鎖 :互斥鎖有兩層語義,除了大家都知道的排他性(即只允許一個執行緒同時訪問)外,還有一層記憶體屏障(full memory barrier)的語義,即保證臨界區的操作不會被亂序到臨界區外。Pthread庫裡面常用的mutex,conditional variable等操作都自帶記憶體屏障這層語義。此外,使用pthread庫,每次呼叫都需要應用程式從使用者態陷入到核心態中檢視當前環境,在鎖衝突不是很嚴重的情況下,效率相對比較低。
自旋鎖 :傳統的互斥鎖,只要一檢測到鎖被其他執行緒所佔用了,就立刻放棄cpu時間片,把cpu留給其他執行緒,這就會產生一次上下文切換。當系統壓力大的時候,頻繁的上下文切換會導致sys值過高。自旋鎖,在檢測到鎖不可用的時候,首先cpu忙等一小會兒,如果還是發現不可用,再放棄cpu,進行切換。互斥鎖消耗cpu sys值,自旋鎖消耗cpu usr值。
遞迴鎖 :如果在同一個執行緒中,對同一個互斥鎖連續加鎖兩次,即第一次加鎖後,沒有釋放,繼續進行對這個鎖進行加鎖,那麼如果這個互斥鎖不是遞迴鎖,將導致死鎖。可以把遞迴鎖理解為一種特殊的互斥鎖。
死鎖 :構成死鎖有四大條件,其中有一個就是加鎖順序不一致,如果能保證不同型別的鎖按照某個特定的順序加鎖,就能大大降低死鎖發生的概率,之所以不能完全消除,是因為同一種型別的鎖依然可能發生死鎖。另外,對同一個鎖連續加鎖兩次,如果是非遞迴鎖,也將導致死鎖。

原子操作

現代的cpu提供了對單一變數簡單操作的原子指令,即這個變數的這些簡單操作只需要一條cpu指令即可完成,這樣就不用對這個操作加互斥鎖了,在鎖衝突不激烈的情況下,減少了使用者態和核心態的切換,化悲觀鎖為樂觀鎖,從而提高了效率。此外,現在外面很火的所謂無鎖程式設計(類似CAS操作),底層就是用了這些原子操作。gcc為了方便程式設計師使用這些cpu原子操作,提供了一系列__sync開頭的函式,這些函式如果包含記憶體屏障語義,則同時禁止編譯器指令重排和cpu亂序執行。
InnoDB針對不同的作業系統以及編譯器環境,自己封裝了一套原子操作,在標頭檔案os0sync.h中。下面的操作基於Linux x86 64位環境, gcc 4.1以上的版本進行分析。
os_compare_and_swap_xxx(ptr, old_val, new_val)型別的操作底層都使用了gcc包裝的__sync_bool_compare_and_swap(ptr, old_val, new_val)函式,語義為,交換成功則返回true,ptr是交換後的值,old_val是之前的值,new_val是交換後的預期值。這個原子操作是個記憶體屏障(full memory barrier)。
os_atomic_increment_xxx型別的操作底層使用了函式__sync_add_and_fetchos_atomic_decrement_xxx型別的操作使用了函式__sync_sub_and_fetch,分別表示原子遞增和原子遞減。這個兩個原子操作也都是記憶體屏障(full memory barrier)。
另外一個比較重要的原子操作是os_atomic_test_and_set_byte(ptr, new_val),這個操作使用了__sync_lock_test_and_set(ptr, new_val)這個函式,語義為,把ptr設定為new_val,同時返回舊的值。這個操作提供了原子改變某個變數值的操作,InnoDB鎖實現的同步機制中,大量的用了這個操作,因此比較重要。需要注意的是,參看gcc文件,這個操作不是full memory barrier,只是一個acquire barrier,簡單的說就是,程式碼中__sync_lock_test_and_set之後操作不能被亂序或者重排到__sync_lock_test_and_set之前,但是__sync_lock_test_and_set之前的操作可能被重排到其之後。
關於記憶體屏障的專門指令,MySQL 5.7提供的比較完善。os_rmb表示acquire barrier,os_wmb表示release barrier。如果在程式設計時,需要在某個位置準確的讀取一個變數的值時,記得在讀取之前加上os_rmb,同理,如果需要在某個位置保證一個變數已經被寫了,記得在寫之後呼叫os_wmb。

條件通知機制

條件通知機制在多執行緒協作中非常有用,一個執行緒往往需要等待其他執行緒完成指定工作後,再進行工作,這個時候就需要有執行緒等待和執行緒通知機制。Pthread_cond_XXX類似的變數和函式來完成等待和通知的工作。InnoDB中,對Pthread庫進行了簡單的封裝,並在此基礎上,進一步抽象,提供了一套方便易用的介面函式給呼叫者使用。

系統條件變數

在檔案os0sync.cc中,os_cond_XXX類似的函式就是InnoDB對Pthread庫的封裝。常用的幾個函式如:
os_cond_t是核心的操作物件,其實就是pthread_cond_t的一層typedef而已,os_cond_init初始化函式,os_cond_destroy銷燬函式,os_cond_wait條件等待,不會超時,os_cond_wait_timed條件等待,如果超時則返回,os_cond_broadcast喚醒所有等待執行緒,os_cond_signal只喚醒其中一個等待執行緒,但是在閱讀原始碼的時候發現,似乎沒有什麼地方呼叫了os_cond_signal。。。
此外,還有一個os_cond_module_init函式,用來window下的初始化操作。
在InnoDB下,os_cond_XXX模組的函式主要是給InnoDB自己設計的條件變數使用。

InnoDB條件變數

如果在InnoDB層直接使用系統條件變數的話,主要有四個弊端,首先,弊端1,系統條件變數的使用需要與一個系統互斥鎖(詳見下一節)相配合使用,使用完還要記得及時釋放,使用者會比較麻煩。接著,弊端2,在條件等待的時候,需要在一個迴圈中等待,使用者還是比較麻煩。最後,弊端3,也是比較重要的,不方便系統監控。
基於以上幾點,InnoDB基於系統的條件變數和系統互斥鎖自己實現了一套條件通知機制。主要在檔案os0sync.cc中實現,相關資料結構以及介面進一層的包裝在標頭檔案os0sync.h中。使用方法如下:
InnoDB條件變數核心資料結構為os_event_t,類似pthread_cont_t。如果需要建立和銷燬則分別使用os_event_createos_event_free函式。需要等待某個條件變數,先呼叫os_event_reset(原因見下一段),然後使用os_event_wait,如果需要超時等待,使用os_event_wait_time替換os_event_wait即可,os_event_wait_XXX這兩個函式,解決了弊端1和弊端2,此外,建議把os_event_reset返回值傳給他們,這樣能防止多執行緒情況下的無限等待(詳見下下段)。如果需要發出一個條件通知,使用os_event_set。這個幾個函式,裡面都插入了一些監控資訊,方便InnoDB上層管理。怎麼樣,方便多了吧~

多執行緒環境下可能發生的問題

首先來說說兩個執行緒下會發生的問題。建立後,正常的使用順序是這樣的,執行緒A首先os_event_reset(步驟1),然後os_event_wait(步驟2),接著執行緒B做完該做的事情後,執行os_event_set(步驟3)傳送訊號,通知執行緒A停止等待,但是在多執行緒的環境中,會出現以下兩種步驟順序錯亂的情況:亂序A: 步驟1--步驟3--步驟2,亂序B: 步驟3--步驟1--步驟2。對於亂序B,屬於條件通知在條件等待之前發生,目前InnoDB條件變數的機制下,會發生無限等待,所以上層呼叫的時候一定要注意,例如在InnoDB在實現互斥鎖和讀寫鎖的時候為了防止發生條件通知在條件等待之前發生,在等待之前對lock_word再次進行了判斷,詳見InnoDB自旋互斥鎖這一節。為了解決亂序A,InnoDB在核心資料結構os_event中引入布林型變數is_set,is_set這個變數就表示是否已經發生過條件通知,在每次呼叫條件通知之前,會把這個變數設定為true(在os_event_reset時改為false,便於多次通知),在條件等待之前會檢查一下這變數,如果這個變數為true,就不再等待了。所以,亂序A也能保證不會發生無限等待。
接著我們來說說大於兩個執行緒下可能會發生的問題。執行緒A和C是等待執行緒,等待同一個條件變數,B是通知執行緒,通知A和C結束等待。考慮一個亂序C:執行緒A執行os_event_reset(步驟1),執行緒B馬上就執行os_event_set(步驟2)了,接著執行緒C執行了os_event_reset(步驟3),最後執行緒A執行os_event_wait(步驟4),執行緒C執行os_event_wait(步驟5)。乍一眼看,好像看不出啥問題,但是實際上你會發現A和C執行緒在無限等待了。原因是,步驟2,把is_set這個變數設定為false,但是在步驟3,執行緒C通過reset又把它給重新設回false了。。然後執行緒A和C在os_event_wait中誤以為還沒有發生過條件通知,就開始無限等待了。為了解決這個問題,InnoDB在核心資料結構os_event中引入64位整形變數signal_count,用來記錄已經發出條件訊號的次數。每次發出一個條件通知,這個變數就遞增1。os_event_reset的返回值就把當前的signal_count值取出來。os_event_wait如果發現有這個引數的傳入,就會判斷傳入的引數與當前的signal_count值是否相同,如果不相同,表示這個已經通知過了,就不會進入等待了。舉個例子,假設亂序C,一開始的signal_count為100,步驟1把這個引數傳給了步驟4,在步驟4中,os_event_wait會發現傳入值100與當前的值101(步驟2中遞增了1)不同,所以執行緒A認為訊號已經發生過了,就不會再等待了。。。然而。。執行緒C呢?步驟3返回的值應該是101,傳給步驟5後,發生於當前值一樣。。繼續等待。。。仔細分析可以發現,執行緒C是屬於條件變數通知發生在等待之前(步驟2,步驟3,步驟5),上一段已經說過了,針對這種通知提前發出的,目前InnoDB沒有非常好的解法,只能呼叫者自己控制。
總結一下, InnoDB條件變數能方便InnoDB上層做監控,也簡化了條件變數使用的方法,但是呼叫者上層邏輯必須保證條件通知不能過早的發出,否則就會有無限等待的可能。

互斥鎖

互斥鎖保證一段程式同時只能一個執行緒訪問,保證臨界區得到正確的序列化訪問。同條件變數一樣,InnoDB對Pthread的mutex簡單包裝了一下,提供給其他模組用(主要是輔助其他自己實現的資料結構,不用InnoDB自己的互斥鎖是為了防止遞迴引用,詳見輔助結構這一節)。但與條件變數不同的是,InnoDB自己實現的一套互斥鎖並沒有依賴Pthread庫,而是依賴上述的原子操作(如果平臺不支援原子操作則使用Pthread庫,但是這種情況不太會發生,因為gcc在4.1就支援原子操作了)和上述的InnoDB條件變數。

系統互斥鎖

相比與系統條件變數,系統互斥鎖除了包裝Pthread庫外,還做了一層簡單的監控統計,結構名為os_mutex_t。在檔案os0sync.cc中,os_mutex_create建立mutex,並呼叫os_fast_mutex_init_func建立pthread的mutex,值得一提的是,建立pthread mutex的引數是my_fast_mutexattr的東西,其在MySQL server層函式my_thread_global_init初始化 ,只要pthread庫支援,則預設成初始化為PTHREAD_MUTEX_ADAPTIVE_NP和PTHREAD_MUTEX_ERRORCHECK。前者表示,當鎖釋放,之前在等待的鎖進行公平的競爭,而不是按照預設的優先順序模式。後者表示,如果發生了遞迴的加鎖,即同一個執行緒對同一個鎖連續加鎖兩次,第二次加鎖會報錯。另外三個有用的函式為,銷燬鎖os_mutex_free,加鎖os_mutex_enter,解鎖os_mutex_exit
一般來說,InnoDB上層模組不需要直接與系統互斥鎖打交道,需要用鎖的時候一般用InnoDB自己實現的一套互斥鎖。系統互斥鎖主要是用來輔助實現一些資料結構,例如最後一節提到的一些輔助結構,由於這些輔助結構可能本身就要提供給InnoDB自旋互斥鎖用,為了防止遞迴引用,就暫時用系統互斥鎖來代替。

InnoDB自旋互斥鎖

為什麼InnoDB需要實現自己的一套互斥鎖,不直接用上述的系統互斥鎖呢?這個主要有以下幾個原因,首先,系統互斥鎖是基於pthread mutex的,Heikki Tuuri(同步模組的作者,也是Innobase的創始人)認為在當時的年代pthread mutex上下文切換造成的cpu開銷太大,使用spin lock的方式在多處理器的機器上更加有效,尤其是在鎖競爭不是很嚴重的時候,Heikki Tuuri還總結出,在spin lock大概自旋20微秒的時候在多處理的機器下效率最高。其次,不使用pthread spin lock的原因是,當時在1995年左右的時候,spin lock的類似實現,效率很低,而且當時的spin lock不支援自定義自旋時間,要知道自旋鎖在單處理器的機器上沒什麼卵用。最後,也是為了更加完善的監控需求。總的來說,有歷史原因,有監控需求也有自定義自旋時間的需求,然後就有了這一套InnoDB自旋互斥鎖。
InnoDB自旋互斥鎖的實現主要在檔案sync0sync.cc和sync0sync.ic中,標頭檔案sync0sync.h定義了核心資料結構ib_mutex_t。使用方法很簡單,mutex_create建立鎖,mutex_free釋放鎖,mutex_enter嘗試獲得鎖,如果已經被佔用了,則等待。mutex_exit釋放鎖,同時喚醒所有等待的執行緒,拿到鎖的執行緒開始執行,其餘執行緒繼續等待。mutex_enter_nowait這個函式類似pthread的trylock,只要已檢測到鎖不用,就直接返回錯誤,不進行自旋等待。總體來說,InnoDB自旋互斥鎖的用法和語義跟系統互斥鎖一模一樣,但是底層實現卻大相徑庭。
在ib_mutex_t這個核心資料結構中,最重要的是前面兩個變數:event和lock_word。lock_word為0表示鎖空閒,1表示鎖被佔用,InnoDB自旋互斥鎖使用__sync_lock_test_and_set這個函式對lock_word進行原子操作,加鎖的時候,嘗試把其設定為1,函式返回值不指示是否成功,指示的是嘗試設定之前的值,因此如果返回值是0,表示加鎖成功,返回是1表示失敗。如果加鎖失敗,則會自旋一段時間,然後等待在條件變數event(os_event_wait)上,當鎖佔用者釋放鎖的時候,會使用os_event_set來喚醒所有的等待者。簡單的來說,byte型別的lock_word基於平臺提供的原子操作來實現互斥訪問,而event是InnoDB條件變數型別,用來實現鎖釋放後喚醒等待執行緒的操作。

接下來,詳細介紹一下,mutex_entermutex_exit的邏輯,InnoDB自旋互斥鎖的精華都在這兩個函式中。
mutex_enter的虛擬碼如下:

程式碼還是有點小複雜的。這裡分析幾點如下:
1. SPIN_ROUNDS控制了在放棄cpu時間片(yield_cpu)之前,一共進行多少次忙等,這個引數就是對外可配置的innodb_sync_spin_loops,而SPIN_WAIT_DELAY控制了每次忙等的時間,這個引數也就是對外可配置的innodb_spin_wait_delay。這兩個引數一起決定了自旋的時間。Heikki Tuuri建議在單處理器的機器上調小spin的時間,在對稱多處理器的機器上,可以適當調大。比較有意思的是innodb_spin_wait_delay的單位,這個是100MHZ的奔騰處理器處理1毫秒的時間,預設innodb_spin_wait_delay配置成6,表示最多在100MHZ的奔騰處理器上自旋6毫秒。由於現在cpu都是按照GHZ來計算的,所以按照預設配置自旋時間往往很短。此外,自旋不真是cpu傻傻的在那邊100%的跑,在現代的cpu上,給自旋專門提供了一條指令,在筆者的測試環境下,這條指令是pause,檢視Intel的文件,其對pause的解釋是:不會發生使用者態和核心態的切換,cpu在使用者態自旋,因此不會發生上下文切換,同時這條指令不會消耗太多的能耗。。。所以那些說spin lock太浪費電的不攻自破了。。。另外,編譯器也不會把ut_delay給優化掉,因為其裡面估計修改了一個全域性變數。
2. yield_cpu 操作在筆者的環境中,就是呼叫了pthread_yield函式,這個函式把放棄當前cpu的時間片,然後把當前執行緒放到cpu可執行佇列的末尾。
3. 在指示點1後面的迴圈,沒有采用原子操作讀取資料,是因為,Heikki Tuuri認為由於原子操作在記憶體和cpu cache之間會產生過的資料交換,如果只是讀本地的cache,可以減少匯流排的爭用。即使本地讀到髒的資料,也沒關係,因為在跳出迴圈的指示點2,依然會再一次使用原子操作進行校驗。
4. get cell這個操作是從sync array執行的,sync array詳見輔助資料結構這一節,簡單的說就是提供給監控執行緒使用的。
5. 注意一下,os_event_resetos_event_wait這兩個函式的呼叫位置,另外,有一點必須清楚,就是os_event_set(鎖持有者釋放所後會呼叫這個函式通知所有等待者)可能在這整段程式碼執行到任意位置出現,有可能出現在指示點4的位置,這樣就構成了條件變數通知在條件變數等待之前,會造成無限等待。為了解決這個問題,才有了指示點3下面的程式碼,需要重新再次檢測一下lock_word,另外,即使os_event_set發生在os_event_reset之後,有了這些程式碼,也能讓當前執行緒提前拿到鎖,不用執行後續os_event_wait的程式碼,一定程度上提高了效率。

mutex_exit的虛擬碼就簡單多了,如下:

1. waiter是ib_mutex_t中的一個變數,用來表示當前是否有執行緒在等待這個鎖。整個程式碼邏輯很簡單,就是先把lock_word設定為0,然後如果發現有等待者,就把所有等待者給喚醒。facebook的mark callaghan在2014年測試過,相比現在已經比較完善的pthread庫,InnoDB自旋互斥鎖只在併發量相對較低(小於256執行緒)和鎖等待時間比較短的情況下有優勢,在高併發且較長的鎖等待時間情況下,退化比較嚴重,其中一個很重要的原因就是InnoDB自旋互斥鎖在鎖釋放的時候需要喚醒所有等待者。由於os_event_ret底層通過pthread_cond_boardcast來通知所有的等待者,一種改進是把pthread_cond_boardcast改成pthread_cond_signal,即只喚醒一個執行緒,但Inaam Rana Mark測試後發現,如果只喚醒一個執行緒的話,在高併發的情況下,這個執行緒可能不會立刻被cpu排程到。。由此看來,似乎喚醒一個特定數量的等待者是一個比較好的選擇。
2. 虛擬碼中的這段註釋筆者估計加上去的,大意是由於編譯器或者cpu的指令重排亂序執行,mutex->waiter這個變數的讀取可能在發生在原子操作之前,從而導致一些無線等待的問題。然後還專門開了一個叫做sync_arr_wake_threads_if_sema_free的函式來做清理。這個函式是在後臺執行緒srv_error_monitor_thread中做的,每隔1秒鐘執行一次。在現代的cpu和編譯器上,完全可以用記憶體屏障的技術來防止指令重排和亂序執行,這個函式可以被去掉,官方的意見貌似是,不要這麼激進,萬一其他地方還需要這個函式呢。。詳見BUG #79477。

總體來說,InnoDB自旋互斥鎖的底層實現還是比較有意思的,非常適合學習研究。這套鎖機制在現在完善的Pthread庫和高達4GMHZ的cpu下,已經有點力不從心了,mark callaghan研究發現,在高負載的壓力下,使用這套鎖機制的InnoDB,大部分cpu時間都給了sys和usr,基本沒有空閒,而pthread mutex在相同情況下,卻有平均80%的空閒。同時,由於ib_mutex_t這個結構體體積比較龐大,當buffer pool比較大的時候,會發現鎖佔用了很多的記憶體。最後,從程式碼風格上來說,有不少程式碼沒有解耦,如果需要把鎖模組單獨打成一個函式庫,比較困難。
基於上述幾個缺陷,MySQL 5.7及後續的版本中,對互斥鎖進行了大量的重新,包括以下幾點(WL#6044):
1. 使用了C++中的類繼承關係,系統互斥鎖和InnoDB自己實現的自旋互斥鎖都是一個父類的子類。
2. 由於bool pool的鎖對效能要求比較高,因此使用靜態繼承(也就是模板)的方式來減少繼承中虛指標造成的開銷。
3. 保留舊的InnoDB自旋互斥鎖,並實現了一種基於futex的鎖。簡單的說,futex鎖與上述的原子操作類似,能減少使用者態和核心態切換的開銷,但同時保留類似mutex的使用方法,大大降低了程式編寫的難度。

InnoDB讀寫鎖

與條件變數、互斥鎖不同,InnoDB裡面沒有Pthread庫的讀寫鎖的包裝,其完全依賴依賴於原子操作和InnoDB的條件變數,甚至都不需要依賴InnoDB的自旋互斥鎖。此外,讀寫鎖還實現了寫操作的遞迴鎖,即同一個執行緒可以多次獲得寫鎖,但是同一個執行緒依然不能同時獲得讀鎖和寫鎖。InnoDB讀寫鎖的核心資料結構rw_lock_t中,並沒有等待佇列的資訊,因此不能保證先到的請求一定會先進入臨界區。這與系統互斥量用PTHREAD_MUTEX_ADAPTIVE_NP來初始化有異曲同工之妙。
InnoDB讀寫鎖的核心實現在原始檔sync0rw.cc和sync0rw.ic中,核心資料結構rw_lock_t定義在sync0rw.h中。使用方法與InnoDB自旋互斥鎖很類似,只不過讀請求和寫請求要呼叫不同的函式。加讀鎖呼叫rw_lock_s_lock, 加寫鎖呼叫rw_lock_x_lock,釋放讀鎖呼叫rw_lock_s_unlock, 釋放寫鎖呼叫rw_lock_x_unlock,建立讀寫鎖呼叫rw_lock_create,釋放讀寫鎖呼叫rw_lock_free。函式rw_lock_x_lock_nowaitrw_lock_s_lock_nowait表示,當加讀寫鎖失敗的時候,直接返回,而不是自旋等待。

核心機制

rw_lock_t中,核心的成員有以下幾個:lock_word, event, waiters, wait_ex_event,writer_thread, recursive。
與InnoDB自旋互斥鎖的lock_word不同,rw_lock_t中的lock_word是int 型,注意不是unsigned的,其取值範圍是(-2X_LOCK_DECR, X_LOCK_DECR],其中X_LOCK_DECR為0x00100000,差不多100多W的一個數。在InnoDB自旋互斥鎖互斥鎖中,lock_word的取值範圍只有0,1,因為這兩個狀態就能把互斥鎖的所有狀態都表示出來了,也就是說,只需要檢視一下這個lock_word就能確定當前的執行緒是否能獲得鎖。rw_lock_t中的lock_word也扮演了相同的角色,只需要檢視一下當前的lock_word落在哪個取值範圍中,就確定當前執行緒能否獲得鎖。至於rw_lock_t中的lock_word是如何做到這一點的,這其實是InnoDB讀寫鎖乃至InnoDB同步機制中最神奇的地方,下文我們會詳細分析。
event是一個InnoDB條件變數,噹噹前的鎖已經被一個執行緒以寫鎖方式獨佔時,後續的讀鎖和寫鎖都等待在這個event上,當這個執行緒釋放寫鎖時,等待在這個event上的所有讀鎖和寫鎖同時競爭。waiters這變數,與event一起用,當有等待者在等待時,這個變數被設定為1,否則為0,鎖被釋放的時候,需要通過這個變數來判斷有沒有等待者從而執行os_event_set
與InnoDB自旋互斥鎖不同,InnoDB讀寫鎖還有wait_ex_event和recursive兩個變數。wait_ex_event也是一個InnoDB條件變數,但是它用來等待第一個寫鎖(因為寫請求可能會被先前的讀請求堵住),當先前到達的讀請求都讀完了,就會通過這個event來喚醒這個寫鎖的請求。
由於InnoDB讀寫鎖實現了寫鎖的遞迴,因此需要儲存當前寫鎖被哪個執行緒佔用了,後續可以通過這個值來判斷是否是這個執行緒的寫鎖請求,如果是則加鎖成功,否則失敗,需要等待。執行緒的id就儲存在writer_thread這個變數中。
recursive是個bool變數,用來表示當前的讀寫鎖是否支援遞迴寫模式,在某些情況下,例如需要另外一個執行緒來釋放這個讀寫鎖(insert buffer需要這個功能)的時候,就不要開啟遞迴模式了。

接下來,我們來詳細介紹一下lock_word的變化規則:
1. 當有一個讀請求加鎖成功時,lock_word原子遞減1。
2. 當有一個寫請求加鎖成功時,lock_word原子遞減X_LOCK_DECR。
3. 如果讀寫鎖支援遞迴寫,那麼第一個遞迴寫鎖加鎖成功時,lock_word依然原子遞減X_LOCK_DECR,而後續的遞迴寫鎖加鎖成功是,lock_word只是原子遞減1。
在上述的變化規則約束下,lock_word會形成以下幾個區間:
lock_word == X_LOCK_DECR: 表示鎖空閒,即當前沒有執行緒獲得了這個鎖。
0 < lock_word < X_LOCK_DECR: 表示當前有X_LOCK_DECR – lock_word個讀鎖
lock_word == 0: 表示當前有一個寫鎖
-X_LOCK_DECR < lock_word < 0: 表示當前有-lock_word個讀鎖,他們還沒完成,同時後面還有一個寫鎖在等待
lock_word <= -X_LOCK_DECR: 表示當前處於遞迴鎖模式,同一個執行緒加了2 – (lock_word + X_LOCK_DECR)次寫鎖。

另外,還可以得出以下結論
1. 由於lock_word的範圍被限制(rw_lock_validate)在(-2X_LOCK_DECR, X_LOCK_DECR]中,結合上述規則,可以推斷出,一個讀寫鎖最多能加X_LOCK_DECR個讀鎖。在開啟遞迴寫鎖的模式下,一個執行緒最多同時加X_LOCK_DECR+1個寫鎖。
2. 在讀鎖釋放之前,lock_word一定處於(-X_LOCK_DECR, 0)U(0, X_LOCK_DECR)這個範圍內。
3. 在寫鎖釋放之前,lock_word一定處於(-2*X_LOCK_DECR, -X_LOCK_DECR]或者等於0這個範圍內。
4. 只有在lock_word大於0的情況下才可以對它遞減。有一個例外,就是同一個執行緒需要加遞迴寫鎖的時候,lock_word可以在小於0的情況下遞減。

接下來,舉個讀寫鎖加鎖的例子,方便讀者理解讀寫鎖底層加鎖的原理。假設有讀寫加鎖請求按照以下順序依次到達:R1->R2->W1->R3->W2->W3->R4,其中W2和W3是屬於同一個執行緒的寫加鎖請求,其他所有讀寫請求均來自不同執行緒。初始化後,lock_word的值為X_LOCK_DECR(十進位制值為1048576)。R1讀加鎖請求首先到,其發現lock_word大於0,表示可以加讀鎖,同時lock_word遞減1,結果為1048575,R2讀加鎖請求接著來到,發現lock_word依然大於0,繼續加讀鎖並遞減lock_word,最終結果為1048574。注意,如果R1和R2幾乎是同時到達,即使時序上是R1先請求,但是並不保證R1首先遞減,有可能是R2首先拿到原子操作的執行許可權。如果在R1或者R2釋放鎖之前,寫加鎖請求W1到來,他發現lock_word依舊大於0,於是遞減X_LOCK_DECR,並把自己的執行緒id記錄在writer_thread這個變數裡,再檢查lock_word的值(此時為-2),由於結果小於0,表示前面有未完成的讀加鎖請求,於是其等待在wait_ex_event這個條件變數上。後續的R3, W2, W3, R4請求發現lock_word小於0,則都等待在條件變數event上,並且設定waiter為1,表示有等待者。假設R1先釋放讀鎖(lock_word遞增1),R2後釋放(lock_word再次遞增1)。R2釋放後,由於lock_word變為0了,其會在wait_ex_event上呼叫os_event_set,這樣W3就被喚醒了,他可以執行臨界區內的程式碼了。W3執行完後,lock_word被恢復為X_LOCK_DECR,然後其發現waiter為1,表示在其後面有新的讀寫加鎖請求在等待,然後在event上呼叫os_event_set,這樣R3, W2, W3, R4同時被喚醒,進行原子操作執行許可權爭搶(可以簡單的理解為誰先得到cpu排程)。假設W2首先搶到了執行許可權,其會把lock_word再次遞減為0並自己的執行緒id記錄在writer_thread這個變數裡,當檢查lock_word的時候,發現值為0,表示前面沒有讀請求了,於是其就進入臨界區執行程式碼了。假設此時,W3得到了cpu的排程,由於lock_word只有在大於0的情況下才能遞減,所以其遞減lock_word失敗,但是其通過對比writer_thread和自己的執行緒id,發現前面的寫鎖是自己加的,如果這個時候開啟了遞迴寫鎖,即recursive值為true,他把lock_word再次遞減X_LOCK_DECR(現在lock_word變為-X_LOCK_DECR了),然後進入臨界區執行程式碼。這樣就保證了同一個執行緒多次加寫鎖也不發生死鎖,也就是遞迴鎖的概念。後續的R3和R4發現lock_word小於等於0,就直接等待在event條件變數上,並設定waiter為1。直到W2和W3都釋放寫鎖,lock_word又變為X_LOCK_DECR,最後一個釋放的,檢查waiter變數發現非0,就會喚醒event上的所有等待者,於是R3和R4就可以執行了。
讀寫鎖的核心函式函式結構跟InnoDB自旋互斥鎖的基本相同,主要的區別就是用rw_lock_x_lock_lowrw_lock_s_lock_low替換了__sync_lock_test_and_set原子操作。rw_lock_x_lock_lowrw_lock_s_lock_low就按照上述的lock_word的變化規則來原子的改變(依然使用了__sync_lock_test_and_set)lock_word這個變數。

在MySQL 5.7中,讀寫鎖除了可以加讀鎖(Share lock)請求和加寫鎖(exclusive lock)請求外,還可以加share exclusive鎖請求,鎖相容性如下:

按照WL#6363的說法,是為了修復index->lock這個鎖的衝突。

輔助結構

InnoDB同步機制中,還有很多使用的輔助結構,他們的作用主要是為了監控方便和死鎖的預防和檢測。這裡主要介紹sync array, sync thread level array和srv_error_monitor_thread。
sync array主要的資料結構是sync_array_t,可以把他理解為一個資料,陣列中的元素為sync_cell_t。當一個鎖(InnoDB自旋互斥鎖或者InnoDB讀寫鎖,下同)需要發生os_event_wait等待時,就需要在sync array中申請一個sync_cell_t來儲存當前的資訊,這些資訊包括等待鎖的指標(便於死鎖檢測),在哪一個檔案以及哪一行發生了等待(也就是mutex_enter, rw_lock_s_lock或者rw_lock_x_lock被呼叫的地方,只在debug模式下有效),發生等待的執行緒(便於死鎖檢測)以及等待開始的時間(便於統計等待的時間)。當鎖釋放的時候,就把相關聯的sync_cell_t重置為空,方便複用。sync_cell_t在sync_array_t中的個數,是在初始化同步模組時候就指定的,其個數一般為OS_THREAD_MAX_N,而OS_THREAD_MAX_N是在InnoDB初始化的時候被計算,其包括了系統後臺開啟的所有執行緒,以及max_connection指定的個數,還預留了一些。由於一個執行緒在某一個時刻最多隻能發生一個鎖等待,所以不用擔心sync_cell_t不夠用。從上面也可以看出,在每個鎖進行等待和釋放的時候,都需要對sync array操作,因此在高併發的情況下,單一的sync array可能成為瓶頸,在MySQL 5.6中,引入了多sync array, 個數可以通過innodb_sync_array_size進行控制,這個值預設為1,在高併發的情況下,建議調高。

InnoDB作為一個成熟的儲存引擎,包含了完善的死鎖預防機制和死鎖檢測機制。在每次需要鎖等待時,即呼叫os_event_wait之前,需要啟動死鎖檢測機制來保證不會出現死鎖,從而造成無限等待。在每次加鎖成功(lock_word遞減後,函式返回之前)時,都會啟動死鎖預防機制,降低死鎖出現的概率。當然,由於死鎖預防機制和死鎖檢測機制需要掃描比較多的資料,演算法上也有遞迴操作,所以只在debug模式下開啟。
死鎖檢測機制主要依賴sync array中儲存的資訊以及死鎖檢測演算法來實現。死鎖檢測機制通過sync_cell_t儲存的等待鎖指標和發生等待的執行緒以及教科書上的有向圖環路檢測演算法來實現,具體實現在sync_array_deadlock_stepsync_array_detect_deadlock中實現,仔細研究後發現個小問題,由於sync_array_find_thread函式僅僅在當前的sync array中遍歷,當有多個sync array時(innodb_sync_array_size > 1),如果死鎖發生在不同的sync array上,現有的死鎖檢測演算法將無法發現這個死鎖。
死鎖預防機制是由sync thread level array和全域性鎖優先順序共同保證的。InnoDB為了降低死鎖發生的概率,上層的每種型別的鎖都有一個優先順序。例如回滾段鎖的優先順序就比檔案系統page頁的優先順序高,雖然兩者底層都是InnoDB互斥鎖或者InnoDB讀寫鎖。有了這個優先順序,InnoDB規定,每個鎖建立是必須制定一個優先順序,同一個執行緒的加鎖順序必須從優先順序高到低,即如果一個執行緒目前已經加了一個低優先順序的鎖A,在釋放鎖A之前,不能再請求優先順序比鎖A高(或者相同)的鎖。形成死鎖需要四個必要條件,其中一個就是不同的加鎖順序,InnoDB通過鎖優先順序來降低死鎖發生的概率,但是不能完全消除。原因是可以把鎖設定為SYNC_NO_ORDER_CHECK這個優先順序,這是最高的優先順序,表示不進行死鎖預防檢查,如果上層的程式設計師把自己建立的鎖都設定為這個優先順序,那麼InnoDB提供的這套機制將完全失效,所以要養成給鎖設定優先順序的好習慣。sync thread level array是一個陣列,每個執行緒單獨一個,在同步模組初始化時分配了OS_THREAD_MAX_N個,所以不用擔心不夠用。這個陣列中記錄了某個執行緒當前鎖擁有的所有鎖,當新加了一個鎖B時,需要掃描一遍這個陣列,從而確定目前執行緒所持有的鎖的優先順序都比鎖B高。

最後,我們來講講srv_error_monitor_thread這個執行緒。這是一個後臺執行緒,在InnoDB啟動的時候啟動,每隔1秒鐘執行一下指定的操作。跟同步模組相關的操作有兩點,去除無限等待的鎖和報告長時間等待的異常鎖。
去除無線等待的鎖,如上文所屬,就是sync_arr_wake_threads_if_sema_free這個函式。這個函式通過遍歷sync array,如果發現鎖已經可用(sync_arr_cell_can_wake_up),但是依然有等待者,則直接呼叫os_event_set把他們喚醒。這個函式是為了解決由於cpu亂序執行或者編譯器指令重排導致鎖無限等待的問題,但是可以通過記憶體屏障技術來避免,所以可以去掉。
報告長時間等待的異常鎖,通過sync_cell_t裡面記錄的鎖開始等待時間,我們可以很方便的統計鎖等待發生的時間。在目前的實現中,當鎖等待超過240秒的時候,就會在錯誤日誌中看到資訊。如果同一個鎖被檢測到等到超過600秒且連續10次被檢測到,則InnoDB會通過assert來自殺。。。相信當做運維DBA的同學一定看到過如下的報錯:

一般出現這種錯誤都是pread或者pwrite長時間不返回,導致鎖超時。至於pread或者pwrite長時間不返回的root cause常常是有很多的讀寫請求在極短的時間內到達導致磁碟扛不住或者磁碟已經壞了。。。

總結

本文詳細介紹了原子操作,條件變數,互斥鎖以及讀寫鎖在InnoDB引擎中的實現。原子操作由於其能減少不必要的使用者態和核心態的切換以及更精簡的cpu指令被廣泛的應用到InnoDB自旋互斥鎖和InnoDB讀寫鎖中。InnoDB條件變數使用更加方便,但是一定要注意條件通知必須在條件等待之後,否則會有無限等待發生。InnoDB自旋互斥鎖加鎖和解鎖過程雖然複雜但是都是必須的操作。InnoDB讀寫鎖神奇的lock_word控制方法給我們留下了深刻影響。正因為InnoDB底層同步機制的穩定、高效,MySQL在我們的伺服器上才能執行的如此穩定。

相關文章