RCU鎖機制原理解析

背景

爲了保護共享數據,需要一些同步機制,如自旋鎖(spinlock),讀寫鎖(rwlock),它們使用起來非常簡單,而且是一種很有效的同步機制,在UNIX系統和Linux系統中得到了廣泛的使用。但是隨着計算機硬件的快速發展,獲得這種鎖的開銷相對於CPU的速度在成倍地增加,原因很簡單,CPU的速度與訪問內存的速度差距越來越大,而這種鎖使用了原子操作指令,它需要原子地訪問內存,也就說獲得鎖的開銷與訪存速度相關,另外在大部分非x86架構上獲取鎖使用了內存柵(Memory Barrier),這會導致處理器流水線停滯或刷新,因此它的開銷相對於CPU速度而言就越來越大。一些鎖在多CPU情況下, 由於加鎖的頻度變高,性能反倒比一個CPU時性能差。正是在這種背景下,一個高性能的鎖機制RCU呼之欲出,它克服了以上鎖的缺點,具有很好的擴展性,但是這種鎖機制的使用範圍比較窄,它只適用於讀多寫少的情況,如網絡路由表的查詢更新、設備狀態表的維護、數據結構的延遲釋放以及多徑I/O設備的維護等。

原理

Read Copy Update

讀(Read):讀者不需要獲得任何鎖就可訪問RCU保護的臨界區;

拷貝(Copy):寫者在訪問臨界區時,寫者“自己”將先拷貝一個臨界區副本,然後對副本進行修改;

更新(Update):RCU機制將在在適當時機使用一個回調函數把指向原來臨界區的指針重新指向新的被修改的臨界區,鎖機制中的垃圾收集器負責回調函數的調用。(時機:所有引用該共享臨界區的CPU都退出對臨界區的操作。即沒有CPU再去操作這段被RCU保護的臨界區後,這段臨界區即可回收了,此時回調函數即被調用)

quiescent state(靜默狀態過程),它表示爲CPU發生上下文切換的過程

grace period(即“適當時機”),它表示爲所有CPU都經歷一次quiescent state所需要的等待的時間,也即系統中所有的讀者完成對共享臨界區的訪問

RCU的結構體定義,只有一個用於串接鏈表的next指針和一個函數指針,這個函數指針即是上述提及的回調函數,這個需使用RCU機制的用戶向鏈表註冊,即掛接到鏈表下,從而在適當時機下得到調用

示例:寫者從鏈表中刪除元素B。

寫者首先遍歷該鏈表得到指向元素B的指針

然後修改元素B的前一個元素的next指針指向元素B的next指針指向的元素C,修改元素B的next指針指向的元素C的prep指針指向元素B的prep指針指向的元素A。在此期間可能有讀者訪問該鏈表,由於修改指針指向的操作是原子的,因此這個過程不需要同步,而元素B的指針並沒有去修改,因爲讀者可能正在使用B元素來得到鏈表的下一個或前一個元素,即A或C。當寫者完成上述操作後便向系統註冊一個回調函數func以便在 grace period之後能夠刪除元素B,註冊完畢後寫着便可認爲它已經完成刪除操作(實際上並未完成)。

垃圾收集器在檢測到所有的CPU不在引用該鏈表後,即所有的CPU已經經歷了一次quiescent state(即grace period),當grace period完成後,系統便會去調用先前寫者註冊的回調函數func,從而真正的刪除了元素B。這便是RCU機制的一種使用範例。

API介紹

  1. rcu_read_lock() & rcu_read_unlock()
#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)

用來保持一個讀者的RCU臨界區.在該臨界區內不允許發生上下文切換

  1. rcu_dereference()
#define rcu_dereference(p) rcu_dereference_check(p, 0)

#define rcu_dereference_check(p, c) 

         __rcu_dereference_check((p), rcu_read_lock_held() || (c), __rcu)

#define __rcu_dereference_check(p, c, space) 
         ({ 
                 typeof(*p) *_________p1 = (typeof(*p)*__force )ACCESS_ONCE(p); 
                 
                 rcu_lockdep_assert(c, "suspicious rcu_dereference_check()" 

                                       " usage"); 

                 rcu_dereference_sparse(p, space); 

                 smp_read_barrier_depends(); 

                 ((typeof(*p) __force __kernel *)(_________p1)); 
         })

該宏用於在RCU讀端臨界區獲得一個RCU保護的指針,該指針可以在以後安全地引用,內存柵只在alpha架構上才使用

  1. rcu_assign_pointer()
#define rcu_assign_pointer(p, v) 
         __rcu_assign_pointer((p), (v), __rcu)

#define __rcu_assign_pointer(p, v, space) 
         do { 
                 smp_wmb(); 

                 (p) = (typeof(*v) __force space *)(v); 
         } while (0)

寫者使用該函數來爲被RCU保護的指針分配一個新的值.這樣是爲了安全從寫者到讀者更改其值.這個函數會返回一個新值

  1. synchronize_rcu()
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);
}

static void wakeme_after_rcu(struct rcu_head  *head)

{
            struct rcu_synchronize *rcu;

            rcu = container_of(head, struct rcu_synchronize, head);

            complete(&rcu->completion);
}

在RCU中是一個最核心的函數,寫者用來等待之前的讀者全部退出。該函數由RCU寫端調用,它將阻塞寫者,直到經過grace period後,即所有的讀者已經完成讀端臨界區,寫者纔可以繼續下一步操作。如果有多個RCU寫端調用該函數,他們將在一個grace period之後全部被喚醒。

  1. call_rcu()
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);
}

call_rcu()用來等待之前的讀者操作完成之後,就會調用函數func,用在不可睡眠的條件中,如中斷上下文。而synchronize_rcu()用在可睡眠的環境下。

  1. 鏈表操作
    除了這些API,RCU還增加了鏈表操作的RCU版本,因爲對於RCU,對共享數據的操作必須保證能夠被沒有使用同步機制的讀者看到,所以內存柵是非常必要的。

static inline void list_add_rcu(struct list_head *new, struct list_head *head)

該函數把鏈表項new插入到RCU保護的鏈表head的開頭。使用內存柵保證了在引用這個新插入的鏈表項之前,新鏈表項的鏈接指針的修改對所有讀者是可見的。

static inline void list_add_tail_rcu(struct list_head *new, struct list_head *head)

該函數類似於list_add_rcu,它將把新的鏈表項new添加到被RCU保護的鏈表的末尾。

static inline void list_del_rcu(struct list_head *entry)

該函數從RCU保護的鏈表中移走指定的鏈表項entry,並且把entry的prev指針設置爲LIST_POISON2,但是並沒有把entry的next指針設置爲LIST_POISON1,因爲該指針可能仍然在被讀者用於遍歷該鏈表。

static inline void list_replace_rcu(struct list_head *old, struct list_head *new)

該函數是RCU新添加的函數,並不存在非RCU版本。它使用新的鏈表項new取代舊的鏈表項old,內存柵保證在引用新的鏈表項之前,它的鏈接指針的修正對所有讀者可見。

list_for_each_rcu(pos, head)

該宏用於遍歷由RCU保護的鏈表head,只要在讀端臨界區使用該函數,它就可以安全地和其它_rcu鏈表操作函數(如list_add_rcu)併發運行。

list_for_each_safe_rcu(pos, n, head)

該宏類似於list_for_each_rcu,但不同之處在於它允許安全地刪除當前鏈表項pos。

list_for_each_entry_rcu(pos, head, member)

該宏類似於list_for_each_rcu,不同之處在於它用於遍歷指定類型的數據結構鏈表,當前鏈表項pos爲一包含struct list_head結構的特定的數據結構。

list_for_each_continue_rcu(pos, head)

該宏用於在退出點之後繼續遍歷由RCU保護的鏈表head。

static inline void hlist_del_rcu(struct hlist_node *n)

它從由RCU保護的哈希鏈表中移走鏈表項n,並設置n的ppre指針爲LIST_POISON2,但並沒有設置next爲LIST_POISON1,因爲該指針可能被讀者使用用於遍利鏈表。

static inline void hlist_add_head_rcu(struct hlist_node *n, struct hlist_head *h)

該函數用於把鏈表項n插入到被RCU保護的哈希鏈表的開頭,但同時允許讀者對該哈希鏈表的遍歷。內存柵確保在引用新鏈表項之前,它的指針修正對所有讀者可見。

hlist_for_each_rcu(pos, head)

該宏用於遍歷由RCU保護的哈希鏈表head,只要在讀端臨界區使用該函數,它就可以安全地和其它_rcu哈希鏈表操作函數(如hlist_add_rcu)併發運行。

hlist_for_each_entry_rcu(tpos, pos, head, member)

類似於hlist_for_each_rcu,不同之處在於它用於遍歷指定類型的數據結構哈希鏈表,當前鏈表項pos爲一包含struct list_head結構的特定的數據結構。

應用示例

  1. 多個寫者同步
    例子來源於linux kernel文檔中的whatisRCU.txt。這個例子使用RCU的核心API來保護一個指向動態分配內存的全局指針。
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);
	// gbl_foo = new_fp;
	spin_unlock(&foo_mutex);
	synchronize_rcu();
	kfree(old_fp);
}

int foo_get_a(void)
{
	int retval;
	foo *fp;
	rcu_read_lock();
	//fp = gbl_foo;
	fp = rcu_dereference(gbl_foo);
	retval = fp->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()的代碼如下:

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進程在上圖1:----標識處被B進程搶點.B進程也執行了foo_update_a().等B執行完後,再切換回A進程.此時,A進程所持的old_fd實際上已經被B進程給釋放掉了.此後A進程對old_fd的操作都是非法的。所以在此我們得到一個重要結論:RCU允許多個讀者同時訪問被保護的數據,也允許多個讀者在有寫者時訪問被保護的數據(但是注意:是否可以有多個寫者並行訪問取決於寫者之間使用的同步機制)。

  1. CPU指令優化同步問題
  • 寫者刪除操作
    如下程序,是針對於全局變量gbl_foo的操作。假設有兩個線程同時運行 foo_ read和foo_update,當foo_ read執行完賦值操作後,線程發生切換;此時另一個線程開始執行foo_update並執行完成。當foo_ read運行的進程切換回來後,運行dosomething 的時候,fp已經被刪除,這將對系統造成危害。
struct foo {
           int a;
           char b;
           long c;
 };
 
DEFINE_SPINLOCK(foo_mutex);
 
struct foo *gbl_foo;
 
void foo_read (void)
{
     foo *fp = gbl_foo;
     if ( fp != NULL )
        	dosomething(fp->a, fp->b , fp->c );
}
 
void foo_update( foo* new_fp )
{
     spin_lock(&foo_mutex);
     foo *old_fp = gbl_foo;
     gbl_foo = new_fp;
     spin_unlock(&foo_mutex);
     kfee(old_fp);
}


可以通過在24行kfree函數之前插入synchronize_rcu函數,執行刪除操作後,先進入grace period。下圖中每行代表一個線程,最下面的一行是foo_update刪除線程,當它執行完刪除操作後,線程進入了寬限期。寬限期的意義是,在一個刪除動作發生後,它必須等待所有在寬限期開始前已經開始的讀線程結束,纔可以進行銷燬操作。這樣做的原因是這些線程有可能讀到了要刪除的元素。圖中的寬限期必須等待1和2結束;而讀線程5在寬限期開始前已經結束,不需要考慮;而3,4,6也不需要考慮,因爲在寬限期開始後的線程不可能讀到已刪除的元素

void foo_read(void)
{
	rcu_read_lock();
	foo *fp = gbl_foo;
	if ( fp != NULL )
			dosomething(fp->a,fp->b,fp->c);
	rcu_read_unlock();
}
 
void foo_update( foo* new_fp )
{
	spin_lock(&foo_mutex);
	foo *old_fp = gbl_foo;
	gbl_foo = new_fp;
	spin_unlock(&foo_mutex);
	synchronize_rcu();
	kfee(old_fp);
}
  • 寫者修改問題
    這段代碼中,我們期望的是6,7,8行的代碼在第10行代碼之前執行。但優化後的代碼並不對執行順序做出保證,一個寫線程可能先執行第10行代碼。在這種情形下,一個讀線程foo_read很可能讀到 new_fp,但new_fp的成員賦值還沒執行完成。當讀線程執行dosomething(fp->a, fp->b , fp->c ) 的時候,就有不確定的參數傳入到dosomething,極有可能造成不期望的結果,甚至程序崩潰。可以通過優化屏障來解決該問題,在第9行插入屏障,保證成員賦值之後,再執行指針賦值操作。不過,RCU機制對優化屏障做了包裝,提供了專用的API來解決該問題。這時候,第10行不再是直接的指針賦值,而應該改爲 : rcu_assign_pointer(gbl_foo,new_fp);
void foo_update( foo* new_fp )
{
	spin_lock(&foo_mutex);
	foo *old_fp = gbl_foo;
	
	new_fp->a = 1;
	new_fp->b = ‘b’;
	new_fp->c = 100;
	
	gbl_foo = new_fp;
	spin_unlock(&foo_mutex);
	synchronize_rcu();
	kfee(old_fp);
}
  • 讀者保護問題
    如下代碼,在DEC Alpha CPU機器上還有一種更強悍的優化,讀線程foo_read第6行的 fp->a,fp->b,fp->c可能會在第3行還沒執行的時候就預先判斷運行,當foo_read和foo_update線程同時運行的時候,可能導致傳入dosomething的一部分屬於舊的gbl_foo,而另外的屬於新的。這樣導致運行結果的錯誤。爲了避免該類問題,在第4行之後插入內存屏障,保障fp賦值之後,再執行dosomething。不過,RCU還是提供了宏來解決該問題,直接對第4行修改:fp = rcu_dereference(gbl_foo);
void foo_read(void)
{		
	rcu_read_lock();
	foo *fp = gbl_foo;
	if ( fp != NULL )
		dosomething(fp->a, fp->b ,fp->c);
	rcu_read_unlock();
}

適用場景

  1. 適合用於同步基於指針實現的數據結構(例如鏈表,哈希表等)。因爲指針賦值是一條單指令.也就是說是一個原子操作. 因它更改指針指向沒必要考慮它的同步.只需要考慮cache的影響

  2. 適用用讀操作遠遠大與寫操作的場景。RCU實際上是一種改進的rwlock,讀者幾乎沒有什麼同步開銷,它不需要鎖,不使用原子指令,而且在除alpha的所有架構上也不需要內存柵(Memory Barrier),因此不會導致鎖競爭,內存延遲以及流水線停滯。不需要鎖也使得使用更容易,因爲死鎖問題就不需要考慮了。寫者的同步開銷比較大,它需要延遲數據結構的釋放,複製被修改的數據結構,它也必須使用某種鎖機制同步並行的其它寫者的修改操作。

參考鏈接

https://www.ibm.com/developerworks/cn/linux/l-rcu/index.html

https://www.cnblogs.com/wuchanming/p/3816103.html

http://abcdxyzk.github.io/blog/2015/07/31/kernel-sched-rcu/

http://blog.jobbole.com/106856/

https://blog.csdn.net/junguo/article/details/8244530#

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章