核心中的排程與同步(轉)

BSDLite發表於2007-08-15
核心中的排程與同步(轉)[@more@]摘要

本章將為大家介紹核心中存在的各種任務排程機理以及它們之間的邏輯關係(這裡將覆蓋程式排程、推後執行、中斷等概念),在此基礎上向大家解釋核心中需要同步保護的根本原因和保護方法。最後提供一個核心共享連結串列同步訪問的例子,幫助大家理解核心程式設計中的同步問題。

核心任務排程與同步關係引言

對於從事應用程式開發的朋友來說,使用者空間的任務排程與同步之間的關係相對簡單,無需過多考慮需要同步的原因。這一是因為在使用者空間中各個程式都擁有獨立的執行空間,程式內部的資料對外不可見,所以各個程式即使併發執行也不會產生對資料訪問的競爭。第二是因為使用者空間與核心空間獨立,所以使用者程式不會與核心任務交錯執行,因此使用者程式不存在與核心任務併發的可能。以上兩個原因使得使用者同步僅僅需要在程式間通訊和多執行緒程式設計時需要考慮。

但是在核心空間中情況要複雜得多,需要考慮同步的原因大大增加了。這是因為核心空間中的共享資料對核心中的所有任務可見,所以當在核心中訪問資料時,就必須考慮是否會有其他核心任務併發訪問的可能、是否會產生競爭條件、是否需要對資料同步。而核心併發的“罪魁禍首”便是核心中複雜多變的任務排程——這裡的任務排程包含所有可能引起核心任務更換的情況。

併發,競爭和同步的概念,我們假定大家都有所瞭解,本文不再重申。下面一段描述了上述幾個概念之間的大致關係,這種關係在核心中同樣適用。

對於多執行緒程式的開發者來說,往往會利用多執行緒訪問共享資料,避免繁瑣的程式間通訊。但是多執行緒對共享資料的併發訪問有可能產生競爭,使得資料處於不一致狀態,所以需要一些同步方法來保護共享資料。多執行緒的併發執行是由於執行緒被搶佔式的排程——一個執行緒在對共享資料訪問期間(還未完成)被排程程式中斷,將另一個執行緒投入執行——如果新被排程的執行緒也要對這個共享資料進行訪問,就將產生競爭。為了避免競爭產生,需要使執行緒序列地訪問共享資料 ,也就是說訪問需要同步——在一方對資料訪問結束後,另一方才能對同一資料進行訪問。

核心任務

這裡所定義的核心任務是指核心中執行的一切活動物件,每個核心任務都擁有一個獨立的程式計數器、棧和一組暫存器。更重要的是,它們都屬於核心排程(這裡的排程是廣義上的,不要與程式排程混淆)物件,也就是說它們是可以在核心中交錯執行的。

核心任務分類

核心任務包含“核心執行緒”、“系統呼叫”、“硬體中斷”、“半底任務”等幾類。下來我們就簡要地討論上述幾類核心任務的特點。

系統呼叫

系統呼叫是使用者程式透過門機制來進入核心執行的核心例程,它執行在核心態,處於程式上下文中(程式上下文包括程式的堆疊等等環境),可以認為是代表使用者程式的核心任務,因此具有使用者態任務的特性,比如可以執行程式排程程式(schedule())、可以睡眠、可以訪問當前程式資料(透過current)。但它屬於核心任務,所以在執行過程中不能被搶佔(2.6核心前),只能自己放棄cpu(睡眠)時,系統才能可能重新排程別的任務。(有關係統呼叫部分請看《系統呼叫》一章)

硬中斷任務

硬中斷是指那些由處理器以外的外設產生的中斷,這些中斷被處理器接收後交給核心中的中斷處理程式處理。要注意的是:第一, 硬中斷是非同步產生的,中斷髮生後立刻得到處理,也就是說中斷操作可以搶佔核心中正在執行的程式碼。這點非常重要。第二,中斷操作是發生在中斷上下文中的(所謂中斷上下文指的是和任何程式無關的上下文環境)。中斷上下文中不可以使用程式相關的資源,也不能夠進行排程或睡眠。因為排程會引起睡眠,但睡眠必須針對程式而言(睡眠其實是標記程式狀態,然後把當前程式推入睡眠列隊),而非同步發生的中斷處理程式根本不知道當前程式的任何資訊,也不關心當前哪個程式在執行,它完全是個過客。(有關硬體中斷部分請看《硬體中斷》一章)

下半底任務

半底的來歷完全出自上面提到的硬中斷的影響。硬體中斷任務(處理程式)是一個快速、非同步、簡單地對硬體做出迅速響應並在最短時間內完成必要操作的中斷處理程式。硬中斷處理程式可以搶佔核心任務並且執行時還會遮蔽同級中斷或其它中斷,因此中斷處理必須要快、不能阻塞。這樣一來對於一些要求處理過程比較複雜的任務就不合適在中斷任務中一次處理。比如,網路卡接收資料的過程中,首先網路卡傳送中斷訊號告訴CPU來取資料,然後系統從網路卡中讀取資料存入系統緩衝區中,再下來解析資料然後送入應用層。這些如果都讓中斷處理程式來處理顯然過程太長,造成新來的中斷丟失。因此Linux開發人員將這種任務分割為兩個部分,一個叫上底,即中斷處理程式,短平快地處理與硬體相關的操作(如從網路卡讀資料到系統快取);而把對時間要求相對寬鬆的任務(如解析資料的工作)放在另一個部分執行,這個部分就是我們這裡要講的下半底。

下半底是一種推後執行任務,它將某些不那麼緊迫的任務推遲到系統更方便的時刻執行。核心中實現下半底的手段經過不斷演化,目前已經從最原始的BH(bottom thalf)演生出BH、任務佇列(Task queues)、軟中斷(Softirq)、Tasklet、工作佇列(Work queues)(2.6核心中新出現的)。下面我們就介紹一下他們各自的特點。



軟中斷操作

軟中斷(softirq)不象硬中斷那樣是由硬體中斷訊號觸發執行的,所以也不同於硬體中斷那樣時隨時都能夠被執行,籠統來講,軟中斷會在核心處理任務完畢後返回使用者級程式前得到處理機會。具體的講,有三個時刻它將被執行(do_softirq()):硬體中斷操作完成後;系統呼叫返回時;核心排程程式中;(另外,核心執行緒ksoftirqd週期執行軟中斷)。從中可以看出軟中斷會緊隨硬中斷處理(好象狐假虎威),所以搶佔核心任務——至少在時鐘中斷後總有機會執行一次。還要記得軟中斷可以在不同處器上併發執行。

在有對稱多處理器的機器上,那麼兩個任務就可以真正的在臨界區中同時執行了,這種型別被稱為真併發。相對而言在,單處理器上併發其實並不是真的同時發生,而是相互交錯執行,是偽併發。但它們都同樣會造成競爭條件,而且也需要同樣的保護。

軟中斷是很底層的機制,一般除了在網路子系統和SCSI子系統這樣對效能要求很高以及要求併發處理的時候,才會選擇使用軟中斷。軟中斷雖然靈活性高和效率高,但是你自己必須處理複雜的同步處理(因為它可在多處理器上併發),所以通常都不直接使用,而是作為支援Tasklet和BH的根本。

需要說明的是,軟中斷的執行也處於中斷上下文中,所以中斷上下文對它的限制是和硬中斷一樣的。

Tasklet

Tasklet和bottom half都是建立在軟中斷之上的兩種延遲機制,其具體不同之處在於軟中斷是靜態分配的,而且同類軟中斷可以併發地在幾個CPU上執行;Tasklet可以動態分配,並且不同種類的Tasklets可以併發地在幾個CPU上執行,但同類的tasklets 不可以;bottom half只能靜態分配,實質上,下半部分是一個不能與其它下半部分併發執行的高優先順序tasklet,即使它們型別不同,而且在不同CPU上執行。Tasklet可以理解為軟中斷的派生,所以它的排程時機與軟中斷一致。

對於核心中需要延遲執行的多數任務都可以利用tasklet來完成,由於同類tasklet本身已經進行了同步保護,所以使用tasklet相比軟中斷要簡單得多,而且效率也不錯。

bottom half

是 BH時最早的核心延遲方法,它原始、簡單且容易控制,因為所有的BH處理程式都被嚴格地順序執行——不允許任何兩個BH處理程式同時併發執行,即使它們的型別不同也不可以,這樣一來BH執行其間減少了許多同步保護。但是BH不得不被淘汰,因為它的“簡便”犧牲了多處理器併發處理的高效能,等於一隊人過獨木橋那樣速度受到牽制。

任務佇列

任務列隊是BH的替代品,來自BH,所以它的屬性也和BH相同。它的原意在於簡化BH的操作介面,但它的隨意性(數量隨意、執行時機隨意)卻給系統帶來了混亂,所以到今天已經被工作佇列(在2.6核心中)所取代。

不過在2.4核心中任務佇列還是被大量應用,尤其是排程佇列、定時器佇列和立即佇列等三種任務佇列(除了這三種系統已接管的特定任務佇列外,你自己也可隨心所欲的建立自己的任務佇列,當然這時你要自己排程它)。排程佇列的任務會在每次程式排程時得到處理,它是在程式上下文中處理的;定時器佇列會在每次時鐘滴答時得到處理;立即佇列會在中斷返回或排程時獲得處理(所以處理最快),他們都是在中斷上下文中處理的。

這些任務佇列在核心內由一個統一的核心執行緒排程,該執行緒名為keventd,程式號是2(2.4.18)。你可用ps命令檢視到該程式。

核心執行緒

核心執行緒可以理解成在核心中執行的特殊程式,它有自己的“程式上下文”(借用呼叫它的使用者程式的上下文),所以同樣被程式排程程式排程,也可以睡眠——它和使用者程式屬性何其相似,不同之處就在於核心執行緒執行於核心空間,可訪問核心資料,執行期間不能被搶佔。

傳統的Unix系統把一些重要的任務委託給週期性執行的程式,這些任務包括重新整理磁碟快取記憶體,交換出不用的頁面,維護網路連結等等。事實上,以嚴格線性的方式執行這些任務的確效率不高,如果把他們放在後臺排程,不管是對它們的函式還是對終端使用者程式都能得到較好地響應。因為一些系統程式只執行在核心態,現代作業系統把它們的函式委託給核心執行緒(Kernel Thread),核心執行緒不受不必要的使用者態上下文的拖累。

核心中的同步

核心只要存在任務交錯執行,就必然會存在對共享資料的併發問題,也就必然存在對資料的保護。而核心中任務交錯執行的原因歸根結底還是由於核心任務排程造成的。我們下面歸納一下核心中同步的原因。

同步原因

l 中斷——中斷幾乎可以在任何時刻非同步發生,也就可能隨時打斷當前正在執行的程式碼。

l 睡眠及與使用者空間的同步——在核心執行的程式可能會睡眠,這就會喚醒排程程式,從而導致排程一個新的使用者程式執行。

l 對稱多處理——兩個或多個處理器可以同時執行程式碼。

l 核心搶佔——因為核心具有搶佔性,所以核心中的任務可能會被另一任務搶佔(在2.6核心引進的新能力)。

後兩種情況大大增加了核心任務併發執行的可能性,使得併發隨時隨刻都有可能發生,而且不可清晰預見,規律難尋。

核心任務之間的併發關係

上述核心任務很多情況是可以交錯執行的——記住,一個下半部實際上可能在任何時候執行,所以很有可能產生競爭(都要訪問同一個資料結構時,就產生了競爭)。下面分析這些核心任務之間有哪些可能的併發行為。

可以抽象出,程式(使用者態和核心態一樣)併發執行的總原因無非是正在執行中的程式被其它程式搶佔,所以我們必須看看核心任務之間的搶佔關係:



n 中斷處理程式可以搶佔核心中的所有程式(當沒有鎖保護時),包括軟中斷,tasklet,bottom half和系統的呼叫、核心執行緒,甚至也包括硬中斷處理程式。也就是說中斷處理程式可以和這些所有的核心任務併發執行,如果被搶佔的程式和中斷處理程式都要訪問同一個資源,就必然有可能產生競爭。

n 軟體中斷也可以搶佔核心中的所有任務,所以核心程式碼(比如,系統呼叫、核心執行緒等)中有資料和軟中斷共享,就會有競爭——除此外硬體中斷處理程式也有可能被軟中斷打斷,條件是硬中斷被其它硬中斷打斷,軟中斷隨即便獲得了執行機會,因為軟中斷是跟在硬中斷後執行的。此外要注意的是,軟中斷即使是同種型別的也可以併發地執行在不同處理器上,所以它們之間共享資料都會產生競爭。(如果在同一個處理器上,軟中斷之間是不能相互搶佔的)。

n 同類的tasklet不可能同時執行,所以對於同類tasklet之間是序列執行的,他們不會產生併發;但兩個不同種類的tasklet有可能在不同處理器上併發執行,如果之間有資料共享就會產生競爭(在同一個處理器上執行的tasklet不發生相互搶佔的情況)。

n Bottom half 無論是否是同類的,即使在不同處理器上也都不能併發執行,它是絕對序列化的,所以它們之間永遠不能產生競爭。任務列隊屬性基本同BH。

n 系統呼叫和核心執行緒這種執行在程式上下文中的核心任務可能和各種核心任務併發,除了上面提到的中斷(軟,硬)來搶佔它而產生併發外,它也有可能自發性地主動睡眠(比如在一些阻塞性的操作中),放棄處理器,重新排程其它任務,所以系統呼叫和核心執行緒除會與軟硬中斷(半底等)發生競爭,也會與其他(包括自己)系統呼叫與核心執行緒發生競爭。我們尤其要注意這種情況。

注意:tasklet和bottom half是建立在軟中斷之上的,所以它們也都遵從軟中斷的排程規則——都可以打斷程式上下文中的核心程式碼(系統呼叫),都可被硬中斷打斷——這些情況下都可能產生併發。

核心同步措施

為了避免併發,防止競爭。核心提供了一組同步方法來提供對共享資料的保護。 我們的重點不是介紹這些方法的詳細用法,而是強調為什麼使用這些方法和它們之間的差別。

Linux使用的同步機制可以說從2.0到2.6以來不斷髮展完善。從最初的原子操作,到後來的訊號量,從大核心鎖到今天的自旋鎖。這些同步機制的發展伴隨 Linux從單處理器到對稱多處理器的過度;伴隨著從非搶佔核心到搶佔核心的過度。鎖機制越來越有效,也越來越複雜。

目前來說核心中原子操作多用來做計數使用,其它情況最常用的是兩重鎖以及它們的變種,一個是自旋鎖,另一個是訊號量。我們下面就來著重介紹一下這兩種鎖機制。

自旋鎖

自旋鎖是專為防止多處理器併發而引入的一種鎖,它在核心中大量應用於中斷處理等部分(對於單處理器來說,防止中斷處理中的併發可簡單採用關閉中斷的方式,不需要自旋鎖)。

自旋鎖最多隻能被一個核心任務持有,如果一個核心任務試圖請求一個已被爭用(已經被持有)的自旋鎖,那麼這個任務就會一直進行忙迴圈——旋轉——等待鎖重新可用。要是鎖未被爭用,請求它的核心任務便能立刻得到它並且繼續進行。自旋鎖可以在任何時刻防止多於一個的核心任務同時進入臨界區,因此這種鎖可有效地避免多處理器上併發執行的核心任務競爭共享資源。

事實上,自旋鎖的初衷就是:在短期間內進行輕量級的鎖定。一個被爭用的自旋鎖使得請求它的執行緒在等待鎖重新可用的期間進行自旋(特別浪費處理器時間),所以自旋鎖不應該被持有時間過長。如果需要長時間鎖定的話, 最好使用訊號量。

自旋鎖的基本形式如下:

spin_lock(&mr_lock);

/*臨界區*/

spin_unlock(&mr_lock);

因為自旋鎖在同一時刻只能被最多一個核心任務持有,所以一個時刻只有一個執行緒允許存在於臨界區中。這點很好地滿足了對稱多處理機器需要的鎖定服務。在單處理器上,自旋鎖僅僅當作一個設定核心搶佔的開關。如果核心搶佔也不存在,那麼自旋鎖會在編譯時被完全剔除出核心。

自旋鎖在核心中有許多變種,如對bottom half 而言,可以使用spin_lock_bh()用來獲得特定鎖並且關閉半底執行。相反的操作由spin_unlock_bh()來執行;如果臨界區的訪問邏輯可以被清晰的分為讀和寫這種模式,那麼可以使用讀者/寫者自旋鎖,呼叫形式為:

讀者的程式碼路徑:

read_lock(&mr_rwlock);

/*只讀臨界區*/

read_unlock(&mr_rwlock);

寫者的程式碼路徑:

write_lock(&mr_rwlock);

/*讀寫臨界區*/

write_unlock(&mr_rwlock);

簡單的說,自旋鎖在核心中主要用來防止多處理器中併發訪問臨界區,防止核心搶佔造成的競爭。另外自旋鎖不允許任務睡眠(持有自旋鎖的任務睡眠會造成自死鎖——因為睡眠有可能造成持有鎖的核心任務被重新排程,而再次申請自己已持有的鎖),它能夠在中斷上下文中使用。

死鎖:假設有一個或多個核心任務和一個或多個資源,每個核心都在等待其中的一個資源,但所有的資源都已經被佔用了。這便會發生所有核心任務都在相互等待,但它們永遠不會釋放已經佔有的資源,於是任何核心任務都無法獲得所需要的資源,無法繼續執行,這便意味著死鎖發生了。自死瑣是說自己佔有了某個資源,然後自己又申請自己已佔有的資源,顯然不可能再獲得該資源,因此就自縛手腳了。

訊號量

Linux中的訊號量是一種睡眠鎖。如果有一個任務試圖獲得一個已被持有的訊號量時,訊號量會將其推入等待佇列,然後讓其睡眠。這時處理器獲得自由去執行其它程式碼。當持有訊號量的程式將訊號量釋放後,在等待佇列中的一個任務將被喚醒,從而便可以獲得這個訊號量。

訊號量的睡眠特性,使得訊號量適用於鎖會被長時間持有的情況;只能在程式上下文中使用,因為中斷上下文中是不能被排程的;另外當程式碼持有訊號量時,不可以再持有自旋鎖。

訊號量基本使用形式為:

static DECLARE_MUTEX(mr_sem);//宣告互斥訊號量



if(down_interruptible(&mr_sem))

/*可被中斷的睡眠,當訊號來到,睡眠的任務被喚醒 */

/*臨界區…*/

up(&mr_sem);

同自旋鎖一樣,訊號量在核心中也有許多變種,比如讀者-寫者訊號量等,這裡不再做介紹了。

訊號量和自旋鎖區別

雖然聽起來兩者之間的使用條件複雜,其實在實際使用中訊號量和自旋鎖並不易混淆。注意以下原則。

如果程式碼需要睡眠——這往往是發生在和使用者空間同步時——使用訊號量是唯一的選擇。由於不受睡眠的限制,使用訊號量通常來說更加簡單一些。如果需要在自旋鎖和訊號量中作選擇,應該取決於鎖被持有的時間長短。理想情況是所有的鎖都應該儘可能短的被持有,但是如果鎖的持有時間較長的話,使用訊號量是更好的選擇。另外,訊號量不同於自旋鎖,它不會關閉核心搶佔,所以持有訊號量的程式碼可以被搶佔。這意味者訊號量不會對影響排程反應時間帶來負面影響。

自旋鎖對訊號量

―――――――――――――――――――――――――――――――

需求 建議的加鎖方法

低開銷加鎖 優先使用自旋鎖

短期鎖定 優先使用自旋鎖

長期加鎖 優先使用訊號量

中斷上下文中加鎖 使用自旋鎖

持有鎖是需要睡眠、排程 使用訊號量

―――――――――――――――――――――――――――――――

引自 《Linux核心開發》

防止併發的方式除了上面提到的外還有很多,我們不詳細介紹了。說了這麼多,希望大家認識到,併發控制在核心程式設計中是個特別難纏的問題,要駕御它必須清楚地認識到核心中各種任務的排程時機與特點,並且在開發初期就應特別小心保護共享資料(一切共享資料、一切能被別人看到的資料都要注意保護),別等到開發完成才去亡羊補牢。

併發控制例項

我們下面給出一個多核心任務訪問共享資源的具體例子,其中會用到上面提到的各種同步方法,希望能給大家一個形象的記憶。

該例子的具體場景描述如下。

我們主要的共享資源是連結串列(mine),操作它的核心任務有三種:一個是100個核心執行緒(sharelist),它們負責從表頭將新節點(struct my_struct)插入連結串列。二是定時器任務(qt_task),它負責每個時鐘滴答時從連結串列頭刪除一個節點。三是系統呼叫(由rmmod命令呼叫的share_exit),它負責銷燬連結串列並解除安裝模組。

我們利用模組(sharelist.o)實現上述場景。載入模組時會建立定時器任務列隊,並將要執行的任務(task.rounting=qt_task)插入定時器佇列(tq_timer),然後反覆排程執行(但別不停地執行)。與此同時利用系統中的keventd核心執行緒(它的目的是執行任務佇列,由schedule_task啟用,PID=2),建立100個核心執行緒(建立函式kernel_thread)執行插入連結串列的工作(由sharelist完成)——但當連結串列長度超過100時,則從連結串列尾刪除節點。最後當你需要解除安裝模組時,呼叫share_exit函式銷燬整個連結串列,並做一些諸如銷燬我們建立的核心程式的收尾工作。

下面我們具體看看在程式中該如何保護我們的連結串列。上述場景中存在的核心併發包括——核心執行緒之間的併發、核心任務與定時器任務的併發。要知道核心執行緒執行在程式上下文中,而定時器任務屬於下半部分,執行在中斷上下文中。在這兩部分交錯執行中進行保護則需要採用自旋鎖。我們例子中使用了spin_lock_bh()鎖在核心執行緒的執行路徑中對連結串列進行保護;在下半部分,由於任務佇列是序列執行並且不能被核心任務或系統呼叫打斷,所以不必加鎖。另外在解除安裝模組時,刪除連結串列中仍然存在系統呼叫與下半部分的併發可能,因此也需要按上述方式加鎖。

除了對共享連結串列訪問使用自旋鎖以外,還有兩個需要同步的地方,一是計數(count),該變數屬於原子型別,用於記錄連結串列接點的id。另外一個是利用訊號量同步核心建立執行緒,排程keventd後執行被堵塞住(down),等核心執行緒實際啟動後, 才可繼續執行(up)。

結束

併發的發生隨處都有,但是由它引起的錯誤可並非每次都有,因為併發過程中引起錯誤的地方往往就一兩步,因此交錯執行這一兩步要靠“運氣”,出錯的機率有時很小。但是一旦發生後果都是災難性的,比如當機,破壞資料完整性等。所以我們對併發絕不能掉以輕心,必須拿出“把紙老虎當真老虎的”決心來對待一切核心程式碼中可能的併發,即便在單處理器上程式設計也需要考慮到移植到多處理器的情況,總之一切都要謹慎小心。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/10617542/viewspace-958910/,如需轉載,請註明出處,否則將追究法律責任。

相關文章