Linux 2.6內核筆記【內核同步】

 

Utensil按:這應該是最實用,最接近日常編程的一章了。

 

同步機制用於避免對共享數據的不安全訪問而導致的數據崩潰。下面按從輕到重講述內核同步機制。

 

最好的同步

 

同步是一件煩人、容易出錯,最重要的是拖慢並行的事情,所以最好的同步就是不用同步——這不是廢話,而是在內核設計時的重要考慮。對不同的任務,量體裁衣,以不同的機制來處理;對每種機制,加以不同程度的限制,從而不同程度地簡化用這個機制完成任務的編碼難度,其中就包括減少對同步機制的需要。以下是一些書中舉出的“設計簡化同步”的例子:

 

  • Interrupt handlers and tasklets need not to be coded as reentrant functions.

  • Per-CPU variables accessed by softirqs and tasklets only do not require synchronization.

  • A data structure accessed by only one kind of tasklet does not require synchronization.

每CPU變量(Per-CPU variables)

 

第二好的同步技術,是不共享。因此我們有了每CPU變量。但注意:內核搶佔可能使每CPU變量產生競爭條件,因此內核控制路徑應該在禁用搶佔的情況下訪問每CPU變量。

 

原子操作(Atomic operation)

 

具有“讀-修改-寫”特徵的指令,如果不是原子的,就會出現競爭條件。

 

非對齊的內存訪問不是原子的;

單處理器中,inc、dec這樣的操作是原子的;

多處理器中,由於會發生內存總線被其它CPU竊用,所以這些操作要加上lock前綴(0xf0),這樣可以鎖定內存總線,保證一條指令的原子性;

有rep前綴(0xf2、0xf3)的指令不是原子的,每一循環控制單元都會檢查掛起的中斷。

 

Linux提供了atomic_t和一系列的宏來進行原子操作。

 

優化屏障(Optimization barrier)、內存屏障(Memory barrier)

 

編譯器喜歡在優化代碼時重新安排代碼的執行順序,由於它對某些代碼順序執行的意義沒有感知,所以可能對一些必須順序執行的代碼構成致命傷,比如把同步原語之後的指令放到同步原語之前去執行——順便帶一句,C++0x中對並行的改進正是努力使編譯器能感知這些順序的意義。

 

優化屏障barrier()宏,展開來是asm volatile("":::"memory") 。這是一段空彙編,但volatile關鍵字禁止它與程序中的其它指令重新組合,而 memory則強迫編譯器認爲RAM的所有內存單元都給這段彙編改過了,因此編譯器不能因爲懶惰和優化直接使用之前放在寄存器裏的內存變量值。但 優化屏障只阻止指令組合,不足以阻止指令重新排序。

 

內存屏障原語mb()保證,在原語之後的操作開始執行之前,原語之前的已經完成,任何彙編語言指令都不能穿過內存屏障。

 

80x86處理器中,I/O操作指令,有lock前綴的指令,寫控制、系統、調試寄存器的指令,自動起內存屏障的作用。Pentium 4還引入了lfence、sfence和mfence這些指令,專門實現內存屏障。

 

rmb()在Pentium 4之後使用lfence,之前則使用帶lock的無意義指令來實現。wmb()直接展開爲barrier(),因爲Intel處理器不會對寫內存訪問重新排序。

 

自旋鎖(Spin Locks)

 

自旋鎖是一種忙等的鎖,當獲取鎖失敗,進程不會休眠,而是一直在那裏自旋(spin)或者說忙等(busy waiting),不斷循環執行cpu_relax()——它等價於pause指令或者rep; nop指令。

 

自旋鎖用spinlock_t表示,其中兩個字段,slock代表鎖的狀態(1爲未鎖),break_lock代表有無其它進程在忙等這個鎖,這兩個字段都受到原子操作的保護。

 

我們詳細討論一下spin_lock(slp)宏(slp代表要獲取的spinlock_t):

 

首先禁用內核搶佔(preempt_disable()),然後調用平臺相關的_raw_spin_trylock(),其中用xchg原子性地交換了8位寄存器%al(存着0)和slp->slock,如果交換出來的是正數(說明原先未鎖),那麼鎖已經獲得(0已經寫入了slp->slock,上好了鎖)。

 

否則,獲鎖失敗,執行下列步驟:

 

1)執行preempt_enable(),這樣其它進程就有可能取代正在等待自旋鎖的進程。注意preempt_enable()本質上僅僅是將顯式禁用搶佔的次數減一,並不意味着就一定可以搶佔了,能否搶佔還取決於本次禁用之前有否禁用搶佔、是否正在中斷處理中、是否禁用了軟中斷以及PREEMPT_ACTIVE標誌等等因素。就像,領導說:“我這裏沒問題了,你問問別的領導的意見吧。”。

 

2)如果break_lock==0,就置爲1.這樣,持有鎖的進程就能感知有沒人在等鎖,如果它覺得自己佔着太長時間了,可以提前釋放。

 

3)執行等待循環:while (spin_is_locked(slp) && slp->break_lock) cpu_relax();

 

4)跳轉回到“首先”,再次試圖獲取自旋鎖。

 

奇怪的是,我未能在LXR中找到這段描述對應的源代碼,也無從驗證我由while (spin_is_locked(slp) && slp->break_lock) 產生的的一個疑問:當鎖易手之後,怎麼處理break_lock這個字段?

 

讀/寫自旋鎖(Read/Write Spin Locks)與順序鎖(Seqlock)

 

讀/寫自旋鎖允許併發讀,寫鎖則獨佔。注意:在已有讀者加讀鎖的情況下,寫者不能獲得寫鎖。讀/寫自旋鎖rwlock_t的32位字段lock使用了25位,拆分爲兩部分,24位被設置則表示未鎖,0-23位是讀者計數器的補碼,有讀者時,0-23位不爲0,有寫者時,0-23位爲0(寫時無讀者)。

 

順序鎖則允許在讀者正在讀的時候,寫者寫入。這樣做的優點是:寫者無需等待讀鎖,缺點是有時讀者不得不重複讀取直到獲得有效的副本。順序鎖seqlock_t有兩個字段:一個是spinlock_t,寫者需要獲取,一個是順序計數器,寫者寫時其值爲奇數,寫完時爲偶數。讀者每次讀,前後都會檢查順序計數器。

 

順序鎖的適用場合:讀者的臨界區代碼沒有副作用,寫者不常寫,而且,被保護的數據結構不包括寫者會改而讀者會解引用(dereference, *)的指針。

 

RCU(Read-Copy Update)

 

鎖還是少用的好:使用被所有CPU共享的鎖,由於高速緩存行偵聽(原書譯爲竊用)和失效而有很高的開銷(a high overhead due to cache line-snooping and invalidation)。

 

RCU允許多個讀者和寫者併發運行,它不使用鎖,但它僅能保護被動態分配並通過指針引用的數據結構,而且在被RCU保護的臨界區,任何內核控制路徑都不能睡眠。

 

讀者讀時執行rcu_read_lock()(僅相當於preempt_disable()),讀完執行rcu_read_unlock()(僅相當於 preempt_enable( ) )。這很輕鬆,但是,內核要求每個讀者在執行進程切換、返回用戶態執行或執行idle循環之前,必須結束讀並執行 rcu_read_unlock(),原因在寫者這邊:

 

寫者要更新一個數據結構的時候,會讀取並製作一份拷貝,更新拷貝里的值然後修改指向舊數據的指針指向拷貝,這裏會使用一個內存屏障來保證只有修改完成,指針才進行更新。但難點是,指針更新完之後不能馬上釋放舊數據,因爲讀者可能還在讀,所以,寫者調用call_rcu()。

 

call_rcu()接受rcu_head描述符(通常嵌入在要釋放的數據結構中——它自己知道自己是註定要受RCU保護的)的指針和回調函數(通常用來“析構”...)作爲參數,把它們放在一個rcu_head描述符裏,然後插入到一個每CPU的鏈表中。

 

每一個時鐘中斷,內核都會檢查是否已經經過了靜止狀態(gone through quiescent state,即已發生進程切換、返回用戶態執行或執行idle循環) ——如果已經經過了靜止狀態,加上每個讀者都遵循了內核的要求,自然所有的讀者也都讀完了舊拷貝。如果所有的CPU都經過了靜止狀態,那麼就可以大開殺戒,讓本地tasklet去執行鏈表中的回調函數來釋放舊的數據結構。

 

RCU是2.6的新功能,用在網絡層和虛擬文件系統中。

 

(按:RCU描述起來可累了,尤其是原書和源代碼中對靜止狀態都語焉不詳,很難理解其確切含義,暫時只能整理成上面這種理解,以後在研究下usage,弄清實際上應該如何理解。疑問所在:因爲靜止狀態從字面上感覺,應該指舊數據結構仍需“靜止地”殘餘的狀態,但是由於內核後來還需要檢查是否否度過了靜止階段,那麼如何檢查這種“仍需”?顯然更爲容易的是檢查進程切換什麼的,所以只好把靜止狀態理解爲還未發生進程切換、返回用戶態執行或執行idle循環的狀態,然後再“ 經過了 ”。怎麼想怎麼彆扭。)

 

信號量(Semaphores)

 

這個可不是System V的IPC信號量,僅僅是供內核路徑使用的信號量。信號量對於內核而言太重了,因爲獲取不到鎖的時候需要進程睡眠!所以中斷處理程序不能用,可延遲函數也不能用...

 

信號量struct semaphore包含3個字段,一個是atomic_t的count,也就是我們在IPC信號量那裏已經熟知的表示可用資源的一個計數器;一個是一個互斥的等待隊列,因爲這裏涉及了睡眠,信號量的up()原語在釋放資源的同時需要喚醒一個之前心裏堵得慌睡着了的進程;最後一個是sleepers,表示是否有進程堵在那裏,用於在down()裏面進行細節得恐怖而又非常有效的優化(爲此,作者感嘆:Much of the complexity of the semaphore implementation is precisely due to the effort of avoiding costly instructions in the main branch of the execution flow.)

 

自然還有讀/寫信號量,這裏不再敷述。

 

完成原語(Completion)

 

原書將之非常不準確地翻譯爲補充原語。

 

Completion是一種類似信號量的原語,其數據結構如下:

 

struct completion { unsigned int done; wait_queue_head_t wait; };

 

它擁有類似於up()的函數complete()和類似於down()的wait_for_completion()。

 

它和信號量的真正區別是如何使用等待隊列中包含的自旋鎖。在完成原語這邊,自旋鎖用來確保complete()和wait_for_completion()之間不會相互競爭(併發執行),而在信號量那邊,自旋鎖用於避免down()與down()的相互競爭。

 

那麼在什麼情況下up()和down()可能出現競爭呢?

 

其實do_fork()的源代碼中就包含一個活生生的例子,用於實現vfork(),下面略去了與vfork()無關的代碼:

 

long do_fork(...)
{
        struct task_struct *p;
        /* ... */
        long pid = alloc_pidmap();
        /* ... */
        /* p是複製出來的新進程  */
        p = copy_process(...);  
        
        if (!IS_ERR(p)) {
                /* 聲明一個叫做vfork的完成原語  */
                struct completion vfork; 

                if (clone_flags & CLONE_VFORK) {
                        /* 把vfork這個完成原語傳遞給新進程 */
                        p->vfork_done = &vfork;
                        
                        /* 初始化:未完成狀態;
                        這相當於一個一開始就爲0的信號量——初始關閉,獲取必睡的鎖 */
                        init_completion(&vfork);
                }

                /* ... */

                if (!(clone_flags & CLONE_STOPPED))
                    /* 此時新進程運行 */
                    wake_up_new_task(p, clone_flags);
                else
                        p->state = TASK_STOPPED;
                        
                /* ... */

                if (clone_flags & CLONE_VFORK) {
                        /* 等待:新進程執行完會調用complete()標誌done——相當於up()。
                    這裏相當於一個down(),所以老進程睡了 */
                        wait_for_completion(&vfork);
                 /* 接下來的代碼繼續執行的時候,老進程醒了,這並不一定說明新進程結束了。新進程可能僅僅是正在另外一個CPU上執行complete()函數,這時就出現了競爭條件。 */
                        /* ... */
                
                    }/* 完成原語vfork出作用域,消失了。如果使用的是信號量而非完成原語,相當於該信號量被銷燬了,而這時新進程可能還在另外一個CPU執行up()/complete() */
        } else {
                free_pidmap(pid);
                pid = PTR_ERR(p);
        }
        return pid;
}
 

禁止本地中斷

 

local_irq_disable()宏使用了cli彙編指令,通過清除IF標誌,關閉了本地CPU上的中斷。離開臨界區時,則會恢復IF標誌原先的值。

 

禁止中斷,在單CPU情形可以確保一組內核語句被當作一個臨界區處理,因爲這樣不會受到新的中斷的打擾。然而多CPU的情形中,禁止的僅是本地CPU的中斷,因此,要和自旋鎖配合使用,Linux提供了一組宏來把中斷激活/禁止與自旋鎖結合起來,例如spin_lock_irq()、spin_lock_bh()等。

 

禁止可延遲函數

 

可延遲函數禁止是中斷禁止的一種弱化的形式,它通過前一篇筆記描述過的preempt_count字段來進行,具體的調用函數是local_bh_disable()。這裏不再重複。

 

系統的併發度

 

爲了性能,系統的併發度應該儘可能高。它取決於同時運轉的I/O設備數(這需要儘可能減短中斷禁止的時間),也取決於進行有效工作的CPU數(這需要儘可能避免使用基於自旋鎖的同步原語,因爲它對硬件高速緩存有不良影響)。

 

有兩種情況,既可以維持較高的併發度,也可以達到同步:

 

共享的數據結構是一個單獨的整數值,這樣原子操作就足以保護它,這是在內核中廣泛使用的引用計數器;

 

類似將元素插入鏈表中這樣的操作設計兩次指針賦值,雖然不是原子的,但只要兩次賦值依序進行,單一的一次操作仍能保證數據的一致性和完整性,因此,需要在兩個指針賦值中間加入一個寫內存屏障原語。

 

大內核鎖(Big Kernel Lock,BKL)

 

大內核鎖從前被廣泛使用,現在用於保護舊的代碼,從前它的實現是自旋鎖,2.6.11之後則變成了一種特殊的信號量kernel_sem。kernel_sem中有一個lock_depth的字段,允許一個進程多次獲得BKL。

 

改變實現的目的是使得在被大內核鎖保護的臨界區內允許內核搶佔或自願切換。在自願進程切換的情形(進程在持有BKL的情況下調用schedule()),schedule()會爲之釋放鎖,切換回來的時候又爲之獲取鎖,非常周到的服務。在搶佔的情形,preempt_schedule_irq()會通過篡改lock_depth欺騙schedule()這個進程沒有持有BKL,因此被搶佔的進程得以繼續持有這個鎖。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章