Linux中的RCU機制[一] - 原理與使用方法【轉】

轉自:https://zhuanlan.zhihu.com/p/89439043

RCU機制是自內核2.5版本引入的(2002年10月),而後不斷完善,其在Linux的locking機制中的使用佔比也是逐年攀升。

基本原理

RCU的基本思想是這樣的:先創建一箇舊數據的copy,然後writer更新這個copy,最後再用新的數據替換掉舊的數據。這樣講似乎比較抽象,那麼結合一個實例來看或許會更加直觀。

假設有一個單向鏈表,其中包含一個由指針p指向的節點:

現在,我們要使用RCU機制來更新這個節點的數據,那麼首先需要分配一段新的內存空間(由指針q指向),用於存放這個copy。

然後將p指向的節點數據,以及它和下一節點[11, 4, 8]的關係,都完整地copy到q指向的內存區域中。

接下來,writer會修改這個copy中的數據(將[5, 6, 7]修改爲[5, 2, 3])。

修改完成之後,writer就可以將這個更新“發佈”了(publish),對於reader來說就“可見”了。因此,pubulish之後纔開始讀取操作的reader(比如讀節點[1, 2, 3]的下一個節點),得到的就是新的數據[5, 2, 3](圖中紅色邊框表示有reader在引用)。

而在publish之前就開始讀取操作的reader則不受影響,依然使用舊的數據[5, 6, 7]。

等到所有引用舊數據區的reader都完成了相關操作,writer纔會釋放由p指向的內存區域。

可見,在此期間,reader如果讀取這個節點的數據,得到的要麼全是舊的數據,要麼全是新的數據,反正不會是「半新半舊」的數據,數據的一致性是可以保證的。重要的是,RCU中的reader不用像rwlock中的reader那樣,在writer操作期間必須spin等待了。

RCU的全稱是"read copy update",可以這樣來理解:read和進行copy的線程並行,目的是爲了update。好像有點"copy on write"的意思?反正有人覺得RCU的命名不夠準確,寧願叫它"publish protocol"(比如 Fedor Pikus)。不管怎樣,RCU的命名已經成了業界默認的,我們還是就叫它RCU吧。

那RCU具體應該如何使用呢?這得走進真正的代碼,才能一探究竟。

使用方法

從rwlock到RCU

前面的例子爲了簡化,採用的是單向鏈表來演示,這裏我們切換到Linux中常用的雙向鏈表上來。由於RCU可理解爲是基於rwlock演進而來的,所以筆者將結合上文講解的rwlock的用法,來對比討論RCU的使用。

假設現在reader正在遍歷/查詢一個鏈表,而writer正在刪除該鏈表中的一個節點。那麼,使用rwlock(左)和RCU(右)來實現的讀取一側的代碼分別是這樣的:

rwlock 和 RCU 的使用對比 (讀取)

同rwlock類似,rcu_read_lock()和rcu_read_unlock()界定了RCU讀取一側的critical section。如果在內核配置時選擇了"CONFIG_PREEMPT",那麼這2個函數實際要做的工作僅僅是分別關閉和打開CPU的可搶佔性而已,等同於preempt_disable()和preempt_enable()。

這種命名,體現了RCU和rwlock的「一脈相承」,但在RCU的讀取一側,其實並沒有什麼"lock",所以可能命名爲rcu_enter()和rcu_exit()之類的更加貼切。

在寫入一側的RCU實現中,爲了防止多個writer對鏈表的同時操作,使用了一個標準的spinlock。

rwlock 和 RCU 的使用對比 (寫入)

list_del_rcu()的實現和普通的list_del()基本一致,但多了一個對"prev"指針的"poison"處理,以避免接下來reader再通過該節點訪問前向節點。

static inline void list_del_rcu(struct list_head *entry)
{
    __list_del_entry(entry);
    entry->prev = LIST_POISON2;
}

沒有同時"poison"後向指針的原因,請參考這個解釋

此外,在調用kfree()釋放節點之前,多了一個synchronize_rcu()函數。synchronize就是「同步」,那它在和誰同步呢?就是前面說的那些“引用舊數據區的reader”啦,因爲此時它們可能還在引用指針p。這相當於給了這些reader一個優雅退出的寬限區,因此這段同步等待的時間被稱爲Grace Period(簡稱GP)。

不過,必須是在synchronize之前就已經進入critical section的reader纔可以,至於之後的reader麼,直接讀新的數據就可以了,用不着writer來等待。比如下面這個場景中,作爲writer的CPU 1只會等待CPU 0,CPU 0離開critical section後就結束同步,而不會理會CPU 2。

也許,把synchronize_rcu()改名成wait_for_readers_to_leave()會更加直觀。

等待與回調

如果grace period的時間比較長,writer這麼幹等着,豈不是會影響這個CPU上更高優先級的任務執行?在這種情況下,可以使用基於callback機制的call_rcu()來替換synchronize_rcu()。

void call_rcu(struct rcu_head *head, rcu_callback_t func);

call_rcu()會註冊一個回調函數"func",當所有的reader都退出critical section後,該回調函數將被執行。第一個參數的類型是struct rcu_head,它的定義是這樣的:

struct callback_head {
    struct callback_head *next;
    void (*func)(struct callback_head *head);
} __attribute__((aligned(sizeof(void *))));

#define rcu_head callback_head

CPU調用call_rcu()後就可以離開去做其他事情了,之後它完全可能再次調用call_rcu(),所以它每次註冊的回調函數,需要通過"next"指針排隊串接起來,等grace period結束後,依次執行。如果需要處理的回調函數比較多,可能需要分批進行,詳細的討論可參考這篇文章

第二個參數就是前面講的回調函數,其功能主要就是釋放掉“舊指針”指向的內存空間。來看一個使用call_rcu()的具體實例:

call_rcu(&old->rcu, kvfree_rcu);

rcu_head是註冊時傳遞給"kvfree_rcu"的參數,可是要釋放的舊指針在哪裏?

static void kvfree_rcu(struct rcu_head *head)
{
    struct list_lru_memcg *mlru;
    mlru = container_of(head, struct list_lru_memcg, rcu);
    kvfree(mlru);
}

原來啊,它同"list_head"一樣,往往是「嵌」在某個結構體中,通過container_of()的技巧來獲得所在結構體的首地址的。

新舊更迭

本着循序漸進的原則,以上代碼採用的是基於鏈表的"delete"操作來討論,僅涵蓋了對“舊指針”的處理。而本文的開頭,使用的例子是鏈表的"replace"操作,還包括了對“新指針”的處理,所以接下來看下代碼中和“新指針”有關的部分吧。

static inline void __list_add_rcu(struct list_head *new,
	                          struct list_head *prev, struct list_head *next)		
{
    new->next = next;
    new->prev = prev;
    rcu_assign_pointer(list_next_rcu(prev), new);
    next->prev = new;
}

和普通的list_add()相比,多了一個rcu_assign_pointer()。它起的作用就是前面說的"publish",publish之後,writer就可以進入grace period了。同時,它的實現中包含了一個Memory Barrier,以避免在“新指針”準備好之前,就被引用了。

#define rcu_assign_pointer(p, v)  smp_store_release(&(p), (v));

這裏使用的是list_add_rcu(),而不是list_replace_rcu()來講解,這是因爲"delete"只需要synchronize_rcu()/call_rcu(),而"add"只需要rcu_assign_pointer(),它們都是最基礎的操作,而"replace"完全可以視作是先進行"delete",再進行"add"的複合操作。理解了基礎操作,複合操作就不在話下了。

並行的粒度

以grace period爲界,整個更新操作被劃分爲了"removal"和"reclamation"兩個階段,writer的角色也被對應地劃分爲了updater和reclaimer。還是用鏈表操作的這個例子,removal階段將一個節點從鏈表中移除,而等待所有reader解除對該節點的引用後,就進入回收/釋放這個節點所佔內存的reclamation階段。

因爲writer在removal階段就會解除對節點的引用,所以reader需要調用rcu_dereference()宏,將節點指針的值賦給一個臨時指針,保存起來。它的實現可簡單理解成這樣:

#define rcu_dereference(p)  READ_ONCE(p);

接下來這些reader對該節點的操作都是引用這個臨時指針,它們訪問到的也都是publish之前的數據。不過,因爲該節點的內存會在最後一個引用它的reader退出臨界區後,被reclaimer釋放,所以對這個節點的引用,只在讀取一側的臨界區內有效。

rcu_read_lock();
p1 = rcu_dereference(p);
rcu_read_unlock();
x = p1->address;

像上述代碼這種退出臨界區還在使用,是不行的。下圖中的"p1"和"p2"分別代表reader保存的引用"data 1"和"data 2"的指針。

雖然seqlock也可以實現reader和writer的並行,但在writer操作期間,reader的操作需要推到重來,所以其實是無效的。而在RCU中,reader和updater可以實現真正的並行,updater你更新你的,反正我reader讀的是舊的數據。updater和reclaimer也可以並行,所以在某一時刻,一份數據可能有多個version(圖中藍色箭頭表示“舊指針”,黑色箭頭表示“新指針”)。

但updater和updater之間不能並行,需要加spinlock來互斥。至於reader和reclaimer之間能不能並行,則取決於reclaimer對應的grace period是否包含相關的reader。

從上文seqlock和本文RCU的實現來看,讀取一側其實都是沒有鎖的,reader和writer的同步在seqlock中靠的是sequence number,而在RCU中主要靠的是grace period。

對於RCU,不能簡單地說它是隻支持“多讀一寫”還是支持“多讀多寫”的,但它通過對writer更細粒度的劃分,相比seqlock確實提供了更高的並行度。更高的並行度意味着能讓更多的CPU處在busy的狀態,也就能讓硬件資源得到更充分的利用,提高效率。

三角關係

伴隨着對RCU基本原理和使用方法的講解,RCU中讀取一側和寫入一側5個基礎的API其實也都逐步出現了,事實上,RCU的很多其他API都是基於這5個API組合而成的,就像紅黃藍三原色一樣。

伴隨着writer角色和功能的劃分,RCU中存在的是reader, updater和reclaimer三者之間的關聯。藉助下面這張圖,我們可以一覽RCU的全貌,同時梳理這些聯繫。

以鏈表的"replace"操作爲例,作爲updater,在對copy的數據更新完成後,需要通過rcu_assign_pointer(),用這個copy替換原節點在鏈表中的位置,並移除對原節點的引用,而後調用synchronize_rcu()或call_rcu()進入grace period。因爲synchronize_rcu()會阻塞等待,所以只能在進程上下文中使用,而call_rcu()可在中斷上下文中使用。

作爲reader,在調用rcu_read_lock()進入臨界區後,因爲所使用的節點可能被updater解除引用,因而需要通過rcu_dereference()保留一份對這個節點的指針指向。進入grace period意味着數據已經更新,而這些reader在退出臨界區之前,只能使用舊的數據,也就是說,它們需要暫時忍受“過時”的數據,不過這在很多情況下是沒有多大影響的。

作爲reclaimer,對於所有進入grace period之前就進入臨界區的reader,需要等待它們都調用了rcu_read_unlock()退出臨界區,之後grace period結束,原節點所在的內存區域被釋放。

當內存不再需要了就回收,講到這裏,你有沒有覺得,RCU的方法有點"Garbage Collection"(GC)的味道?它確實可以算一種user-driven的GC機制(區別於automatic的)。

那reclaimer是如何知道這些reader都已經退出了讀取一側的臨界區呢?請看下文分解。

 

參考:

原創文章,轉載請註明出處。

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