現代作業系統以及硬體基本都支援并發程式,而在并發程式設計中,各個程序或者線程需要對公共變量的通路加以制約,此外,不同的程序或者線程需要協同工作以完成特征的任務,這就需要一套完善的同步機制,在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以上的版本進行分析。
<code>os_compare_and_swap_xxx(ptr, old_val, new_val)</code>類型的操作底層都使用了gcc包裝的<code>__sync_bool_compare_and_swap(ptr, old_val, new_val)</code>函數,語義為,交換成功則傳回true,ptr是交換後的值,old_val是之前的值,new_val是交換後的預期值。這個原子操作是個記憶體屏障(full memory barrier)。
<code>os_atomic_increment_xxx</code>類型的操作底層使用了函數<code>__sync_add_and_fetch</code>,<code>os_atomic_decrement_xxx</code>類型的操作使用了函數<code>__sync_sub_and_fetch</code>,分别表示原子遞增和原子遞減。這個兩個原子操作也都是記憶體屏障(full memory barrier)。
另外一個比較重要的原子操作是<code>os_atomic_test_and_set_byte(ptr, new_val)</code>,這個操作使用了<code>__sync_lock_test_and_set(ptr, new_val)</code>這個函數,語義為,把ptr設定為new_val,同時傳回舊的值。這個操作提供了原子改變某個變量值的操作,InnoDB鎖實作的同步機制中,大量的用了這個操作,是以比較重要。需要注意的是,參看gcc文檔,這個操作不是full memory barrier,隻是一個acquire barrier,簡單的說就是,代碼中<code>__sync_lock_test_and_set</code>之後操作不能被亂序或者重排到<code>__sync_lock_test_and_set</code>之前,但是<code>__sync_lock_test_and_set</code>之前的操作可能被重排到其之後。
關于記憶體屏障的專門指令,MySQL 5.7提供的比較完善。<code>os_rmb</code>表示acquire barrier,<code>os_wmb</code>表示release barrier。如果在程式設計時,需要在某個位置準确的讀取一個變量的值時,記得在讀取之前加上os_rmb,同理,如果需要在某個位置保證一個變量已經被寫了,記得在寫之後調用os_wmb。
條件通知機制在多線程協作中非常有用,一個線程往往需要等待其他線程完成指定工作後,再進行工作,這個時候就需要有線程等待和線程通知機制。<code>Pthread_cond_XXX</code>類似的變量和函數來完成等待和通知的工作。InnoDB中,對Pthread庫進行了簡單的封裝,并在此基礎上,進一步抽象,提供了一套友善易用的接口函數給調用者使用。
在檔案os0sync.cc中,<code>os_cond_XXX</code>類似的函數就是InnoDB對Pthread庫的封裝。常用的幾個函數如:
<code>os_cond_t</code>是核心的操作對象,其實就是pthread_cond_t的一層typedef而已,<code>os_cond_init</code>初始化函數,<code>os_cond_destroy</code>銷毀函數,<code>os_cond_wait</code>條件等待,不會逾時,<code>os_cond_wait_timed</code>條件等待,如果逾時則傳回,<code>os_cond_broadcast</code>喚醒所有等待線程,<code>os_cond_signal</code>隻喚醒其中一個等待線程,但是在閱讀源碼的時候發現,似乎沒有什麼地方調用了<code>os_cond_signal</code>。。。
此外,還有一個<code>os_cond_module_init</code>函數,用來window下的初始化操作。
在InnoDB下,<code>os_cond_XXX</code>子產品的函數主要是給InnoDB自己設計的條件變量使用。
如果在InnoDB層直接使用系統條件變量的話,主要有四個弊端,首先,弊端1,系統條件變量的使用需要與一個系統互斥鎖(詳見下一節)相配合使用,使用完還要記得及時釋放,使用者會比較麻煩。接着,弊端2,在條件等待的時候,需要在一個循環中等待,使用者還是比較麻煩。最後,弊端3,也是比較重要的,不友善系統監控。
基于以上幾點,InnoDB基于系統的條件變量和系統互斥鎖自己實作了一套條件通知機制。主要在檔案os0sync.cc中實作,相關資料結構以及接口進一層的包裝在頭檔案os0sync.h中。使用方法如下:
InnoDB條件變量核心資料結構為<code>os_event_t</code>,類似<code>pthread_cont_t</code>。如果需要建立和銷毀則分别使用<code>os_event_create</code>和<code>os_event_free</code>函數。需要等待某個條件變量,先調用<code>os_event_reset</code>(原因見下一段),然後使用<code>os_event_wait</code>,如果需要逾時等待,使用<code>os_event_wait_time</code>替換<code>os_event_wait</code>即可,<code>os_event_wait_XXX</code>這兩個函數,解決了弊端1和弊端2,此外,建議把<code>os_event_reset</code>傳回值傳給他們,這樣能防止多線程情況下的無限等待(詳見下下段)。如果需要發出一個條件通知,使用<code>os_event_set</code>。這個幾個函數,裡面都插入了一些監控資訊,友善InnoDB上層管理。怎麼樣,友善多了吧~
首先來說說兩個線程下會發生的問題。建立後,正常的使用順序是這樣的,線程A首先<code>os_event_reset</code>(步驟1),然後<code>os_event_wait</code>(步驟2),接着線程B做完該做的事情後,執行<code>os_event_set</code>(步驟3)發送信号,通知線程A停止等待,但是在多線程的環境中,會出現以下兩種步驟順序錯亂的情況:亂序A: <code>步驟1--步驟3--步驟2</code>,亂序B: <code>步驟3--步驟1--步驟2</code>。對于亂序B,屬于條件通知在條件等待之前發生,目前InnoDB條件變量的機制下,會發生無限等待,是以上層調用的時候一定要注意,例如在InnoDB在實作互斥鎖和讀寫鎖的時候為了防止發生條件通知在條件等待之前發生,在等待之前對lock_word再次進行了判斷,詳見InnoDB自旋互斥鎖這一節。為了解決亂序A,InnoDB在核心資料結構os_event中引入布爾型變量is_set,is_set這個變量就表示是否已經發生過條件通知,在每次調用條件通知之前,會把這個變量設定為true(在<code>os_event_reset</code>時改為false,便于多次通知),在條件等待之前會檢查一下這變量,如果這個變量為true,就不再等待了。是以,亂序A也能保證不會發生無限等待。
接着我們來說說大于兩個線程下可能會發生的問題。線程A和C是等待線程,等待同一個條件變量,B是通知線程,通知A和C結束等待。考慮一個亂序C:線程A執行<code>os_event_reset</code>(步驟1),線程B馬上就執行<code>os_event_set</code>(步驟2)了,接着線程C執行了<code>os_event_reset</code>(步驟3),最後線程A執行<code>os_event_wait</code>(步驟4),線程C執行<code>os_event_wait</code>(步驟5)。乍一眼看,好像看不出啥問題,但是實際上你會發現A和C線程在無限等待了。原因是,步驟2,把is_set這個變量設定為false,但是在步驟3,線程C通過reset又把它給重新設回false了。。然後線程A和C在<code>os_event_wait</code>中誤以為還沒有發生過條件通知,就開始無限等待了。為了解決這個問題,InnoDB在核心資料結構os_event中引入64位整形變量signal_count,用來記錄已經發出條件信号的次數。每次發出一個條件通知,這個變量就遞增1。<code>os_event_reset</code>的傳回值就把目前的signal_count值取出來。<code>os_event_wait</code>如果發現有這個參數的傳入,就會判斷傳入的參數與目前的signal_count值是否相同,如果不相同,表示這個已經通知過了,就不會進入等待了。舉個例子,假設亂序C,一開始的signal_count為100,步驟1把這個參數傳給了步驟4,在步驟4中,<code>os_event_wait</code>會發現傳入值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庫外,還做了一層簡單的監控統計,結構名為<code>os_mutex_t</code>。在檔案os0sync.cc中,<code>os_mutex_create</code>建立mutex,并調用<code>os_fast_mutex_init_func</code>建立pthread的mutex,值得一提的是,建立pthread mutex的參數是<code>my_fast_mutexattr</code>的東西,其在MySQL server層函數<code>my_thread_global_init</code>初始化 ,隻要pthread庫支援,則預設成初始化為PTHREAD_MUTEX_ADAPTIVE_NP和PTHREAD_MUTEX_ERRORCHECK。前者表示,當鎖釋放,之前在等待的鎖進行公平的競争,而不是按照預設的優先級模式。後者表示,如果發生了遞歸的加鎖,即同一個線程對同一個鎖連續加鎖兩次,第二次加鎖會報錯。另外三個有用的函數為,銷毀鎖<code>os_mutex_free</code>,加鎖<code>os_mutex_enter</code>,解鎖<code>os_mutex_exit</code>。
一般來說,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。使用方法很簡單,<code>mutex_create</code>建立鎖,<code>mutex_free</code>釋放鎖,<code>mutex_enter</code>嘗試獲得鎖,如果已經被占用了,則等待。<code>mutex_exit</code>釋放鎖,同時喚醒所有等待的線程,拿到鎖的線程開始執行,其餘線程繼續等待。<code>mutex_enter_nowait</code>這個函數類似pthread的trylock,隻要已檢測到鎖不用,就直接傳回錯誤,不進行自旋等待。總體來說,InnoDB自旋互斥鎖的用法和語義跟系統互斥鎖一模一樣,但是底層實作卻大相徑庭。
在ib_mutex_t這個核心資料結構中,最重要的是前面兩個變量:event和lock_word。lock_word為0表示鎖空閑,1表示鎖被占用,InnoDB自旋互斥鎖使用<code>__sync_lock_test_and_set</code>這個函數對lock_word進行原子操作,加鎖的時候,嘗試把其設定為1,函數傳回值不訓示是否成功,訓示的是嘗試設定之前的值,是以如果傳回值是0,表示加鎖成功,傳回是1表示失敗。如果加鎖失敗,則會自旋一段時間,然後等待在條件變量event(<code>os_event_wait</code>)上,當鎖占用者釋放鎖的時候,會使用<code>os_event_set</code>來喚醒所有的等待者。簡單的來說,byte類型的lock_word基于平台提供的原子操作來實作互斥通路,而event是InnoDB條件變量類型,用來實作鎖釋放後喚醒等待線程的操作。
接下來,詳細介紹一下,<code>mutex_enter</code>和<code>mutex_exit</code>的邏輯,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. 注意一下,<code>os_event_reset</code>和<code>os_event_wait</code>這兩個函數的調用位置,另外,有一點必須清楚,就是<code>os_event_set</code>(鎖持有者釋放所後會調用這個函數通知所有等待者)可能在這整段代碼執行到任意位置出現,有可能出現在訓示點4的位置,這樣就構成了條件變量通知在條件變量等待之前,會造成無限等待。為了解決這個問題,才有了訓示點3下面的代碼,需要重新再次檢測一下lock_word,另外,即使<code>os_event_set</code>發生在<code>os_event_reset</code>之後,有了這些代碼,也能讓目前線程提前拿到鎖,不用執行後續<code>os_event_wait</code>的代碼,一定程度上提高了效率。
mutex_exit的僞代碼就簡單多了,如下:
1. waiter是ib_mutex_t中的一個變量,用來表示目前是否有線程在等待這個鎖。整個代碼邏輯很簡單,就是先把lock_word設定為0,然後如果發現有等待者,就把所有等待者給喚醒。facebook的mark callaghan在2014年測試過,相比現在已經比較完善的pthread庫,InnoDB自旋互斥鎖隻在并發量相對較低(小于256線程)和鎖等待時間比較短的情況下有優勢,在高并發且較長的鎖等待時間情況下,退化比較嚴重,其中一個很重要的原因就是InnoDB自旋互斥鎖在鎖釋放的時候需要喚醒所有等待者。由于<code>os_event_ret</code>底層通過pthread_cond_boardcast來通知所有的等待者,一種改進是把pthread_cond_boardcast改成pthread_cond_signal,即隻喚醒一個線程,但Inaam Rana Mark測試後發現,如果隻喚醒一個線程的話,在高并發的情況下,這個線程可能不會立刻被cpu排程到。。由此看來,似乎喚醒一個特定數量的等待者是一個比較好的選擇。
2. 僞代碼中的這段注釋筆者估計加上去的,大意是由于編譯器或者cpu的指令重排亂序執行,mutex->waiter這個變量的讀取可能在發生在原子操作之前,進而導緻一些無線等待的問題。然後還專門開了一個叫做<code>sync_arr_wake_threads_if_sema_free</code>的函數來做清理。這個函數是在背景線程<code>srv_error_monitor_thread</code>中做的,每隔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裡面沒有Pthread庫的讀寫鎖的包裝,其完全依賴依賴于原子操作和InnoDB的條件變量,甚至都不需要依賴InnoDB的自旋互斥鎖。此外,讀寫鎖還實作了寫操作的遞歸鎖,即同一個線程可以多次獲得寫鎖,但是同一個線程依然不能同時獲得讀鎖和寫鎖。InnoDB讀寫鎖的核心資料結構rw_lock_t中,并沒有等待隊列的資訊,是以不能保證先到的請求一定會先進入臨界區。這與系統互斥量用PTHREAD_MUTEX_ADAPTIVE_NP來初始化有異曲同工之妙。
InnoDB讀寫鎖的核心實作在源檔案sync0rw.cc和sync0rw.ic中,核心資料結構rw_lock_t定義在sync0rw.h中。使用方法與InnoDB自旋互斥鎖很類似,隻不過讀請求和寫請求要調用不同的函數。加讀鎖調用<code>rw_lock_s_lock</code>, 加寫鎖調用<code>rw_lock_x_lock</code>,釋放讀鎖調用<code>rw_lock_s_unlock</code>, 釋放寫鎖調用<code>rw_lock_x_unlock</code>,建立讀寫鎖調用<code>rw_lock_create</code>,釋放讀寫鎖調用<code>rw_lock_free</code>。函數<code>rw_lock_x_lock_nowait</code>和<code>rw_lock_s_lock_nowait</code>表示,當加讀寫鎖失敗的時候,直接傳回,而不是自旋等待。
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,鎖被釋放的時候,需要通過這個變量來判斷有沒有等待者進而執行<code>os_event_set</code>。
與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上調用<code>os_event_set</code>,這樣W3就被喚醒了,他可以執行臨界區内的代碼了。W3執行完後,lock_word被恢複為X_LOCK_DECR,然後其發現waiter為1,表示在其後面有新的讀寫加鎖請求在等待,然後在event上調用<code>os_event_set</code>,這樣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自旋互斥鎖的基本相同,主要的差別就是用<code>rw_lock_x_lock_low</code>和<code>rw_lock_s_lock_low</code>替換了<code>__sync_lock_test_and_set</code>原子操作。<code>rw_lock_x_lock_low</code>和<code>rw_lock_s_lock_low</code>就按照上述的lock_word的變化規則來原子的改變(依然使用了<code>__sync_lock_test_and_set</code>)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讀寫鎖,下同)需要發生<code>os_event_wait</code>等待時,就需要在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作為一個成熟的存儲引擎,包含了完善的死鎖預防機制和死鎖檢測機制。在每次需要鎖等待時,即調用<code>os_event_wait</code>之前,需要啟動死鎖檢測機制來保證不會出現死鎖,進而造成無限等待。在每次加鎖成功(lock_word遞減後,函數傳回之前)時,都會啟動死鎖預防機制,降低死鎖出現的機率。當然,由于死鎖預防機制和死鎖檢測機制需要掃描比較多的資料,算法上也有遞歸操作,是以隻在debug模式下開啟。
死鎖檢測機制主要依賴sync array中儲存的資訊以及死鎖檢測算法來實作。死鎖檢測機制通過sync_cell_t儲存的等待鎖指針和發生等待的線程以及教科書上的有向圖環路檢測算法來實作,具體實作在<code>sync_array_deadlock_step</code>和<code>sync_array_detect_deadlock</code>中實作,仔細研究後發現個小問題,由于<code>sync_array_find_thread</code>函數僅僅在目前的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,如果發現鎖已經可用(<code>sync_arr_cell_can_wake_up</code>),但是依然有等待者,則直接調用<code>os_event_set</code>把他們喚醒。這個函數是為了解決由于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在我們的伺服器上才能運作的如此穩定。
About me
大學畢業于西安東大男子技術專修學校(好評1,好評2)
碩士浪迹于帝都中關村,出沒在融科計算機教育訓練學校(好評1,好評2),整天捉摸着黃色圖檔、血腥暴力圖檔、反動圖檔的監控,說白了就是為某牆服務,呵呵
家住浙江甯波,是以在阿裡巴巴工作(16年7月入職),阿裡雲事業部,雲資料庫ApsaraDB源碼組,主攻MySQL核心開發
喜愛計算機,熱愛程式設計,尤其是資料庫領域,熟悉MySQL,資料庫基本理論,資料庫中間件等
對高性能伺服器開發、高性能代碼優化也略有涉獵
此外,偏愛攝影,目前維護lofter照片分享網站,立志成為碼農界最好的攝影師~
有什麼事的話,可以在這裡留言或者通路我的新浪微網誌哦~