本文首發於:行者AI數據庫
鎖在生活中用處很直接,好比給電瓶車加鎖就是防止被偷。在編程世界裏,「鎖」就五花八門了,它們有着各自不一樣的開銷和應用場景。在存在數據競爭的場景,若是選對了鎖,能大大提升系統性能,不然會互相拖後腿,性能急劇下降。編程
加鎖的目的就是保證共享資源在任意時間內,只有一個線程能夠訪問,以此避免數據共享致使錯亂的問題。最底層就是兩種鎖:「互斥鎖」和「自旋鎖」,其餘高級鎖,如讀寫鎖、悲觀鎖、樂觀鎖等都是基於它們實現的。架構
1. 互斥鎖和自旋鎖:誰更輕鬆高效?
想知道它們誰更高效,要先了解它們在作同一件事情的行爲有何不一樣。假設有一個線程加鎖成功,其餘線程加鎖天然會失敗,失敗線程的處理方式以下:異步
- 互斥鎖加鎖失敗後,線程釋放CPU,給其餘線程;
- 自旋鎖加鎖失敗後,線程會忙等待,直到它拿到鎖;
持有互斥鎖的線程在看到鎖已經有主了以後,就會禮貌的退出,等待以後鎖釋放時本身被系統喚醒;而自旋鎖呢,它竟然在反覆的詢問鎖使用完了沒有,這實在是... 我寫個while循環反覆爭奪資源,那不就是自旋鎖咯?不會吧,不會吧,不會真的有人用自旋鎖吧?誰更輕鬆高效這不是一目瞭然嗎?性能
其實吧,自旋鎖也沒那麼不堪,使用場景還挺多,在不少場合比互斥鎖更好用,我要在本文給自旋鎖洗地。至於怎麼洗,那須要詳細說說它們各自的原理,工程方面的選擇,還真就是這麼神奇。url
2. 互斥鎖
互斥鎖是一種「獨佔鎖」,好比當線程 A 加鎖成功後,此時互斥鎖已經被線程 A 獨佔了,只要線程 A 沒有釋放手中的鎖,線程 B 加鎖就會失敗,失敗的線程B因而就會釋放 CPU 讓給其餘線程,既然線程 B 釋放掉了 CPU,天然線程 B 加鎖的代碼就會被阻塞。操作系統
對於互斥鎖加鎖失敗而阻塞的現象,是由操做系統內核實現的。當加鎖失敗時,內核會將線程置爲「睡眠」狀態,等到鎖被釋放後,內核會在合適的時機喚醒線程,當這個線程成功獲取到鎖後,因而就能夠繼續執行。以下圖:.net
互斥鎖加鎖失敗,就會從用戶態陷入內核態,內核幫咱們切換線程,這簡化了互斥鎖使用的難度,但也存在性能開銷。線程
那這個開銷成本是什麼呢?會有兩次線程上下文切換的成本:協程
- 當線程加鎖失敗時,內核會把線程的狀態從「運行」狀態設置爲「睡眠」狀態,而後把 CPU 切換給其餘線程運行;
- 接着,當鎖被釋放時,以前「睡眠」狀態的線程會變爲「就緒」狀態,而後內核會在合適的時間,把 CPU 切換給該線程運行。
線程的上下文切換的是什麼?當兩個線程是屬於同一個進程,由於虛擬內存是共享的,因此在切換時,虛擬內存這些資源就保持不動,只須要切換線程的私有數據、寄存器等不共享的數據。
上下文切換須要幾十納秒到幾微秒之間,若是鎖住的代碼執行時間極短(常見狀況),那花在兩次上下文切換的時間就會遠多於鎖住代碼的執行時長。並且,線程的私有數據已經在CPU的cache上都預熱好了,這一出一進,數據可能就涼透了,以後反覆的cache miss那可就真的酸爽。因此,鎖住的代碼執行只須要幾納秒的話,爲啥不持有CPU繼續自旋等待呢?
3. 互斥鎖的原理
上面的互斥鎖都基於一個假設: 這鎖小明拿了,其餘人都不可能再染指,除非小明不要了。咦! 這是咋作到的?
先考慮單核場景:能不能硬件作一種加鎖的原子操做呢?能! 「test and set」指令就是作這個事情的,由於本身是一條硬件指令,最小執行單位,絕對不可能被打斷。有了」test and set"原子指令,單核環境下,鎖的實現問題獲得了圓滿的解決。
那麼多核環境呢?簡單嘛,仍是「test and set」不就得了,這是一條指令,原子的,不會有問題的。真的嗎?單獨一條指令可以保證該指令在單個核上執行過程當中不會被中斷,可是兩個核同時執行這個指令呢?再想一想,硬件執行時仍是得從內存中讀取lock,判斷並設置狀態到內存,貌似這個過程也不是那麼原子嘛,這可真是套娃啊。那多個核執行怎麼辦呢?首先咱們得明白這個地方的關鍵點,關鍵點是兩個核會並行操做內存並且從操做內存這個調度來看「test and set」不是原子的,須要先讀內存而後再寫內存,若是咱們保證這個內存操做不能並行,那就回歸單核場景了呀!恰好,硬件提供了鎖內存總線的機制,咱們在鎖內存總線的狀態下執行test and set操做,就能保證同時只有一個核來test and set,從而避免了多核下發生的問題。
在x86 平臺上,CPU提供了在指令執行期間對總線加鎖 的手段。CPU芯片上有一條引線#HLOCK pin,若是彙編語言的程序中在一條指令前面加上前綴"LOCK" ,通過彙編之後的機器代碼就使CPU在執行這條指令的時候把#HLOCK pin的電位拉低,持續到這條指令結束時放開,從而把總線鎖住,這樣同一總線上別的CPU就暫時不能經過總線訪問內存了,保證了這條指令在多處理器環境中的原子性。
可以和 LOCK 指令前綴一塊兒使用的指令以下所示:
BT, BTS, BTR, BTC (mem, reg/imm) XCHG, XADD (reg, mem / mem, reg) ADD, OR, ADC, SBB (mem, reg/imm) AND, SUB, XOR (mem, reg/imm) NOT, NEG, INC, DEC (mem)
4. 自旋鎖
自旋鎖是最比較簡單的一種鎖,一直自旋,利用 CPU 週期,直到鎖可用。須要注意,在單核 CPU 上,須要搶佔式的調度器(即經過時鐘中斷一個線程,運行其餘線程)。不然,自旋鎖在單 CPU 上沒法使用,由於一個自旋的線程永遠不會放棄 CPU。
自旋鎖開銷少,在多核系統下通常不會主動產生線程切換,適合異步、協程等在用戶態切換請求的編程方式,但若是被鎖住的代碼執行時間過長,自旋的線程會長時間佔用 CPU 資源,因此自旋的時間和被鎖住的代碼執行的時間是成「正比」的關係,咱們須要清楚的知道這一點。
自旋鎖與互斥鎖使用層面比較類似,但實現層面上徹底不一樣:當加鎖失敗時,互斥鎖用「線程切換」來應對,自旋鎖則用「忙等待」來應對。這裏的忙等待,能夠用「while」循環實現,但最好不要這麼幹!!CPU提供了「PAUSE」指令來實現忙等待。
5. 自旋鎖原理
自旋鎖不就是不停的while循環去獲取鎖,還須要講原理?等等,去獲取鎖狀態的時候怎麼保證數據原子性?難道又用互斥鎖?若是真套一層互斥鎖,那我就給自旋鎖洗不了地了。顯然在這裏不能這麼套娃!
反覆嘗試加鎖的時候,包含兩個步驟:
- 第一步,查看鎖的狀態,若是鎖是空閒的,則執行第二步;
- 第二步,將鎖設置爲當前線程持有;
這個過程叫作「Compare And Swap」,簡稱「CAS」,它把上述兩個步驟合併成一條硬件級指令,在「用戶態」完成加鎖和解鎖操做,不會主動產生線程上下文切換,因此相比互斥鎖來講,會快一些,開銷也小一些。
上面說,不推薦while循環獲取鎖,Intel CPU提供的「PAUSE」指令,「PAUSE」指令是什麼?那它如何解決無腦while循環佔用CPU且低效率的問題呢?
其實自旋鎖不會主動釋放CPU,因此不可能解決佔用CPU的問題,但能讓這個過程更省電,搶佔鎖效率更高。
「PAUSE」指令經過讓CPU休息必定的時鐘週期,在此休息期間,耗電幾乎停滯。休息的時鐘週期,不一樣版本CPU不同,大概在幾十到上百時鐘週期之間。以5Ghz主頻運行的CPU爲例,一個時鐘週期就是0.2納秒。
休息的時鐘週期不是越大越好。好比Intel新一代的Skylake架構中,初期「PAUSE」指令的休息週期高達140個時鐘週期。這直接致使MySQL在理論上性能更好的CPU上,數據庫性能跑出了比前幾年CPU更糟糕的成績,擠出的牙膏吸回去了!在隨後的步進中下降了「PAUSE」的時鐘週期到上一代的10個時鐘週期,數據庫展示的性能才恢復了牙膏廠該有的水準(每代性能提高一丟丟)。
另外一個優勢跟流水線有關係,頻繁的檢測會讓流水線上充滿了讀操做。另一個線程往流水線上丟入一個鎖變量寫操做的時候,必須對流水線進行重排,由於CPU必須保證全部讀操做讀到正確的值。流水線重排十分耗時,影響lock()的性能。設想一下,當一個得到鎖的工做線程W從臨界區退出,在調用unlock釋放鎖的時候,有若干個等待線程S都在自旋檢測鎖是否可用,此時W線程會產生一個store指令,若干個S線程會產生不少load指令,在store以後的load指令要等待store在流水線上執行完畢才能執行,因爲處理器是亂序執行,在沒有store指令以前,處理器對多個沒有依賴的load是能夠隨機亂序執行的,當有了store指令以後,須要reorder從新排序執行,此時會嚴重影響處理器性能,按照intel的說法,會帶來25倍的性能損失。Pause指令的做用就是減小並行load的數量,從而減小reorder時所耗時間。
6. 總結
互斥鎖和自旋鎖沒有優略之分,工程中使用哪一種鎖,主要仍是看使用場景(洗地操做)。
通常狀況使用互斥鎖。若是咱們明確知道被鎖住的代碼的執行時間很短(這樣的場景最廣泛,就算不廣泛也要改代碼讓這種場景廣泛),那咱們應該選擇開銷比較小的自旋鎖,由於自旋鎖加鎖失敗時,並不會主動產生線程切換,而是一直忙等待,直到獲取到鎖,那麼若是被鎖住的代碼執行時間很短,那這個忙等待的時間相對應也很短。
無論使用的哪一種鎖,咱們的加鎖的代碼範圍應該儘量的小,也就是加鎖的粒度要小,這樣執行速度會比較快。
PS:更多技術乾貨,快關注【公衆號 | xingzhe_ai】,與行者一塊兒討論吧!