核心同步講的比較多了,我也就不太囉嗦了,先說一些概念,然後就是方法。
同步就是避免並發和防止競爭條件。有關臨界區的例子我就不舉了,隨便一本作業系統的書上都有。鎖機制的提出也算解決了一些問題,我們待會再說,現在只要知道鎖的使用是自願的,非強制的。linux自身也提供了幾種不同的鎖機制,區別主要在於當鎖被爭用時,有些會簡單地執行等待,而有些鎖會使當前任務睡眠直到鎖可用為止,這個後面細說。真正的困難在於發現並辨認出真正需要共用的資料和相應的共用區。先來說一些感性的話:大多數核心資料結構都需要加鎖,如果有其他執行線程可以訪問這些資料,那麼就給這些資料加上某種形式的鎖。如果任何其他什麼東西能看到它,那麼就要鎖住它。簡而言之,幾乎訪問所有的核心全域變數和共用資料都需要某種形式的同步方法。有關加鎖的細節,在嵌套的鎖時,要保證以相同的順序擷取鎖,不要重複請求同一個鎖,釋放時,最好還是以獲得鎖的相反順序來釋放鎖。加鎖的粒度用來描述加鎖保護的資料規模。接下來就開始討論真正的同步方法:
1.原子操作。就是指執行過程不被打斷的操作,是 不能夠被分割的指令。關於這個linux核心提供了兩組原子操作介面:原子整數操作和原子位操作。好,先來說說這個原子整數操作。針對整數的原子操作只能對atomic_t類型的資料進行處理。需要說明的是,儘管linux支援的所有機器上的整形資料都是32位的,但是使用atomic_t的代碼只能將該類型的資料當作24位來用,原因就不說了。使用原子操作需要的聲明在asm/atomic.h中。原子整數巨集指令清單如下;
在編寫代碼的時候,能使用原子操作的時候,就盡量不要使用複雜的加鎖機制,因為大多數或者100%情況下,原子操作比更複雜的同步方法相比較而言,給系統帶來的開銷小,對快取行(cache-line)的影響也很小。
對應於原子整數操作,還有一種原子操作就是原子位操作,它們是與體繫結構相關的操作,定義在檔案<asm/bitops.h>,它是對普通的記憶體位址進行操作的。它的參數是一個指標和一個位號,第0位是給定地址的最低有效位。這裡沒有想atomic_t一樣的資料結構,只要指標指向任何希望的資料,就可以進行操作。原子位操作函數列表如下:
同時,核心還提供了一組與上述操作對應的非原子位函數,操作完全相同,不同在於不保證原子性且名字首碼多了兩個底線,例如與test_bit()對應的非原子形式是__test_bit().如果不需要原子操作,這時這些函數的執行效率可能更高。核心還提供了兩個函數用來從指定的地址開始搜尋第一個被設定(或未被設定)的位:
int find_first_bit(unsigned long *addr,unsigned int size);int find_first_zero_bit(unsigned long *addr,unsigned int size);
其中,第一個參數是一個指標,第二個參數是要搜尋的總位元。傳回值分別是第一個被設定的(或沒被設定的)位的位號。如果要搜素的範圍僅限於一個字,使用_ffs()和__ffz()這兩個函數更好,它們只需要給定一個要搜尋的地址做參數。
2.自旋鎖。臨界區遠沒有我們想象那樣的那樣簡單,有時可能跨越多個函數。自旋鎖是linux核心中最常見的鎖。如果一個執行線程試圖獲得一個被爭用的(已經被持有)的自旋鎖,那麼這個線程就會一直進行忙迴圈----旋轉----等待鎖重新可用。同一個鎖可以用在多個位置,例如,對於給定資料的訪問都可以得到保護和同步。上述的自旋過程是很費時間的,所以自旋鎖不應該被長時間持有。我們前邊所過也可以讓請求線程休眠,CPU可以執行其他代碼,直到鎖可用時在喚醒它,但自旋鎖由於忙等待,它是佔用CPU的。上面的休眠過程也會帶來環境切換帶來的開銷,所以持有自旋鎖的時間最好小於完成兩次環境切換的時間。自旋鎖的實現和體繫結構密切相關,代碼往往用彙編實現,這些與體繫結構相關的部分定義在<asm/spinlock.h>,實際需要用到的介面定義在檔案<linux/spinlock.h>中。自旋鎖巨集指令清單如下:
自旋鎖僅僅被當作一個設定核心搶佔機制時候被啟用的開關,如果禁止核心搶佔,那麼在編譯時間自旋鎖會被完全剔除出核心。另外就是linux核心實現的自旋鎖是不可遞迴的。自旋鎖可是用在中斷處理常式中(使用訊號量,會導致睡眠)。在中斷處理常式中使用自旋鎖,一定要在擷取鎖之前禁止本地中斷(當前處理器上的插斷要求)。否則,中斷處理常式會打斷正持有鎖的核心代碼,有可能會試圖去爭用這個已經被持有的自旋鎖。這樣一來,中斷處理常式就會自旋。但鎖的持有人在這個中斷處理常式執行完畢前不可能運行,這就是雙重請求死結。注意,需要關閉的只是當前處理器上中斷,如果中斷髮生在不同的處理器上,即使中斷處理常式在同一個鎖上自旋,也不會妨礙鎖的持有人(在不同處理器上)最終釋放鎖。最後兩個函數spin_lock_bh()用於擷取指定鎖,同時它會禁止所有下半部的執行。相應的spin_unlock_bh()函數執行相反的操作。最後,提醒要注意自旋鎖和下半部的關係。
3.讀--寫自旋鎖。有時,鎖的用途是可以明確分為讀取和寫入的。當對某個資料結構的操作可以被劃分為讀/寫兩種類別時,就可以使用這裡說的讀---寫自旋鎖。這種機製為讀和寫分別提供了不同的鎖。一個或多個讀任務可以並發的持有讀者鎖,而寫鎖只能被一個寫任務持有,而且此時不能有並發的讀。操作函數列表如下:
事實上,即使一個線程遞迴地獲得同一讀鎖也是安全的。還是那句話,使用之前要能明確的分清讀和寫。如果在中斷處理常式中只有讀操作而沒有寫操作,那麼,就可以混合使用“中斷禁止”鎖,使用read_lock()而不是read_lock_irqsave()對讀進行保護。不過,你還是需要用write_lock_irqsave()禁止有寫操作的中斷,否則,中斷裡讀操作就有可能鎖死在寫鎖上(假如讀者進行中操作,包含寫操作的中斷髮生了,由於讀鎖還沒有全部被釋放,所以寫操作會自旋,而讀操作只能在包含寫操作的中斷返回後才能繼續,釋放讀鎖,這時死結就發生了)。最後需要說明的是,這種機制偏向與讀鎖:當讀鎖被持有時,寫操作為了互斥訪問只能等待,但是,讀者卻可以繼續成功地佔用鎖。而自旋等待的寫者在所有讀鎖釋放鎖之前是無法獲得鎖的。所以大量的讀者會使掛起的寫者處於饑餓狀態。
4.訊號量。它是一種睡眠鎖,實現和體繫結構相關的,定義在<asm/semaphore.h>。如果有一個任務試圖獲得一個已經被佔用的訊號量時,訊號量會將其推進一個等待隊列,然後讓其睡眠。這時的處理器會重獲自由,從而去執行其他代碼。當持有訊號量的進程將訊號量釋放後,處於等待隊列中的那個任務將被喚醒,並獲得該訊號量。如果需要在自旋鎖和訊號量中做出選擇,應該根據鎖被持有的時間長短做判斷,如果加鎖時間不長並且代碼不會休眠,利用自旋鎖是最佳選擇。相反,如果加鎖時間可能很長或者代碼在持有鎖有可能睡眠,那麼最好使用訊號量來完成加鎖功能。訊號量一個有用特性就是它可以同時允許任意數量的鎖持有人,而自旋鎖在一個時刻最多允許一個任務持有它。訊號量同時允許的持有人數量可以在聲明訊號量時指定,當為1時,成為互斥訊號量,否則成為計數訊號量。操作函數列表如下:
訊號量支援pv操作,我就不說了。
5.讀-寫訊號量。這個和訊號量的關係是和自旋鎖與讀寫自旋鎖是一樣的關係,由rw_semaphore結構表示的。定義在linux/rwsem.h中。所有的讀寫訊號量都是互斥訊號量。操作函數有:
DECLARE_RWSEM(name)//聲明名為name的讀寫訊號量,並初始化它。void init_rwsem(struct rw_semaphore *sem);//對讀寫訊號量sem進行初始化。void down_read(struct rw_semaphore *sem);//讀者用來擷取sem,若沒獲得時,則調用者睡眠等待。void up_read(struct rw_semaphore *sem);//讀者釋放sem。int down_read_trylock(struct rw_semaphore *sem); //讀者嘗試擷取sem,如果獲得返回1,如果沒有獲得返回0。可在中斷上下文使用。void down_write(struct rw_semaphore *sem);//寫者用來擷取sem,若沒獲得時,則調用者睡眠等待。int down_write_trylock(struct rw_semaphore *sem);//寫者嘗試擷取sem,如果獲得返回1,如果沒有獲得返回0。可在中斷上下文使用void up_write(struct rw_semaphore *sem);//寫者釋放sem。void downgrade_write(struct rw_semaphore *sem);//把寫者降級為讀者。
其實上面的就是分了兩類:訊號量和自旋鎖,使用時選擇的比較如下:
6.完成變數。如果核心中一個任務需要發出訊號通知另外一個任務發生了某個特定事件,利用完成變數(complete variables)是使兩個任務得以同步的簡單方法。如果任務要執行一些工作時,另一個任務就會在完成量上等待,當這個任務完成工作後,會使用完成變數去喚醒在等待的任務,就像訊號量一樣,它們兩者思想是一樣的, 它僅僅提供了代替訊號量的一個簡單地解決方案。它有結構completion表示,定義在linux/completion.h中。操作介面列表如下:
完成變數的通常用法是將完成變數作為資料結構中的一項動態建立,而完成變數的初始化工作的核心代碼將調用wait_for_completion()進行等待。初始化完成後,初始化函數調用completion()喚醒在等待的核心任務。
7.BKL(大核心鎖)。是一個全域自旋鎖,年代有些久遠了,使用它主要是為了方便實現從linux最初的SMP過度到細粒度加鎖機制,有趣的特性如下:
1.持有BKL的任務可以睡眠。因為當任務無法調度時,所加鎖會自動被丟棄,所加鎖會自動被丟棄;當任務被調度時,鎖又會被重新獲得。當然,這不是 說,當任務持有BKL時,睡眠是安全的,僅僅是這樣做,因為睡眠不會造成任務死結。 2.BKL是一種遞迴鎖,一個進程可以多次請求一個鎖,並不會像自旋鎖那樣產生死結現象。 3.BKL可以在進程上下文中。 4.BKL是有害的。 |
不要奇怪,這種機制在核心中已經幾乎不存在了,也不鼓勵使用。這裡提到這樣的思想和介面,是為了萬一遇到呢?操作介面(linux/smp_lock.h)列表如下:
BKL在被持有的時候同樣會禁止核心搶佔。多數情況下,BKL更像是保護代碼而不是保護資料。
8.Seq鎖。這種鎖提供了一種簡單機制,用於讀寫共用資料。實現這種鎖主要依靠一個序列計數器。當資料被進行寫入操作時,會得到一個鎖,並且序列值會增加。在讀取資料之前和之後,序號都被讀取。如果讀取的序號值相同,說明在讀操作進行的過程中沒有被寫操作打斷過。此外,如果讀取的值是偶數,那麼就說明寫操作沒有發生(要明白因為鎖的初始值是0,所以寫鎖會使值為奇數,釋放的時候變成偶數)。操作介面如下:
seqlock_t mr_seq_lock = SEQLOCK_UNLOCKED; //定義順序鎖write_seqlock(&mr_seq_lock); //寫作業碼塊…write_sequnlock(&mr_seq_lock);
這和普通自旋鎖類似,不同的情況發生在讀時,寫自旋鎖有很大不同:
unsigned long seq;do{seq = read_seqbegin(&my_seq_lock);}while(read_seqretry(&my_seq_lock,seq));
在多個讀者和少數讀者共用一把鎖的時候,seq鎖有助於提供一種非常輕量級和具有可擴充性的外觀。但是seq鎖對寫者更有利。只有沒有其他寫者,寫鎖總是能夠被成功獲得。讀者不會影響寫鎖,這點和讀寫自旋鎖及訊號量是一樣的。另外,掛起的寫者會不斷地使得讀操作迴圈,知道不再有任何寫者鎖持有鎖為止。
9.禁止搶佔:實際情況是這樣的,有時我們不需要鎖,但同時又不希望進程搶佔(核心搶佔)的修改某些資料,這時就要關閉核心搶佔。可以通過preempt_disable()禁止核心搶佔。這時一個可以嵌套調用的函數,可以使用任意次。每次調用都必須有一個相應的preempt_enable()調用,當最後一次preempt_enable()被調用時,核心搶佔才重新啟用。核心搶佔相關操作如下:
搶佔技術存放著被持有鎖的數量和preempt_disable()的調用次數,當計數為0時,那麼核心可以進行搶佔。否則不能。為了用更簡潔的方法解決每個處理器上的資料訪問問題,你可以通過get_cpu()獲得處理器編號(假定用這種編號來對每個處理器的資料進行索引的)。這個函數在返回當前處理器號前首先會關閉核心搶佔:
int cpu = get_cpu();...對每個處理器的資料進行操作put_cpu();
10.屏障。當處理多處理器之間或硬體裝置之間的同步問題時,有時需要在程式碼中以指定的順序發出讀記憶體(寫入)和寫記憶體(儲存)指令。在和硬體互動時,時常需要確保一個給定的讀操作發生在其他讀或寫操作之前。另外,在多處理器上,可能需要按寫資料的順序讀資料(通常確保後以同樣的順序進行讀取)。但是編譯器和處理器為了提高效率,可能對讀和寫重新排序,這樣無疑是問題複雜化了。幸好,所有可能重新排序和寫的處理器提供了機器指令來確保順序要求。同樣也可以指示編譯器不要對給定點周圍的指令序列進行重新排序。這些確保順序的指令叫做屏障(barriers);這樣的記憶體和編譯屏障方法列表如下:
最後,說明的是,對於不同的體繫結構,屏障的實際效果差別很大。但應該在最壞的情況下使用恰當的記憶體屏障,這樣代碼才能在編譯時間執行針對體繫結構的最佳化。