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->a 、 p->b 、 p->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()返回后,所有持有老節點引用的讀者就都退出臨界區了,此時可以釋放該節點
