MySQL · 引擎特性 · InnoDB 同步機制

前言

現代操做系統以及硬件基本都支持併發程序,而在併發程序設計中,各個進程或者線程須要對公共變量的訪問加以制約,此外,不一樣的進程或者線程須要協同工做以完成特徵的任務,這就須要一套完善的同步機制,在Linux內核中有相應的技術實現,包括原子操做,信號量,互斥鎖,自旋鎖,讀寫鎖等。InnoDB考慮到效率和監控兩方面的緣由,實現了一套獨有的同步機制,提供給其餘模塊調用。本文的分析默認基於MySQL 5.6,CentOS 6,gcc 4.8,其餘版本的信息會另行指出。mysql

基礎知識

同步機制對於其餘數據庫模塊來講相對獨立,可是須要比較多的操做系統以及硬件知識,這裏簡單介紹一下幾個有用的概念,便於讀者理解後續概念。
內存模型 :主要分爲語言級別的內存模型和硬件級別的內存模型。語言級別的內存模型,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庫進行了簡單的封裝,並在此基礎上,進一步抽象,提供了一套方便易用的接口函數給調用者使用。sql

系統條件變量

在文件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的僞代碼以下:多線程

if (__sync_lock_test_and_set(mutex->lock_word, 1) == 0) {
    get mutex successfully;
    return;
}
loop1:
    i = 0;
loop2:
    /*指示點1*/
    while (mutex->lock_word ! = 0 && i < SPIN_ROUNDS) {
             random spin using ut_delay, spin max time depend on SPIN_WAIT_DELAY;
             i++;
}
if (i == SPIN_ROUNDS) {
    yield_cpu;
}
/*指示點2*/
if (__sync_lock_test_and_set(mutex->lock_word, 1) == 0) {
    get mutex successfully;
    return;
}
if (i < SPIN_ROUNDS) {
     goto loop2
}
/*指示點4*/
get cell from sync array and call os_event_reset(mutex->event);
mutex->waiter =1;
/*指示點3*/
for (i = 0; i < 4; i++) {
    if (__sync_lock_test_and_set(mutex->lock_word, 1) == 0) {
        get mutex successfully;
        free cell;
        return;
    }
}
sync array wait and os_event_wait(mutex->event);
goto loop1;

代碼仍是有點小複雜的。這裏分析幾點以下:
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的僞代碼就簡單多了,以下:

__sync_lock_test_and_set(mutex->lock_word, 0);

/* A problem: we assume that mutex_reset_lock word                                                                     
        is a memory barrier, that is when we read the waiters                                                                  
        field next, the read must be serialized in memory                                                                      
        after the reset. A speculative processor might                                                                         
        perform the read first, which could leave a waiting                                                                    
        thread hanging indefinitely.                                                                                                                                                                                                                        
Our current solution call every second                                                                                 
        sync_arr_wake_threads_if_sema_free()                                                                                   
        to wake up possible hanging threads if                                                                                 
        they are missed in mutex_signal_object. */ 

if (mutex->waiter != 0) {
     mutex->waiter = 0;
     os_event_set(mutex->event);
}

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)在(-2
X_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鎖請求,鎖兼容性以下:

LOCK COMPATIBILITY MATRIX
    S SX  X
 S  +  +  -
 SX +  -  -
 X  -  -  -

按照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的同窗必定看到過以下的報錯:

InnoDB: Warning: a long semaphore wait:
--Thread 139774244570880 has waited at log0read.h line 765 for 241.00 seconds the semaphore:
Mutex at 0x30c75ca0 created file log0read.h line 522, lock var 1 
Last time reserved in file /home/yuhui.wyh/mysql/storage/innobase/include/log0read.h line 765, waiters flag 1
InnoDB: ###### Starts InnoDB Monitor for 30 secs to print diagnostic info:
InnoDB: Pending preads 0, pwrites 0

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

總結

本文詳細介紹了原子操做,條件變量,互斥鎖以及讀寫鎖在InnoDB引擎中的實現。原子操做因爲其能減小沒必要要的用戶態和內核態的切換以及更精簡的cpu指令被普遍的應用到InnoDB自旋互斥鎖和InnoDB讀寫鎖中。InnoDB條件變量使用更加方便,可是必定要注意條件通知必須在條件等待以後,不然會有無限等待發生。InnoDB自旋互斥鎖加鎖和解鎖過程雖然複雜可是都是必須的操做。InnoDB讀寫鎖神奇的lock_word控制方法給咱們留下了深入影響。正由於InnoDB底層同步機制的穩定、高效,MySQL在咱們的服務器上才能運行的如此穩定。

相關文章
相關標籤/搜索