深刻理解 Linux 的 RCU 機制

RCU(Read-Copy Update),是 Linux 中比較重要的一種同步機制。顧名思義就是「讀,拷貝更新」,再直白點是「隨意讀,但更新數據的時候,須要先複製一份副本,在副本上完成修改,再一次性地替換舊數據」。這是 Linux 內核實現的一種針對「讀多寫少」的共享數據的同步機制。html

不一樣於其餘的同步機制,它容許多個讀者同時訪問共享數據,並且讀者的性能不會受影響(「隨意讀」),讀者與寫者之間也不須要同步機制(但須要「複製後再寫」),但若是存在多個寫者時,在寫者把更新後的「副本」覆蓋到原數據時,寫者與寫者之間須要利用其餘同步機制保證同步。linux

RCU 的一個典型的應用場景是鏈表,在 Linux kernel 中還專門提供了一個頭文件(include/linux/rculist.h),提供了利用 RCU 機制對鏈表進行增刪查改操做的接口。本文將經過一個例子,利用 rculist.h 提供的接口對鏈表進行增刪查改的操做,來說述 RCU 的原理,以及介紹 Linux kernel 中相關的 API(基於 Linux v3.4.0 的源碼)。架構

增長鏈表項

Linux kernel 中利用 RCU 往鏈表增長項的源碼以下:ide

#define list_next_rcu(list)     (*((struct list_head __rcu **)(&(list)->next)))

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_next_rcu() 函數中的 __rcu 是一個供代碼分析工具 Sparse 使用的編譯選項,規定有 __rcu 標籤的指針不能直接使用,而須要使用 rcu_dereference() 返回一個受 RCU 保護的指針才能使用。rcu_dereference() 接口的相關知識會在後文介紹,這一節重點關注 rcu_assign_pointer() 接口。首先看一下 rcu_assign_pointer() 的源碼:函數

#define __rcu_assign_pointer(p, v, space) \
	({ \
		smp_wmb(); \
		(p) = (typeof(*v) __force space *)(v); \
	})
複製代碼

上述代碼的最終效果是把 v 的值賦值給 p,關鍵點在於第 3 行的內存屏障。什麼是內存屏障(Memory Barrier)呢?CPU 採用流水線技術執行指令時,只保證有內存依賴關係的指令的執行順序,例如 p = v; a = *p;,因爲第 2 條指令訪問的指針 p 所指向的內存依賴於第 1 條指令,所以 CPU 會保證第 1 條指令在第 2 條指令執行前執行完畢。但對於沒有內存依賴的指令,例如上述 __list_add_rcu() 接口中,假如把第 8 行寫成 prev->next = new;,因爲這個賦值操做並沒涉及到對 new 指針指向的內存的訪問,所以認爲不依賴於 6,7 行對 new->nextnew->prev 的賦值,CPU 有可能實際運行時會先執行 prev->next = new; 再執行 new->prev = prev;,這就會形成 new 指針(也就是新加入的鏈表項)還沒完成初始化就被加入了鏈表中,假如這時恰好有一個讀者恰好遍歷訪問到了該新的鏈表項(由於 RCU 的一個重要特色就是可隨意執行讀操做),就會訪問到一個未完成初始化的鏈表項!經過設置內存屏障就能解決該問題,它保證了在內存屏障前邊的指令必定會先於內存屏障後邊的指令被執行。這就保證了被加入到鏈表中的項,必定是已經完成了初始化的。工具

最後提醒一下,這裏要注意的是,若是可能存在多個線程同時執行添加鏈表項的操做,添加鏈表項的操做須要用其餘同步機制(如 spin_lock 等)進行保護。性能

訪問鏈表項

Linux kernel 中訪問 RCU 鏈表項常見的代碼模式是:優化

rcu_read_lock();
list_for_each_entry_rcu(pos, head, member) {
    // do something with `pos`
}
rcu_read_unlock();
複製代碼

這裏要講到的 rcu_read_lock()rcu_read_unlock(),是 RCU 「隨意讀」 的關鍵,它們的效果是聲明瞭一個讀端的臨界區(read-side critical sections)。在說讀端臨界區以前,咱們先看看讀取鏈表項的宏函數 list_for_each_entry_rcu。追溯源碼,獲取一個鏈表項指針主要調用的是一個名爲 rcu_dereference() 的宏函數,而這個宏函數的主要實現以下:spa

#define __rcu_dereference_check(p, c, space) \
	({ \
		typeof(*p) *_________p1 = (typeof(*p)*__force )ACCESS_ONCE(p); \
		rcu_lockdep_assert(c, "suspicious rcu_dereference_check()" \
				      " usage"); \
		rcu_dereference_sparse(p, space); \
		smp_read_barrier_depends(); \
		((typeof(*p) __force __kernel *)(_________p1)); \
	})
複製代碼

第 3 行:聲明指針 _________p1 = p; 第 7 行:smp_read_barrier_depends(); 第 8 行:返回 _________p1;.net

上述兩塊代碼,實際上能夠看做這樣一種模式:

rcu_read_lock();
p1 = rcu_dereference(p);
if (p1 != NULL) {
    // do something with p1, such as:
    printk("%d\n", p1->field);
}
rcu_read_unlock();
複製代碼

根據 rcu_dereference() 的實現,最終效果就是把一個指針賦值給另外一個,那若是把上述第 2 行的 rcu_dereference() 直接寫成 p1 = p 會怎樣呢?在通常的處理器架構上是一點問題都沒有的。但在 alpha 上,編譯器的 value-speculation 優化選項聽說可能會「猜想」 p1 的值,而後重排指令先取值 p1->field~ 所以 Linux kernel 中,smp_read_barrier_depends() 的實現是架構相關的,arm、x86 等架構上是空實現,alpha 上則加了內存屏障,以保證先得到 p 真正的地址再作解引用。所以上一節 「增長鏈表項」 中提到的 「__rcu」 編譯選項強制檢查是否使用 rcu_dereference() 訪問受 RCU 保護的數據,其實是爲了讓代碼擁有更好的可移植性。

如今回到讀端臨界區的問題上來。多個讀端臨界區不互斥,即多個讀者可同時處於讀端臨界區中,但一塊內存數據一旦可以在讀端臨界區內被獲取到指針引用,這塊內存塊數據的釋放必須等到讀端臨界區結束,等待讀端臨界區結束的 Linux kernel API 是synchronize_rcu()。讀端臨界區的檢查是全局的,系統中有任何的代碼處於讀端臨界區,synchronize_rcu() 都會阻塞,知道全部讀端臨界區結束纔會返回。爲了直觀理解這個問題,舉如下的代碼實例:

/* `p` 指向一塊受 RCU 保護的共享數據 */

/* reader */
rcu_read_lock();
p1 = rcu_dereference(p);
if (p1 != NULL) {
    printk("%d\n", p1->field);
}
rcu_read_unlock();

/* free the memory */
p2 = p;
if (p2 != NULL) {
    p = NULL;
    synchronize_rcu();
    kfree(p2);
}
複製代碼

用如下圖示來表示多個讀者與內存釋放線程的時序關係:

上圖中,每一個讀者的方塊表示得到 p 的引用(第5行代碼)到讀端臨界區結束的時間週期;t1 表示 p = NULL 的時間;t2 表示 synchronize_rcu() 調用開始的時間;t3 表示 synchronize_rcu() 返回的時間。咱們先看 Reader1,2,3,雖然這 3 個讀者的結束時間不同,但都在 t1 前得到了 p 地址的引用。t2 時調用 synchronize_rcu(),這時 Reader1 的讀端臨界區已結束,但 Reader2,3 還處於讀端臨界區,所以必須等到 Reader2,3 的讀端臨界區都結束,也就是 t3,t3 以後,就能夠執行 kfree(p2) 釋放內存。 synchronize_rcu() 阻塞的這一段時間,有個名字,叫作 Grace period。而 Reader4,5,6,不管與 Grace period 的時間關係如何,因爲獲取引用的時間在 t1 以後,都沒法得到 p 指針的引用,所以不會進入 p1 != NULL 的分支。

刪除鏈表項

知道了前邊說的 Grace period,理解鏈表項的刪除就很容易了。常見的代碼模式是:

p = seach_the_entry_to_delete();
list_del_rcu(p->list);
synchronize_rcu();
kfree(p);
複製代碼

其中 list_del_rcu() 的源碼以下,把某一項移出鏈表:

/* list.h */
static inline void __list_del(struct list_head * prev, struct list_head * next)
{
	next->prev = prev;
	prev->next = next;
}

/* rculist.h */
static inline void list_del_rcu(struct list_head *entry)
{
	__list_del(entry->prev, entry->next);
	entry->prev = LIST_POISON2;
}
複製代碼

根據上一節「訪問鏈表項」的實例,假如一個讀者可以從鏈表中得到咱們正打算刪除的鏈表項,則確定在 synchronize_rcu() 以前進入了讀端臨界區,synchronize_rcu() 就會保證讀端臨界區結束時纔會真正釋放鏈表項的內存,而不會釋放讀者正在訪問的鏈表項。

更新鏈表項

前文提到,RCU 的更新機制是 「Copy Update」,RCU 鏈表項的更新也是這種機制,典型代碼模式是:

p = search_the_entry_to_update();
q = kmalloc(sizeof(*p), GFP_KERNEL);
*q = *p;
q->field = new_value;
list_replace_rcu(&p->list, &q->list);
synchronize_rcu();
kfree(p);
複製代碼

其中第 3,4 行就是複製一份副本,並在副本上完成更新,而後調用 list_replace_rcu() 用新節點替換掉舊節點。源碼以下: 其中第 3,4 行就是複製一份副本,並在副本上完成更新,而後調用 list_replace_rcu() 用新節點替換掉舊節點,最後釋放舊節點內存。list_replace_rcu() 源碼以下:

static inline void list_replace_rcu(struct list_head *old,
				struct list_head *new)
{
	new->next = old->next;
	new->prev = old->prev;
	rcu_assign_pointer(list_next_rcu(new->prev), new);
	new->next->prev = new;
	old->prev = LIST_POISON2;
}
複製代碼

References

[1] What is RCU, Fundamentally? [2] www.kernel.org/doc/Documen… [3] www.kernel.org/doc/Documen… [4] www.kernel.org/doc/Documen… [5] LINUX內核以內存屏障

相關文章
相關標籤/搜索