RCU需求之旅

版權所有 IBM Corporation, 2015

作者: Paul E. McKenney

本文的初始版本出現在 LWN 上,分別是這些文章: 第一部分, 第二部分, 和 第三部分

簡介

Read-copy update(RCU,讀取-複製更新)是一種同步機制,通常用作讀者-寫者鎖的替代品。RCU 的特別之處在於,更新者不會阻塞讀者,這意味著 RCU 的讀取端原語可以非常快速且可擴充套件。此外,更新者可以與讀者併發地進行有用的前進。但是,RCU 讀者和更新者之間的所有這些併發性確實提出了一個問題,即 RCU 讀者究竟在做什麼,這反過來又提出了 RCU 的要求究竟是什麼的問題。

因此,本文件總結了 RCU 的要求,可以被認為是 RCU 的一個非正式的、高階的規範。重要的是要理解,RCU 的規範本質上主要是經驗性的;事實上,我是在經歷了許多困難之後才瞭解到其中的許多要求的。這種情況可能會引起一些驚愕,但是,不僅這個學習過程非常有趣,而且能夠與這麼多願意以有趣的新方式應用技術的人一起工作也是一種巨大的榮幸。

拋開這些不談,以下是當前已知的 RCU 要求的類別

  1. 基本要求

  2. 基本非要求

  3. 並行的生活常識

  4. 實現質量要求

  5. Linux核心的複雜性

  6. 軟體工程要求

  7. 其他 RCU 風格

  8. 可能的未來變化

接下來是一個 總結,但是,每個小測驗的答案緊隨其後。用滑鼠選擇大的空白區域來檢視答案。

基本要求

RCU 的基本要求是 RCU 最接近於嚴格的數學要求的東西。這些是

  1. 寬限期保證

  2. 釋出/訂閱保證

  3. 記憶體屏障保證

  4. 保證無條件執行的 RCU 原語

  5. 保證從讀取到寫入的升級

寬限期保證

RCU 的寬限期保證非常特別,因為它是預先考慮好的:Jack Slingwine 和我在 20 世紀 90 年代初開始研究 RCU(當時稱為 “rclock”)時,就牢記著這個保證。也就是說,過去二十年使用 RCU 的經驗已經產生了對這個保證的更詳細的理解。

RCU 的寬限期保證允許更新者等待所有預先存在的 RCU 讀取端臨界區的完成。RCU 讀取端臨界區以標記 rcu_read_lock() 開始,以標記 rcu_read_unlock() 結束。這些標記可以巢狀,RCU 將巢狀集視為一個大的 RCU 讀取端臨界區。rcu_read_lock()rcu_read_unlock() 的生產質量實現非常輕量級,事實上,在為生產用途構建的 Linux 核心中,使用 CONFIG_PREEMPTION=n 時,它們的開銷正好為零。

這個保證允許以極低的讀者開銷強制執行排序,例如

 1 int x, y;
 2
 3 void thread0(void)
 4 {
 5   rcu_read_lock();
 6   r1 = READ_ONCE(x);
 7   r2 = READ_ONCE(y);
 8   rcu_read_unlock();
 9 }
10
11 void thread1(void)
12 {
13   WRITE_ONCE(x, 1);
14   synchronize_rcu();
15   WRITE_ONCE(y, 1);
16 }

由於第 14 行的 synchronize_rcu() 等待所有預先存在的讀者,因此從 x 載入值為零的任何 thread0() 例項必須在 thread1() 儲存到 y 之前完成,因此該例項也必須從 y 載入值為零。類似地,從 y 載入值為 1 的任何 thread0() 例項必須在 synchronize_rcu() 啟動之後開始,因此也必須從 x 載入值為 1。因此,結果

(r1 == 0 && r2 == 1)

不可能發生。

小測驗:

等等!你說過更新者可以與讀者併發地進行有用的前進,但是預先存在的讀者會阻塞 synchronize_rcu()!!! 你到底想騙誰???

答案:

首先,如果更新者不希望被讀者阻塞,他們可以使用 call_rcu()kfree_rcu(),稍後將對此進行討論。其次,即使在使用 synchronize_rcu() 時,其他的更新端程式碼確實與讀者併發執行,無論讀者是預先存在的還是新加入的。

這個場景類似於 RCU 在 DYNIX/ptx 中的第一個用途之一,它管理著一個分散式鎖管理器到適合處理從節點故障中恢復的狀態的轉換,或多或少如下所示

 1 #define STATE_NORMAL        0
 2 #define STATE_WANT_RECOVERY 1
 3 #define STATE_RECOVERING    2
 4 #define STATE_WANT_NORMAL   3
 5
 6 int state = STATE_NORMAL;
 7
 8 void do_something_dlm(void)
 9 {
10   int state_snap;
11
12   rcu_read_lock();
13   state_snap = READ_ONCE(state);
14   if (state_snap == STATE_NORMAL)
15     do_something();
16   else
17     do_something_carefully();
18   rcu_read_unlock();
19 }
20
21 void start_recovery(void)
22 {
23   WRITE_ONCE(state, STATE_WANT_RECOVERY);
24   synchronize_rcu();
25   WRITE_ONCE(state, STATE_RECOVERING);
26   recovery();
27   WRITE_ONCE(state, STATE_WANT_NORMAL);
28   synchronize_rcu();
29   WRITE_ONCE(state, STATE_NORMAL);
30 }

do_something_dlm() 中的 RCU 讀取端臨界區與 start_recovery() 中的 synchronize_rcu() 一起工作,以保證 do_something() 永遠不會與 recovery() 併發執行,但在 do_something_dlm() 中幾乎沒有或沒有同步開銷。

小測驗:

為什麼需要第 28 行上的 synchronize_rcu()

答案:

如果沒有額外的寬限期,記憶體重排序可能會導致 do_something_dlm() 與 recovery() 的最後幾位併發執行 do_something()。

為了避免死鎖等致命問題,RCU 讀取端臨界區不得包含對 synchronize_rcu() 的呼叫。類似地,RCU 讀取端臨界區不得包含任何直接或間接等待 synchronize_rcu() 的呼叫的完成的內容。

雖然 RCU 的寬限期保證本身很有用,並且具有 相當多的用例,但如果能夠使用 RCU 來協調對連結資料結構的讀取端訪問,那就更好了。為此,寬限期保證是不夠的,如下面的函式 add_gp_buggy() 所示。稍後我們將檢視讀者的程式碼,但與此同時,只需將讀者想象成無鎖地獲取 gp 指標,並且如果載入的值不是 NULL,則無鎖地訪問 ->a->b 欄位。

 1 bool add_gp_buggy(int a, int b)
 2 {
 3   p = kmalloc(sizeof(*p), GFP_KERNEL);
 4   if (!p)
 5     return -ENOMEM;
 6   spin_lock(&gp_lock);
 7   if (rcu_access_pointer(gp)) {
 8     spin_unlock(&gp_lock);
 9     return false;
10   }
11   p->a = a;
12   p->b = a;
13   gp = p; /* ORDERING BUG */
14   spin_unlock(&gp_lock);
15   return true;
16 }

問題在於編譯器和弱序 CPU 都有權按如下方式對這段程式碼進行重排序

 1 bool add_gp_buggy_optimized(int a, int b)
 2 {
 3   p = kmalloc(sizeof(*p), GFP_KERNEL);
 4   if (!p)
 5     return -ENOMEM;
 6   spin_lock(&gp_lock);
 7   if (rcu_access_pointer(gp)) {
 8     spin_unlock(&gp_lock);
 9     return false;
10   }
11   gp = p; /* ORDERING BUG */
12   p->a = a;
13   p->b = a;
14   spin_unlock(&gp_lock);
15   return true;
16 }

如果 RCU 讀者在 add_gp_buggy_optimized 執行第 11 行後立即獲取 gp,它將在 ->a->b 欄位中看到垃圾。而這只是編譯器和硬體最佳化可能導致問題的眾多方式之一。因此,我們顯然需要某種方法來防止編譯器和 CPU 以這種方式進行重排序,這使我們進入了下一節討論的釋出-訂閱保證。

釋出/訂閱保證

RCU 的釋出-訂閱保證允許將資料插入到連結的資料結構中,而不會中斷 RCU 讀者。更新者使用 rcu_assign_pointer() 插入新資料,讀者使用 rcu_dereference() 訪問資料,無論是新的還是舊的。以下顯示了一個插入示例

 1 bool add_gp(int a, int b)
 2 {
 3   p = kmalloc(sizeof(*p), GFP_KERNEL);
 4   if (!p)
 5     return -ENOMEM;
 6   spin_lock(&gp_lock);
 7   if (rcu_access_pointer(gp)) {
 8     spin_unlock(&gp_lock);
 9     return false;
10   }
11   p->a = a;
12   p->b = a;
13   rcu_assign_pointer(gp, p);
14   spin_unlock(&gp_lock);
15   return true;
16 }

第 13 行上的 rcu_assign_pointer() 在概念上等同於一個簡單的賦值語句,但它也保證它的賦值將在第 11 行和第 12 行的兩個賦值之後發生,類似於 C11 memory_order_release 儲存操作。它還防止了任意數量的 “有趣” 的編譯器最佳化,例如,使用 gp 作為緊接在賦值之前的暫存位置。

小測驗:

但是 rcu_assign_pointer() 並沒有阻止對 p->ap->b 的兩個賦值被重排序。這難道不會導致問題嗎?

答案:

不,不會。讀者在賦值給 gp 之前無法看到這兩個欄位中的任何一個,到那時,這兩個欄位都已經完全初始化。因此,對 p->ap->b 的賦值進行重排序不可能導致任何問題。

很容易假設讀者不需要做任何特別的事情來控制其對 RCU 保護資料的訪問,如下面的 do_something_gp_buggy() 所示

 1 bool do_something_gp_buggy(void)
 2 {
 3   rcu_read_lock();
 4   p = gp;  /* OPTIMIZATIONS GALORE!!! */
 5   if (p) {
 6     do_something(p->a, p->b);
 7     rcu_read_unlock();
 8     return true;
 9   }
10   rcu_read_unlock();
11   return false;
12 }

但是,必須抵制這種誘惑,因為編譯器(或像 DEC Alpha 這樣的弱序 CPU)可能會以驚人的方式絆倒這段程式碼。舉一個例子,如果編譯器缺少暫存器,它可能會選擇從 gp 重新獲取,而不是像下面這樣在 p 中保留一個單獨的副本

 1 bool do_something_gp_buggy_optimized(void)
 2 {
 3   rcu_read_lock();
 4   if (gp) { /* OPTIMIZATIONS GALORE!!! */
 5     do_something(gp->a, gp->b);
 6     rcu_read_unlock();
 7     return true;
 8   }
 9   rcu_read_unlock();
10   return false;
11 }

如果此函式與一系列用新結構替換當前結構的更新併發執行,那麼對 gp->agp->b 的獲取很可能來自兩個不同的結構,這可能會導致嚴重的混亂。為了防止這種情況(以及其他許多情況),do_something_gp() 使用 rcu_dereference()gp 獲取

 1 bool do_something_gp(void)
 2 {
 3   rcu_read_lock();
 4   p = rcu_dereference(gp);
 5   if (p) {
 6     do_something(p->a, p->b);
 7     rcu_read_unlock();
 8     return true;
 9   }
10   rcu_read_unlock();
11   return false;
12 }

rcu_dereference() 在 Linux 核心中使用 volatile 轉換和(對於 DEC Alpha)記憶體屏障。如果出現 C11 memory_order_consume 的高質量實現 [PDF],那麼 rcu_dereference() 可以實現為 memory_order_consume 載入。無論具體實現如何,透過 rcu_dereference() 獲取的指標不得在包含該 rcu_dereference() 的最外層 RCU 讀取端臨界區之外使用,除非相應的資料元素的保護已從 RCU 傳遞到某些其他的同步機制,最常見的是鎖定或引用計數(請參閱 由 RCU 保護的列表/陣列的元素的引用計數設計)。

簡而言之,更新者使用 rcu_assign_pointer(),讀者使用 rcu_dereference(),這兩個 RCU API 元素協同工作,以確保讀者可以一致地檢視新新增的資料元素。

當然,還需要從 RCU 保護的資料結構中刪除元素,例如,使用以下過程

  1. 從封閉結構中刪除資料元素。

  2. 等待所有預先存在的 RCU 讀取端臨界區完成(因為只有預先存在的讀者才可能引用新刪除的資料元素)。

  3. 此時,只有更新者引用了新刪除的資料元素,因此它可以安全地回收資料元素,例如,透過將其傳遞給 kfree()

這個過程由 remove_gp_synchronous() 實現

 1 bool remove_gp_synchronous(void)
 2 {
 3   struct foo *p;
 4
 5   spin_lock(&gp_lock);
 6   p = rcu_access_pointer(gp);
 7   if (!p) {
 8     spin_unlock(&gp_lock);
 9     return false;
10   }
11   rcu_assign_pointer(gp, NULL);
12   spin_unlock(&gp_lock);
13   synchronize_rcu();
14   kfree(p);
15   return true;
16 }

這個函式很簡單,第 13 行等待一個寬限期,然後第 14 行釋放舊的資料元素。這個等待保證了讀者將在資料元素被 p 引用的資料元素釋放之前到達 do_something_gp() 的第 7 行。第 6 行上的 rcu_access_pointer() 類似於 rcu_dereference(),但

  1. 不能對 rcu_access_pointer() 返回的值進行解引用。如果你想訪問指標指向的值以及指標本身,請使用 rcu_dereference() 而不是 rcu_access_pointer()

  2. 不需要保護對 rcu_access_pointer() 的呼叫。相比之下,rcu_dereference() 必須位於 RCU 讀取端臨界區內,或者位於指標無法更改的程式碼段中,例如,在受相應更新端鎖保護的程式碼中。

小測驗:

如果沒有 rcu_dereference()rcu_access_pointer(),編譯器可能會利用哪些破壞性最佳化?

答案:

讓我們從 do_something_gp() 如果未能使用 rcu_dereference() 會發生什麼開始。它可以重用以前從此指標獲取的值。它也可以以一次一個位元組的方式從 gp 獲取指標,導致載入撕裂,進而導致兩個不同的指標值的按位元組混合。它甚至可以使用值推測最佳化,即它做出錯誤的猜測,但在它進行檢查時,更新已經更改了指標以匹配錯誤的猜測。在此期間返回預初始化垃圾的任何解引用都很糟糕!對於 remove_gp_synchronous(),只要在持有 gp_lock 的同時執行對 gp 的所有修改,上述最佳化都是無害的。但是,如果您使用 __rcu 定義 gp,然後不使用 rcu_access_pointer()rcu_dereference() 訪問它,sparse 會抱怨。

簡而言之,RCU 的釋出-訂閱保證由 rcu_assign_pointer()rcu_dereference() 的組合提供。此保證允許將資料元素安全地新增到 RCU 保護的連結資料結構中,而不會中斷 RCU 讀者。此保證可以與寬限期保證結合使用,以允許從 RCU 保護的連結資料結構中刪除資料元素,同樣不會中斷 RCU 讀者。

這個保證只是部分預先考慮好的。DYNIX/ptx 使用顯式記憶體屏障進行釋出,但沒有任何類似於 rcu_dereference() 用於訂閱的東西,也沒有任何類似於依賴關係排序屏障的東西,後者後來被納入 rcu_dereference(),後來又納入 READ_ONCE()。與 C 和 C++ 標準委員會的最新工作為編譯器中的技巧和陷阱提供了很多教育。簡而言之,編譯器在 20 世紀 90 年代初的技巧性要差得多,但在 2015 年,甚至不要考慮省略 rcu_dereference()

記憶體屏障保證

前一節的簡單鏈接資料結構場景清楚地表明,在具有多個 CPU 的系統上需要 RCU 嚴格的記憶體排序保證

  1. 每個具有在 synchronize_rcu() 啟動之前開始的 RCU 讀取端臨界區的 CPU 保證在 RCU 讀取端臨界區結束的時間和 synchronize_rcu() 返回的時間之間執行完整的記憶體屏障。如果沒有這個保證,預先存在的 RCU 讀取端臨界區可能會在 remove_gp_synchronous() 的第 14 行的 kfree() 之後持有對新刪除的 struct foo 的引用。

  2. 每個具有在 synchronize_rcu() 返回之後結束的 RCU 讀取端臨界區的 CPU 保證在 synchronize_rcu() 開始的時間和 RCU 讀取端臨界區開始的時間之間執行完整的記憶體屏障。如果沒有這個保證,在 remove_gp_synchronous() 的第 14 行上的 kfree() 之後執行的後面的 RCU 讀取端臨界區可能會稍後執行 do_something_gp() 並找到新刪除的 struct foo

  3. 如果呼叫 synchronize_rcu() 的任務保持在給定的 CPU 上,那麼保證該 CPU 在執行 synchronize_rcu() 的某個時間點執行一個完整的記憶體屏障。這個保證確保了 remove_gp_synchronous() 的第 14 行的 kfree() 真的在第 11 行的刪除之後執行。

  4. 如果呼叫 synchronize_rcu() 的任務在該呼叫期間在一組 CPU 之間遷移,那麼保證該組中的每個 CPU 在執行 synchronize_rcu() 的某個時間點執行一個完整的記憶體屏障。這個保證也確保了 remove_gp_synchronous() 的第 14 行的 kfree() 真的在第 11 行的刪除之後執行,即使執行 synchronize_rcu() 的執行緒在此期間遷移。

小測驗:

鑑於多個 CPU 可以在任何時間啟動 RCU 讀端臨界區,沒有任何排序要求,RCU 怎麼可能判斷給定的 RCU 讀端臨界區是否在 synchronize_rcu() 的給定例項之前開始?

答案:

如果 RCU 無法判斷給定的 RCU 讀端臨界區是否在 synchronize_rcu() 的給定例項之前開始,那麼它必須假設 RCU 讀端臨界區首先開始。換句話說,只有當 synchronize_rcu() 可以證明 synchronize_rcu() 首先開始時,synchronize_rcu() 的給定例項才能避免等待給定的 RCU 讀端臨界區。一個相關的問題是 “當 rcu_read_lock() 不生成任何程式碼時,它與寬限期的關係為什麼重要?” 答案是,重要的不是 rcu_read_lock() 本身的關係,而是封閉的 RCU 讀端臨界區內的程式碼與寬限期之前和之後的程式碼的關係。如果我們採取這種觀點,那麼當寬限期之前的某個訪問觀察到臨界區內的某個訪問的影響時,給定的 RCU 讀端臨界區會在給定的寬限期之前開始,在這種情況下,臨界區內的任何訪問都不能觀察到寬限期之後的任何訪問的影響。

截至 2016 年底,RCU 的數學模型都採用了這種觀點,例如,請參見 2016 LinuxCon EU 簡報的第 62 和 63 頁。

小測驗:

第一個和第二個保證要求令人難以置信的嚴格排序!所有這些記憶體屏障真的需要嗎?

答案:

是的,它們確實是必需的。要了解為什麼需要第一個保證,請考慮以下事件序列

  1. CPU 1: rcu_read_lock()

  2. CPU 1: q = rcu_dereference(gp); /* 很可能返回 p. */

  3. CPU 0: list_del_rcu(p);

  4. CPU 0: synchronize_rcu() 開始。

  5. CPU 1: do_something_with(q->a); /* 沒有 smp_mb(),所以可能發生在 kfree() 之後。 */

  6. CPU 1: rcu_read_unlock()

  7. CPU 0: synchronize_rcu() 返回。

  8. CPU 0: kfree(p);

因此,在 RCU 讀端臨界區結束和寬限期結束之間必須存在完整的記憶體屏障。

證明第二條規則必要性的事件序列大致相似

  1. CPU 0: list_del_rcu(p);

  2. CPU 0: synchronize_rcu() 開始。

  3. CPU 1: rcu_read_lock()

  4. CPU 1: q = rcu_dereference(gp); /* 如果沒有記憶體屏障,可能會返回 p */

  5. CPU 0: synchronize_rcu() 返回。

  6. CPU 0: kfree(p);

  7. CPU 1: do_something_with(q->a); /* Boom!!! */

  8. CPU 1: rcu_read_unlock()

同樣,如果沒有寬限期開始和 RCU 讀端臨界區開始之間的記憶體屏障,CPU 1 可能會最終訪問空閒列表。

當然,“彷彿”規則適用,因此任何表現得好像已就位適當的記憶體屏障的實現都是正確的實現。也就是說,欺騙自己相信你已經遵守了彷彿規則比實際遵守它要容易得多!

小測驗:

您聲稱 rcu_read_lock()rcu_read_unlock() 在某些核心構建中絕對不生成任何程式碼。這意味著編譯器可能會任意重新排列連續的 RCU 讀端臨界區。鑑於這種重新排列,如果給定的 RCU 讀端臨界區已完成,您如何確保所有先前的 RCU 讀端臨界區都已完成?編譯器重新排列不會使這不可能確定嗎?

答案:

rcu_read_lock()rcu_read_unlock() 絕對不生成任何程式碼的情況下,RCU 僅在特殊位置(例如,在排程程式中)推斷靜止狀態。因為呼叫 schedule() 最好防止呼叫程式碼對共享變數的訪問在呼叫 schedule() 之間重新排列,所以如果 RCU 檢測到給定的 RCU 讀端臨界區的結束,它將必然檢測到所有先前 RCU 讀端臨界區的結束,無論編譯器多麼積極地擾亂程式碼。同樣,這一切都假定編譯器無法在呼叫排程程式、中斷處理程式、空閒迴圈、使用者模式程式碼等之間擾亂程式碼。但是,如果您的核心構建允許這種擾亂,那麼您破壞的不僅僅是 RCU!

請注意,這些記憶體屏障要求不會取代 RCU 的基本要求,即寬限期等待所有預先存在的讀取者。相反,本節中呼叫的記憶體屏障必須以強制執行此基本要求的方式執行。當然,不同的實現以不同的方式強制執行此要求,但它們必須強制執行。

保證無條件執行的 RCU 原語

常見的 RCU 原語是無條件的。它們被呼叫,它們完成它們的工作,然後它們返回,沒有出錯的可能性,也不需要重試。這是 RCU 的一個關鍵設計理念。

但是,這種理念是務實的,而不是頑固的。如果有人提出了一個對特定條件 RCU 原語的良好理由,那麼它很可能會被實現和新增。畢竟,這種保證是被逆向工程的,而不是預先策劃的。RCU 原語的無條件性質最初是實現上的偶然事件,後來使用具有條件原語的同步原語的經驗使我將此偶然事件提升為保證。因此,將條件原語新增到 RCU 的理由需要基於詳細且引人注目的用例。

保證的讀到寫升級

就 RCU 而言,始終可以在 RCU 讀端臨界區內執行更新。例如,該 RCU 讀端臨界區可能會搜尋給定的資料元素,然後可能會獲取更新端自旋鎖以更新該元素,所有這些都在該 RCU 讀端臨界區內進行。當然,在呼叫 synchronize_rcu() 之前必須退出 RCU 讀端臨界區,但是,可以透過使用本文件後面描述的 call_rcu()kfree_rcu() API 成員來避免這種不便。

小測驗:

但是,升級到寫操作如何排除其他讀取者?

答案:

它不會,就像正常的 RCU 更新一樣,它也不會排除 RCU 讀取者。

此保證允許在讀端和更新端程式碼之間共享查詢程式碼,並且是預先策劃的,出現在最早的 DYNIX/ptx RCU 文件中。

基本非要求

RCU 提供了非常輕量級的讀取者,並且它的讀端保證雖然非常有用,但相應地也是輕量級的。因此,很容易假設 RCU 保證的比實際情況更多。當然,RCU 不保證的事情的列表是無限長的,但是,以下各節列出了一些引起混淆的非保證。除非另有說明,否則這些非保證是預先策劃的。

  1. 讀取者施加最小排序

  2. 讀取者不排除更新者

  3. 更新者僅等待舊的讀取者

  4. 寬限期不劃分讀端臨界區

  5. 讀端臨界區不劃分寬限期

讀取者施加最小排序

讀取者端標記(例如 rcu_read_lock()rcu_read_unlock())除了透過它們與寬限期 API(例如 synchronize_rcu())的互動之外,絕對不提供任何排序保證。要了解這一點,請考慮以下一對執行緒

 1 void thread0(void)
 2 {
 3   rcu_read_lock();
 4   WRITE_ONCE(x, 1);
 5   rcu_read_unlock();
 6   rcu_read_lock();
 7   WRITE_ONCE(y, 1);
 8   rcu_read_unlock();
 9 }
10
11 void thread1(void)
12 {
13   rcu_read_lock();
14   r1 = READ_ONCE(y);
15   rcu_read_unlock();
16   rcu_read_lock();
17   r2 = READ_ONCE(x);
18   rcu_read_unlock();
19 }

在 thread0() 和 thread1() 併發執行之後,完全有可能出現

(r1 == 1 && r2 == 0)

(也就是說,y 似乎在 x 之前被賦值),如果 rcu_read_lock()rcu_read_unlock() 具有很大的排序屬性,這是不可能的。但它們沒有,因此 CPU 有權進行大量的重新排序。這是設計使然:任何重要的排序約束都會降低這些快速路徑 API 的速度。

小測驗:

編譯器不是也可以重新排序這段程式碼嗎?

答案:

不,READ_ONCE() 和 WRITE_ONCE() 中的 volatile 轉換會阻止編譯器在這種特殊情況下進行重新排序。

讀取者不排除更新者

rcu_read_lock()rcu_read_unlock() 都不排除更新。它們所做的只是阻止寬限期結束。以下示例說明了這一點

 1 void thread0(void)
 2 {
 3   rcu_read_lock();
 4   r1 = READ_ONCE(y);
 5   if (r1) {
 6     do_something_with_nonzero_x();
 7     r2 = READ_ONCE(x);
 8     WARN_ON(!r2); /* BUG!!! */
 9   }
10   rcu_read_unlock();
11 }
12
13 void thread1(void)
14 {
15   spin_lock(&my_lock);
16   WRITE_ONCE(x, 1);
17   WRITE_ONCE(y, 1);
18   spin_unlock(&my_lock);
19 }

如果 thread0() 函式的 rcu_read_lock() 排除 thread1() 函式的更新,則 WARN_ON() 永遠不會觸發。但事實是,rcu_read_lock() 除了後續的寬限期(thread1() 沒有)之外,幾乎不排除任何東西,因此 WARN_ON() 可以並且確實會觸發。

更新者僅等待舊的讀取者

可能會認為在 synchronize_rcu() 完成後,沒有讀取者正在執行。必須避免這種誘惑,因為新的讀取者可以在 synchronize_rcu() 開始後立即開始,並且 synchronize_rcu() 沒有義務等待這些新的讀取者。

小測驗:

假設 synchronize_rcu() 等到所有讀取者都已完成,而不是僅等待預先存在的讀取者。更新者可以在多長時間內依靠沒有讀取者?

答案:

根本沒有時間。即使 synchronize_rcu() 等到所有讀取者都已完成,新的讀取者也可能在 synchronize_rcu() 完成後立即開始。因此,synchronize_rcu() 之後的程式碼永遠不能依賴於沒有讀取者。

寬限期不劃分讀端臨界區

可能會認為如果一個 RCU 讀端臨界區的任何部分在給定的寬限期之前,並且另一個 RCU 讀端臨界區的任何部分在同一寬限期之後,那麼第一個 RCU 讀端臨界區的所有部分必須在第二個 RCU 讀端臨界區的所有部分之前。但是,事實並非如此:單個寬限期不會劃分 RCU 讀端臨界區的集合。這種情況的示例可以說明如下,其中 xyz 最初都為零

 1 void thread0(void)
 2 {
 3   rcu_read_lock();
 4   WRITE_ONCE(a, 1);
 5   WRITE_ONCE(b, 1);
 6   rcu_read_unlock();
 7 }
 8
 9 void thread1(void)
10 {
11   r1 = READ_ONCE(a);
12   synchronize_rcu();
13   WRITE_ONCE(c, 1);
14 }
15
16 void thread2(void)
17 {
18   rcu_read_lock();
19   r2 = READ_ONCE(b);
20   r3 = READ_ONCE(c);
21   rcu_read_unlock();
22 }

結果表明,結果

(r1 == 1 && r2 == 0 && r3 == 1)

完全有可能。下圖顯示了這種情況如何發生,每個帶圈的 QS 表示 RCU 為每個執行緒記錄靜止狀態的點,也就是說,RCU 知道該執行緒不可能是正在進行的在當前寬限期之前開始的 RCU 讀端臨界區中

../../../_images/GPpartitionReaders1.svg

如果需要以這種方式劃分 RCU 讀端臨界區,則需要使用兩個寬限期,其中第一個寬限期已知在第二個寬限期開始之前結束

 1 void thread0(void)
 2 {
 3   rcu_read_lock();
 4   WRITE_ONCE(a, 1);
 5   WRITE_ONCE(b, 1);
 6   rcu_read_unlock();
 7 }
 8
 9 void thread1(void)
10 {
11   r1 = READ_ONCE(a);
12   synchronize_rcu();
13   WRITE_ONCE(c, 1);
14 }
15
16 void thread2(void)
17 {
18   r2 = READ_ONCE(c);
19   synchronize_rcu();
20   WRITE_ONCE(d, 1);
21 }
22
23 void thread3(void)
24 {
25   rcu_read_lock();
26   r3 = READ_ONCE(b);
27   r4 = READ_ONCE(d);
28   rcu_read_unlock();
29 }

在這裡,如果 (r1 == 1),則 thread0() 對 b 的寫入必須在 thread1() 的寬限期結束之前發生。如果此外 (r4 == 1),則 thread3() 對 b 的讀取必須在 thread2() 的寬限期開始之後發生。如果也是 (r2 == 1),則 thread1() 的寬限期結束必須在 thread2() 的寬限期開始之前發生。這意味著兩個 RCU 讀端臨界區不能重疊,從而保證 (r3 == 1)。因此,結果

(r1 == 1 && r2 == 1 && r3 == 0 && r4 == 1)

不可能發生。

這種非要求也不是預先策劃的,但在研究 RCU 與記憶體排序的互動時變得顯而易見。

讀端臨界區不劃分寬限期

如果 RCU 讀端臨界區發生在兩個寬限期之間,則可能會認為這些寬限期不能重疊。但是,這種誘惑不會導致任何好的結果,可以透過以下方式說明,其中所有變數最初都為零

 1 void thread0(void)
 2 {
 3   rcu_read_lock();
 4   WRITE_ONCE(a, 1);
 5   WRITE_ONCE(b, 1);
 6   rcu_read_unlock();
 7 }
 8
 9 void thread1(void)
10 {
11   r1 = READ_ONCE(a);
12   synchronize_rcu();
13   WRITE_ONCE(c, 1);
14 }
15
16 void thread2(void)
17 {
18   rcu_read_lock();
19   WRITE_ONCE(d, 1);
20   r2 = READ_ONCE(c);
21   rcu_read_unlock();
22 }
23
24 void thread3(void)
25 {
26   r3 = READ_ONCE(d);
27   synchronize_rcu();
28   WRITE_ONCE(e, 1);
29 }
30
31 void thread4(void)
32 {
33   rcu_read_lock();
34   r4 = READ_ONCE(b);
35   r5 = READ_ONCE(e);
36   rcu_read_unlock();
37 }

在這種情況下,結果

(r1 == 1 && r2 == 1 && r3 == 1 && r4 == 0 && r5 == 1)

完全有可能,如下圖所示

../../../_images/ReadersPartitionGP1.svg

同樣,RCU 讀端臨界區可以與給定的寬限期幾乎所有部分重疊,只要它不與整個寬限期重疊即可。因此,RCU 讀端臨界區不能劃分一對 RCU 寬限期。

小測驗:

分隔鏈條開頭和結尾的 RCU 讀端臨界區需要多長時間的寬限期序列,每個寬限期都由一個 RCU 讀端臨界區分隔?

答案:

理論上是無限數量。實際上是一個未知的數字,它對實現細節和時間考慮因素都很敏感。因此,即使在實踐中,RCU 使用者也必須遵守理論上的答案,而不是實際上的答案。

並行性的生活真相

這些並行性的生活真相絕非 RCU 獨有,但 RCU 實現必須遵守它們。因此,有必要重複一下

  1. 任何 CPU 或任務都可能在任何時候被延遲,並且任何透過停用搶佔、中斷或任何其他方式來避免這些延遲的嘗試都是完全徒勞的。這在可搶佔的使用者級環境和虛擬化環境(其中給定客戶作業系統的 VCPU 可以隨時被底層虛擬機器管理程式搶佔)中最為明顯,但由於 ECC 錯誤、NMI 和其他硬體事件,也可能發生在裸機環境中。雖然超過大約 20 秒的延遲可能會導致崩潰,但 RCU 實現有義務使用可以容忍極長延遲的演算法,但其中“極長”不足以允許在遞增 64 位計數器時迴繞。

  2. 編譯器和 CPU 都可以重新排序記憶體訪問。在重要的地方,RCU 必須使用編譯器指令和記憶體屏障指令來保持排序。

  3. 對任何給定快取行中的記憶體位置的衝突寫入將導致昂貴的快取未命中。更大數量的併發寫入和更頻繁的併發寫入將導致更顯著的減速。因此,RCU 有義務使用具有足夠區域性性的演算法,以避免顯著的效能和可伸縮性問題。

  4. 作為一條粗略的經驗法則,在任何給定的獨佔鎖的保護下,只能執行一個 CPU 的處理量。因此,RCU 必須使用可伸縮的鎖定設計。

  5. 計數器是有限的,尤其是在 32 位系統上。因此,RCU 對計數器的使用必須容忍計數器迴繞,或者設計成計數器迴繞所花費的時間比單個系統可能執行的時間長得多。十年的正常執行時間是完全可能的,一個世紀的執行時就不太可能了。作為後者的一個例子,RCU 的 dyntick-idle 巢狀計數器允許中斷巢狀級別使用 54 位(即使在 32 位系統上,此計數器也是 64 位的)。溢位此計數器需要在一個給定的 CPU 上進行 254 次半中斷,而該 CPU 從未進入空閒狀態。如果每微秒發生一次半中斷,則需要 570 年的執行時才能溢位此計數器,目前認為這是一個可接受的長的時間。

  6. Linux 系統可以在單個共享記憶體環境中執行數千個 CPU 執行單個 Linux 核心。因此,RCU 必須密切關注高階可伸縮性。

最後一個並行性的生活真相意味著 RCU 必須特別關注前面的生活真相。Linux 可能會擴充套件到具有數千個 CPU 的系統的想法在 1990 年代會受到一些懷疑,但這些要求在 1990 年代初就已經不足為奇了。

實現質量要求

這些章節列出了實現質量要求。雖然可以仍然使用忽略這些要求的 RCU 實現,但它可能會受到限制,使其不適合工業強度生產使用。實現質量要求的類別如下

  1. 專業化

  2. 效能和可伸縮性

  3. 前進性

  4. 可組合性

  5. 極端情況

以下章節將介紹這些類別。

專業化

RCU 始終主要用於讀取為主的情況,這意味著 RCU 的讀取端原語經過最佳化,通常以犧牲其更新端原語為代價。到目前為止的經驗已被以下情況列表捕獲

  1. 讀取為主的資料,其中陳舊和不一致的資料不是問題:RCU 工作得很好!

  2. 讀取為主的資料,其中資料必須一致:RCU 工作良好。

  3. 讀寫資料,其中資料必須一致:RCU 可能 可以正常工作。或者不能。

  4. 寫入為主的資料,其中資料必須一致:RCU 很可能不是適合這項工作的工具,但以下例外情況除外,RCU 可以提供

    1. 更新友好機制的存在保證。

    2. 即時使用的無等待讀端原語。

這種對讀取為主情況的關注意味著 RCU 必須與其他同步原語互操作。例如,前面討論的 add_gp() 和 remove_gp_synchronous() 示例使用 RCU 來保護讀取者並鎖定以協調更新者。但是,這種需求擴充套件到更遠,要求各種同步原語在 RCU 讀端臨界區內都是合法的,包括自旋鎖、序列鎖、原子操作、引用計數器和記憶體屏障。

小測驗:

那麼睡眠鎖呢?

答案:

在Linux核心RCU讀取側臨界區內,這些操作是被禁止的,因為在RCU讀取側臨界區內放置靜止狀態(在本例中為自願上下文切換)是不合法的。但是,睡眠鎖可以在使用者空間RCU讀取側臨界區內使用,也可以在Linux核心可睡眠RCU (SRCU) 讀取側臨界區內使用。此外,-rt補丁集將自旋鎖轉換為睡眠鎖,以便相應的臨界區可以被搶佔,這也意味著這些睡眠鎖化的自旋鎖(但不是其他睡眠鎖!)可以在-rt-Linux核心RCU讀取側臨界區內獲取。請注意,正常的RCU讀取側臨界區可以有條件地獲取睡眠鎖(如mutex_trylock()),但只要它不無限迴圈嘗試有條件地獲取該睡眠鎖即可。關鍵是像mutex_trylock()這樣的函式,要麼在持有互斥鎖的情況下返回,要麼在互斥鎖無法立即使用的情況下返回錯誤指示。無論哪種方式,mutex_trylock()都會立即返回,而不會睡眠。

通常令人驚訝的是,許多演算法不需要資料的一致檢視,但許多演算法可以在這種模式下執行,網路路由就是一個典型的例子。網際網路路由演算法需要相當長的時間來傳播更新,因此當更新到達給定的系統時,該系統已經以錯誤的方式傳送網路流量相當長的時間了。讓一些執行緒繼續以錯誤的方式傳送流量幾毫秒顯然不是問題:在最壞的情況下,TCP重傳最終會將資料傳送到需要去的地方。一般來說,當跟蹤計算機外部的宇宙狀態時,由於光速延遲等原因,必須容忍一定程度的不一致性。

此外,在許多情況下,外部狀態的不確定性是固有的。例如,一對獸醫可能會使用心跳來確定給定的貓是否還活著。但是,在最後一次心跳之後,他們應該等待多久才能確定這隻貓確實已經死了?等待時間少於400毫秒是沒有意義的,因為這意味著一隻放鬆的貓會被認為每分鐘在死亡和生命之間迴圈100多次。此外,就像人類一樣,貓的心臟可能會停止一段時間,因此確切的等待時間是一種判斷。我們這對獸醫中的一位可能會在宣佈貓死亡之前等待30秒,而另一位可能會堅持等待整整一分鐘。然後,兩位獸醫會在最後一次心跳後的一分鐘的最後30秒內對貓的狀態產生分歧。

有趣的是,這種情況也適用於硬體。關鍵時刻,我們如何判斷某個外部伺服器是否發生故障?我們會定期向其傳送訊息,如果在給定的時間內未收到響應,則宣告其發生故障。策略決策通常可以容忍短時間的不一致。該策略是前一段時間決定的,現在才開始生效,因此幾毫秒的延遲通常無關緊要。

但是,有些演算法絕對必須看到一致的資料。例如,使用者級別SystemV訊號量ID與相應的核心資料結構之間的轉換受到RCU的保護,但絕對禁止更新剛剛刪除的訊號量。在Linux核心中,這種對一致性的需求透過從RCU讀取側臨界區內獲取位於核心資料結構中的自旋鎖來滿足,這在上面的圖中用綠色框表示。可以使用許多其他技術,並且實際上在Linux核心中使用。

簡而言之,RCU不需要保持一致性,當需要一致性時,可以與其他機制結合使用。RCU的專業化使其能夠出色地完成其工作,並且它與其他同步機制互操作的能力允許為給定的工作使用正確的同步工具組合。

效能和可擴充套件性

能源效率是當今效能的關鍵組成部分,因此Linux核心RCU實現必須避免不必要地喚醒空閒CPU。我不能聲稱這種要求是有預謀的。事實上,我是在一次電話交談中得知這一點的,在電話中,我收到了關於電池供電系統中能源效率的重要性和Linux核心RCU實現的具體能源效率缺點的“坦誠和公開”的反饋。以我的經驗來看,電池供電的嵌入式社群會認為任何不必要的喚醒都是非常不友好的行為。以至於僅僅是Linux核心郵件列表帖子不足以發洩他們的憤怒。

在大多數情況下,記憶體消耗並不是特別重要,並且隨著記憶體容量的擴大和記憶體成本的下降,其重要性也在降低。但是,正如我從Matt Mackall的bloatwatch努力中瞭解到的一樣,在具有不可搶佔(CONFIG_PREEMPTION=n)核心的單CPU系統上,記憶體佔用至關重要,因此tiny RCU應運而生。Josh Triplett後來透過他的Linux核心精簡化專案接管了小記憶體的旗幟,該專案導致SRCU對於那些不需要它的核心成為可選的。

其餘的效能要求在很大程度上並不令人意外。例如,為了與RCU讀取側的專業化保持一致,rcu_dereference()應該具有可以忽略不計的開銷(例如,抑制一些次要的編譯器最佳化)。同樣,在不可搶佔的環境中,rcu_read_lock()rcu_read_unlock()應該完全沒有開銷。

在可搶佔的環境中,如果RCU讀取側臨界區沒有被搶佔(對於最高優先順序的即時程序來說,情況就是這樣),rcu_read_lock()rcu_read_unlock()應該具有最小的開銷。特別是,它們不應包含原子讀-修改-寫操作、記憶體屏障指令、搶佔停用、中斷停用或向後分支。但是,如果RCU讀取側臨界區被搶佔,rcu_read_unlock()可能會獲取自旋鎖並停用中斷。這就是為什麼最好將RCU讀取側臨界區巢狀在搶佔停用區域內,而不是反過來,至少在臨界區足夠短以避免不適當地降低即時延遲的情況下。

synchronize_rcu()寬限期等待原語針對吞吐量進行了最佳化。因此,除了最長的RCU讀取側臨界區的持續時間之外,它還可能會產生幾毫秒的延遲。另一方面,需要多次併發呼叫synchronize_rcu()來使用批處理最佳化,以便可以透過單個底層寬限期等待操作來滿足它們。例如,在Linux核心中,單個寬限期等待操作服務於1,000多個單獨的synchronize_rcu()呼叫是很常見的,從而將每次呼叫的開銷分攤到幾乎為零。但是,寬限期最佳化也需要避免可測量的即時排程和中斷延遲的降低。

在某些情況下,幾毫秒的synchronize_rcu()延遲是不可接受的。在這些情況下,可以改用synchronize_rcu_expedited(),從而將寬限期延遲降低到小型系統上的幾十微秒,至少在RCU讀取側臨界區很短的情況下。目前對大型系統上的synchronize_rcu_expedited()沒有特殊的延遲要求,但是,與RCU規範的經驗性質一致,這可能會發生變化。但是,肯定有可擴充套件性要求:在4096個CPU上爆發synchronize_rcu_expedited()呼叫至少應該取得合理的進展。作為其較短延遲的回報,允許synchronize_rcu_expedited()對非空閒線上CPU施加適度的即時延遲降低。在這裡,“適度”意味著與排程時鐘中斷大致相同的延遲降低。

在許多情況下,即使是synchronize_rcu_expedited()的減少的寬限期延遲也是不可接受的。在這些情況下,可以使用非同步call_rcu()代替synchronize_rcu(),如下所示

 1 struct foo {
 2   int a;
 3   int b;
 4   struct rcu_head rh;
 5 };
 6
 7 static void remove_gp_cb(struct rcu_head *rhp)
 8 {
 9   struct foo *p = container_of(rhp, struct foo, rh);
10
11   kfree(p);
12 }
13
14 bool remove_gp_asynchronous(void)
15 {
16   struct foo *p;
17
18   spin_lock(&gp_lock);
19   p = rcu_access_pointer(gp);
20   if (!p) {
21     spin_unlock(&gp_lock);
22     return false;
23   }
24   rcu_assign_pointer(gp, NULL);
25   call_rcu(&p->rh, remove_gp_cb);
26   spin_unlock(&gp_lock);
27   return true;
28 }

最後需要struct foo的定義,它出現在第1-5行。函式remove_gp_cb()在第25行傳遞給call_rcu(),並且將在隨後的寬限期結束後被呼叫。這與remove_gp_synchronous()具有相同的效果,但無需強制更新器等待寬限期過去。call_rcu()函式可以在許多情況下使用,在這些情況下,synchronize_rcu()synchronize_rcu_expedited()都是非法的,包括在搶佔停用程式碼、local_bh_disable()程式碼、中斷停用程式碼和中斷處理程式中。但是,即使是call_rcu()在NMI處理程式以及來自空閒和離線CPU的程式碼中也是非法的。回撥函式(在本例中為remove_gp_cb())將在Linux核心中的softirq(軟體中斷)環境中執行,無論是在真正的softirq處理程式中還是在local_bh_disable()的保護下。在Linux核心和使用者空間中,編寫執行時間過長的RCU回撥函式都是不好的做法。長時間執行的操作應該交給單獨的執行緒或(在Linux核心中)工作佇列。

小測驗:

為什麼第19行使用rcu_access_pointer()?畢竟,第25行的call_rcu()儲存到結構中,這會與併發插入產生不良影響。這是否意味著需要rcu_dereference()

答案:

大概第18行獲取的->gp_lock排除了任何更改,包括rcu_dereference()會防止的任何插入。因此,任何插入都將延遲到第25行釋放->gp_lock之後,這反過來意味著rcu_access_pointer()就足夠了。

但是,remove_gp_cb()所做的只是在資料元素上呼叫kfree()。這是一個常見的習慣用法,並由kfree_rcu()支援,它允許“發射後不管”操作,如下所示

 1 struct foo {
 2   int a;
 3   int b;
 4   struct rcu_head rh;
 5 };
 6
 7 bool remove_gp_faf(void)
 8 {
 9   struct foo *p;
10
11   spin_lock(&gp_lock);
12   p = rcu_dereference(gp);
13   if (!p) {
14     spin_unlock(&gp_lock);
15     return false;
16   }
17   rcu_assign_pointer(gp, NULL);
18   kfree_rcu(p, rh);
19   spin_unlock(&gp_lock);
20   return true;
21 }

請注意,remove_gp_faf()僅呼叫kfree_rcu()並繼續,而無需進一步關注隨後的寬限期和kfree()。允許從與call_rcu()相同的環境呼叫kfree_rcu()。有趣的是,DYNIX/ptx具有與call_rcu()kfree_rcu()等效的功能,但沒有synchronize_rcu()。這是因為RCU在DYNIX/ptx中沒有被大量使用,因此極少數需要類似synchronize_rcu()的功能的地方只是簡單地打開了它。

小測驗:

之前聲稱call_rcu()kfree_rcu()允許更新器避免被讀取器阻止。但是,考慮到回撥的呼叫和記憶體的釋放(分別)仍然必須等待寬限期過去,這怎麼可能是正確的?

答案:

我們可以這樣定義,但請記住,這種定義會說垃圾回收語言中的更新在下次垃圾回收器執行之前無法完成,這似乎完全不合理。關鍵是,在大多數情況下,使用call_rcu()kfree_rcu()的更新器一旦呼叫call_rcu()kfree_rcu(),就可以繼續進行下一個更新,而無需等待隨後的寬限期。

但是,如果更新器必須等待在寬限期結束後執行的程式碼完成,但同時可以執行其他任務呢?輪詢式get_state_synchronize_rcu()cond_synchronize_rcu()函式可以用於此目的,如下所示

 1 bool remove_gp_poll(void)
 2 {
 3   struct foo *p;
 4   unsigned long s;
 5
 6   spin_lock(&gp_lock);
 7   p = rcu_access_pointer(gp);
 8   if (!p) {
 9     spin_unlock(&gp_lock);
10     return false;
11   }
12   rcu_assign_pointer(gp, NULL);
13   spin_unlock(&gp_lock);
14   s = get_state_synchronize_rcu();
15   do_something_while_waiting();
16   cond_synchronize_rcu(s);
17   kfree(p);
18   return true;
19 }

在第14行,get_state_synchronize_rcu()從RCU獲取一個“cookie”,然後在第15行執行其他任務,最後,如果在該期間寬限期已經過去,則第16行立即返回,否則根據需要等待。對get_state_synchronize_rcucond_synchronize_rcu()的需求最近才出現,因此判斷它們是否經得起時間的考驗還為時過早。

因此,RCU提供了一系列工具,允許更新器在延遲、靈活性和CPU開銷之間進行所需的權衡。

向前進展

理論上,延遲寬限期完成和回撥呼叫是無害的。在實踐中,不僅記憶體大小有限,而且回撥有時會進行喚醒,並且足夠延遲的喚醒可能難以與系統掛起區分開來。因此,RCU必須提供許多機制來促進向前進展。

這些機制並非萬無一失,也無法做到。舉一個簡單的例子,RCU讀取側臨界區中的無限迴圈必須定義為阻止以後的寬限期完成。對於一個更復雜的例子,考慮一個使用CONFIG_RCU_NOCB_CPU=y構建並使用rcu_nocbs=1-63啟動的64CPU系統,其中CPU 1到63在呼叫call_rcu()的緊密迴圈中旋轉。即使這些緊密迴圈也包含對cond_resched()的呼叫(從而允許寬限期完成),CPU 0也根本無法像其他63個CPU註冊它們一樣快地呼叫回撥,至少在系統耗盡記憶體之前是這樣。在這兩個例子中,都適用蜘蛛俠原則:能力越大,責任越大。但是,除非達到這種濫用程度,否則RCU需要確保及時完成寬限期和及時呼叫回撥。

RCU採取以下步驟來鼓勵及時完成寬限期

  1. 如果寬限期在100毫秒內未能完成,RCU會導致未來在拒不合作的CPU上呼叫cond_resched()以提供RCU靜止狀態。RCU還會導致這些CPU的need_resched()呼叫返回true,但僅在相應CPU的下一個排程時鐘之後。

  2. nohz_full核心啟動引數中提到的CPU可以在核心中無限期地執行,而無需排程時鐘中斷,這會破壞上述need_resched()策略。因此,RCU將在109毫秒後仍在拒絕合作的任何nohz_full CPU上呼叫resched_cpu()。

  3. 在使用CONFIG_RCU_BOOST=y構建的核心中,如果給定的任務在RCU讀取側臨界區內被搶佔的時間超過500毫秒,RCU將求助於優先順序提升。

  4. 如果CPU在寬限期開始後的10秒鐘內仍然拒絕合作,則無論其nohz_full狀態如何,RCU都會在其上呼叫resched_cpu()。

上述值是在HZ=1000下執行的系統的預設值。它們會隨著HZ的值而變化,也可以使用相關的Kconfig選項和核心啟動引數進行更改。RCU目前沒有對這些引數進行太多的健全性檢查,因此在更改它們時請務必小心。請注意,這些向前進展措施僅適用於RCU,不適用於SRCUTasks RCU

當任何給定的非rcu_nocbs CPU具有10,000個回撥,或者比上次提供鼓勵時多10,000個回撥時,RCU會在call_rcu()中採取以下步驟來鼓勵及時呼叫回撥

  1. 啟動一個寬限期,如果一個寬限期尚未進行中。

  2. 強制立即檢查靜止狀態,而不是等待自寬限期開始以來經過三毫秒。

  3. 立即使用CPU的寬限期完成編號標記CPU的回撥,而不是等待RCU_SOFTIRQ處理程式來處理它。

  4. 取消回撥執行批處理限制,這會以降低即時響應為代價來加速回調呼叫。

同樣,這些是在HZ=1000下執行時的預設值,並且可以被覆蓋。同樣,這些向前進展措施僅適用於RCU,不適用於SRCUTasks RCU。即使對於RCU,rcu_nocbs CPU的回撥呼叫向前進展也沒有得到很好的發展,部分原因是受益於rcu_nocbs CPU的工作負載傾向於相對不頻繁地呼叫call_rcu()。如果出現既需要rcu_nocbs CPU又需要高call_rcu()呼叫速率的工作負載,則需要進行額外的向前進展工作。

可組合性

近年來,可組合性受到了廣泛關注,這可能部分是由於多核硬體與在單執行緒環境中為單執行緒使用而設計的面向物件技術的碰撞。理論上,RCU讀取側臨界區可以組合,並且實際上可以任意深度地巢狀。在實踐中,與所有可組合結構的實際實現一樣,存在限制。

對於rcu_read_lock()rcu_read_unlock()不生成任何程式碼的RCU實現(例如,當CONFIG_PREEMPTION=n時,Linux核心RCU)可以任意深度地巢狀。畢竟,沒有開銷。除非所有這些rcu_read_lock()rcu_read_unlock()的例項對編譯器可見,否則編譯最終會由於耗盡記憶體、海量儲存或使用者耐心而失敗,無論哪個先到。如果巢狀對編譯器不可見(就像每個都在自己的轉換單元中的相互遞迴函式一樣),則會導致堆疊溢位。如果巢狀採用迴圈的形式(可能偽裝成尾遞迴),則控制變數將溢位,或者(在Linux核心中)你將收到RCU CPU stall警告。儘管如此,此類RCU實現是現有可組合性最強的結構之一。

顯式跟蹤巢狀深度的 RCU 實現受巢狀深度計數器的限制。例如,Linux 核心的可搶佔 RCU 將巢狀限制為 INT_MAX。對於幾乎所有實際用途來說,這都應該足夠了。也就是說,如果兩個連續的 RCU 讀端臨界區之間存在一個等待寬限期的操作,則不能將它們包含在另一個 RCU 讀端臨界區中。這是因為在 RCU 讀端臨界區內等待寬限期是不合法的:這樣做要麼會導致死鎖,要麼會導致 RCU 隱式地拆分包含它的 RCU 讀端臨界區,這兩種情況都不利於核心的長期穩定和繁榮。

值得注意的是,RCU 並不是唯一限制可組合性的機制。例如,許多事務記憶體實現禁止組合由不可撤銷操作(例如,網路接收操作)分隔的一對事務。再例如,基於鎖的臨界區可以以驚人的自由度進行組合,但前提是避免死鎖。

簡而言之,儘管 RCU 讀端臨界區具有高度的可組合性,但在某些情況下需要小心,就像任何其他可組合的同步機制一樣。

極端情況

給定的 RCU 工作負載可能具有無休止且密集的 RCU 讀端臨界區流,甚至可能如此密集,以至於任何時刻都至少有一個 RCU 讀端臨界區在執行。RCU 不能允許這種情況阻止寬限期:只要所有 RCU 讀端臨界區都是有限的,寬限期也必須是有限的。

也就是說,可搶佔的 RCU 實現可能會導致 RCU 讀端臨界區被搶佔很長時間,從而產生一個長時間的 RCU 讀端臨界區。這種情況僅在負載很重的系統中才會出現,但使用即時優先順序的系統當然更容易受到影響。因此,提供了 RCU 優先順序提升來幫助處理這種情況。也就是說,隨著經驗的積累,對 RCU 優先順序提升的確切要求可能會發生變化。

其他工作負載可能具有非常高的更新率。儘管可以認為此類工作負載應該使用 RCU 以外的其他機制,但事實仍然是 RCU 必須優雅地處理此類工作負載。這個要求是推動寬限期批處理的另一個因素,但它也是 call_rcu() 程式碼路徑中檢查大量排隊的 RCU 回撥函式的原因。最後,高更新率不應延遲 RCU 讀端臨界區,儘管在使用 synchronize_rcu_expedited() 時可能會出現一些小的讀端延遲,這要歸功於此函式對 smp_call_function_single() 的使用。

儘管所有這三個極端情況在 1990 年代初就已經被理解,但在 2000 年代初,一個簡單的使用者級測試,包括在一個緊密迴圈中使用 close(open(path)),突然提供了對高更新率極端情況的更深刻理解。該測試還促使添加了一些 RCU 程式碼來應對高更新率,例如,如果給定的 CPU 發現自己排隊了超過 10,000 個 RCU 回撥函式,它將導致 RCU 採取規避措施,更積極地啟動寬限期,並更積極地強制完成寬限期處理。這種規避措施會導致寬限期更快地完成,但代價是限制了 RCU 的批處理最佳化,從而增加了該寬限期產生的 CPU 開銷。

軟體工程要求

考慮到墨菲定律和“人非聖賢,孰能無過”,有必要防範事故和誤用。

  1. 很容易忘記在需要使用 rcu_read_lock() 的地方都使用它,因此使用 CONFIG_PROVE_RCU=y 構建的核心如果 rcu_dereference() 在 RCU 讀端臨界區之外使用,則會崩潰。更新端程式碼可以使用 rcu_dereference_protected(),它接受一個 lockdep 表示式 來指示什麼提供了保護。如果未提供指示的保護,則會發出 lockdep splat。在讀者和更新者之間共享的程式碼可以使用 rcu_dereference_check(),它也接受一個 lockdep 表示式,並且如果 rcu_read_lock() 和指示的保護都不存在,則會發出 lockdep splat。此外,rcu_dereference_raw() 用於那些(希望罕見的)無法輕鬆描述所需保護的情況。最後,提供了 rcu_read_lock_held(),以允許函式驗證它是否已在 RCU 讀端臨界區內被呼叫。在 Thomas Gleixner 稽核了一些 RCU 用法後不久,我就意識到了這一組要求。

  2. 給定的函式可能希望在進入時檢查與 RCU 相關的先決條件,然後再使用任何其他 RCU API。rcu_lockdep_assert() 完成此工作,在啟用了 lockdep 的核心中斷言表示式,否則不執行任何操作。

  3. 也很容易忘記使用 rcu_assign_pointer()rcu_dereference(),可能(錯誤地)用簡單的賦值替換。為了捕獲這種錯誤,可以使用 __rcu 標記給定的 RCU 保護的指標,之後,sparse 將抱怨對該指標的簡單賦值訪問。Arnd Bergmann 讓我意識到了此要求,並提供了所需的 補丁系列

  4. 使用 CONFIG_DEBUG_OBJECTS_RCU_HEAD=y 構建的核心如果一個數據元素連續兩次傳遞給 call_rcu(),並且兩次呼叫之間沒有寬限期,則會崩潰。(此錯誤類似於雙重釋放。)動態分配的相應 rcu_head 結構會自動跟蹤,但是分配在堆疊上的 rcu_head 結構必須使用 init_rcu_head_on_stack() 初始化,並使用 destroy_rcu_head_on_stack() 清理。同樣,靜態分配的非堆疊 rcu_head 結構必須使用 init_rcu_head() 初始化,並使用 destroy_rcu_head() 清理。Mathieu Desnoyers 讓我意識到了此要求,並提供了所需的 補丁

  5. RCU 讀端臨界區中的無限迴圈最終將觸發 RCU CPU stall 警告 splat,其中“最終”的持續時間由 RCU_CPU_STALL_TIMEOUT Kconfig 選項控制,或者由 rcupdate.rcu_cpu_stall_timeout 啟動/sysfs 引數控制。但是,除非有寬限期等待該特定的 RCU 讀端臨界區,否則 RCU 沒有義務生成此 splat。

    某些極端工作負載可能會有意延遲 RCU 寬限期,並且執行這些工作負載的系統可以使用 rcupdate.rcu_cpu_stall_suppress 啟動,以抑制 splat。此核心引數也可以透過 sysfs 設定。此外,RCU CPU stall 警告在 sysrq 轉儲和 panic 期間會適得其反。因此,RCU 提供了 rcu_sysrq_start() 和 rcu_sysrq_end() API 成員,以便在長時間的 sysrq 轉儲之前和之後呼叫。RCU 還提供了 rcu_panic() 通知程式,該通知程式會在 panic 開始時自動呼叫,以抑制進一步的 RCU CPU stall 警告。

    早在 1990 年代初,當需要除錯 CPU stall 時,就知道了此要求。也就是說,與 Linux 相比,DYNIX/ptx 中的初始實現非常通用。

  6. 儘管檢測指標從 RCU 讀端臨界區洩漏出來非常好,但目前沒有好的方法可以做到這一點。一個複雜因素是需要區分指標洩漏和已從 RCU 移交給其他同步機制的指標,例如,引用計數。

  7. 在使用 CONFIG_RCU_TRACE=y 構建的核心中,透過事件跟蹤提供與 RCU 相關的資訊。

  8. 使用 rcu_assign_pointer()rcu_dereference() 來建立典型的連結資料結構可能會非常容易出錯。因此,RCU 保護的 連結串列 和最近 RCU 保護的 雜湊表 可用。Linux 核心和使用者空間 RCU 庫中提供了許多其他專用 RCU 保護的資料結構。

  9. 某些連結結構是在編譯時建立的,但仍然需要 __rcu 檢查。RCU_POINTER_INITIALIZER() 宏用於此目的。

  10. 建立要透過單個外部指標釋出的連結結構時,無需使用 rcu_assign_pointer()。為此任務提供了 RCU_INIT_POINTER() 宏。

這不是一個硬性列表:RCU 的診斷能力將繼續受到在實際 RCU 使用中發現的用法錯誤的數量和型別的指導。

Linux 核心的複雜性

Linux 核心為各種軟體(包括 RCU)提供了一個有趣的環境。以下是一些相關的興趣點

  1. 配置

  2. 韌體介面

  3. 早期啟動

  4. 中斷和 NMI

  5. 可載入模組

  6. 熱插拔 CPU

  7. 排程程式和 RCU

  8. 跟蹤和 RCU

  9. 對使用者記憶體和 RCU 的訪問

  10. 能源效率

  11. 排程時鐘中斷和 RCU

  12. 記憶體效率

  13. 效能、可伸縮性、響應時間和可靠性

此列表可能不完整,但它確實給出了最值得注意的 Linux 核心複雜性的感覺。以下每個部分都涵蓋了上述主題之一。

配置

RCU 的目標是自動配置,因此幾乎沒有人需要擔心 RCU 的 Kconfig 選項。對於幾乎所有使用者來說,RCU 實際上都可以很好地“開箱即用”。

但是,有些專門的用例由核心啟動引數和 Kconfig 選項處理。不幸的是,Kconfig 系統會明確地詢問使用者新的 Kconfig 選項,這要求幾乎所有這些選項都隱藏在 CONFIG_RCU_EXPERT Kconfig 選項後面。

這一切應該非常明顯,但事實仍然是 Linus Torvalds 最近不得不 提醒 我這個要求。

韌體介面

在許多情況下,核心從韌體獲取有關係統的資訊,有時資訊會在翻譯過程中丟失。或者翻譯是準確的,但原始訊息是偽造的。

例如,某些系統的韌體會過度報告 CPU 的數量,有時會過度報告很大。如果 RCU 天真地相信韌體(就像以前那樣),它將建立太多的每個 CPU 的 kthread。儘管生成的系統仍然可以正確執行,但額外的 kthread 會不必要地消耗記憶體,並且當它們出現在 ps 列表中時可能會引起混亂。

因此,RCU 必須等待給定的 CPU 實際上線,然後才能允許自己相信該 CPU 確實存在。由此產生的“幽靈 CPU”(永遠不會上線)會導致許多 有趣的複雜情況

早期啟動

Linux 核心的啟動序列是一個有趣的過程,RCU 甚至在呼叫 rcu_init() 之前就被早期使用。事實上,只要初始任務的 task_struct 可用並且啟動 CPU 的每個 CPU 變數都已設定,就可以使用許多 RCU 的原語。讀端原語(rcu_read_lock()rcu_read_unlock()rcu_dereference()rcu_access_pointer())將在早期正常執行,rcu_assign_pointer() 也是如此。

儘管可以在啟動期間的任何時間呼叫 call_rcu(),但在生成所有 RCU 的 kthread 之後,才能保證呼叫回撥函式,這發生在 early_initcall() 時。回撥函式呼叫的延遲是由於 RCU 在完全初始化後才呼叫回撥函式,並且在排程程式將其自身初始化到 RCU 可以生成和執行其 kthread 的程度之前,無法進行此完全初始化。從理論上講,可以更早地呼叫回撥函式,但是,這不是萬靈藥,因為對這些回撥函式可以呼叫的操作將有嚴格的限制。

也許令人驚訝的是,synchronize_rcu()synchronize_rcu_expedited() 將在非常早期的啟動期間正常執行,原因是隻有一個 CPU 並且停用了搶佔。這意味著呼叫 synchronize_rcu()(或其友元)本身是一個靜止狀態,因此是一個寬限期,因此早期啟動實現可以是一個空操作。

但是,一旦排程程式生成了它的第一個 kthread,此早期啟動技巧對於 CONFIG_PREEMPTION=y 核心中的 synchronize_rcu()(以及 synchronize_rcu_expedited()) 失敗。原因是 RCU 讀端臨界區可能會被搶佔,這意味著隨後的 synchronize_rcu() 確實必須等待某些東西,而不是簡單地立即返回。不幸的是,在生成所有 kthread 之前,synchronize_rcu() 無法做到這一點,直到 early_initcalls() 時才發生。但這並不是藉口:儘管如此,RCU 仍然需要在此時段內正確處理同步寬限期。一旦所有 kthread 都啟動並執行,RCU 就會開始正常執行。

小測驗:

在生成 RCU 的所有 kthread 之前,RCU 如何可能處理寬限期???

答案:

非常小心!在排程程式生成第一個任務到生成 RCU 的所有 kthread 之間的“死區”期間,所有同步寬限期都由加速的寬限期機制處理。在執行時,此加速機制依賴於工作佇列,但在死區期間,請求任務本身會驅動所需的加速寬限期。由於死區執行發生在任務上下文中,因此一切正常。一旦死區結束,加速寬限期將恢復為使用工作佇列,這是避免使用者任務在驅動加速寬限期時收到 POSIX 訊號時可能發生的問題所必需的。

是的,這確實意味著在排程程式生成其第一個 kthread 到 RCU 的所有 kthread 都已生成之間的這段時間內,向隨機任務傳送 POSIX 訊號是沒有幫助的。如果將來發現有充分的理由在此期間傳送 POSIX 訊號,則將進行適當的調整。(如果發現在此期間傳送 POSIX 訊號沒有充分的理由,則將進行其他調整,適當與否。)

我從一系列系統掛起中瞭解了這些啟動時間要求。

中斷和 NMI

Linux 核心具有中斷,並且 RCU 讀端臨界區在中斷處理程式和程式碼的停用中斷區域內是合法的,呼叫 call_rcu() 也是如此。

某些 Linux 核心體系結構可以從非空閒程序上下文中進入中斷處理程式,然後永遠不會離開它,而是偷偷地轉換回程序上下文。有時使用此技巧從核心內部呼叫系統呼叫。這些“半中斷”意味著 RCU 必須非常小心地計算中斷巢狀級別。在重寫 RCU 的 dyntick-idle 程式碼期間,我以慘痛的方式瞭解了此要求。

Linux 核心具有不可遮蔽中斷 (NMI),並且 RCU 讀端臨界區在 NMI 處理程式內是合法的。值得慶幸的是,禁止在 NMI 處理程式內使用 RCU 更新端原語,包括 call_rcu()

儘管名稱如此,但某些 Linux 核心體系結構可以具有巢狀的 NMI,RCU 必須正確處理。Andy Lutomirski 讓我感到驚訝,他提出了此要求;他還好心地 驚訝地向我提出了一種演算法,該演算法滿足此要求。

此外,NMI 處理程式可能會被 RCU 認為是正常中斷的中斷。發生這種情況的一種方法是從 NMI 處理程式中呼叫直接呼叫 ct_irq_enter() 和 ct_irq_exit() 的程式碼。這個令人震驚的事實促使了當前的程式碼結構,該結構具有 ct_irq_enter() 呼叫 ct_nmi_enter() 和 ct_irq_exit() 呼叫 ct_nmi_exit()。是的,我也以慘痛的方式瞭解了此要求。

可載入模組

Linux 核心具有可載入模組,並且這些模組也可以解除安裝。解除安裝給定的模組後,任何嘗試呼叫其函式的嘗試都會導致段錯誤。因此,模組解除安裝函式必須取消對可載入模組函式的任何延遲呼叫,例如,必須透過 timer_shutdown_sync() 或類似方法處理任何未完成的 mod_timer()

不幸的是,沒有辦法取消 RCU 回撥函式;一旦你呼叫 call_rcu(),最終將呼叫回撥函式,除非系統首先崩潰。因為通常認為響應模組解除安裝請求而使系統崩潰是不負責任的,所以我們需要一些其他方法來處理正在進行的 RCU 回撥函式。

因此,RCU 提供了 rcu_barrier(),它會一直等待,直到所有正在進行的 RCU 回撥函式都被呼叫。如果一個模組使用 call_rcu(),它的退出函式應該阻止將來呼叫 call_rcu(),然後呼叫 rcu_barrier()。從理論上講,底層模組解除安裝程式碼可以無條件地呼叫 rcu_barrier(),但在實踐中,這會產生不可接受的延遲。

Nikita Danilov 注意到了一個類似的檔案系統解除安裝情況的此要求,Dipankar Sarma 將 rcu_barrier() 合併到 RCU 中。後來才發現模組解除安裝需要 rcu_barrier()

重要

rcu_barrier() 函式沒有義務等待寬限期,重複一遍,沒有 義務。相反,它只需要等待已經發布的 RCU 回撥函式。因此,如果系統中沒有釋出任何 RCU 回撥函式,rcu_barrier() 有權立即返回。即使釋出了回撥函式,rcu_barrier() 也不一定需要等待寬限期。

小測驗:

等一下!每個 RCU 回撥函式必須等待一個寬限期才能完成,並且 rcu_barrier() 必須等待每個預先存在的回撥函式被呼叫。因此,如果系統中的任何地方存在一個回撥函式,rcu_barrier() 不是需要等待一個完整的寬限期嗎?

答案:

絕對不是!!!是的,每個 RCU 回撥函式必須等待一個寬限期才能完成,但在呼叫 rcu_barrier() 時,它可能已經部分(甚至完全)完成了等待。在這種情況下,rcu_barrier() 只需要等待寬限期的剩餘部分流逝。因此,即使釋出了相當多的回撥函式,rcu_barrier() 也可能會很快返回。

因此,如果您需要等待寬限期以及所有預先存在的回撥函式,您將需要同時呼叫 synchronize_rcu()rcu_barrier()。如果延遲是一個問題,您可以始終使用工作佇列來併發地呼叫它們。

熱插拔 CPU

Linux 核心支援 CPU 熱插拔,這意味著 CPU 可以來來去去。從離線 CPU 使用任何 RCU API 成員當然是非法的,除了 SRCU 讀端臨界區。這個要求從 DYNIX/ptx 的第一天就存在了,但另一方面,Linux 核心的 CPU 熱插拔實現是“有趣的”。

Linux 核心的 CPU 熱插拔實現具有通知器,用於允許各種核心子系統(包括 RCU)對給定的 CPU 熱插拔操作做出適當的響應。大多數 RCU 操作可以從 CPU 熱插拔通知器呼叫,甚至包括同步寬限期操作,例如 (synchronize_rcu()synchronize_rcu_expedited())。但是,這些同步操作會阻塞,因此不能從透過 stop_machine() 執行的通知器中呼叫,特別是那些在 CPUHP_AP_OFFLINECPUHP_AP_ONLINE 狀態之間的通知器。

此外,所有回撥等待操作(例如 rcu_barrier())不得從任何 CPU 熱插拔通知器中呼叫。此限制是由於 CPU 熱插拔操作的某些階段,傳出 CPU 的回撥函式直到 CPU 熱插拔操作結束後才會被呼叫,這也可能導致死鎖。此外,rcu_barrier() 在執行期間會阻止 CPU 熱插拔操作,這會導致從 CPU 熱插拔通知器呼叫時出現另一種型別的死鎖。

最後,RCU 必須避免由於熱插拔、定時器和寬限期處理之間的互動而導致的死鎖。它透過維護自己的書籍來複制集中維護的 cpu_online_mask,並且還在 CPU 離線時顯式報告靜止狀態。這種顯式報告靜止狀態避免了強制靜止狀態迴圈 (FQS) 報告離線 CPU 的靜止狀態。但是,作為一種除錯措施,如果離線 CPU 阻塞 RCU 寬限期過長,FQS 迴圈會崩潰。

離線 CPU 的靜止狀態將被報告為

  1. 當 CPU 使用 RCU 的熱插拔通知器 (rcutree_report_cpu_dead()) 離線時。

  2. 當寬限期初始化 (rcu_gp_init()) 檢測到與 CPU 離線或與任務在葉子 rcu_node 結構上解除阻塞的競爭時,其中葉子 rcu_node 結構的所有 CPU 均已離線。

CPU 線上路徑 (rcutree_report_cpu_starting()) 永遠不需要報告離線 CPU 的靜止狀態。但是,作為一種除錯措施,如果尚未為該 CPU 報告靜止狀態,它會發出警告。

在檢查/修改 RCU 的熱插拔簿記期間,將持有相應 CPU 的葉子節點鎖。這避免了 RCU 的熱插拔通知器掛鉤、寬限期初始化程式碼和 FQS 迴圈之間的競爭條件,所有這些都引用或修改此簿記。

排程器和 RCU

RCU 使用 kthreads,並且有必要避免這些 kthreads 過度累積 CPU 時間。這個要求並不令人意外,但當使用 CONFIG_NO_HZ_FULL=y 構建且執行上下文切換繁重的工作負載時,RCU 違反了該要求,這 確實令人驚訝 [PDF]。 RCU 在滿足此要求方面取得了良好進展,即使對於上下文切換繁重的 CONFIG_NO_HZ_FULL=y 工作負載也是如此,但仍有進一步改進的空間。

rcu_read_unlock() 上持有任何排程器的執行佇列或優先順序繼承自旋鎖不再有任何禁止,即使在相應 RCU 讀端臨界區的某個地方啟用了中斷和搶佔。因此,現在可以完全合法地在啟用搶佔的情況下執行 rcu_read_lock(),獲取其中一個排程器鎖,並在匹配的 rcu_read_unlock() 上持有該鎖。

類似地,RCU 風味整合消除了對負巢狀的需求。程式碼的停用中斷區域隱式地充當 RCU 讀端臨界區,從而避免了早期透過中斷處理程式使用 RCU 導致破壞性遞迴的問題。

跟蹤和 RCU

可以在 RCU 程式碼上使用跟蹤,但跟蹤本身使用 RCU。因此,提供了 rcu_dereference_raw_check() 以供跟蹤使用,這避免了可能隨之而來的破壞性遞迴。一些架構中的虛擬化也使用此 API,其中 RCU 讀取器在無法使用跟蹤的環境中執行。跟蹤人員找到了需求並提供了所需的修復,因此這個令人驚訝的需求相對來說是毫不費力的。

訪問使用者記憶體和 RCU

核心需要訪問使用者空間記憶體,例如,訪問系統呼叫引數引用的資料。get_user() 宏執行此作業。

但是,使用者空間記憶體很可能已分頁到磁碟,這意味著 get_user() 很可能會發生頁面錯誤,因此在等待生成的 I/O 完成時會阻塞。編譯器將 get_user() 呼叫重新排序到 RCU 讀端臨界區中將是一件非常糟糕的事情。

例如,假設原始碼如下所示

1 rcu_read_lock();
2 p = rcu_dereference(gp);
3 v = p->value;
4 rcu_read_unlock();
5 get_user(user_v, user_p);
6 do_something_with(v, user_v);

不允許編譯器將此原始碼轉換為以下內容

1 rcu_read_lock();
2 p = rcu_dereference(gp);
3 get_user(user_v, user_p); // BUG: POSSIBLE PAGE FAULT!!!
4 v = p->value;
5 rcu_read_unlock();
6 do_something_with(v, user_v);

如果編譯器在 CONFIG_PREEMPTION=n 核心版本中進行了此轉換,並且如果 get_user() 確實發生了頁面錯誤,則結果將是在 RCU 讀端臨界區中間的靜止狀態。這個錯位的靜止狀態可能導致第 4 行是使用後釋放訪問,這對您核心的精算統計資料可能是不利的。可以使用對 get_user() 的呼叫在 rcu_read_lock() 之前來構建類似的示例。

不幸的是,get_user() 沒有任何特定的排序屬性,並且在某些架構中,底層 asm 甚至沒有標記為 volatile。即使它被標記為 volatile,上面對 p->value 的訪問也不是 volatile,因此編譯器沒有任何理由保持這兩個訪問的順序。

因此,rcu_read_lock()rcu_read_unlock() 的 Linux 核心定義必須充當編譯器屏障,至少對於巢狀 RCU 讀端臨界區集合中的最外層 rcu_read_lock()rcu_read_unlock() 例項。

能源效率

中斷空閒 CPU 被認為是社會上不可接受的,尤其是對於具有電池供電嵌入式系統的人們。因此,RCU 透過檢測哪些 CPU 處於空閒狀態來節省能源,包括跟蹤已從空閒狀態中斷的 CPU。這是能源效率要求的大部分,所以我透過憤怒的電話瞭解到了這一點。

因為 RCU 避免中斷空閒 CPU,所以在空閒 CPU 上執行 RCU 讀端臨界區是非法的。(使用 CONFIG_PROVE_RCU=y 構建的核心會在您嘗試時崩潰。)

類似地,中斷在使用者空間中執行的 nohz_full CPU 在社會上也是不可接受的。因此,RCU 必須跟蹤 nohz_full 使用者空間執行。因此,RCU 必須能夠對兩個時間點上的狀態進行取樣,並且能夠確定是否有任何其他 CPU 花費任何時間處於空閒狀態和/或在使用者空間中執行。

這些能源效率要求已被證明非常難以理解和滿足,例如,RCU 的能源效率程式碼已經有超過五次的全新重寫,其中最後一次終於能夠證明 在真實硬體上執行的真實節能 [PDF]。如前所述,我透過憤怒的電話瞭解到了許多這些要求:在 Linux 核心郵件列表上火焰攻擊我顯然不足以充分發洩他們對 RCU 能源效率錯誤的憤怒!

排程時鐘中斷和 RCU

核心在核心非空閒執行、使用者空間執行和空閒迴圈之間轉換。根據核心配置,RCU 以不同的方式處理這些狀態

HZ Kconfig

核心中

使用者模式

空閒

HZ_PERIODIC

可以依賴排程時鐘中斷。

可以依賴排程時鐘中斷及其對使用者模式中斷的檢測。

可以依賴 RCU 的 dyntick-idle 檢測。

NO_HZ_IDLE

可以依賴排程時鐘中斷。

可以依賴排程時鐘中斷及其對使用者模式中斷的檢測。

可以依賴 RCU 的 dyntick-idle 檢測。

NO_HZ_FULL

只能有時依賴排程時鐘中斷。在其他情況下,有必要限制核心執行時間和/或使用 IPI。

可以依賴 RCU 的 dyntick-idle 檢測。

可以依賴 RCU 的 dyntick-idle 檢測。

小測驗:

為什麼 NO_HZ_FULL 核心中執行不能像 HZ_PERIODICNO_HZ_IDLE 那樣依賴排程時鐘中斷?

答案:

因為,作為一種效能最佳化,NO_HZ_FULL 不一定會在每次進入系統呼叫時重新啟用排程時鐘中斷。

但是,必須可靠地告知 RCU 任何給定的 CPU 當前是否處於空閒迴圈中,並且對於 NO_HZ_FULL,還要告知該 CPU 是否在使用者模式下執行,如 前面 所討論的。它還需要在 RCU 需要時啟用排程時鐘中斷

  1. 如果 CPU 處於空閒狀態或在使用者模式下執行,並且 RCU 認為它處於非空閒狀態,則排程時鐘節拍最好正在執行。否則,您將收到 RCU CPU 停頓警告。或者充其量,非常長(11 秒)的寬限期,並且不時地發出無意義的 IPI 以喚醒 CPU。

  2. 如果 CPU 處於核心中執行 RCU 讀端臨界區的部分,並且 RCU 認為此 CPU 處於空閒狀態,您將得到隨機的記憶體損壞。不要這樣做!!! 這是使用 lockdep 進行測試的原因之一,它會抱怨這類事情。

  3. 如果 CPU 處於核心的某個部分,該部分絕對保證絕不會執行任何 RCU 讀端臨界區,並且 RCU 認為此 CPU 處於空閒狀態,則沒有問題。某些架構將此類事情用於輕量級異常處理程式,然後可以避免在異常進入和退出時分別使用 ct_irq_enter() 和 ct_irq_exit() 的開銷。有些人更進一步,避免使用整個 irq_enter() 和 irq_exit()。只需確保您使用 CONFIG_PROVE_RCU=y 執行一些測試,以防萬一您的某個程式碼路徑實際上是在開玩笑說不執行 RCU 讀端臨界區。

  4. 如果 CPU 在核心中使用排程時鐘中斷被停用的情況下執行,並且 RCU 認為此 CPU 處於非空閒狀態,並且如果 CPU 每隔幾個節拍(從 RCU 的角度來看)就變為空閒狀態,則沒有問題。通常可以接受空閒週期之間偶爾出現長達一秒左右的間隔。如果間隔變得太長,您將收到 RCU CPU 停頓警告。

  5. 如果 CPU 處於空閒狀態或在使用者模式下執行,並且 RCU 認為它處於空閒狀態,則當然沒有問題。

  6. 如果 CPU 在核心中執行,核心程式碼路徑正在以合理的頻率透過靜止狀態(最好大約每幾個節拍一次,但偶爾超出到一秒左右通常是可以的),並且排程時鐘中斷已啟用,則當然沒有問題。如果連續一對靜止狀態之間的間隔變得太長,您將收到 RCU CPU 停頓警告。

小測驗:

但是,如果我的驅動程式有一個硬體中斷處理程式,可以執行數秒?畢竟,我無法從硬體中斷處理程式中呼叫 schedule()!

答案:

一種方法是每隔一段時間執行 ct_irq_exit();ct_irq_enter();。但是,鑑於長時間執行的中斷處理程式可能會導致其他問題,尤其是對於響應時間,您不應該努力將中斷處理程式的執行時保持在合理的範圍內嗎?

但是,只要正確告知 RCU 核心狀態在核心中執行、使用者模式執行和空閒之間的轉換,並且只要在 RCU 需要時啟用排程時鐘中斷,您就可以放心,您遇到的錯誤將在 RCU 的其他部分或核心的其他部分中!

記憶體效率

雖然小記憶體非即時系統可以簡單地使用 Tiny RCU,但程式碼大小隻是記憶體效率的一個方面。另一方面是 call_rcu()kfree_rcu() 使用的 rcu_head 結構的大小。雖然此結構僅包含一對指標,但它確實出現在許多受 RCU 保護的資料結構中,包括一些對大小至關重要的結構。page 結構就是一個例子,正如該結構中多次出現 union 關鍵字所證明的那樣。

這種對記憶體效率的需求是 RCU 使用手工製作的單鏈表來跟蹤等待寬限期流逝的 rcu_head 結構的原因之一。這也是 rcu_head 結構不包含除錯資訊的原因,例如跟蹤 call_rcu()kfree_rcu()(釋出它們)的檔案和行的欄位。雖然此資訊可能會在除錯專用核心版本中的某個時間點出現,但與此同時,->func 欄位通常會提供所需的除錯資訊。

但是,在某些情況下,對記憶體效率的需求會導致更極端的措施。回到 page 結構,rcu_head 欄位與在相應頁面的生命週期的各個點使用的許多其他結構共享儲存空間。為了正確解決某些 競爭條件,Linux 核心的記憶體管理子系統需要一個特定的位在寬限期處理的所有階段都保持為零,並且該位恰好對映到 rcu_head 結構的 ->next 欄位的底部位。只要使用 call_rcu() 來發布回撥(而不是 kfree_rcu() 或未來可能有一天為提高能源效率而建立的 call_rcu() 的某些“延遲”變體),RCU 就可以保證這一點。

也就是說,存在限制。RCU 要求 rcu_head 結構與兩位元組邊界對齊,並且將未對齊的 rcu_head 結構傳遞給 call_rcu() 系列函式中的一個將導致崩潰。因此,在打包包含 rcu_head 型別欄位的結構時,必須謹慎。為什麼不是四位元組甚至八位元組的對齊要求?因為 m68k 架構僅提供兩位元組對齊,因此充當對齊的最小公分母。

保留指向 rcu_head 結構的指標的底部位的原因是為可以安全延遲呼叫的“延遲”回撥開啟大門。延遲呼叫可能會帶來能源效率方面的好處,但前提是對於某些重要的工作負載,非延遲迴調的速率顯著降低。與此同時,保留底部位可以使此選項保持開啟狀態,以防有一天它變得有用。

效能、可伸縮性、響應時間和可靠性

擴充套件 先前討論,RCU 被 Linux 核心的網路、安全、虛擬化和排程程式碼路徑中對效能至關重要的部分中的熱程式碼路徑大量使用。因此,RCU 必須使用高效的實現,尤其是在其讀端原語中。為此,如果可搶佔 RCU 的 rcu_read_lock() 實現可以內聯,那就太好了,但是,這樣做需要解決與 task_struct 結構相關的 #include 問題。

Linux 核心支援具有多達 4096 個 CPU 的硬體配置,這意味著 RCU 必須具有極強的可伸縮性。頻繁獲取全域性鎖或頻繁對全域性變數執行原子操作的演算法在 RCU 實現中是完全無法容忍的。因此,RCU 大量使用基於 rcu_node 結構的組合樹。RCU 需要容忍所有 CPU 持續呼叫 RCU 的執行時原語的任何組合,並且每個操作的開銷最小。事實上,在許多情況下,增加負載必須降低每個操作的開銷,參見 synchronize_rcu()call_rcu()synchronize_rcu_expedited()rcu_barrier() 的批處理最佳化。通常,RCU 必須愉快地接受 Linux 核心的其餘部分決定拋給它的任何東西。

Linux 核心用於即時工作負載,特別是與 -rt 補丁集 結合使用。即時延遲響應要求是,跨 RCU 讀端臨界區停用搶佔的傳統方法是不合適的。因此,使用 CONFIG_PREEMPTION=y 構建的核心使用一種 RCU 實現,該實現允許搶佔 RCU 讀端臨界區。在使使用者明確表示早期的 即時補丁 無法滿足他們的需求之後,出現了這一要求,並結合了 -rt 補丁集的早期版本遇到的一些 RCU 問題

此外,RCU 必須在低於 100 微秒的即時延遲預算內完成。事實上,在具有 -rt 補丁集的小型系統上,Linux 核心為包括 RCU 在內的整個核心提供低於 20 微秒的即時延遲。因此,RCU 的可伸縮性和延遲必須足以滿足這些型別的配置。令我驚訝的是,低於 100 微秒的即時延遲預算 甚至適用於最大的系統 [PDF],包括具有 4096 個 CPU 的系統。這種即時需求促使了寬限期 kthread 的出現,這也簡化了許多競爭條件的處理。

RCU 必須避免降低 CPU 密集型執行緒的即時響應,無論是在使用者模式下執行(這是 CONFIG_NO_HZ_FULL=y 的一種用例)還是在核心中。也就是說,核心中的 CPU 密集型迴圈必須至少每幾十毫秒執行一次 cond_resched(),以避免收到來自 RCU 的 IPI。

最後,RCU 作為同步原語的狀態意味著任何 RCU 故障都可能導致任意記憶體損壞,這可能非常難以除錯。這意味著 RCU 必須非常可靠,這在實踐中也意味著 RCU 必須具有積極的壓力測試套件。此壓力測試套件稱為 rcutorture

雖然對 rcutorture 的需求並不令人意外,但當前 Linux 核心的巨大受歡迎程度正在帶來有趣且可能前所未有的驗證挑戰。要了解這一點,請記住,考慮到 Android 智慧手機、Linux 驅動的電視和伺服器,如今執行的 Linux 核心例項已超過 10 億個。隨著著名的物聯網的出現,預計這個數字會急劇增加。

假設 RCU 中存在一個競爭條件,平均每百萬年執行時才會出現一次。那麼,在已安裝的裝置中,這個 bug 大約每天會發生三次。RCU 可以簡單地隱藏在硬體錯誤率之下,因為沒有人真的期望他們的智慧手機能使用一百萬年。然而,任何對此想法感到過於安慰的人都應該考慮到,在大多數司法管轄區,對給定機制(可能包括 Linux 核心)進行成功的多年測試,足以獲得多種型別的安全關鍵認證。事實上,有傳言說 Linux 核心已經在生產中用於安全關鍵型應用。我不知道你怎麼想,但如果 RCU 中的一個 bug 導致有人死亡,我會感到非常難過。這可能解釋了我最近對驗證和確認的關注。

其他 RCU 變體

關於 RCU,更令人驚訝的事情之一是,現在至少有五種 變體 或 API 系列。此外,到目前為止,唯一關注的主要變體有兩種不同的實現方式:不可搶佔式和可搶佔式。其他四種變體如下所列,每種變體的要求將在單獨的章節中進行描述。

  1. Bottom-Half 變體(歷史)

  2. Sched 變體(歷史)

  3. 可睡眠 RCU

  4. Tasks RCU

  5. Tasks Trace RCU

Bottom-Half 變體(歷史)

作為將三個變體整合為單個變體的一部分,RCU-bh 變體已經用其他 RCU 變體來表示。讀取端 API 仍然存在,並且仍然停用 softirq 並由 lockdep 記錄。因此,本節中的大部分內容本質上是歷史性的。

softirq-disable(又名 “bottom-half”,因此縮寫為 “_bh”)RCU 變體,或 RCU-bh,由 Dipankar Sarma 開發,旨在提供一種可以承受 Robert Olsson 研究的基於網路的拒絕服務攻擊的 RCU 變體。這些攻擊給系統帶來了如此多的網路負載,以至於某些 CPU 永遠不會退出 softirq 執行,進而阻止這些 CPU 執行上下文切換,而在當時的 RCU 實現中,這阻止了寬限期的結束。結果是記憶體不足的情況和系統掛起。

解決方案是建立 RCU-bh,它在其讀取端關鍵部分執行 local_bh_disable(),並且除了上下文切換、空閒、使用者模式和離線之外,還使用從一種型別的 softirq 處理到另一種型別的轉換作為靜止狀態。這意味著即使某些 CPU 無限期地在 softirq 中執行,RCU-bh 寬限期也可以完成,從而允許基於 RCU-bh 的演算法承受基於網路的拒絕服務攻擊。

由於 rcu_read_lock_bh()rcu_read_unlock_bh() 停用和重新啟用 softirq 處理程式,因此在 RCU-bh 讀取端關鍵部分啟動 softirq 處理程式的任何嘗試都將被延遲。在這種情況下,rcu_read_unlock_bh() 將呼叫 softirq 處理,這可能需要相當長的時間。當然,可以爭辯說,這種 softirq 開銷應該與 RCU-bh 讀取端關鍵部分後面的程式碼相關聯,而不是 rcu_read_unlock_bh(),但事實是,大多數分析工具都不能指望做出這種精細的區分。例如,假設一個三毫秒長的 RCU-bh 讀取端關鍵部分在網路負載很重的時間內執行。很可能會嘗試在該三毫秒內呼叫至少一個 softirq 處理程式,但任何此類呼叫都將被延遲到 rcu_read_unlock_bh() 時。這當然可能乍一看好像 rcu_read_unlock_bh() 執行得非常慢。

RCU-bh API 包括 rcu_read_lock_bh(), rcu_read_unlock_bh(), rcu_dereference_bh(), rcu_dereference_bh_check(), 和 rcu_read_lock_bh_held()。但是,舊的 RCU-bh 更新端 API 現在已經消失,取而代之的是 synchronize_rcu(), synchronize_rcu_expedited(), call_rcu(), 和 rcu_barrier()。此外,任何停用 bottom halves 的操作也標記了一個 RCU-bh 讀取端關鍵部分,包括 local_bh_disable() 和 local_bh_enable()、local_irq_save() 和 local_irq_restore() 等。

Sched 變體(歷史)

作為將三個變體整合為單個變體的一部分,RCU-sched 變體已經用其他 RCU 變體來表示。讀取端 API 仍然存在,並且繼續停用搶佔並由 lockdep 記錄。因此,本節中的大部分內容本質上是歷史性的。

在可搶佔 RCU 之前,等待 RCU 寬限期也具有等待所有預先存在的中斷和 NMI 處理程式的副作用。但是,存在不具有此屬性的合法的可搶佔 RCU 實現,因為 RCU 讀取端關鍵部分之外的任何程式碼點都可以是靜止狀態。因此,建立了 RCU-sched,它遵循 “經典” RCU,即 RCU-sched 寬限期等待預先存在的中斷和 NMI 處理程式。在使用 CONFIG_PREEMPTION=n 構建的核心中,RCU 和 RCU-sched API 具有相同的實現,而使用 CONFIG_PREEMPTION=y 構建的核心為每個 API 提供單獨的實現。

請注意,在 CONFIG_PREEMPTION=y 核心中,rcu_read_lock_sched()rcu_read_unlock_sched() 分別停用和重新啟用搶佔。這意味著如果在 RCU-sched 讀取端關鍵部分期間發生搶佔嘗試,rcu_read_unlock_sched() 將進入排程程式,並帶來所有延遲和開銷。正如 rcu_read_unlock_bh() 一樣,這可以使它看起來好像 rcu_read_unlock_sched() 執行得非常慢。但是,最高優先順序的任務不會被搶佔,因此該任務將享受低開銷的 rcu_read_unlock_sched() 呼叫。

RCU-sched API 包括 rcu_read_lock_sched(), rcu_read_unlock_sched(), rcu_read_lock_sched_notrace(), rcu_read_unlock_sched_notrace(), rcu_dereference_sched(), rcu_dereference_sched_check(), 和 rcu_read_lock_sched_held()。但是,舊的 RCU-sched 更新端 API 現在已經消失,取而代之的是 synchronize_rcu(), synchronize_rcu_expedited(), call_rcu(), 和 rcu_barrier()。此外,任何停用搶佔的操作也標記了一個 RCU-sched 讀取端關鍵部分,包括 preempt_disable() 和 preempt_enable()、local_irq_save() 和 local_irq_restore() 等。

可睡眠 RCU

十多年來,有人說 “我需要在 RCU 讀取端關鍵部分內阻塞” 是一個可靠的跡象,表明此人不理解 RCU。畢竟,如果您總是在 RCU 讀取端關鍵部分中阻塞,那麼您可能可以使用更高開銷的同步機制。但是,隨著 Linux 核心的通知程式的出現,這種情況發生了變化,通知程式的 RCU 讀取端關鍵部分幾乎從不睡眠,但有時需要睡眠。這導致了 可睡眠 RCUSRCU 的引入。

SRCU 允許定義不同的域,每個域都由 srcu_struct 結構的例項定義。指向此結構的指標必須傳遞給每個 SRCU 函式,例如,synchronize_srcu(&ss),其中 sssrcu_struct 結構。這些域的關鍵好處是,一個域中的慢速 SRCU 讀取器不會延遲某些其他域中的 SRCU 寬限期。也就是說,這些域的一個結果是,讀取端程式碼必須將 “cookie” 從 srcu_read_lock() 傳遞到 srcu_read_unlock(),例如,如下所示

1 int idx;
2
3 idx = srcu_read_lock(&ss);
4 do_something();
5 srcu_read_unlock(&ss, idx);

如上所述,在 SRCU 讀取端關鍵部分內阻塞是合法的,但是,強大的力量伴隨著巨大的責任。如果您在給定域的 SRCU 讀取端關鍵部分之一中永久阻塞,那麼該域的寬限期也將被永久阻塞。當然,永久阻塞的一個好方法是死鎖,如果給定域的 SRCU 讀取端關鍵部分中的任何操作可以直接或間接地等待該域的寬限期到期,則可能會發生死鎖。例如,這將導致自死鎖

1 int idx;
2
3 idx = srcu_read_lock(&ss);
4 do_something();
5 synchronize_srcu(&ss);
6 srcu_read_unlock(&ss, idx);

但是,如果第 5 行獲取了一個互斥鎖,該互斥鎖在 ss 域的 synchronize_srcu() 中持有,仍然可能發生死鎖。此外,如果第 5 行獲取了一個互斥鎖,該互斥鎖在某些其他域 ss1synchronize_srcu() 中持有,並且如果 ss1 域 SRCU 讀取端關鍵部分獲取了另一個互斥鎖,該互斥鎖在 sssynchronize_srcu() 中持有,則可能再次發生死鎖。這樣的死鎖迴圈可以跨越任意數量的不同 SRCU 域。再次,強大的力量伴隨著巨大的責任。

與其他 RCU 變體不同,SRCU 讀取端關鍵部分可以在空閒甚至離線 CPU 上執行。此功能要求 srcu_read_lock()srcu_read_unlock() 包含記憶體屏障,這意味著 SRCU 讀取器將比 RCU 讀取器執行得慢一些。這也激發了 smp_mb__after_srcu_read_unlock() API,它與 srcu_read_unlock() 結合使用,保證了完整的記憶體屏障。

同樣與其他 RCU 變體不同,由於 SRCU 寬限期使用計時器以及計時器暫時 “滯留” 在傳出 CPU 上的可能性,因此 不能 從 CPU 熱插拔通知程式呼叫 synchronize_srcu()。計時器的這種滯留意味著釋出到傳出 CPU 的計時器將不會觸發,直到 CPU 熱插拔過程的後期。問題是,如果通知程式正在等待 SRCU 寬限期,則該寬限期正在等待計時器,並且該計時器滯留在傳出 CPU 上,那麼通知程式將永遠不會被喚醒,換句話說,發生了死鎖。當然,這種相同的情況也禁止從 CPU 熱插拔通知程式呼叫 srcu_barrier()

SRCU 與其他 RCU 變體的不同之處還在於,SRCU 的加速和非加速寬限期由相同的機制實現。這意味著在當前的 SRCU 實現中,加速未來的寬限期具有加速所有尚未完成的先前寬限期的副作用。(但請注意,這是當前實現的屬性,不一定是未來實現的屬性。)此外,如果 SRCU 處於空閒狀態的時間長於 srcutree.exp_holdoff 核心引導引數(預設情況下為 25 微秒)指定的時間間隔,並且 synchronize_srcu() 呼叫結束了此空閒期,則該呼叫將自動加速。

從 v4.12 開始,SRCU 的回撥按 CPU 維護,消除了先前核心版本中存在的鎖定瓶頸。儘管這將允許使用者對 call_srcu() 施加更大的壓力,但重要的是要注意 SRCU 尚未採取任何特殊措施來處理回撥洪水。因此,如果您每秒每個 CPU 釋出(例如)10,000 個 SRCU 回撥,那麼您可能完全沒問題,但如果您打算每秒每個 CPU 釋出(例如)1,000,000 個 SRCU 回撥,請先執行一些測試。SRCU 可能需要進行一些調整才能處理這種負載。當然,您的結果可能會因 CPU 的速度和記憶體的大小而異。

SRCU API 包括 srcu_read_lock(), srcu_read_unlock(), srcu_dereference(), srcu_dereference_check(), synchronize_srcu(), synchronize_srcu_expedited(), call_srcu(), srcu_barrier(), 和 srcu_read_lock_held()。它還包括 DEFINE_SRCU()、DEFINE_STATIC_SRCU() 和 init_srcu_struct() API,用於定義和初始化 srcu_struct 結構。

最近,SRCU API 添加了輪詢介面

  1. start_poll_synchronize_srcu() 返回一個 cookie,用於標識未來 SRCU 寬限期的完成,並確保將啟動此寬限期。

  2. poll_state_synchronize_srcu() 返回 true,當且僅當指定的 cookie 對應於已經完成的 SRCU 寬限期。

  3. get_state_synchronize_srcu() 返回一個 cookie,就像 start_poll_synchronize_srcu() 一樣,但不同之處在於它不採取任何措施來確保將啟動任何未來的 SRCU 寬限期。

這些函式用於避免在具有多階段老化機制的某些型別的緩衝區快取演算法中出現不必要的 SRCU 寬限期。想法是,到塊從快取中完全老化時,SRCU 寬限期很可能已經過去。

Tasks RCU

某些形式的跟蹤使用 “trampolines” 來處理安裝不同型別探針所需的二進位制重寫。能夠釋放舊的 trampolines 是件好事,這聽起來像是某種形式的 RCU 的工作。但是,由於有必要能夠在程式碼中的任何位置安裝跟蹤,因此不可能使用讀取端標記,例如 rcu_read_lock()rcu_read_unlock()。此外,在 trampoline 本身中使用這些標記也是行不通的,因為在 rcu_read_unlock() 之後需要有指令。synchronize_rcu() 將保證執行到達 rcu_read_unlock(),但它無法保證執行已完全離開 trampoline。更糟糕的是,在某些情況下,trampoline 的保護必須擴充套件到執行到達 trampoline 之前 的幾個指令。例如,這幾個指令可能會計算 trampoline 的地址,以便在執行實際到達 trampoline 本身之前很長一段時間預先確定進入 trampoline。

解決方案是以 Tasks RCU 的形式出現,它具有由自願上下文切換(即,呼叫 schedule()、cond_resched() 和 synchronize_rcu_tasks())分隔的隱式讀取端關鍵部分。此外,進出使用者空間執行的轉換也會分隔 tasks-RCU 讀取端關鍵部分。Tasks RCU 會忽略空閒任務,並且可以使用 Tasks Rude RCU 與它們互動。

請注意,非自願上下文切換 不是 Tasks-RCU 靜止狀態。畢竟,在可搶佔核心中,在 trampoline 中執行程式碼的任務可能會被搶佔。在這種情況下,Tasks-RCU 寬限期顯然不能結束,直到該任務恢復並且其執行離開該 trampoline。這意味著,除其他事項外,cond_resched() 不提供 Tasks RCU 靜止狀態。(相反,從 softirq 使用 rcu_softirq_qs(),否則使用 rcu_tasks_classic_qs()。)

tasks-RCU API 非常簡潔,僅包含 call_rcu_tasks()synchronize_rcu_tasks()rcu_barrier_tasks()。在 CONFIG_PREEMPTION=n 核心中,trampoline 不會被搶佔,因此這些 API 對映到 call_rcu()synchronize_rcu()rcu_barrier()。在 CONFIG_PREEMPTION=y 核心中,trampoline 可以被搶佔,因此這三個 API 由單獨的函式實現,這些函式會檢查自願上下文切換。

Tasks Rude RCU

某些形式的跟蹤需要等待在任何線上 CPU 上執行的所有停用搶佔的程式碼區域,包括 RCU 未監視時執行的程式碼。這意味著 synchronize_rcu() 是不夠的,必須使用 Tasks Rude RCU。這種 RCU 透過強制在每個線上 CPU 上排程一個工作佇列來完成其工作,因此得名 “Rude”。對於不希望其 nohz_full CPU 接收 IPI 的即時工作負載以及不希望其空閒 CPU 被喚醒的電池供電系統來說,此操作被認為是相當粗魯的。

一旦核心入口/出口和深度空閒函式被正確標記為 noinstr,Tasks RCU 就可以開始關注空閒任務(除了那些從 RCU 角度來看是空閒的任務),然後可以將 Tasks Rude RCU 從核心中刪除。

tasks-rude-RCU API 也是無讀者標記的,因此非常簡潔,僅包含 synchronize_rcu_tasks_rude()

Tasks Trace RCU

某些形式的跟蹤需要在讀取器中休眠,但不能容忍 SRCU 的讀取端開銷,包括 srcu_read_lock()srcu_read_unlock() 中的完整記憶體屏障。這種需求由 Tasks Trace RCU 處理,它使用排程器鎖定和 IPI 來與讀取器同步。不能容忍 IPI 的即時系統可以使用 CONFIG_TASKS_TRACE_RCU_READ_MB=y 構建其核心,這避免了 IPI,但代價是在讀取端原語中添加了完整的記憶體屏障。

tasks-trace-RCU API 也相當簡潔,包括 rcu_read_lock_trace()rcu_read_unlock_trace()、rcu_read_lock_trace_held()、call_rcu_tasks_trace()synchronize_rcu_tasks_trace()rcu_barrier_tasks_trace()

可能的未來變化

RCU 用於實現更新端可伸縮性的技巧之一是隨著 CPU 數量的增加而增加寬限期延遲。如果這成為一個嚴重的問題,則有必要重新設計寬限期狀態機,以避免需要額外的延遲。

RCU 在一些地方停用了 CPU 熱插拔,最值得注意的是在 rcu_barrier() 操作中。 如果有充分的理由在 CPU 熱插拔通知程式中使用 rcu_barrier(),則有必要避免停用 CPU 熱插拔。 這會引入一些複雜性,因此最好有一個 *非常* 好的理由。

一方面是寬限期延遲,另一方面是其他 CPU 的中斷,這兩者之間的權衡可能需要重新審視。 期望當然是零寬限期延遲以及在加速寬限期操作期間進行的零處理器間中斷。 雖然這種理想不太可能實現,但很可能可以進行進一步的改進。

RCU 的多處理器實現使用組合樹來對 CPU 進行分組,以減少鎖爭用並提高快取區域性性。 但是,此組合樹不會將其記憶體分散到 NUMA 節點上,也不會使 CPU 組與硬體功能(如套接字或核心)對齊。 目前認為這種分散和對齊是不必要的,因為熱路徑讀取端原語不訪問組合樹,並且通常情況下 call_rcu() 也不訪問。 如果您認為您的架構需要這種分散和對齊,那麼您的架構也應該受益於 rcutree.rcu_fanout_leaf 啟動引數,該引數可以設定為套接字、NUMA 節點或任何東西中的 CPU 數量。 如果 CPU 數量太大,請使用 CPU 數量的一部分。 如果 CPU 數量是一個大的質數,那麼這當然是一個“有趣”的架構選擇! 可以考慮更靈活的安排,但前提是 rcutree.rcu_fanout_leaf 已被證明不足,並且只有在透過精心執行和真實的系統級工作負載證明其不足之後。

請注意,需要 RCU 重新對映 CPU 編號的安排將需要極好的需求論證以及對替代方案的全面探索。

RCU 的各種 kthread 是相對較新的補充。 很可能需要進行調整才能更優雅地處理極端負載。 可能還需要能夠將 RCU 的 kthread 和軟中斷處理程式使用的 CPU 利用率與引發此 CPU 利用率的程式碼相關聯。 例如,RCU 回撥開銷可能會被計入原始 call_rcu() 例項,儘管可能不會在生產核心中這樣做。

可能需要做更多的工作,以在繁重負載下為寬限期和回撥呼叫提供合理的向前進展保證。

總結

本文件介紹了二十多年來 RCU 的需求。 鑑於需求不斷變化,這不會是關於此主題的最後結論,但至少它可以用來闡明需求的一個重要子集。

致謝

我感謝 Steven Rostedt、Lai Jiangshan、Ingo Molnar、Oleg Nesterov、Borislav Petkov、Peter Zijlstra、Boqun Feng 和 Andy Lutomirski 在使本文易於理解方面的幫助,以及 Michelle Rankin 對此努力的支援。 其他貢獻已在 Linux 核心的 git 存檔中得到確認。