無鎖編程—RCU


當我們對鏈表等數據結構進行並發讀寫時,通常會通過讀寫鎖進行保護。但是,每一次對讀寫鎖的操作都必須直接在內存中進行,不能夠使用cache,這也就導致了讀寫鎖的效率其實是比較低的。即使是在沒有寫者的情況下,每一次上讀鎖仍然需要訪問內存。更嚴重的是如果多個CPU同時執行到CAS指令,每一次CAS指令的執行都會導致其他CPU的cache失效,需要重新讀取內存,也就意味着最壞情況下執行CAS指令的代價是O(n^2)

而一種實現無鎖編程的方法就是RCU(Read-Copy Update)。RCU最常見的地方就是鏈表,在Linux內核中甚至有一個單獨的RCU鏈表include/linux/rculist.h

對於寫者來說有以下三種情況:

  • 修改鏈表元素的值,將字符串修改為另一個字符串;此時讀者可能讀取到正在被修改的字符串
  • 插入一個鏈表元素;讀者讀取到一個未插入完成的元素,next指針未能指向下一個元素
  • 刪除一個元素;讀者讀取到一個已經被刪除的元素

RCU主要就是通過一種無鎖的方法修復上述問題,該方法會使寫者的速度變慢,但是讀者能不用鎖、不需要寫入內存,速度會明顯變快。

對於鏈表修改的情況,RCU是禁止發生的,因此需要把鏈表修改替換為鏈表結點替換:

H1 -> E1 -> E2 -> E3 -> E4

H1 -> E1    E2 -> E3 -> E4
      |---> E2' --^

例如修改E2結點,就先建立新結點E2',使其先指向E3,之后修改E1的指針指向E2'。對於讀者來說,如果在E2位置,那么可以順利讀取到E2的舊值以及E3,如果在E1位置,那么可以順利讀取到新的E2'。不會出現錯誤的核心就是寫入E1->next = E2'的操作是原子的,也就是E1->next要么指向E2要么指向E2'。這種特性是RCU是最基本也是最重要的性質,例如如果是雙向鏈表,就難以實現RCU,因為不能原子性地進行修改;而對於樹來說,就是一種能夠實現RCU的結構。

這里存在一個問題就是處理器和編譯器會進行指令重排,導致E1指向E2'發生在E2'指向E3之前,這時候我們就需要使用內存屏障來避免發生指令重排了。

另一個問題就是什么時候對舊的E2進行刪除,保證沒有讀者在讀取E2。使用引用計數是一種方法,但是引用計數每次讀取就要讀寫內存增加計數,這就和我們使用RCU的目的相違背了。另一個方法就是使用垃圾回收,垃圾回收器能准確地對舊E2進行回收。但是如果是在內核等沒有垃圾回收器的環境中,又要怎么處理呢?

我們可以使用一種規則來在合適的時候釋放舊元素:

  1. 讀者不能在上下文切換時持有被RCU保護的元素,即讀者不能在RCU臨界區內釋放CPU
  2. 當每個CPU核都執行了一次上下文切換時,寫者就可以刪除舊元素

即寫者的操作變為了如下所示:

E1->next = E2'
synchronize_rcu()
free(E2)

synchronize_rcu的執行可能會需要1ms左右,看起來代價很大,但是被RCU保護的數據是讀多寫少的,這個代價還是可以接受的。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM