linux seqlock & rcu 淺析

來源:互聯網
上載者:User
在linux核心中,有很多同步機制。比較經典的有spin_lock(忙等待的鎖)、mutex(互斥鎖)、semaphore(訊號量)、等。並且它們幾乎都有對應的rw_XXX(讀寫鎖),以便在能夠區分讀與寫的情況下,讓讀操作相互不互斥(讀寫、寫寫依然互斥)。
而seqlock和rcu應該可以不算在經典之列,它們是兩種比較有意思的同步機制。

seqlock(順序鎖)

用於能夠區分讀與寫的場合,並且是讀操作很多、寫操作很少,寫操作的優先權大於讀操作。
seqlock的實現思路是,用一個遞增的整型數表示sequence。寫操作進入臨界區時,sequence++;退出臨界區時,sequence再++。寫操作還需要獲得一個鎖(比如mutex),這個鎖僅用於寫寫互斥,以保證同一時間最多隻有一個進行中的寫操作。
當sequence為奇數時,表示有寫操作進行中,這時讀操作要進入臨界區需要等待,直到sequence變為偶數。讀操作進入臨界區時,需要記錄下當前sequence的值,等它退出臨界區的時候用記錄的sequence與當前sequence做比較,不相等則表示在讀操作進入臨界區期間發生了寫操作,這時候讀操作讀到的東西是無效的,需要返回重試。

seqlock寫寫是必須要互斥的。但是seqlock的應用情境本身就是讀多寫少的情況,寫衝突的機率是很低的。所以這裡的寫寫互斥基本上不會有什麼效能損失。
而讀寫操作是不需要互斥的。seqlock的應用情境是寫操作優先於讀操作,對於寫操作來說,幾乎是沒有阻塞的(除非發生寫寫衝突這一小機率事件),只需要做sequence++這一附加動作。而讀操作也不需要阻塞,只是當發現讀寫衝突時需要retry。

seqlock的一個典型應用是時鐘的更新,系統中每1毫秒會有一個時鐘中斷,相應的中斷處理常式會更新時鐘(見《linux時鐘淺析》)(寫操作)。而使用者程式可以調用gettimeofday之類的系統調用來擷取目前時間(讀操作)。在這種情況下,使用seqlock可以避免過多的gettimeofday系統調用把中斷處理常式給阻塞了(如果使用讀寫鎖,而不用seqlock的話就會這樣)。中斷處理常式總是優先的,而如果gettimeofday系統調用與之衝突了,那使用者程式多等等也無妨。

seqlock的實現非常簡單:
寫操作進入臨界區時:
void write_seqlock(seqlock_t *sl)
{
spin_lock(&sl->lock); // 上寫寫互斥鎖
++sl->sequence;       // sequence++
}
寫操作退出臨界區時:
void write_sequnlock(seqlock_t *sl)
{
sl->sequence++;         // sequence再++
spin_unlock(&sl->lock); // 釋放寫寫互斥鎖
}

讀操作進入臨界區時:
unsigned read_seqbegin(const seqlock_t *sl)
{
unsigned ret;
repeat:
ret = sl->sequence;      // 讀sequence值
if (unlikely(ret & 1)) { // 如果sequence為奇數自旋等待
goto repeat;
}
return ret;
}
讀操作嘗試退出臨界區時:
int read_seqretry(const seqlock_t *sl, unsigned start)
{
return (sl->sequence != start); // 看看sequence與進入臨界區時是否發生過改變
}
而讀操作一般會這樣進行:
do {
seq = read_seqbegin(&seq_lock);      // 進入臨界區
do_something();
} while (read_seqretry(&seq_lock, seq)); // 嘗試退出臨界區,存在衝突則重試

RCU(read-copy-update)

RCU也是用於能夠區分讀與寫的場合,並且也是讀多寫少,但是讀操作的優先權大於寫操作(與seqlock相反)。
RCU的實現思路是,讀操作不需要互斥、不需要阻塞、也不需要原子指令,直接讀就行了。而寫操作在進行之前需要把被寫的對象copy一份,寫完之後再更新回去。其實RCU所能保護的並不是任意的臨界區,它只能保護由指標指向的對象(而不保護指標本身)。讀操作通過這個指標來訪問對象(這個對象就是臨界區);寫操作把對象複製一份,然後更新,最後修改指標使其指向新的對象。由於指標總是一個字長的,對它的讀寫對於CPU來說總是原子的,所以不用擔心更新指標只更新到一半就被讀取的情況(指標的值為0x11111111,要更新為0x22222222,不會出現類似0x11112222這樣的中間狀態)。所以,當讀寫操作同時發生時,讀操作要麼讀到指標的舊值,引用了更新前的對象、要麼讀到了指標的新值,引用了更新後的對象。即使同時有多個寫操作發生也沒關係(是否需要寫寫互斥跟寫操作本身的情境相關)。

RCU封裝了rcu_dereference和rcu_assign_pointer兩個函數,分別用於對指標進行讀和寫。
rcu_assign_pointer(p, v) => (p) = (v)
rcu_dereference(p) => (p)
裡面其實就是簡單的指標讀和寫,然後可能設定記憶體屏障(以避免編譯器或CPU指令亂序對程式造成影響)。當然,如果出現了一種奇怪的不能直接保證原子性讀寫指標的體繫結構,還需要這兩個函數來保證原子性。

可以看到,使用了RCU之後,讀寫操作竟然神奇地都不需要阻塞了,臨界區已經不是臨界區了。只不過寫操作稍微麻煩些,需要read、copy再update。不過RCU的核心問題並不是如何同步,而是如何釋放舊的對象。指向對象的指標被更新了,但是之前發生的讀操作可能還在引用舊的對象呢,舊的對象什麼時候釋放掉呢?讓讀操作來釋放舊的對象似乎並不是很和理,它不知道對象是否已經被更新了,也不知道有多少讀操作都引用了這箇舊對象。給對象加一個引用計數呢?這或許可以奏效,但是這也太不通用了,RCU是一種機制,如果要求每個使用RCU的對象都在對象的某某位置維護一個引用計數,相當於RCU機制要跟具體的對象耦合上了。並且對引用計數的修改還需要另一套同步機制來提供保障。
為解決舊對象釋放的問題,RCU提供了四個函數(另外還有一些它們的變形):
rcu_read_lock(void)、rcu_read_unlock(void)
synchronize_rcu(void)、call_rcu(struct rcu_head *head, void (*func)(struct rcu_head *head))。
當讀操作要調用rcu_dereference訪問對象之前,需要先調用rcu_read_lock;當不再需要訪問對象時,調用rcu_read_unlock。
當寫操作調用rcu_assign_pointer完成對對象的更新之後,需要調用synchronize_rcu或call_rcu。其中synchronize_rcu會阻塞等待在此之前所有調用了rcu_read_lock的讀操作都已經調用rcu_read_unlock,synchronize_rcu返回後寫操作一方就可以將被它替換掉的舊對象釋放了;而call_rcu則是通過註冊回呼函數的方式,由回呼函數來釋放舊對象,寫操作一方將不需要阻塞等待。同樣,等到在此之前所有調用了rcu_read_lock的讀操作都調用rcu_read_unlock之後,回呼函數將被調用。

如果你足夠細心,可能已經注意到了這樣一個問題。synchronize_rcu和call_rcu會等待的是“在此之前所有調用了rcu_read_lock的讀操作都已經調用了rcu_read_unlock”,然而在rcu_assign_pointer與synchronize_rcu或call_rcu之間,可能也有讀操作發生(調用了rcu_read_lock),它們引用到的是寫操作rcu_assign_pointer後的新對象。按理說寫操作一方想要釋放舊對象時,是不需要等待這樣的讀操作的。但是由於這些讀操作發生在synchronize_rcu或call_rcu之前,按照RCU的機制,還真得等它們都rcu_read_unlock。這豈不是多等了一些時日?
實際情況的確是這樣,甚至可能更糟。因為目前linux核心裡面的RCU是一個全域的實現,注意,rcu_read_lock、synchronize_rcu、等等操作都是不帶參數的。它不像seqlock或其他同步機制那樣,一把鎖保護一個臨界區。這個全域的RCU將保護使用RCU機制的所有臨界區。所以,對於寫操作一方來說,在它調用synchronize_rcu或call_rcu之前發生的所有讀操作它都得等待(不管讀的對象與該寫操作有無關係),直到這些讀操作都rcu_read_unlock之後,舊的對象才能被釋放。所以,寫操作更新對象之後,舊對象並不是精確地在它能夠被釋放之時立刻被釋放的,可能會存在一定的延遲。
不過話說回來,這樣的實現減少了很多不必要的麻煩,因為舊的對象晚一些釋放是不會有太大關係的。想一想,精確舊對象的釋放時機有多大意義呢?無非是儘可能早的回收一些記憶體(一般來說,核心裡面使用的這些對象並不會太大吧,晚一點回收也不會晚得太過分吧)。但是為此你得花費很大的代價去跟蹤每一個對象的引用情況,這是不是有些得不償失呢?

最後,RCU要求,讀操作在rcu_read_lock與rcu_read_unlock之間是不能睡眠的(WHY?),call_rcu提供的回呼函數也不能睡眠(因為回呼函數一般會在非強制中斷裡面去調用,中斷上下文是不能睡眠的,見《linux中斷處理淺析》)。

那麼,RCU具體是怎麼實現的呢?儘管沒有要求在精確的時間回收舊對象,RCU的實現還是很複雜的。以下簡單討論一下rcu_read_lock、rcu_read_unlock、call_rcu三個函數的實現。而synchronize_rcu實際上是利用call_rcu來實現的(調用call_rcu提交一個回呼函數,然後自己進入睡眠,而回呼函數要做的事情就是把自己喚醒)。
在linux 2.6.30版本中,RCU有三種實現,分別命名為rcuclassic、rcupreempt、rcutree。這三種實現也是逐步發展出來的,最開始是rcuclassic,然後rcupreempt,最後rcutree。在編譯核心時可以通過編譯選項選擇需要使用的RCU實現。

rcuclassic
rcuclassic的實現思路是,讀操作在rcu_read_lock時禁止核心搶佔、在rcu_read_unlock時重新啟用核心搶佔。由於RCU只會在核心態裡面使用,而且RCU也要求rcu_read_lock與rcu_read_unlock之間不能睡眠。所以在rcu_read_lock之後,這個讀操作的相關代碼肯定會在當前CPU上持續被執行,直到rcu_read_unlock之後才可能被調度。而同一時間,在一個CPU上,也最多隻能有一個進行中的讀操作。可以說,rcuclassic是基於CPU來跟蹤讀操作的。
於是,如果發現一個CPU已經發生了調度,就說明這個CPU上的讀操作肯定已經rcu_read_unlock了(注意這裡又是一次延遲,rcu_read_unlock之後可能還要過一段時間才會發生調度。RCU的實現中,這樣的延遲隨處可見,因為它根本就不要求在精確的時間點回收舊對象)。於是,從一次call_rcu被調用之後開始,如果等到所有CPU都已經發生了調度,這次call_rcu需要等待的讀操作就必定都已經rcu_read_unlock了,這時候就可以處理這個call_rcu提交的回呼函數了。
但是實現上,rcuclassic並不是為每一次call_rcu都提供一個這樣的等待周期(等待所有CPU都已發生調度),那樣的話粒度太細,實現起來會比較複雜。rcuclassic將現有的全部call_rcu提交的回呼函數分為兩個批次(batch),以批次為單位來進行等待。如果所有CPU都已發生調度,則第一批次的所有回呼函數將被調用,然後將第一批次清空、第二批變為第一批,並繼續下一次的等待。而所有新來的call_rcu總是將回呼函數提交到第二批。
rcuclassic邏輯上通過三個鏈表來管理call_rcu提交的回呼函數,分別是第二批次鏈表、第一批次鏈表、待處理鏈表(2.6.30版本的實現實際用了四個鏈表,把待處理鏈表分解成兩個鏈表)。call_rcu總是將回呼函數提交到第二批次鏈表中,如果發現第一批次鏈表為空白(之前的call_rcu都已經處理完了),就將第二批次鏈表中的回呼函數都移入第一批次鏈表(第二批次鏈表清空);從回呼函數被移入第一批次鏈表開始,如果所有CPU都發生了調度,則將第一批次鏈表中的回呼函數都移入待處理鏈表(第一批次鏈表清空,同時第二批次鏈表中新的回呼函數又被移過來);待處理鏈表裡面的回呼函數都是等待被調用的,下一次進入非強制中斷的時候就要調用它們。
什麼時候檢查“所有CPU都已發生調度”呢?並不是在CPU發生調度的時候。調度的時候只是做一個標記,標記這個CPU已經調度過了。而檢查是放在每毫秒一次的時鐘中斷處理函數裡面來進行的。
另外,這裡提到的第二批次鏈表、第一批次鏈表、待處理鏈表其實是每個CPU維護一份的,這樣可以避免操作鏈結表時CPU之間的競爭。
rcuclassic的實現利用了禁止核心搶佔,這對於一些即時性要求高的環境是不適用的(即時性要求不高則無妨),所以後來又有了rcupreempt的實現。

rcupreempt
rcupreempt是相對於rcuclassic禁止核心搶佔而言的,rcupreempt允許核心搶佔,以滿足更高的即時性要求。
rcupreempt的實現思路是,通過計數器來記錄rcu_read_lock與rcu_read_unlock發生的次數。讀操作在rcu_read_lock時給計數器加1,rcu_read_unlock時則減1。只要計數器的值為0,說明所有的讀操作都rcu_read_unlock了,則在此之前所有call_rcu提交的回呼函數都可以被執行。不過,這樣的話,新來的rcu_read_lock會使得之前的call_rcu不斷延遲(如果rcu_read_unlock總是跟不上rcu_read_lock的速度,那麼計數器可能永遠都無法減為0。但是對於之前的某個call_rcu來說,它所關心的讀操作卻可能都已經rcu_read_unlock了)。所以,rcupreempt還是像rcuclassic那樣,將call_rcu提交的回呼函數分為兩個批次,然後由兩個計數器分別計數。
跟rcuclassic一樣,call_rcu提交的回呼函數總是加入到第二批次,所以rcu_read_lock總是增加第二批次的計數。而當第一批次為空白時,第二批次將移動到第一批次,計數值也應該一起移過來。所以,rcu_read_unlock必須知道它應該減少哪個批次的計數(rcu_read_lock增加第二批次的計數,之後第一批次可能被處理,然後第二批次被移動到第一批次。這種情況下對應的rcu_read_unlock應該減少的是第一批次的計數了)。
實現上,rcupreempt提供了兩個[等待隊列+計數器],並且交替的選擇其中的一個作為“第一批次”。之前說的將第二批次移動到第一批次的過程實際上就是批次交替一次的過程,批次並沒移動,只是兩個[等待隊列+計數器]的含義發生了交換。於是,rcu_read_lock的時候需要記錄下現在增加的是第幾個計數器的計數,rcu_read_unlock就相應減少那個計數就行了。
那麼rcu_read_lock與rcu_read_unlock怎麼對應上呢?rcupreempt已經不禁止核心搶佔了,同一個讀操作裡面的rcu_read_lock和rcu_read_unlock可能發生在不同CPU上,不能通過CPU來聯絡rcu_read_lock與rcu_read_unlock,只能通過上下文,也就是執行rcu_read_lock與rcu_read_unlock的進程。所以,在進程式控制制塊(task_struct)中新增了一個index欄位,用來記錄這個進程上執行的rcu_read_lock增加了哪個計數器的計數,於是這個進程上執行的rcu_read_unlock也應該減少相應的計數。
rcupreempt也維護了一個待處理鏈表。於是,當第一批次的計數為0時,第一批次裡面的回呼函數將被移動到待處理鏈表中,等到下一次進入非強制中斷的時候就調用它們。然後第一批次被清空,兩個批次做交換(相當於第二批次移動到第一批次)。
跟rcuclassic類似,對於計數值的檢查並不是在rcu_read_unlock的時候進行的,rcu_read_unlock只管修改計數值。而檢查也是放在每毫秒一次的時鐘中斷處理函數裡面來進行的。
同樣,這裡提到的等待隊列和計數器也是每個CPU維護一份的,以避免操作鏈結表和計數器時CPU之間的競爭。那麼當然,要檢查第一批次計數為0,是需要把所有CPU的第一批次計數值進行相加的。

rcutree
最後說說rcutree。它跟rcuclassic的實現思路幾乎是一模一樣的,通過禁止搶佔、檢查每一個CPU是否已經發生過調度,來判斷髮生在某一批次rcu_call之前的所有讀操作是否都已經rcu_read_unlock。並且實現上,批次的管理、各種隊列、等等都幾乎一樣,CPU發生調度時也是通過設定一個標記來表示自己已經調度過了,然後又在時鐘中斷的處理常式中判斷是否所有CPU都已經發生過調度……那麼,不同之處在哪裡呢?在於“判斷是否每一個CPU都調度過”這一細節上。
rcuclassic對於多個CPU的管理是對稱的,在時鐘中斷處理函數中,要判斷是否每一個CPU都調度過就得去看每一個CPU所設定的標記,而這個“看”的過程勢必是需要互斥的(因為這些標記也會被其他CPU讀或寫)。這樣就造成了CPU之間的競爭。如果CPU個數不多,就這麼競爭一下倒也無妨。要是CPU很多的話(比如64個?或更多?),那當然越少競爭越好。rcutree就是為了這種擁有很多CPU的環境而設計的,以期減少競爭。
rcutree的思路是提供一個樹型結構,其中的每一個非葉子節點提供一個鎖(代表了一次競爭),而每個CPU就對應到樹的葉子節點上。然後呢?當需要判斷“是否每一個CPU都調度過”的時候,CPU嘗試在自己的父節點上鎖(這個鎖只會由它的子節點來競爭,而不會被所有CPU競爭),然後判斷這個“父節點”的子節點(CPU)是否都已經調度過。如果不是,則顯然“每一個CPU都調度過”不成立。而如果是,則再向上遍曆,直到走到樹根,那麼就可以知道所有CPU都已經調度過了。使用這樣的樹型結構就縮小了每一次加鎖的粒度,減少了CPU間的競爭。

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.