自旋鎖在Linux內核中被重度的使用,尤其在覈心繫統。在很多高性能的框架中也有很多的應用。自旋鎖的邏輯看起來是那麼的不友好,CPU自旋忙等,理論上是最差的鎖策略。但是為什麼卻是位於最高性能要求的地方呢?

因為自旋鎖的使用必須要有前提要求,就是自旋不能一直自旋,甚至不能過多的自旋,一般臨界區有簡單的幾行代碼就差不多了,但是具體多少行比較合適,這在大部分情況下是一門實驗科學。取決於具體的CPU架構和自旋鎖的實現方式。

也就是說在自旋鎖使用的時候,使用者必須要有人為的鎖判斷,必須要能得出這個鎖不應該鎖太久的時間。實際上呢?大部分的使用者並沒有這個判斷能力。對於大部分的應用層面的編程用戶來說,大部分情況應該使用Mutex鎖(或者說Futex),這種鎖的好處是一旦沒有拿到鎖,自己的CPU上下文就會讓渡給其他的線程。當然這個讓渡會與內核的進程調度演算法關聯。內核的調度演算法會判斷有沒有其他的線程有更高的CPU需求,有的話就讓其他的線程先佔用CPU去執行,而不會讓這個加鎖失敗的線程完全霸佔CPU在忙等。但是這樣帶來的代價是線程上下文的切換是一個相對很高性能懲罰的操作,要保存完整的寄存器上下文,要刷Cache,要讓CPU的FrontEnd和BackEnd充滿了來自另外一個線程的內容。當這個加鎖的線程恢復執行的時候,這一切又要重來一遍。如果用cycle來計算的話,這種Mutex的讓渡,相當於最快也成千上萬的Cycle讓步,也就是Mutex加鎖後,他就要有心理準備,即使匯流排就緒,他也最少需要成千上萬的Cycle才能繼續去搶佔這個CPU的執行時間。而一旦發生正在使用CPU的線程是個流氓的情況,比如動態優先順序比較高的時候,這個加了Mutex鎖的線程可能就要陷入十萬百萬級以上的Cycle飢餓。所以,大家普遍約定,Mutex用於應用邏輯,也應當用於應用邏輯。

而Spinlock又為什麼不應當用於應用邏輯呢?現代的編程範式普遍認為應用邏輯不應當過多的參與CPU的資源調度決策,而是儘可能的把這個調度決策讓給內核。使用Mutex就是這樣的一個行為。而在內核內部,很多不是服務於應用邏輯的,需要極短的時間阻塞的響應一個需求,例如connect函數操作一個Socket,就會有Spinlock。自旋鎖的意思是我就要自旋的在這裡等待,是不會讓出CPU的,更加不會觸發內核的調度引擎。這個邏輯本身是十分的危險,例如在硬中斷中一旦沒有用好,就意味著該CPU的性能懲罰會到完全不可接受的程度(在硬中斷期間,該CPU完全失去響應)。甚至在connect這種系統調用的情況,雖然使用了自旋鎖,但是內核中的使用就真的完善嗎?

使用自旋需要一個使用者的自我承諾,就是我充分的瞭解我的邏輯會給CPU帶來的性能懲罰,並且承諾不使用Linux內核的調度演算法來決策線程的調度。我認為這個時候我的調度方式更加科學,我可以在一萬個Cycle(舉例)內完成我當前鎖的解鎖,並且不會頻繁的阻塞在鎖的位置。

這一系列承諾誰敢給?connect這種系統調用?如果你使用Nginx,一大票的listen和connect系統調用,你會發現,系統的瓶頸無一例外的都在自旋鎖上。是自旋鎖的設計有問題嗎?還是使用有問題?的確,自旋鎖會認為自己會很快的用完這個資源,但是有一個極端情況,就是SMP,非常多的核心存在的時候,超多線程的存在使得自旋鎖成為一個非常恐怖的存在。因為,兩個核爭奪同一個資源的時候,短期釋放是可以保證的。但是60個核爭奪同一個內存變數,競爭和釋放都是一個恐怖的60級的指數增長。這裡的競爭是指數的存在,隨著當前核心的增多,並行編程的普及,自旋鎖的性能懲罰威力被迅速的放大,現在自旋鎖幾乎已經成為內核性能瓶頸的最主要的元兇(當然,nf_conntrack那種查表性能耗盡和lport不足的NAT變換性能耗盡就屬於內核的邏輯性能問題了)。

自旋鎖的危害和好處都已經越來越明顯,那自旋鎖的實現又是如何呢?

static inline void
rte_spinlock_lock(rte_spinlock_t *sl)
{
int lock_val = 1;
asm volatile (
"1:
"
"xchg %[locked], %[lv]
"
"test %[lv], %[lv]
"
"jz 3f
"
"2:
"
"pause
"
"cmpl $0, %[locked]
"
"jnz 2b
"
"jmp 1b
"
"3:
"
: [locked] "=m" (sl->locked), [lv] "=q" (lock_val)
: "[lv]" (lock_val)
: "memory");
}

這是DPDK的自旋鎖實現,使用的GCC的ASM擴展來實現的,但是並不影響我們分析對應的彙編邏輯。

在看代碼之前必須要首先了解一個內存匯流排的操作原因。現代的多核系統在互斥的使用一個內存位置的時候,例如原子操作或者是自旋鎖等,都需要排他的訪問這一個內存位置,就是在本CPU訪問該內存位置的時候,其他內存不得訪問。這個操作就是Intel CPU的鎖匯流排操作,對應到彙編指令上就是lock指令前綴。xchg這個指令的用途是交換內存中兩個值的內容,但是有個特殊的地方就是xchg自帶鎖匯流排的操作,也即是就算沒有加lock前綴,也自帶lock前綴。所以該指令的執行有可能失敗。失敗的情況下,就是嘗試加鎖失敗,不會阻塞,指令失敗就繼續執行,不會產生retire效果(就是真實的影響內存或者寄存器的結果)。

2這個分支標籤就是用於對比該鎖的值是否是0,如果是1,表示有人已經上鎖,所以死循環再次嘗試lock匯流排,進行鎖的值交換,也就是加鎖。這就是一個不斷的嘗試加鎖的循環過程,只要該鎖的值已經是1,就永遠不會跳出這個循環。

這個自旋鎖的實現中有一個最關鍵的操作,就是pause。這個pause是一個宏指令,在經過CPU的解碼之後會編程若干個nop操作,就是decode之後的微指令。添加這個pause的原因是OOO(Out of Order)引擎在運行的時候,有嚴重的亂序問題,但是自旋鎖的load和store是一個嚴格的順序操作,這個嚴格主要發生在加鎖解鎖成功的時候,在這個時候,對鎖內容的操作就是一個多讀多寫的過程,我xchg成功了,你就不能成功。如果亂序執行,讀寫之間也沒有嚴格順序,按照Intel的說法是造成重排,會有嚴重的性能問題,pause指令就是用來規避這個指令重排帶來的嚴重性能懲罰的。但是這個具體的懲罰原因,Intel沒有太詳細的硬體邏輯公佈,實驗結果也是如此,所以幾乎所有的實現都會遵循這個循環之前先pause的自旋鎖實現原則。

我們說過pause是被翻譯成多個nop,可能有幾十個上百個nop那麼多(skylake之前是10個cycle,skylake是140個cycle,但是具體有多少個指令取決於頻率,睿頻的時候變化更大)。但是具體是多少個呢?又是否是一樣的呢?這些問題重要,是因為它直接影響了競爭的原理。假設所有的競爭者都是cycle一樣的數量,競爭概率就會加大。競爭問題的退避演算法,業內通用的CSMA演算法,現代的pause展開為nop的時候,一直都是固定的cycle展開,skylake下cycle數顯著增大的改變本身也是對自旋鎖的一個硬體層面的優化。無論是skylake還是以前的10個cycle,在高度競爭的環境下,都應當採用CSMA的演算法。也就是隨機退避,指數增長。這是一個規避競爭的特別有效的手段。該方法極大的減少了衝突鎖匯流排的概率,同時節省了大量功耗(nop消耗很少功耗,該方法相對增加nop),但是對應的讓持有鎖比較長時間的鎖情況的CPU空轉更加嚴重。

由於Skylake的的pause改善,所以你可能會在skylake的CPU上對高並發下內核自旋鎖的性能問題感受會少一些。

問題來了,為什麼GCC和DPDK都沒有使用CSMA演算法?還不是為了追求通用編程領域的極限性能嘛。但是如果線程數量特別多,固定延時的方案的衝突概率顯著增加,哪種方案能夠最終工程上降低延時,提高利用率,得看具體的業務邏輯了。不能武斷判定。

何況,nop操作不可以順便用來幹一些其他的事情嗎?

Spinlock的實現還有一種比較神奇的方式,就是使用Intel CPU的monitor/mwait指令對。這個相比spinlock在耗電上是無比巨大的進步,但是速度上,大部分情況下甚至mwait的方式還更快,但是如果仔細的調整pause版本的實現,也說不準哪個更快。兩種的區別是一個是基於硬體通知式的,一個是基於輪詢式,所以在耗電敏感的場景也可以考慮使用monitor/mwait來實現自旋鎖。pause是比較通用的做法。很難區分兩者的優劣。已經自旋了,都不好。

推薦閱讀:

相關文章