大核心鎖將何去何從轉載處的原文作者:universus 大核心鎖這個簡單且不常用的核心加鎖機制一直是核心開發人員之間頗具爭議的話題。它在早期linux版本裡的廣泛使用,從2.4核心開始逐漸被各種各樣的自旋鎖替代,可是直到現在還不能完全將它拋棄;它曾經使用自旋鎖實現,到了2.6.11版修改為訊號量,可是在2.6.26-rc2又退回到使用自旋鎖的老路上;它甚至引發了linux的創始人Linus Torvalds和著名的完全公平調度(CFS)演算法的貢獻者Ingo Molnar之間的一場爭議。這究竟是怎麼回事呢?
1 應運而生,特立獨行
使用過自旋鎖或訊號量這些核心互斥機制的人幾乎不會想到還有大核心鎖這個東西。和自旋鎖或訊號量一樣,大核心鎖也是用來保護臨界區資源,避免出現多個處理器上的進程同時訪問同一地區的。但這把鎖獨特的地方是,它不象自旋鎖或訊號量一樣可以建立許多執行個體或者叫對象,每個對象保護特定的臨界區。事實上整個核心只有一把這樣的鎖,一旦一個進程獲得大核心鎖,進入了被它保護的臨界區,不但該臨界區被鎖住,所有被它保護的其它臨界區都將無法訪問,直到該進程釋放大核心鎖。這看似不可思議:一個進程在一個處理器上操作一個全域的鏈表,怎麼可能導致其它進程無法訪問另一個全域數組呢?使用兩個自旋鎖,一個保護鏈表,另一個保護數組不就解決了嗎?可是如果你使用大核心鎖,效果就是這樣的。
大核心鎖的產生是有其曆史原因的。早期linux版本對對稱式多處理(SMP)器的支援非常有限,為了保證可靠性,對處理器之間的互斥採取了‘寧可錯殺三千,不可放過一個’的方式:在核心入口處安裝一把‘巨大’的鎖,一旦一個處理器進入核心態就立刻上鎖,其它將要進入核心態的進程只能在門口等待,以此保證每次只有一個進程處於核心態運行。這把鎖就是大核心鎖。有了大核心鎖保護的系統當然可以安全地運行在多處理器上:由於同時只有一個處理器在運行核心代碼,核心的執行本質上和單一處理器沒有什麼區別;而多個處理器同時運行於進程的使用者態也是安全的,因為每個進程有自己獨立的地址空間。但是這樣粗魯地加鎖其缺點也是顯而易見的:多處理器對效能的提示只能體現在使用者態的平行處理上,而在核心態下還是單線執行,完全無法發揮多處理器的威力。於是核心開發人員就開始想辦法逐步縮小這把鎖保護的範圍。實際上核心大部分代碼是多處理器安全的,只有少數全域資源需要需要在做互斥加以保護,所以沒必要限制同時運行於核心態處理器的個數。所有處理器都可隨時進入核心態運行,只要把這些需要保護的資源一一挑出來,限制同時訪問這些資源的處理器個數就可以了。這樣一來,大核心鎖從保護整個核心態縮小為零散地保護核心態某些關鍵片段。這是一個進步,可步伐還不夠大,仍有上面提到的,‘鎖了臥室廚房也沒法進’的毛病。隨著自旋鎖的廣泛應用,新的核心代碼裡已經不再有人使用大核心鎖了。
2 食之無味,揮之不去
既然已經有了替代物,大核心鎖應該可以‘光榮下崗’了。可事實上沒這麼簡單。如果大核心鎖僅僅是‘只有一個執行個體’的自旋鎖,睿智的核心開發人員早就把它替換掉了:為每一種處於自旋鎖保護下的資源建立一把自旋鎖,把大核心鎖加鎖/解鎖替換成相應的自旋鎖的加鎖/解鎖就可以了。但如今的大核心鎖就象一個被寵壞的孩子,核心在一些關鍵點給予了它許多額外關照,使得大核心鎖的替換變得有點煩。下面是Ingo Molnar在一封名為 ’kill the Big Kernel Lock (BKL)’的郵件裡的抱怨:
The biggest technical complication is that the BKL is unlike any other lock: it "self-releases" when schedule() is called. This makes the BKL spinlock very "sticky", "invisible" and viral: it's very easy to add it to a piece of code (even unknowingly) and
you never really know whether it's held or not. PREEMPT_BKL made it even more invisible, because it made its effects even less visible to ordinary users.
這段話的大意是:最大的技術痛點是大核心鎖的與眾不同:它在調用schedule()時能夠‘自動釋放’。這一點使得大核心鎖非常麻煩和隱蔽:它使你能夠非常容易地添加一段代碼而幾乎從不知道它鎖上與否。PREEMPT_BKL選項使得它更加隱蔽,因為這導致它的效果在普通使用者面前更加‘遁形’。
翻譯linux開發人員的話比看懂他們寫的代碼更難,但有一點很明白:是schedule()函數裡對於大核心鎖的自動釋放導致了問題的複雜化。那就看看schedule()裡到底對大核心鎖執行了什麼操作:
linux_2.6.34/kernel/sched.c
- 1 /*
- 2 * schedule()
is the main scheduler
function.
- 3 */
- 4 asmlinkage void __sched schedule(void)
- 5 {
- …
- 19 release_kernel_lock(prev);
- …
- 55 context_switch(rq, prev,
next);
/* unlocks the rq
*/
- …
- 67 if (unlikely(reacquire_kernel_lock(current)
< 0))
{
- 68 prev = rq->curr;
- 69 switch_count =
&prev->nivcsw;
- 70 goto need_resched_nonpreemptible;
- 71 }
- …
在第19行release_kernel_lock(prev)函數釋放當前進程(prev)所佔據的大核心鎖,接著在第55行執行進程的切換,從當前進程prev切換到了下一個進程next。context_switch()可以看做一個超級函數,調用它不是去執行一段代碼,而是去執行另一個進程。系統的多任務切換就是依靠這個超級函數從一個進程切換到另一個進程,從另一個進程再切換下一個進程,如此連續不斷地輪轉。只要被切走的進程還處於就緒狀態,總有一天還會有機會調度回來繼續運行,效果看起來就象函數context_switch()運行完畢返回到了schedule()。繼續運行到第67行,調用函數reacquire_kernel_lock()。這是和release_kernel_lock()配對的函數,將前面釋放的大核心鎖又重新鎖起來。If語句測試為真表示對大核心鎖嘗試加鎖失敗,這時可以做一些最佳化。正常的加鎖應該是‘原地踏步’,在同一個地方反覆查詢大核心鎖的狀態,直到其它進程釋放為止。但這樣做會浪費寶貴的處理器時間,尤其是當運行隊列裡有進程在等待運行時。所以release_lernel_lock()只是做了’try_lock’的工作,即假如沒人把持大核心鎖就把它鎖住,返回0表示成功;假如已經被鎖住就立即返回-1表示失敗。一旦失敗就重新執行一遍schedule()的主體部分,檢查運行隊列,挑選一個合適的進程運行,等到下一次被調度運行時可能鎖就解開了。這樣做利用另一個進程(假如有進程在排隊等候)的運行代替了原地死等,提高了處理器利用率。
除了在schedule()中的‘照顧’,大核心鎖還有另外的優待:在同一進程中你可以對它反覆嵌套加鎖解鎖,只要加鎖個數和解鎖個數能配上對就不會有任何問題,這是自旋鎖望塵莫及的,同一進程裡自旋鎖如果發生嵌套加鎖就會死結。為此在進程式控制制塊(PCB)中專門為大核心鎖開闢了加鎖計數器,即task_struct中的lock_depth域。該域的初始值為-1,表示進程沒有獲得大核心鎖。每次加鎖時lock_depth都會加1,再檢查如果lock_depth為0就執行真正的加鎖操作,這樣保證在加了一次鎖以後所有嵌套的加鎖操作都會被忽略,從而避免了死結。解鎖過程正好相反,每次都將lock_depth減1,直到發現其值變為-1時就執行真正的解鎖操作。
核心對大核心鎖的偏袒導致開發人員在鎖住了它,進入被它保護的臨界區後,執行了不該執行的代碼卻還無法察覺。
其一:程式在鎖住臨界區後必須儘快退出,否則會阻塞其它將要進入臨界區的進程。所以在臨界區裡絕對不可以調用schedule()函數,否則一旦發生進程切換何時能解鎖就變得遙遙無期。另外在使用自旋鎖保護的臨界區中做進程切換很容易造成死結。比如一個進程鎖住了一把自旋鎖,期間調用schedule()切換到另一個進程,而這個進程又要獲得這把鎖,這是系統就會掛死在這個進程等待解鎖的自旋處。這個問題在大核心鎖保護的臨界區是不存在的,因為schedule()函數在調度到新進程之前會自動解鎖已經獲得的大核心鎖;在切回該進程時又會自動將大核心鎖鎖住。使用者在鎖住了大核心鎖後,幾乎無法察覺期間是否用過schedule()函數。這一點就是上面Ingo
Molnar提到的’technical complication’:將大核心鎖替換成自旋鎖後,萬一在加鎖過程中調用了schedule(),會造成不可預估的,災難性的後果。當然作為一個訓練有素的程式員,即使大核心鎖放寬了約束條件,也不會在臨界區中有意識地調用schedule()函數的。可是如果是調用陌生模組的代碼,再高超的程式員也無法保證其中不會調用到該函數。
其二就是上面提到的,在臨界區中不能再次獲得保護該臨界區的鎖,否則會死結。可是由於大核心鎖有加鎖計數器的保護,怎樣嵌套也不會有事。這也是一個’technical complication’:將大核心鎖替換成自旋鎖後,萬一發生了同一把自旋鎖的嵌套加鎖後果也是災難性的。同schedule()函數一樣,訓練有素的程式員是不會有意識地多次鎖住大核心鎖,但在獲得自旋鎖後調用了陌生模組的代碼就無法保證這些模組中不會再次使用大核心鎖。這種情況在開發大型系統時非常常見:每個人都很小心地避免自己模組的死結,可誰也無法避免當調用其它模組時可能引入的死結問題。
Ingo Molnar還提到了大核心鎖的另一弊端:大核心鎖沒有被lockdep所覆蓋。lockdep是linux核心的一個調試模組,用來檢查核心互斥機制尤其是自旋鎖潛在的死結問題。自旋鎖由於是查詢方式等待,不釋放處理器,比一般的互斥機制更容易死結,故引入lockdep檢查以下幾種情況可能的死結(lockdep將有專門的文章詳細介紹,在此只是簡單列舉):
- 同一個進程遞迴地加鎖同一把鎖;
- 一把鎖既在中斷(或中斷下半部)使能的情況下執行過加鎖操作,又在中斷(或中斷下半部)裡執行過加鎖操作。這樣該鎖有可能在鎖定時由於中斷髮生又試圖在同一處理器上加鎖;
- 加鎖後導致依賴圖產產生閉環,這是典型的死結現象。
由於大核心鎖游離於lockdep之外,它自身以及和其它互斥機制之間的依賴關係沒有受到監控,可能會導致死結的情境也無法被記錄下來,使得它的使用越來越混亂,處於失控狀態。
如此看來,大核心鎖已經成了核心的雞肋,而且不能與時俱進,到了非整改不可的地步。可是將大核心鎖完全從核心中移除將要面臨重重挑戰,對於那些散落在‘年久失修’,多年無人問津的代碼裡的大核心鎖,更是沒人敢去動它們。既然完全移除希望不大,那就想辦法最佳化它也不失為一種權宜之計。
3 一改再改:無奈的選擇
早些時候大核心鎖是在自旋鎖的基礎上實現的。自旋鎖是處理器之間臨界區互斥常用的機制。當臨界區非常短暫,比如只改變幾個變數的值時,自旋鎖是一種簡單高效的互斥手段。但自旋鎖的缺點是會增大系統負荷,因為在自旋等待過程中進程依舊佔據處理器,這部分等待時間是在做無用功。尤其是使用大核心鎖時,一把鎖管所有臨界區,發生‘碰撞’的機會就更大了。另外為了使進程能夠儘快全速‘沖’出臨界區,自旋鎖在加鎖的同時關閉了核心搶佔式調度。因此鎖住自旋鎖就意味著在一個處理器上製造了一個調度‘禁區’:期間既不被其它進程搶佔,又不允許調用schedule()進行自主進程切換。也就是說,一旦處理器上某個進程獲得了自旋鎖,該處理器就只能一直運行該進程,即便有高優先順序的即時進程就緒也只能排隊等候。調度禁區的出現增加了調度延時,降低了系統即時反應的速度,這與大家一直努力從事的核心即時化改造是背道而馳的。於是在2.6.7版本的linux中對自旋鎖做了徹底改造,放棄了自旋鎖改用訊號量。訊號量沒有上面提到的兩個問題:在等待訊號量空閑時進程不佔用處理器,處於阻塞狀態;在獲得訊號量後核心搶佔依舊是使能的,不會出現調度盲區。這樣的解決方案應該毫無爭議了。可任何事情都是有利有弊的。訊號量最大的缺陷是太複雜了,每次阻塞一個進程時都要產生費時的進程環境切換,訊號量就緒喚醒等待的進程時又有一次環境切換。除了環境切換耗時,進程切換造成的TLB重新整理,cache冷卻等都有較大開銷。如果阻塞時間比較長,達到毫秒級,這樣的切換是值得的。但是大部分情況下只需在臨界區入口等候幾十上百個指令迴圈另一個進程就可以交出臨界區,這時候這種切換就有點牛刀殺雞了。這就好象去醫院看普通門診,當醫生正在為病人看病時,別的病人在門口等待一會就會輪到了,不必留下電話號碼回家睡覺,直到醫生空閑了打電話通知再匆匆趕往醫院。
由於使用訊號量引起的進程頻繁切換導致大核心鎖在某些情況下出現嚴重性能問題, Linus Torvalds不得不考慮將大核心鎖的實現改回自旋鎖,自然調度延時問題也會跟著回來。這使得以‘延時迷(latency junkie)’自居的Ingo Molnar不太高興。但linux還是Linus Torvalds說了算,於是在2.6.26-rc2版大核心鎖又變成了自旋鎖,直到現在。總的來說Linus Torvalds的改動是有道理的。使用繁瑣,重量級的訊號量保護短暫的臨界區確實不值得;而且Linux也不是以即時性見長的作業系統,不應該片面追求即時信而犧牲了整體效能。
4 日薄西山:謝幕在即
改回自旋鎖並不意味著Linus Torvalds不關心調度延時,相反他真正的觀點是有朝一日徹底剷除大核心鎖,這一點他和Ingo Molnar是英雄所見略同。可是由於剷除大核心鎖的難度和風險巨大,Ingo Molnar覺得‘在當前的遊戲規則下解決大核心鎖是不現實的’必須使用新的遊戲規則。他專門建立一個版本分支叫做kill-the-BLK,在這個分支上將大核心鎖替換為新的互斥機制,一步一步解決這個問題:
- 解決所有已知的,利用到了大核心鎖自動解鎖機制的臨界區;也就是說,消除使用大核心鎖的代碼對自動解鎖機制的依賴,使其更加接近普通的互斥機制;
- 添加許多調試設施用來警告那些在新互斥機制下不再有效假設;
- 將大核心鎖轉換為普通的互斥體,並刪除遺留在調度器裡的自動解鎖代碼;
- 添加lockdep對它的監控;
- 極大簡化大核心鎖代碼,最終將它從核心裡刪除。
這已經是兩年前的事情了。現在這項工作還沒結束,還在‘義無反顧’地向前推進。期待著在不遠的將來大核心鎖這一不和諧的音符徹底淡出linux的核心。