原創 謝寶友 Linux閱碼場 2017-11-04node
本文簡介
本文介紹Linux RCU的基本概念。這不是一篇單獨的文章,這是《謝寶友:深刻理解Linux RCU》系列的第3篇,前序文章:linux
謝寶友: 深刻理解Linux RCU之一——從硬件提及
謝寶友:深刻理解Linux RCU:從硬件提及以內存屏障編程
做者簡介
謝寶友,在編程一線工做已經有20年時間,其中接近10年時間工做於Linux操做系統。在中興通信操做系統產品部工做期間,他做爲技術總工參與的電信級嵌入式實時操做系統,得到了行業最高獎----中國工業大獎。同時,他也是《深刻理解並行編程》一書的譯者。
聯繫方式: mail:scxby@163.com 微信:linux-kernel數組
RCU主要用於對性能要求苛刻的並行實時計算。例如:天氣預報、模擬核爆炸計算、內核同步等等。
假設你正在編寫一個並行實時程序,該程序須要訪問隨時變化的數據。這些數據多是隨着溫度、溼度的變化而逐漸變化的大氣壓。這個程序的實時響應要求是如此嚴格,須要處理的數據量如此巨大,以致於不容許任何自旋或者阻塞,所以不能使用任何鎖。
幸運的是,溫度和壓力的範圍一般變化不大,所以使用默認的數據集也是可行的。當溫度、溼度和壓力抖動時,有必要使用實時數據。可是溫度、溼度和壓力是逐漸變化的,咱們能夠在幾分鐘內更新數據,但不必實時更新值。
在這種狀況下,可使用一個全局指針,即gptr,一般爲NULL,表示要使用默認值。偶爾也能夠將gptr指向假設命名爲a、b和c的變量,以反映氣壓的變化。
傳統的軟件可使用自旋鎖這樣的同步機制,來保護gptr指針的讀寫。一旦舊的值不被使用,就能夠將舊指針指向的數據釋放。這種簡單的方法有一個最大的問題:它會使軟件效率降低數個數量級(注意,不是降低數倍而是降低數個數量級)。
在現代計算系統中,向gptr寫入a、b、c這樣的值,併發的讀者要麼看到一個NULL指針要麼看到指向新結構gptr的指針,不會看到中間結果。也就是說,對於指針賦值來講,某種意義上這種賦值是原子的。讀者不會看到a、b、c以外的其餘結果。而且,更好的一點,也是更重要的一點是:讀者不須要使用任何代價高昂的同步原語,所以這種方法很是適合於實時使用。
真正的難點在於:在讀者得到gptr的引用時,它可能看到a、b、c這三個值中任意一個值,寫者什麼時候才能安全的釋放a、b、c所指向的內存數據結構?
引用計數的方案頗有誘惑力,但正如鎖和順序鎖同樣,引用計數可能消耗掉數百個CPU指令週期,更致命的是,它會引用緩存行在CPU之間的來回顛簸,破壞各個CPU的緩存,引發系統總體性能的降低。很明顯,這種選擇不是咱們所指望的。
想要理解Linux經典RCU實現的讀者,應當認真閱讀下面這段話:
一種實現方法是,寫者徹底不知道有哪些讀者存在。這種方法顯然讓讀者的性能最佳,但留給寫者的問題是:如何才能肯定全部的老讀者已經完成。
最簡單的實現是:讓線程不會被搶佔,或者說,讀者在讀RCU數據期間不能被搶佔。在這種不可搶佔的環境中,每一個線程將一直運行,直到它明確地和自願地阻塞本身(現實世界確實有這樣的操做系統,它由線程本身決定什麼時候釋放CPU。例如大名鼎鼎的Solaris操做系統)。這要求一個不能阻塞的無限循環將使該CPU在循環開始後沒法用於任何其餘目的,還要求還要求線程在持有自旋鎖時禁止阻塞。不然會造成死鎖。
這種方法的示意圖下所示,圖中的時間從頂部推移到底部,CPU 1的list_del()操做是RCU寫者操做,CPU二、CPU3在讀端讀取list節點。
Linux經典RCU的概念便是如此。雖然這種方法在生產環境上的實現可能至關複雜,可是玩具實現卻很是簡單。緩存
1 for_each_online_cpu(cpu) 2 run_on(cpu);
for_each_online_cpu()原語遍歷全部CPU,run_on()函數致使當前線程在指定的CPU上執行,這會強制目標CPU執行上下文切換。所以,一旦for_each_online_cpu()完成,每一個CPU都執行了一次上下文切換,這又保證了全部以前存在的讀線程已經完成。
請注意,這個方法不能用於生產環境。正確處理各類邊界條件和對性能優化的強烈要求意味着用於生產環境的代碼實現將十分複雜。此外,可搶佔環境的RCU實現須要讀者實際作點什麼事情(也就是在讀臨界區內,禁止搶佔。這是Linux經典RCU讀鎖的實現)。不過,這種簡單的不可搶佔的方法在概念上是完整的,有助於咱們理解RCU的基本原理。安全
2、RCU是什麼?
RCU是read-copy-update的簡稱,翻譯爲中文有點彆扭「讀-複製-更新」。它是是一種同步機制,有三種角色或者操做:讀者、寫者和複製操做,我理解其中的複製操做就是不一樣CPU上的讀者複製了不一樣的數據值,或者說擁有同一個指針的不一樣拷貝值,也能夠理解爲:在讀者讀取值的時候,寫者複製並替換其內容(後一種理解來自於RCU做者的解釋)。它於2002年10月引入Linux內核。
RCU容許讀操做能夠與更新操做併發執行,這一點提高了程序的可擴展性。常規的互斥鎖讓併發線程互斥執行,並不關心該線程是讀者仍是寫者,而讀/寫鎖在沒有寫者時容許併發的讀者,相比於這些常規鎖操做,RCU在維護對象的多個版本時確保讀操做保持一致,同時保證只有全部當前讀端臨界區都執行完畢後才釋放對象。RCU定義並使用了高效而且易於擴展的機制,用來發布和讀取對象的新版本,還用於延後舊版本對象的垃圾收集工做。這些機制恰當地在讀端和更新端並行工做,使得讀端特別快速。在某些場合下(好比非搶佔式內核裏),RCU讀端的函數徹底是零開銷。
Seqlock也可讓讀者和寫者併發執行,可是兩者有什麼區別?
首先是兩者的目的不同。Seqlock是爲了保證讀端在讀取值的時候,寫者沒有對它進行修改,而RCU是爲了多核擴展性。
其次是保護的數據結構大小不同。Seqlock能夠保護一組相關聯的數據,而RCU只能保護指針這樣的unsigned long類型的數據。
最重要的區別還在於效率,Seqlock本質上是與自旋鎖同等重量級的原語,其效率與RCU不在同一個數量級上面。
下面從三個基礎機制來闡述RCU到底是什麼?
RCU由三種基礎機制構成,第一個機制用於插入,第二個用於刪除,第三個用於讓讀者能夠不受併發的插入和刪除干擾。分別是:
發佈/訂閱機制,用於插入。
等待已有的RCU讀者完成的機制,用於刪除。
維護對象多個版本的機制,以容許併發的插入和刪除操做。性能優化
一、發佈/訂閱機制
RCU的一個關鍵特性是能夠安全的讀取數據,即便數據此時正被修改。RCU經過一種發佈/訂閱機制達成了併發的數據插入。舉個例子,假設初始值爲NULL的全局指針gp如今被賦值指向一個剛分配並初始化的數據結構。以下所示的代碼片斷:微信
1 struct foo { 2 int a; 3 int b; 4 int c; 5 }; 6 struct foo *gp = NULL; 7 8 /* . . . */ 9 10 p = kmalloc(sizeof(*p), GFP_KERNEL); 11 p->a = 1; 12 p->b = 2; 13 p->c = 3; 14 gp = p;
「發佈」數據結構(不安全)
不幸的是,這塊代碼沒法保證編譯器和CPU會按照編程順序執行最後4條賦值語句。若是對gp的賦值發生在初始化p的各字段以前,那麼併發的讀者會讀到未初始化的值。這裏須要內存屏障來保證事情按順序發生,但是內存屏障又向來以難用而聞名。因此這裏咱們用一句rcuassign pointer()原語將內存屏障封裝起來,讓其擁有發佈的語義。最後4行代碼以下。數據結構
1 p->a = 1; 2 p->b = 2; 3 p->c = 3; 4 rcu_assign_pointer(gp, p);
rcu_assign_pointer()「發佈」一個新結構,強制讓編譯器和CPU在爲p的各字段賦值後再去爲gp賦值。
不過,只保證更新者的執行順序並不夠,由於讀者也須要保證讀取順序。請看下面這個例子中的代碼。併發
1 p = gp; 2 if (p != NULL) { 3 do_something_with(p->a, p->b, p->c); 4 }
這塊代碼看起來好像不會受到亂序執行的影響,惋惜事與願違,在DEC Alpha CPU機器上,還有啓用編譯器值猜想(value-speculation)優化時,會讓p->a,p->b和p->c的值在p賦值以前被讀取。
也許在啓動編譯器的值猜想優化時比較容易觀察到這一情形,此時編譯器會先猜想p->a、p->b、p->c的值,而後再去讀取p的實際值來檢查編譯器的猜想是否正確。這種類型的優化十分激進,甚至有點瘋狂,可是這確實發生在剖析驅動(profile-driven)優化的上下文中。
然而讀者可能會說,咱們通常不會使用編譯器猜想優化。那麼咱們能夠考慮DEC Alpha CPU這樣的極端弱序的CPU。在這個CPU上面,引發問題的根源在於:在同一個CPU內部,使用了不止一個緩存來緩存CPU數據。這樣可能使用p和p->a被分佈不一樣一個CPU的不一樣緩存中,形成緩存一致性方面的問題。
顯然,咱們必須在編譯器和CPU層面阻止這種危險的優化。rcu_dereference()原語用了各類內存屏障指令和編譯器指令來達到這一目的。
1 rcu_read_lock(); 2 p = rcu_dereference(gp); 3 if (p != NULL) { 4 do_something_with(p->a, p->b, p->c); 5 } 6 rcu_read_unlock();
其中rcuread lock()和rcu_read_unlock()這對原語定義了RCU讀端的臨界區。事實上,在沒有配置CONFIG_PREEMPT的內核裏,這對原語就是空函數。在可搶佔內核中,這這對原語就是關閉/打開搶佔。
rcu_dereference()原語用一種「訂閱」的辦法獲取指定指針的值。保證後續的解引用操做能夠看見在對應的「發佈」操做(rcu_assign_pointer())前進行的初始化,即:在看到p的新值以前,可以看到p->a、p->b、p->c的新值。請注意,rcu_assign_pointer()和rcu_dereference()這對原語既不會自旋或者阻塞,也不會阻止listadd rcu()的併發執行。
雖然理論上rcu_assign_pointer()和rcu_derederence()能夠用於構造任何能想象到的受RCU保護的數據結構,可是實踐中經常只用於構建更上層的原語。例如,將rcu_assign_pointer()和rcu_dereference()原語嵌入在Linux鏈表的RCU變體中。Linux有兩種雙鏈表的變體,循環鏈表struct list_head和哈希表structhlist_head/struct hlist_node。前一種以下圖所示。
對鏈表採用指針發佈的例子以下:
1 struct foo { 2 struct list_head *list; 3 int a; 4 int b; 5 int c; 6 }; 7 LIST_HEAD(head); 8 9 /* . . . */ 10 11 p = kmalloc(sizeof(*p), GFP_KERNEL); 12 p->a = 1; 13 p->b = 2; 14 p->c = 3; 15 list_add_rcu(&p->list, &head);
RCU發佈鏈表
第15行必須用某些同步機制(最多見的是各類鎖)來保護,防止多核list_add()實例併發執行。不過,同步並不能阻止list_add()的實例與RCU的讀者併發執行。
訂閱一個受RCU保護的鏈表的代碼很是直接。
1 rcu_read_lock(); 2 list_for_each_entry_rcu(p, head, list) { 3 do_something_with(p->a, p->b, p->c); 4 } 5 rcu_read_unlock();
list_add_rcu()原語向指定的鏈表發佈了一項條目,保證對應的list_foreach entry_rcu()能夠訂閱到同一項條目。
Linux的其餘鏈表、哈希表都是線性鏈表,這意味着它的頭結點只須要一個指針,而不是象循環鏈表那樣須要兩個。所以哈希表的使用能夠減小哈希表的hash bucket數組一半的內存消耗。
向受RCU保護的哈希表發佈新元素和向循環鏈表的操做十分相似,以下所示。
1 struct foo { 2 struct hlist_node *list; 3 int a; 4 int b; 5 int c; 6 }; 7 HLIST_HEAD(head); 8 9 /* . . . */ 10 11 p = kmalloc(sizeof(*p), GFP_KERNEL); 12 p->a = 1; 13 p->b = 2; 14 p->c = 3; 15 hlist_add_head_rcu(&p->list, &head); 和以前同樣,第15行必須用某種同步機制,好比鎖來保護。 訂閱受RCU保護的哈希表和訂閱循環鏈表沒什麼區別。 1 rcu_read_lock(); 2 hlist_for_each_entry_rcu(p, q, head, list) { 3 do_something_with(p->a, p->b, p->c); 4 } 5 rcu_read_unlock();
表9.2是RCU的發佈和訂閱原語,另外還有一個刪除發佈原語。
請注意,list_replace_rcu()、list_del_rcu()、hlist_replacercu()和hlist del_rcu()這些API引入了一點複雜性。什麼時候才能安全地釋放剛被替換或者刪除的數據元素?咱們怎麼能知道什麼時候全部讀者釋放了他們對數據元素的引用?
二、等待已有的RCU讀者執行完畢
從最基本的角度來講,RCU就是一種等待事物結束的方式。固然,有不少其餘的方式能夠用來等待事物結束,好比引用計數、讀/寫鎖、事件等等。RCU的最偉大之處在於它能夠等待(好比)20,000種不一樣的事物,而無需顯式地去跟蹤它們中的每個,也無需去擔憂對性能的影響,對擴展性的限制,複雜的死鎖場景,還有內存泄漏帶來的危害等等使用顯式跟蹤手段會出現的問題。
在RCU的例子中,被等待的事物稱爲「RCU讀端臨界區」。RCU讀端臨界區從rcu_read_lock()原語開始,到對應的rcu_read_unlock()原語結束。RCU讀端臨界區能夠嵌套,也能夠包含一大塊代碼,只要這其中的代碼不會阻塞或者睡眠(先不考慮可睡眠RCU)。若是你遵照這些約定,就可使用RCU去等待任何代碼的完成。
RCU經過間接地肯定這些事物什麼時候完成,才完成了這樣的壯舉。
如上圖所示,RCU是一種等待已有的RCU讀端臨界區執行完畢的方法,這裏的執行完畢也包括在臨界區裏執行的內存操做。不過請注意,在某個寬限期開始後才啓動的RCU讀端臨界區會擴展到該寬限期的結尾處。
下列僞代碼展現了寫者使用RCU等待讀者的基本方法。
1.做出改變,好比替換鏈表中的一個元素。
2.等待全部已有的RCU讀端臨界區執行完畢(好比使用synchronize_rcu()原語)。這裏要注意的是後續的RCU讀端臨界區沒法獲取剛剛刪除元素的引用。
3.清理,好比釋放剛纔被替換的元素。
下圖所示的代碼片斷演示了這個過程,其中字段a是搜索關鍵字。
1 struct foo { 2 struct list_head *list; 3 int a; 4 int b; 5 int c; 6 }; 7 LIST_HEAD(head); 8 9 /* . . . */ 10 11 p = search(head, key); 12 if (p == NULL) { 13 /* Take appropriate action, unlock, and return. */ 14 } 15 q = kmalloc(sizeof(*p), GFP_KERNEL); 16 *q = *p; 17 q->b = 2; 18 q->c = 3; 19 list_replace_rcu(&p->list, &q->list); 20 synchronize_rcu(); 21 kfree(p);
標準RCU替換示例
第1九、20和21行實現了剛纔提到的三個步驟。第16至19行正如RCU其名(讀-複製-更新),在容許併發讀的同時,第16行復制,第17到19行更新。
synchronize_rcu()原語能夠至關簡單。然而,想要達到產品質量,代碼實現必須處理一些困難的邊界狀況,而且還要進行大量優化,這二者都將致使明顯的複雜性。理解RCU的難點,主要在於synchronize_rcu()的實現。
三、維護最近被更新對象的多個版本
下面展現RCU如何維護鏈表的多個版本,供併發的讀者訪問。經過兩個例子來講明在讀者還處於RCU讀端臨界區時,被讀者引用的數據元素如何保持完整性。第一個例子展現了鏈表元素的刪除,第二個例子展現了鏈表元素的替換。
例子1:在刪除過程當中維護多個版本
1 p = search(head, key); 2 if (p != NULL) { 3 list_del_rcu(&p->list); 4 synchronize_rcu(); 5 kfree(p); 6 }
以下圖,每一個元素中的三個數字分別表明字段a、b、c的值。紅色的元素表示RCU讀者此時正持有該元素的引用。請注意,咱們爲了讓圖更清楚,忽略了後向指針和從尾指向頭的指針。
等第3行的list_del_rcu()執行完畢後,「五、六、7」元素從鏈表中被刪除。由於讀者不直接與更新者同步,因此讀者可能還在併發地掃描鏈表。這些併發的讀者有可能看見,也有可能看不見剛剛被刪除的元素,這取決於掃描的時機。不過,恰好在取出指向被刪除元素指針後被延遲的讀者(好比,因爲中斷、ECC內存錯誤),就有可能在刪除後還看見鏈表元素的舊值。所以,咱們此時有兩個版本的鏈表,一個有元素「五、六、7」,另外一個沒有。元素「五、六、7」用黃色標註,代表老讀者可能還在引用它,可是新讀者已經沒法得到它的引用。
請注意,讀者不容許在退出RCU讀端臨界區後還維護元素「五、六、7」的引用。所以,一旦第4行的synchronize_rcu()執行完畢,全部已有的讀者都要保證已經執行完,不能再有讀者引用該元素。這樣咱們又回到了惟一版本的鏈表。
此時,元素「五、六、7」能夠安全被釋放了。這樣咱們就完成了元素「五、六、7」的刪除。
例子2:在替換過程當中維護多個版本
1 q = kmalloc(sizeof(*p), GFP_KERNEL); 2 *q = *p; 3 q->b = 2; 4 q->c = 3; 5 list_replace_rcu(&p->list, &q->list); 6 synchronize_rcu(); 7 kfree(p);
鏈表的初始狀態包括指針p都和「刪除」例子中同樣。
RCU從鏈表中替換元素
和前面同樣,每一個元素中的三個數字分別表明字段a、b、c。紅色的元素表示讀者可能正在引用,而且由於讀者不直接與更新者同步,因此讀者有可能與整個替換過程併發執行。請注意咱們爲了圖表的清晰,再一次忽略了後向指針和從尾指向頭的指針。下面描述了元素「五、二、3」如何替換元素「五、六、7」的過程,任何特定讀者可能看見這兩個值其中一個。第1行用kmalloc()分配了要替換的元素。此時,沒有讀者持有剛分配的元素的引用(用綠色表示),而且該元素是未初始化的(用問號表示)。第2行將舊元素複製給新元素。新元素此時還不能被讀者訪問,可是已經初始化了。第3行將q->b的值更新爲2,第4行將q->c的值更新爲3。如今,第5行開始替換,這樣新元素終於對讀者可見了,所以顏色也變成了紅色。此時,鏈表就有兩個版本了。已經存在的老讀者可能看到元素「五、六、7」(如今顏色是黃色的),而新讀者將會看見元素「五、二、3」。不過這裏能夠保證任何讀者都能看到一個無缺的鏈表。隨着第6行synchronize_rcu()的返回,寬限期結束,全部在list_replace_rcu()以前開始的讀者都已經完成。特別是任何可能持有元素「五、六、7」引用的讀者保證已經退出了它們的RCU讀端臨界區,不能繼續持有引用。所以,再也不有任何讀者持有舊數據的引用,,如第6排綠色部分所示。這樣咱們又回到了單一版本的鏈表,只是用新元素替換了舊元素。等第7行的kfree()完成後,鏈表就成了最後一排的樣子。不過儘管RCU是因替換的例子而得名的,可是RCU在內核中的主要用途仍是用於簡單的刪除。