深入理解RCU實現
——基於核心2.6.21 RCU實現(lvyilong316)
RCU(Read-Copy Update),顧名思義就是讀-拷貝修改,它是基於其原理命名的。對於被RCU保護的共享資料結構,讀者不需要獲得任何鎖就可以訪問它,但寫者在訪問它時首先拷貝一個副本,然後對副本進行修改,最後使用一個回撥(callback)機制在適當的時機把指向原來資料的指標重新指向新的被修改的資料。那麼這個“適當的時機”是怎麼確定的呢?這是由核心確定的,也是我們後面討論的重點。
RCU原理
RCU實際上是一種改進的rwlock,讀者幾乎沒有什麼同步開銷,它不需要鎖,不使用原子指令,而且在除alpha的所有架構上也不需要記憶體柵(Memory Barrier),因此不會導致鎖競爭,記憶體延遲以及流水線停滯。不需要鎖也使得使用更容易,因為死鎖問題就不需要考慮了。寫者的同步開銷比較大,它需要延遲資料結構的釋放,複製被修改的資料結構,它也必須使用某種鎖機制同步並行的其它寫者的修改操作。
讀者必須提供一個訊號給寫者以便寫者能夠確定資料可以被安全地釋放或修改的時機。有一個專門的垃圾收集器來探測讀者的訊號,一旦所有的讀者都已經傳送訊號告知它們都不在使用被RCU保護的資料結構,垃圾收集器就呼叫回撥函式完成最後的資料釋放或修改操作。
RCU與rwlock的不同之處是:它既允許多個讀者同時訪問被保護的資料,又允許多個讀者和多個寫者同時訪問被保護的資料(注意:是否可以有多個寫者並行訪問取決於寫者之間使用的同步機制),讀者沒有任何同步開銷,而寫者的同步開銷則取決於使用的寫者間同步機制。但RCU不能替代rwlock,因為如果寫比較多時,對讀者的效能提高不能彌補寫者導致的損失。
讀者在訪問被RCU保護的共享資料期間不能被阻塞,這是RCU機制得以實現的一個基本前提,也就說當讀者在引用被RCU保護的共享資料期間,讀者所在的CPU不能發生上下文切換,spinlock和rwlock都需要這樣的前提。寫者在訪問被RCU保護的共享資料時不需要和讀者競爭任何鎖,只有在有多於一個寫者的情況下需要獲得某種鎖以與其他寫者同步。
寫者修改資料前首先拷貝一個被修改元素的副本,然後在副本上進行修改,修改完畢後它向垃圾回收器註冊一個回撥函式以便在適當的時機執行真正的修改操作。等待適當時機的這一時期稱為grace period,而CPU發生了上下文切換稱為經歷一個quiescent state,grace period就是所有CPU都經歷一次quiescent state所需要的等待的時間。垃圾收集器就是在grace period之後呼叫寫者註冊的回撥函式來完成真正的資料修改或資料釋放操作的。
要想使用好RCU,就要知道RCU的實現原理。我們拿linux 2.6.21 kernel的實現開始分析,為什麼選擇這個版本的實現呢?因為這個版本的實現相對較為單純,也比較簡單。當然之後核心做了不少改進,如搶佔RCU、可睡眠RCU、分層RCU。但是基本思想都是類似的。所以先從簡單入手。
首先,上一節我們提到,寫者在訪問它時首先拷貝一個副本,然後對副本進行修改,最後使用一個回撥(callback)機制在適當的時機把指向原來資料的指標重新指向新的被修改的資料。而這個“適當的時機”就是所有CPU經歷了一次程式切換(也就是一個grace period)。為什麼這麼設計?因為RCU讀者的實現就是關搶佔執行讀取,讀完了當然就可以程式切換了,也就等於是寫者可以操作臨界區了。那麼就自然可以想到,核心會設計兩個元素,來分別表示寫者被掛起的起始點,以及每cpu變數,來表示該cpu是否經過了一次程式切換(quies state)。就是說,當寫者被掛起後,
1)重置每cpu變數,值為0。
2)當某個cpu經歷一次程式切換後,就將自己的變數設為1。
3)當所有的cpu變數都為1後,就可以喚醒寫者了。
下面我們來分別看linux裡是如何完成這三步的。
從一個例子開始
我們從一個例子入手,這個例子來源於linux kernel文件中的whatisRCU.txt。這個例子使用RCU的核心API來保護一個指向動態分配記憶體的全域性指標。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
struct foo { int a; char b; long c; }; DEFINE_SPINLOCK(foo_mutex); struct foo *gbl_foo; void foo_update_a(int new_a) { struct foo *new_fp; struct foo *old_fp; new_fp = kmalloc(sizeof(*new_fp), GFP_KERNEL); spin_lock(&foo_mutex); old_fp = gbl_foo; *new_fp = *old_fp; new_fp->a = new_a; rcu_assign_pointer(gbl_foo, new_fp); spin_unlock(&foo_mutex); synchronize_rcu(); kfree(old_fp); } int foo_get_a(void) { int retval; rcu_read_lock(); retval = rcu_dereference(gbl_foo)->a; rcu_read_unlock(); return retval; } |
如上程式碼所示,RCU被用來保護全域性指標struct foo *gbl_foo。foo_get_a()用來從RCU保護的結構中取得gbl_foo的值。而foo_update_a()用來更新被RCU保護的gbl_foo的值(更新其a成員)。
首先,我們思考一下,為什麼要在foo_update_a()中使用自旋鎖foo_mutex呢? 假設中間沒有使用自旋鎖.那foo_update_a()的程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
void foo_update_a(int new_a) { struct foo *new_fp; struct foo *old_fp; new_fp = kmalloc(sizeof(*new_fp), GFP_KERNEL); old_fp = gbl_foo; 1:------------------------- *new_fp = *old_fp; new_fp->a = new_a; rcu_assign_pointer(gbl_foo, new_fp); synchronize_rcu(); kfree(old_fp); } |
假設A程式在上圖—-標識處被B程式搶點.B程式也執行了goo_ipdate_a().等B執行完後,再切換回A程式.此時,A程式所持的old_fd實際上已經被B程式給釋放掉了.此後A程式對old_fd的操作都是非法的。所以在此我們得到一個重要結論:RCU允許多個讀者同時訪問被保護的資料,也允許多個讀者在有寫者時訪問被保護的資料(但是注意:是否可以有多個寫者並行訪問取決於寫者之間使用的同步機制)。
說明:本文中說的程式不是使用者態的程式,而是核心的呼叫路徑,也可能是核心執行緒或軟中斷等。
RCU的核心API
另外,我們在上面也看到了幾個有關RCU的核心API。它們為別是:
1 2 3 4 5 |
rcu_read_lock() rcu_read_unlock() synchronize_rcu() rcu_assign_pointer() rcu_dereference() |
其中,rcu_read_lock()和rcu_read_unlock()用來保持一個讀者的RCU臨界區.在該臨界區內不允許發生上下文切換。為什麼不能發生切換呢?因為核心要根據“是否發生過切換”來判斷讀者是否已結束讀操作,我們後面再分析。
rcu_dereference():讀者呼叫它來獲得一個被RCU保護的指標。
rcu_assign_pointer():寫者使用該函式來為被RCU保護的指標分配一個新的值。
synchronize_rcu():這是RCU的核心所在,它掛起寫者,等待讀者都退出後釋放老的資料。
RCU API實現分析
l rcu_read_lock()和rcu_read_unlock()
rcu_read_lock()和rcu_read_unlock()的實現如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
#define rcu_read_lock() __rcu_read_lock() #define rcu_read_unlock() __rcu_read_unlock() #define __rcu_read_lock() do { preempt_disable(); __acquire(RCU); rcu_read_acquire(); } while (0) #define __rcu_read_unlock() do { rcu_read_release(); __release(RCU); preempt_enable(); } while (0) |
其中__acquire(),rcu_read_acquire(),rcu_read_release(),__release()都是一些選擇編譯函式,可以忽略不可看。因此可以得知:rcu_read_lock(),rcu_read_unlock()只是禁止和啟用搶佔.因為在讀者臨界區,不允許發生上下文切換。
l rcu_dereference()和rcu_assign_pointer()
rcu_dereference()和rcu_assign_pointer()的實現如下:
1 2 3 4 5 6 7 8 9 10 11 12 |
rcu_dereference()和rcu_assign_pointer()的實現如下: #define rcu_dereference(p) ({ typeof(p) _________p1 = ACCESS_ONCE(p); smp_read_barrier_depends(); (_________p1); }) #define rcu_assign_pointer(p, v) ({ if (!__builtin_constant_p(v) || ((v) != NULL)) smp_wmb(); (p) = (v); }) |
它們的實現也很簡單.因為它們本身都是原子操作。只是為了cache一致性,插上了記憶體屏障。可以讓其它的讀者/寫者可以看到保護指標的最新值.
l synchronize_rcu()
synchronize_rcu()在RCU中是一個最核心的函式,它用來等待之前的讀者全部退出.我們後面的大部份分析也是圍繞著它而進行.實現如下:
1 2 3 4 5 6 7 8 9 10 |
void synchronize_rcu(void) { struct rcu_synchronize rcu; init_completion(&rcu.completion); /* Will wake me after RCU finished */ call_rcu(&rcu.head, wakeme_after_rcu); /* Wait for it */ wait_for_completion(&rcu.completion); } |
我們可以看到,它初始化了一個本地變數,它的型別為struct rcu_synchronize.呼叫call_rcu()之後.一直等待條件變數rcu.competion的滿足。
在這裡看到了RCU的另一個核心API,它就是call_run()。它的定義如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
void call_rcu(struct rcu_head *head, void (*func)(struct rcu_head *rcu)) { unsigned long flags; struct rcu_data *rdp; head->func = func; head->next = NULL; local_irq_save(flags); rdp = &__get_cpu_var(rcu_data); *rdp->nxttail = head; rdp->nxttail = &head->next; if (unlikely(++rdp->qlen > qhimark)) { rdp->blimit = INT_MAX; force_quiescent_state(rdp, &rcu_ctrlblk); } local_irq_restore(flags); } |
該函式也很簡單,就是將引數傳入的回撥函式fun賦值給一個struct rcu_head變數,再將這個struct rcu_head加在了per_cpu變數rcu_data的nxttail 連結串列上。
rcu_data定義如下,是個每cpu變數:
DEFINE_PER_CPU(struct rcu_data, rcu_data) = { 0L };
接著我們看下call_rcu註冊的函式,我們也可以看到,在synchronize_rcu()中,傳入call_rcu的函式為wakeme_after_rcu(),其實現如下:
1 2 3 4 5 6 7 |
static void wakeme_after_rcu(struct rcu_head *head) { struct rcu_synchronize *rcu; rcu = container_of(head, struct rcu_synchronize, head); complete(&rcu->completion); } |
我們可以看到,該函式將條件變數置真,然後喚醒了在條件變數上等待的程式。
由此,我們可以得知,每一個CPU都有一個rcu_data.每個呼叫call_rcu()/synchronize_rcu()程式的程式都會將一個rcu_head都會掛到rcu_data的nxttail連結串列上(這個rcu_head其實就相當於這個程式在RCU機制中的體現),然後掛起。當讀者都完成讀操作後(經過一個grace period後)就會觸發這個rcu_head上的回撥函式來喚醒寫者。整個過程如下圖所示:
看到這裡,也就到了問題的關鍵,核心是如何判斷當前讀者都已經完成讀操作了呢(經過了一個grace period)?又是由誰來觸發這個回撥函式wakeme_after_rcu呢?下一小節再來分析。
從RCU的初始化說起
那究竟怎麼去判斷當前的讀者已經操作完了呢?我們在之前看到,不是讀者在呼叫rcu_read_lock()的時候要禁止搶佔麼?因此,我們只需要判斷所有的CPU都進過了一次上下文切換,就說明所有讀者已經退出了。要徹底弄清楚這個問題,我們得從RCU的初始化說起。
RCU的初始化開始於start_kernel()–>rcu_init()。而其主要是對每個cpu呼叫了rcu_online_cpu函式。
l rcu_online_cpu
1 2 3 4 5 6 7 8 9 |
static void __cpuinit rcu_online_cpu(int cpu) { struct rcu_data *rdp = &per_cpu(rcu_data, cpu); struct rcu_data *bh_rdp = &per_cpu(rcu_bh_data, cpu); rcu_init_percpu_data(cpu, &rcu_ctrlblk, rdp); rcu_init_percpu_data(cpu, &rcu_bh_ctrlblk, bh_rdp); open_softirq(RCU_SOFTIRQ, rcu_process_callbacks, NULL); } |
這個函式主要完成兩個操作:初始化兩個per cpu變數;註冊RCU_SOFTIRQ軟中斷處理函式rcu_process_callbacks。我們從這裡又看到了另一個per cpu變數:rcu_bh_data.有關bh的部份之後再來分析.在這裡略過這些部分。 下面看下rcu_init_percpu_data()的實現。
l rcu_init_percpu_data
1 2 3 4 5 6 7 8 9 10 11 |
static void rcu_init_percpu_data(int cpu, struct rcu_ctrlblk *rcp, struct rcu_data *rdp) { memset(rdp, 0, sizeof(*rdp)); rdp->curtail = &rdp->curlist; rdp->nxttail = &rdp->nxtlist; rdp->donetail = &rdp->donelist; rdp->quiescbatch = rcp->completed; rdp->qs_pending = 0; rdp->cpu = cpu; rdp->blimit = blimit; } |
呼叫這個函式的第二個引數是一個全域性變數rcu_ctlblk。 在繼續向下分析之前我們先看下這些函式用到的一些重要資料結構。
重要資料結構
說明:下列資料結構只列出了主要成員。
1 2 3 4 5 |
struct rcu_ctrlblk { long cur; long completed; cpumask_t cpumask; }; |
struct rcu_ctrlblk的主要作用就是定義上節提到的全域性變數rcu_ctlblk,這個變數在系統中是唯一的。另外說明一下,為了記錄方便,核心將從啟動開始的每個grace period對應一個數字表示。
這裡的cpumask是為了標識當前系統中的所有cpu,以便標記哪些cpu發生過上下文切換(經歷過一個quiescent state)。而cur,completed,則用來同步。我們可以這樣理解,cur和completed是系統級別的記錄資訊,也即系統實時經歷的grace編號,一般情況下,新開一個graceperiod等待週期的話,cur會加1,當graceperiod結束後,會將completed置為cur,所以通常情況下,都是completed追著cur跑。
那麼我們可能會猜測,是不是如果complete= curr -1 的時候,就表示系統中graceperiod還沒有結束?當completed= curr的時候,就表示系統中不存在graceperiod等待週期了?我們姑且這麼理解,實際上有些許差別,但設計思想都是一樣的。
1 2 3 4 5 6 7 8 9 10 11 |
struct rcu_data { long quiescbatch; int passed_quiesc; long batch; struct rcu_head *nxtlist; struct rcu_head **nxttail; struct rcu_head *curlist; struct rcu_head **curtail; struct rcu_head *donelist; struct rcu_head **donetail; }; |
上面的結構,要達到的作用是,跟蹤單個CPU上的RCU事務。
(1) passed_quiesc:這是一個flag標誌,表示當前cpu是否已經發生過搶佔了(經歷過一個quiescent state),0表示為未傳送過切換,1表示發生過切換;
說明:我們一直強調發生過一次cpu搶佔就是經歷過一個quiescent state,其實這是不嚴格的說法。為什麼呢?因為自系統啟動,各種程式頻繁排程,肯定每個cpu都會發生過搶佔。所以我們這裡說的“發生過搶佔”是指從某個特殊的時間點開始發生過搶佔。那麼這個特殊的時間點是什麼時候呢?就是我們呼叫synchronize_rcu將rcu_head掛在每cpu變數上並掛起程式時,我們後面分析就會證實這一點。
(2) batch:表示一個grace periodid,表示本次被阻塞的寫者,需要在哪個graceperiod之後被啟用;
(3) quiescbatch:表示一個grace periodid,用來比較當前cpu是否處於等待程式切換完成。
下面我們介紹剩下的三組指標,但首先注意這三組連結串列上的元素都是struct rcu_head 型別。這三個連結串列的作用還要結合整個RCU寫者從阻塞到喚醒的過程來看。
rcu寫者的整體流程,假設系統中出現rcu寫者阻塞,那麼流程如下:
(1) 呼叫synchronize_rcu,將代表寫者的rcu_head新增到CPU[n]每cpu變數rcu_data的nxtlist,這個連結串列代表有需要提交給rcu處理的回撥(但還沒有提交);
(2) CPU[n]每次時鐘中斷時檢測自己的nxtlist是否為null,若不為空,因此則喚醒rcu軟中斷;
(3) RCU的軟中斷處理函式rcu_process_callbacks會挨個檢查本CPU的三個連結串列。
下面分析RCU的核心處理函式,也就是軟中斷處理函式rcu_process_callbacks。
RCU軟中斷
在“RCU API實現分析”一節我們知道呼叫synchronize_rcu,將代表寫者的rcu_head新增到了CPU[n]每cpu變數rcu_data的nxtlist。
而另一方面,在每次時鐘中斷中,都會呼叫update_process_times函式。
l update_process_times
1 2 3 4 5 6 7 |
void update_process_times(int user_tick) { //...... if (rcu_pending(cpu)) rcu_check_callbacks(cpu, user_tick); //...... } |
在每一次時鐘中斷,都會檢查rcu_pending(cpu)是否為真,如果是,就會為其呼叫rcu_check_callbacks()。
l rcu_pending
rcu_pending()的程式碼如下:
1 2 3 4 5 |
int rcu_pending(int cpu) { return __rcu_pending(&rcu_ctrlblk, &per_cpu(rcu_data, cpu)) || __rcu_pending(&rcu_bh_ctrlblk, &per_cpu(rcu_bh_data, cpu)); } |
同上面一樣,忽略bh的部份,我們看 __rcu_pending。
l __rcu_pending
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
static int __rcu_pending(struct rcu_ctrlblk *rcp, struct rcu_data *rdp) { /* This cpu has pending rcu entries and the grace period * for them has completed. */ if (rdp->curlist && !rcu_batch_before(rcp->completed, rdp->batch)) return 1; /* This cpu has no pending entries, but there are new entries */ if (!rdp->curlist && rdp->nxtlist) return 1; /* This cpu has finished callbacks to invoke */ if (rdp->donelist) return 1; /* The rcu core waits for a quiescent state from the cpu */ if (rdp->quiescbatch != rcp->cur || rdp->qs_pending) return 1; /* nothing to do */ return 0; } |
可以上面有四種情況會返回1,分別對應:
A. 該CPU上有等待處理的回撥函式,且已經經過了一個batch(grace period).rdp->datch表示rdp在等待的batch序號;
B. 上一個等待已經處理完了,又有了新註冊的回撥函式;
C. 等待已經完成,但尚末呼叫該次等待的回撥函式;
D. 在等待quiescent state.
如果rcu_pending返回1,就會進入到rcu_check_callbacks(),rcu_check_callbacks()中主要工作就是呼叫raise_softirq(RCU_SOFTIRQ),觸發RCU軟中斷。而RCU軟中斷的處理函式為rcu_process_callbacks,其中分別針對每cpu變數rcu_bh_data和rcu_data呼叫__rcu_process_callbacks。我們主要分析針對rcu_data的呼叫。
l __rcu_process_callbacks
1) 先看nxtlist裡有沒有待處理的回撥(rcu_head),如果有的話,說明有寫者待處理,那麼還要分兩種情況:
1.1)如果系統是第一次出現寫者阻塞,也即之前的寫者都已經處理完畢,那麼此時curlist連結串列一定為空(curlist專門存放已被rcu檢測到的寫者請求),於是就把nxtlist裡的所有成員都移動到curlist指向,並把當前CPU需要等待的grace period id:rdp->batch設定為當前系統處理的grace period的下一個grace週期,即rcp->cur+ 1。由於這算是一個新的grace period,即start_rcu_batch,所以還接著需要增加系統的grace period計數,即rcp->cur++,同時,將全域性的cpusmask設定為全f,代表新的grace period開始,需要檢測所有的cpu是否都經過了一次程式切換。程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
void __rcu_process_callbacks(struct rcu_ctrlblk *rcp, struct rcu_data *rdp) { if (rdp->nxtlist && !rdp->curlist) { move_local_cpu_nxtlist_to_curlist(); rdp->batch = rcp->cur + 1; if (!rcp->next_pending) { rcp->next_pending = 1; rcp->cur++; cpus_andnot(rcp->cpumask, cpu_online_map, nohz_cpu_mask); } } } |
接著跳轉至1.2。
1.2) 如果系統之前已經有寫者在被rcu監控著,但還沒來得及經過一個grace period,這個時候curlist不為空,nxtlist也不為空,寫者會被加入nxtlist中。由於curlist不為空,說明上個rcu週期的寫者還沒有處理完,於是不會將本次阻塞的寫者加入curlist,一直到上次的curlist裡的rcu_head被處理完(都移動到了donelist),才會將後來的寫者納入RCU考慮(移動到curlist)(假如這個期間又來了多個寫者,則多個寫者的rcu_head共享下一個grace period,也就是下一個grace period結束後這多個寫者都會被喚醒)。進入下一步。
2) rcu_process_callbacks呼叫每CPU函式rcu_check_quiescent_state開始監控,檢測所有的CPU是否會經歷一個程式切換。 這個函式是如何得知需要開始監控的? 答案在於quiescbatch與全域性的rcp->cur比較。 初始化時rdp->quiescbatch =rcp->completed = rcp->cur。 由於1.1有新grace period開啟,所以rcp->cur已經加1了,於是rdp->quiescbatch和rcp->curr不等,進而將此cpu的rdp->passed_quiesc設為0,表示這個週期開始,我要等待這個cpu經歷一個程式切換,等待該CPU將passed_quiesc置為1。即與前面講到的passed_quiesc標誌置0的時機吻合。最後將rdp->quiescbatch置為 rcp->cur,以避免下次再進入軟中斷裡將passed_quiesc重複置0。
1 2 3 4 5 6 7 8 9 10 11 |
void rcu_check_quiescent_state(struct rcu_ctrlblk *rcp, struct rcu_data *rdp) { if (rdp->quiescbatch != rcp->cur) { /* start new grace period: */ rdp->qs_pending = 1; rdp->passed_quiesc = 0; rdp->quiescbatch = rcp->cur; return; } } |
3) 本次軟中斷結束,下次軟中斷到來,再次進入rcu_check_quiescent_state進行檢測,如果本CPU的rdp->passed_quiesc已經置1,則需要cpu_quiet將本CPU標誌位從全域性的rcp->cpumask中清除,如果cpumask為0了,則說明自上次RCU寫者被掛起以來,所有CPU都已經歷了一次程式切換,於是本次rcu等待週期結束,將rcp->completed置為rcp->cur,重置cpumask為全f,並嘗試重新開啟一個新的grace period。我們可以看到RCU用瞭如此多的同步標誌,卻少用spinlock鎖,是多麼巧妙的設計,不過這也提高了理解的難度。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
void rcu_check_quiescent_state(struct rcu_ctrlblk *rcp, struct rcu_data *rdp) { if (rdp->quiescbatch != rcp->cur) { /* start new grace period: */ rdp->qs_pending = 1; rdp->passed_quiesc = 0; rdp->quiescbatch = rcp->cur; return; } if (!rdp->passed_quiesc) return; /*this cpu has passed a quies state*/ if (likely(rdp->quiescbatch == rcp->cur)) { cpu_clear(cpu, rcp->cpumask); if (cpus_empty(rcp->cpumask)) rcp->completed = rcp->cur; } } |
4)下次再進入rcu軟中斷__rcu_process_callbacks,發現rdp->batch已經比rcp->completed小了(因為上一步驟中,後者增大了),則將rdp->curlist上的回撥移動到rdp->donelist裡,接著還會再次進入rcu_check_quiescent_state,但是由於當前CPU的rdp->qs_pending已經為1了,所以不再往下清除cpu掩碼。__rcu_process_callbacks
程式碼變成了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
void __rcu_process_callbacks(struct rcu_ctrlblk *rcp, struct rcu_data *rdp) { if (rdp->curlist && !rcu_batch_before(rcp->completed, rdp->batch)) { *rdp->donetail = rdp->curlist; rdp->donetail = rdp->curtail; rdp->curlist = NULL; rdp->curtail = &rdp->curlist; } if (rdp->nxtlist && !rdp->curlist) { move_local_cpu_nxtlist_to_curlist(); rdp->batch = rcp->cur + 1; if (!rcp->next_pending) { rcp->next_pending = 1; rcp->cur++; cpus_andnot(rcp->cpumask, cpu_online_map, nohz_cpu_mask); } } if (rdp->donelist) rcu_do_batch(rdp); } |
5) 經過千山萬水終於來到rcu_do_batch(如果rdp->donelist有的話)在此函式裡,執行RCU寫者掛載的回撥,即wakeme_after_rcu。
l rcu_do_batch
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
static void rcu_do_batch(struct rcu_data *rdp) { struct rcu_head *next, *list; int count = 0; list = rdp->donelist; while (list) { next = list->next; prefetch(next); list->func(list); list = next; if (++count >= rdp->blimit) break; } rdp->donelist = list; local_irq_disable(); rdp->qlen -= count; local_irq_enable(); if (rdp->blimit == INT_MAX && rdp->qlen) blimit = blimit; if (!rdp->donelist) rdp->donetail = &rdp->donelist; else raise_rcu_softirq(); } |
它遍歷處理掛在連結串列上的回撥函式.在這裡,注意每次呼叫的回撥函式有最大值限制.這樣做主要是防止一次呼叫過多的回撥函式而產生不必要系統負載.如果donelist中還有沒處理完的資料,開啟RCU軟中斷,在下次軟中斷到來的時候接著處理.
注意:
僅當系統檢測到一個grace period的所有CPU都經歷了程式切換後,才會給系統一個資訊要求啟動新batch,在此期間的所有寫者請求,都暫存在本地CPU的nxtlist連結串列裡。
程式切換
在每一次程式切換的時候,都會呼叫rcu_qsctr_inc().如下程式碼片段如示:
1 2 3 4 5 6 |
asmlinkage void __sched schedule(void) { //...... rcu_qsctr_inc(cpu); //...... } |
rcu_qsctr_inc()程式碼如下:
1 2 3 4 5 |
static inline void rcu_qsctr_inc(int cpu) { struct rcu_data *rdp = &per_cpu(rcu_data, cpu); rdp->passed_quiesc = 1; } |
該函式將對應CPU上的rcu_data的passed_quiesc成員設為了1。或許你已經發現了,這個過程就標識該CPU經過了一次quiescent state,和之前在軟中斷的初始化為0相呼應。
幾種RCU情況分析
1. 如果CPU 1上有程式呼叫rcu_read_lock進入臨界區,之後退出來,發生了程式切換,新程式又通過rcu_read_lock進入臨界區.由於RCU軟中斷中只判斷一次上下文切換,因此,在呼叫回撥函式的時候,仍然有程式處於RCU的讀臨界區,這樣會不會有問題呢?
這樣是不會有問題的.還是上面的例子:
1 2 3 4 5 6 7 8 |
spin_lock(&foo_mutex); old_fp = gbl_foo; *new_fp = *old_fp; new_fp->a = new_a; rcu_assign_pointer(gbl_foo, new_fp); spin_unlock(&foo_mutex); synchronize_rcu(); kfree(old_fp); |
使用synchronize_rcu ()只是為了等待持有old_fd(也就是呼叫rcu_assign_pointer ()更新之前的gbl_foo)的程式退出.而不需要等待所有的讀者全部退出.這是因為,在rcu_assign_pointer ()之後的讀取取得的保護指標,已經是更新好的新值了.
2. 如果一個CPU連續呼叫synchronize_rcu()或者call_rcu()它們會有什麼影響呢?
如果當前有請求在等待,就會新請提交的回撥函式掛到taillist上,一直到前一個等待完成,再將taillist的資料移到curlist,並開啟一個新的等待,因此,也就是說,在前一個等待期間提交的請求,都會放到一起處理.也就是說,他們會共同等待所有CPU切換完成.
舉例說明如下:
帶bh的RCU API
在上面的程式碼分析的時候,經常看到帶有bh的RCU程式碼.現在來看一下這些帶bh的RCU是什麼樣的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
#define rcu_read_lock_bh() __rcu_read_lock_bh() #define rcu_read_unlock_bh() __rcu_read_unlock_bh() #define __rcu_read_lock_bh() do { local_bh_disable(); __acquire(RCU_BH); rcu_read_acquire(); }while (0) #define __rcu_read_unlock_bh() do { rcu_read_release(); __release(RCU_BH); local_bh_enable(); } while (0) |
根據上面的分析:bh RCU跟普通的RCU相比不同的是,普通RCU是禁止核心搶佔,而bh RCU是禁止下半部.
其實,帶bh的RCU一般在軟中斷使用,不過計算quiescent state並不是發生一次上下文切換。而是發生一次softirq.我們在後面的分析中可得到印證.
l call_rcu_bh()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
void call_rcu_bh(struct rcu_head *head, void (*func)(struct rcu_head *rcu)) { unsigned long flags; struct rcu_data *rdp; head->func = func; head->next = NULL; local_irq_save(flags); rdp = &__get_cpu_var(rcu_bh_data); *rdp->nxttail = head; rdp->nxttail = &head->next; if (unlikely(++rdp->qlen > qhimark)) { rdp->blimit = INT_MAX; force_quiescent_state(rdp, &rcu_bh_ctrlblk); } local_irq_restore(flags); } |
它跟call_rcu()不相同的是,rcu是取per_cpu變數rcu__data和全域性變數rcu_ctrlblk.而bh RCU是取rcu_bh_data,rcu_bh_ctrlblk.他們的型別都是一樣的,這樣做只是為了區分BH和普通RCU的等待.
對於rcu_bh_qsctr_inc
1 2 3 4 5 |
static inline void rcu_bh_qsctr_inc(int cpu) { struct rcu_data *rdp = &per_cpu(rcu_bh_data, cpu); rdp->passed_quiesc = 1; } |
它跟rcu_qsctr_inc()機同,也是更改對應成員. 所不同的是,呼叫rcu_bh_qsctr_inc()的地方發生了變化.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
asmlinkage void __do_softirq(void) { //...... do { if (pending & 1) { h->action(h); rcu_bh_qsctr_inc(cpu); } h++; pending >>= 1; } while (pending); //...... } |
也就是說,在發生軟中斷的時候,才會認為是經過了一次quiescent state.
RCU連結串列操作
為了操作連結串列,在include/linux/rculist.h有一套專門的RCU API。如:list_entry_rcu、list_add_rcu、list_del_rcu、list_for_each_entry_rcu等。即對所有kernel 的list的操作都有一個對應的RCU操作。那麼這些操作和原始的list操作有哪些不同呢?我們先對比幾個看下。
l list_entry_rcu
1 2 3 4 |
#define list_entry_rcu(ptr, type, member) \ container_of(rcu_dereference(ptr), type, member) #define list_entry(ptr, type, member) \ container_of(ptr, type, member) |
l __list_for_each_rcu
1 2 3 4 5 6 |
#define __list_for_each_rcu(pos, head) \ for (pos = rcu_dereference((head)->next); \ pos != (head); \ pos = rcu_dereference(pos->next)) #define __list_for_each(pos, head) \ for (pos = (head)->next; pos != (head); pos = pos->next) |
從__list_for_each_rcu和list_entry_rcu的實現可以看出,其將指標的獲取替換為使用rcu_dereference。
l list_replace_rcu
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
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(new->prev->next, new); new->next->prev = new; old->prev = LIST_POISON2; } static inline void list_replace(struct list_head *old, struct list_head *new) { new->next = old->next; new->next->prev = new; new->prev = old->prev; new->prev->next = new; } |
從list_replace_rcu的實現可以看出,RCU API的實現將指標的賦值替換為rcu_assign_pointer。
l list_del_rcu
1 2 3 4 5 6 7 8 9 10 11 |
static inline void list_del_rcu(struct list_head *entry) { __list_del(entry->prev, entry->next); entry->prev = LIST_POISON2; } static inline void list_del(struct list_head *entry) { __list_del(entry->prev, entry->next); entry->next = LIST_POISON1; entry->prev = LIST_POISON2; } |
從list_del_rcu的實現,可以看出RCU API的實現沒有將刪除項的next指標置為無效。這樣實現是為了防止刪除節點時,讀者還在遍歷該節點。
RCU連結串列API使用
下面看下RCU list API的幾個應用示例。
只有增加和刪除的連結串列操作
在這種應用情況下,絕大部分是對連結串列的遍歷,即讀操作,而很少出現的寫操作只有增加或刪除連結串列項,並沒有對連結串列項的修改操作,這種情況使用RCU非常容易,從rwlock轉換成RCU非常自然。路由表的維護就是這種情況的典型應用,對路由表的操作,絕大部分是路由表查詢,而對路由表的寫操作也僅僅是增加或刪除,因此使用RCU替換原來的rwlock順理成章。系統呼叫審計也是這樣的情況。
這是一段使用rwlock的系統呼叫審計部分的讀端程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
static enum audit_state audit_filter_task(struct task_struct *tsk) { struct audit_entry *e; enum audit_state state; read_lock(&auditsc_lock); /* Note: audit_netlink_sem held by caller. */ list_for_each_entry(e, &audit_tsklist, list) { if (audit_filter_rules(tsk, &e->rule, NULL, &state)) { read_unlock(&auditsc_lock); return state; } } read_unlock(&auditsc_lock); return AUDIT_BUILD_CONTEXT; } |
使用RCU後將變成:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
static enum audit_state audit_filter_task(struct task_struct *tsk) { struct audit_entry *e; enum audit_state state; rcu_read_lock(); /* Note: audit_netlink_sem held by caller. */ list_for_each_entry_rcu(e, &audit_tsklist, list) { if (audit_filter_rules(tsk, &e->rule, NULL, &state)) { rcu_read_unlock(); return state; } } rcu_read_unlock(); return AUDIT_BUILD_CONTEXT; } |
這種轉換非常直接,使用rcu_read_lock和rcu_read_unlock分別替換read_lock和read_unlock,連結串列遍歷函式使用_rcu版本替換就可以了。
使用rwlock的寫端程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
static inline int audit_del_rule(struct audit_rule *rule, struct list_head *list) { struct audit_entry *e; write_lock(&auditsc_lock); list_for_each_entry(e, list, list) { if (!audit_compare_rule(rule, &e->rule)) { list_del(&e->list); write_unlock(&auditsc_lock); return 0; } } write_unlock(&auditsc_lock); return -EFAULT; /* No matching rule */ } static inline int audit_add_rule(struct audit_entry *entry, struct list_head *list) { write_lock(&auditsc_lock); if (entry->rule.flags & AUDIT_PREPEND) { entry->rule.flags &= ~AUDIT_PREPEND; list_add(&entry->list, list); } else { list_add_tail(&entry->list, list); } write_unlock(&auditsc_lock); return 0; } |
使用RCU後寫端程式碼變成為:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
static inline int audit_del_rule(struct audit_rule *rule, struct list_head *list) { struct audit_entry *e; /* Do not use the _rcu iterator here, since this is the only * deletion routine. */ list_for_each_entry(e, list, list) { if (!audit_compare_rule(rule, &e->rule)) { list_del_rcu(&e->list); call_rcu(&e->rcu, audit_free_rule, e); return 0; } } return -EFAULT; /* No matching rule */ } static inline int audit_add_rule(struct audit_entry *entry, struct list_head *list) { if (entry->rule.flags & AUDIT_PREPEND) { entry->rule.flags &= ~AUDIT_PREPEND; list_add_rcu(&entry->list, list); } else { list_add_tail_rcu(&entry->list, list); } return 0; } |
對於連結串列刪除操作,list_del替換為list_del_rcu和call_rcu,這是因為被刪除的連結串列項可能還在被別的讀者引用,所以不能立即刪除,必須等到所有讀者經歷一個quiescent state才可以刪除。另外,list_for_each_entry並沒有被替換為list_for_each_entry_rcu,這是因為,只有一個寫者在做連結串列刪除操作,因此沒有必要使用_rcu版本。
通常情況下,write_lock和write_unlock應當分別替換成spin_lock和spin_unlock,但是對於只是對連結串列進行增加和刪除操作而且只有一個寫者的寫端,在使用了_rcu版本的連結串列操作API後,rwlock可以完全消除,不需要spinlock來同步讀者的訪問。對於上面的例子, 由於已經有audit_netlink_sem被呼叫者保持,所以spinlock就沒有必要了。
這種情況允許修改結果延後一定時間才可見,而且寫者對連結串列僅僅做增加和刪除操作,所以轉換成使用RCU非常容易。
寫端需要對連結串列條目進行修改操作
如果寫者需要對連結串列條目進行修改,那麼就需要首先拷貝要修改的條目,然後修改條目的拷貝,等修改完畢後,再使用條目拷貝取代要修改的條目,要修改條目將被在經歷一個grace period後安全刪除。
對於系統呼叫審計程式碼,並沒有這種情況。這裡假設有修改的情況,那麼使用rwlock的修改程式碼應當如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
static inline int audit_upd_rule(struct audit_rule *rule, struct list_head *list, __u32 newaction, __u32 newfield_count) { struct audit_entry *e; struct audit_newentry *ne; write_lock(&auditsc_lock); /* Note: audit_netlink_sem held by caller. */ list_for_each_entry(e, list, list) { if (!audit_compare_rule(rule, &e->rule)) { e->rule.action = newaction; e->rule.file_count = newfield_count; write_unlock(&auditsc_lock); return 0; } } write_unlock(&auditsc_lock); return -EFAULT; /* No matching rule */ } |
如果使用RCU,修改程式碼應當為;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
static inline int audit_upd_rule(struct audit_rule *rule, struct list_head *list, __u32 newaction, __u32 newfield_count) { struct audit_entry *e; struct audit_newentry *ne; list_for_each_entry(e, list, list) { if (!audit_compare_rule(rule, &e->rule)) { ne = kmalloc(sizeof(*entry), GFP_ATOMIC); if (ne == NULL) return -ENOMEM; audit_copy_rule(&ne->rule, &e->rule); ne->rule.action = newaction; ne->rule.file_count = newfield_count; list_replace_rcu(e, ne); call_rcu(&e->rcu, audit_free_rule, e); return 0; } } return -EFAULT; /* No matching rule */ } |
修改操作立即可見
前面兩種情況,讀者能夠容忍修改可以在一段時間後看到,也就說讀者在修改後某一時間段內,仍然看到的是原來的資料。在很多情況下,讀者不能容忍看到舊的資料,這種情況下,需要使用一些新措施,如System V IPC,它在每一個連結串列條目中增加了一個deleted欄位,標記該欄位是否刪除,如果刪除了,就設定為真,否則設定為假,當程式碼在遍歷連結串列時,核對每一個條目的deleted欄位,如果為真,就認為它是不存在的。
還是以系統呼叫審計程式碼為例,如果它不能容忍舊資料,那麼,讀端程式碼應該修改為:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
static enum audit_state audit_filter_task(struct task_struct *tsk) { struct audit_entry *e; enum audit_state state; rcu_read_lock(); list_for_each_entry_rcu(e, &audit_tsklist, list) { if (audit_filter_rules(tsk, &e->rule, NULL, &state)) { spin_lock(&e->lock); if (e->deleted) { spin_unlock(&e->lock); rcu_read_unlock(); return AUDIT_BUILD_CONTEXT; } rcu_read_unlock(); return state; } } rcu_read_unlock(); return AUDIT_BUILD_CONTEXT; } |
注意,對於這種情況,每一個連結串列條目都需要一個spinlock保護,因為刪除操作將修改條目的deleted標誌。此外,該函式如果搜尋到條目,返回時應當保持該條目的鎖。
寫端的刪除操作將變成:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
static inline int audit_del_rule(struct audit_rule *rule, struct list_head *list) { struct audit_entry *e; /* Do not use the _rcu iterator here, since this is the only * deletion routine. */ list_for_each_entry(e, list, list) { if (!audit_compare_rule(rule, &e->rule)) { spin_lock(&e->lock); list_del_rcu(&e->list); e->deleted = 1; spin_unlock(&e->lock); call_rcu(&e->rcu, audit_free_rule, e); return 0; } } return -EFAULT; /* No matching rule */ } |
刪除條目時,需要標記該條目為已刪除。這樣讀者就可以通過該標誌立即得知條目是否已經刪除。
小結
RCU是2.6核心引入的新的鎖機制,在絕大部分為讀而只有極少部分為寫的情況下,它是非常高效的,因此在路由表維護、系統呼叫審計、SELinux的AVC、dcache和IPC等程式碼部分中,使用它來取代rwlock來獲得更高的效能。但是,它也有缺點,延後的刪除或釋放將佔用一些記憶體,尤其是對嵌入式系統,這可能是非常昂貴的記憶體開銷。此外,寫者的開銷比較大,尤其是對於那些無法容忍舊資料的情況以及不只一個寫者的情況,寫者需要spinlock或其他的鎖機制來與其他寫者同步。
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!