Linux 內核 RCU機制介紹


Linux 內核 RCU機制介紹

內容基本上是這篇文章的翻譯

RCU 是一種內核同步機制,在2002年10月加入到 Linux 內核中

RCU 與讀寫自旋鎖和順序鎖不同,后兩者只允許多個讀者的並發,RCU 允許單個寫者和多個讀者的並發

那有人會問了,順序鎖中不也是讀者和寫者同時在運行嘛?RCU 和順序鎖的區別在於

  • 雖然順序鎖也是讀者和寫者並發執行,但 read_seqretry() 原語會強制讀者重試,也就是說當兩者並發執行的時候,事實上讀者是沒有完成任何工作的
  • 但 RCU 能做到即使有並發的寫者存在,讀者也能做有用的工作

RCU 由三個基本機制組成

  • (插入時使用)發布-訂閱機制
  • (刪除時使用)等待已存在的讀者完成
  • (讀者讀取時使用)維持最近更新對象的多個版本

發布-訂閱機制

RCU 的一個關鍵性質就是它允許你安全的訪問數據,即使這時數據正在被修改,而這種性質就是通過發布-訂閱機制保證的

下面這段代碼在分配並初始化一個結構體后將它賦值給指針 gp,問題在於沒法保證編譯器或 CPU 不會重排序代碼,讓 gp = p 在三條賦值語句之前執行,這樣並發的讀者可能會看到未初始化的值,需要使用內存屏障來保證這種情況不會發生

struct foo {
	int a;
	int b;
	int c;
};

struct foo *gp = NULL;
/* . . . */
p = kmalloc(sizeof(*p), GFP_KERNEL);
p->a = 1;
p->b = 2;
p->c = 3;
gp = p;

但眾所周知,內存屏障非常難用,RCU 將它封裝為一個原語 rcu_assign_pointer(),具有發布語義,因此可以將最后四行改為

p->a = 1;
p->b = 2;
p->c = 3;
rcu_assign_pointer(gp, p);

只是寫者強制執行順序的,考慮這段讀者的代碼

p = gp;
if (p != NULL) {
	do_something_with(p->a, p->b, p->c);
}

盡管這段代碼看起來不會被重排序,但在某些上下文里編譯器會去先取 p->ap->bp->c 的值,再取 p 的值,因此要使用 rcu_dereference() 原語來阻止這種激進的優化

rcu_read_lock();
p = rcu_dereference(gp);
if (p != NULL) {
	do_something_with(p->a, p->b, p->c);
}
rcu_read_unlock();

rcu_dereference 可以被看作是訂閱操作,保證后續的解引用操作能看到發生在發布操作之前的初始化值

rcu_read_lock()rcu_read_unlock() 是必須要有的,它們定義了 RCU 讀端臨界區(RCU read-side critical sections)范圍,事實上,它們只是禁止和重啟內核搶占,在沒有配置 CONFIG_PREEMPT 的內核里甚至什么都不做

操作 RCU 保護的鏈表

雖然可以用 rcu_assign_pointer()rcu_dereference() 來構造 RCU 保護的數據結構,但大部分情況下都應該使用封裝好的高層 API,Linux 中有兩種鏈表:循環鏈表 struct list_head 和線性鏈表 struct hlist_head/struct hlist_node

struct foo {
	struct list_head list;
	int a;
	int b;
	int c;
};

LIST_HEAD(head);
/* . . . */
p = kmalloc(sizeof(*p), GFP_KERNEL);
p->a = 1;
p->b = 2;
p->c = 3;
list_add_rcu(&p->list, &head);

list_add_rcu() 必須由某種並發機制保護(通常是使用某種鎖),以防止多個 list_add() 操作並發執行,但 list_add() 是可以和 RCU 讀者並發執行的

訂閱一個由 RCU 保護的鏈表的代碼是比較直接的,所有運行 Linux 的體系結構中,指針的讀寫都是原子的,並且 list_for_each_entry_rcu() 只會向前移動,因此它要么能看到插入的節點,要么看不到插入的節點,不管那種情況,讀者看到的鏈表都是結構良好的(well-formed)

rcu_read_lock();
list_for_each_entry_rcu(p, head, list) {
	do_something_with(p->a, p->b, p->c);
}
rcu_read_unlock();

操作 RCU 保護的 hlist 的代碼也是類似的,同樣的 hlist_add_head_rcu() 也需要同步機制來保護

struct foo {
	struct hlist_node *list;
	int a;
	int b;
	int c;
};

HLIST_HEAD(head);
/* . . . */
p = kmalloc(sizeof(*p), GFP_KERNEL);
p->a = 1;
p->b = 2;
p->c = 3;
hlist_add_head_rcu(&p->list, &head);
rcu_read_lock();
hlist_for_each_entry_rcu(p, head, list) {
	do_something_with(p->a, p->b, p->c);
}
rcu_read_unlock();

RCU API

  • 指針
    • 發布
      • rcu_assign_pointer()
    • 撤銷
      • rcu_assign_pointer(..., NULL)
    • 訂閱
      • rcu_dereference()
  • list
    • 發布
      • list_add_rcu()
      • list_add_tail_rcu()
      • list_replace_rcu()
    • 撤銷
      • list_del_rcu()
    • 訂閱
      • list_for_each_entry_rcu()
  • hlist
    • 發布
      • hlist_add_after_rcu()
      • hlist_add_before_rcu()
      • hlist_add_head_rcu()
      • hlist_replace_rcu()
    • 撤銷
      • hlist_del_rcu()
    • 訂閱
      • hlist_for_each_entry_rcu()

替換和刪除的 API 讓問題更復雜了,何時釋放這些被替換或刪除的元素是安全的,換句話說,我們怎么才能知道所有讀者都釋放了對這些元素的引用,這就需要 RCU 的第二個機制

等待已存在的讀者完成

RCU 是一種等待事情發生的機制,當然內核里提供了許多其他的方法來等待某件事發生,比如引用計數、讀寫鎖、事件等等,RCU 的一大優勢就是它的拓展性極好,你可以同時等待許多事件,而不用擔心性能下降、死鎖或內存泄漏等問題

RCU 中等待的事件叫 RCU 讀端臨界區,由 rcu_read_lock() 開始,到 rcu_read_unlock() 結束,臨界區可以嵌套,可以容納任何代碼,只要這些代碼不要阻塞或睡眠

如下圖所示,RCU 是一種等待已存在的 RCU 讀端臨界區完成的方式

算法使用 RCU 等待讀者的基本形式:

  • Removal 階段,做出改動,比如更換鏈表中的一個元素
  • 等待所有已存在的讀端臨界區完全完成(比如使用 synchronize_rcu() 原語)
    • 后續的 RCU 讀端臨界區是沒法訪問到這個剛剛被移除的元素的
    • 這段等待的時間叫寬限期(Grace Period)
    • 注意,Grace Period 期間才進入的讀端臨界區是可以超過寬限期的,因為我們並不會去等待這些讀者完成
  • Reclamation 階段,做清理工作,比如釋放掉那個被置換的元素
struct foo {
	struct list_head list;
	int a;
	int b;
	int c;
};

LIST_HEAD(head);
/* . . . */
p = search(head, key);
if (p == NULL) {
	/* Take appropriate action, unlock, and return. */
}
q = kmalloc(sizeof(*p), GFP_KERNEL);
*q = *p;
q->b = 2;
q->c = 3;
list_replace_rcu(&p->list, &q->list);
synchronize_rcu();
kfree(p);

當所有的已存在的 RCU 讀端臨界區都完成后,synchronize_rcu() 就會返回

之前我們說過,用來標識讀端臨界區范圍的 rcu_read_lock()rcu_read_unlock() 在沒有配置 CONFIG_PREEMPT 的情況下甚至不會產生任何代碼,那么 RCU 該怎么判斷讀者是否已經離開臨界區了呢?

這里就有一個 trick,那就是讀端臨界區是不允許阻塞和休眠的,因此,當某個 CPU 執行上下文切換的時候,就保證之前的讀端臨界區已經完成了。這也就意味着,只要所有的 CPU 都做了一次上下文切換,之前的所有讀端臨界區肯定都完成了,synchronize_rcu() 也就可以返回了

從概念上來講,我們可以認為 synchronize_rcu() 等價於

for_each_online_cpu(cpu)
	run_on(cpu);

run_on() 將當前線程切換到指定 CPU 上去,該函數將導致這個 CPU 發生一次上下文切換,for_each_online_cpu() 遍歷每個 CPU,因此循環結束后每個 CPU 都至少進行了一次上下文切換,這個方法適用於在讀端臨界區中禁用搶占的內核,不適用於配置了 CONFIG_PREEMPT_RT 選項的內核

當然 Linux 內核中真實的實現要比這復雜的多,因為它要處理中斷、NMI、CPU 熱插拔等復雜的情況,還要保持良好的性能和可拓展性

異步等待

除了使用 synchronize_rcu() 執行同步的等待,還可以調用基於回調的 call_rcu()

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

typedef void (*rcu_callback_t)(struct rcu_head *head);

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

調用 call_rcu() 注冊了回調函數后,就可以去做其他事情了,由於可能多次注冊回調,因此需要將每次注冊的回調函數用 next 指針串起來

head 是傳遞給回調函數 func 的參數,那回調函數該如何拿到需要被釋放的結構體指針呢?以一個回調函數 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);
}

可以看到 rcu_head 的用法是類似於 list_head,通過嵌入到某個結構體中,然后用 container_of 來獲取該結構體的地址

調用 rcu_barrier() 等待所有 RCU 回調執行完畢

維持最近更新對象的多個版本

這章講述 RCU 如何通過維持對象的多個版本,以保證讀者能夠不做任何同步地去訪問正在被更新地對象,我們將用兩個例子來做說明

例子1:在刪除期間維持多版本

p = search(head, key);
if (p != NULL) {
	list_del_rcu(&p->list);
	synchronize_rcu();
	kfree(p);
}

下面的示意圖中,紅色邊框表示讀者仍可能持有它們的引用,為了清晰起見,我們省略掉了 prev 指針

list_del_rcu() 完成后,元素 5,6,7 已經從鏈表中移除了,如上圖的狀態 2,由於讀者不直接的與寫者做同步,可能還有讀者會訪問到該元素,因此事實上現在是有兩個版本的鏈表,一個有元素 5,6,7,而另一個沒有

synchronize_rcu() 執行完畢后,說明所有已存在的讀端臨界區都完成了,因此沒有讀者再持有元素 5,6,7 的引用了,即上圖的狀態 3,當前狀態下只存在單一版本的鏈表,此時元素 5,6,7 可以被安全的釋放了

如果將這個刪除的整個過程用鎖互斥,那么最多只可能同時存在兩個版本的鏈表,如果我們減小互斥的臨界區,比如

spin_lock(&mylock);
p = search(head, key);
if (p == NULL)
	spin_unlock(&mylock);
else {
	list_del_rcu(&p->list);
	spin_unlock(&mylock);
	synchronize_rcu();
	kfree(p);
}

能支持更多同時存在的鏈表版本,這意味着同時有多個寫者在 synchronize_rcu() 上等待,但要注意,RCU 並不適合頻繁更新的數據結構

例子2:在替換期間維持多版本

q = kmalloc(sizeof(*p), GFP_KERNEL);
*q = *p;
q->b = 2;
q->c = 3;
list_replace_rcu(&p->list, &q->list);
synchronize_rcu();
kfree(p);

  • 先分配新節點並拷貝原節點的值
  • 在新結點上做修改
  • list_replace_rcu() 執行替換操作,然后后續的讀者就能看到新結點了,然而仍有已存在的讀者可能訪問到老節點
  • synchronize_rcu() 返回后,所有持有老節點引用的讀者就都退出臨界區了,此時可以釋放該節點


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM