圖解 RCU 原理

Linux 內核設計了多種鎖機制,比如 讀寫鎖自旋鎖 和 信號量 等。爲什麼要設計這麼多鎖機制呢?這是因爲不同的鎖機制適用於不同的場景,比如 讀寫鎖 適用於讀多寫少的場景;而 信號量 適用於進程長時間佔用鎖,並且允許上下文切換的場景。

本文主要介紹一種 Linux 內核中性能非常高的鎖機制:RCU鎖機制

RCU 是 Read Copy Update 的縮寫,中文意思是 讀取複製更新。RCU 鎖機制 就是通過讀取、複製和更新這三個操作來實現鎖功能。在介紹 RCU鎖 之前,我們先來看看下面的實例。

struct foo {
    int  a;
    char b;
    long c;
 };

struct foo *gbl_foo;

void foo_read(void)
{
    foo *fp = gbl_foo;
    if (fp != NULL)
        do_something(fp->a, fp->b, fp->c);
}

void foo_update(foo *new_fp)
{
    foo *old_fp = gbl_foo;
    gbl_foo = new_fp;
    free(old_fp);
}

假如有線程 A 和線程 B 同時執行 foo_read(),而另線程 C 執行 foo_update(),那麼會出現以下幾種情況:

  1. 線程 A 和線程 B 同時讀取到舊的 gbl_foo 的指針。

  2. 線程 A 和線程 B 同時讀取到新的 gbl_foo 的指針。

  3. 線程 A 和線程 B 有一個讀取到新的 gbl_foo 的指針,另外一個讀取到舊的 gbl_foo 的指針。

如果線程 A 或線程 B 在讀取舊的 gbl_foo 數據還沒完成時,線程 C 釋放了舊的 gbl_foo 指針,那麼將會導致程序奔潰。

也就是說,在不加鎖的情況下,對公共數據的訪問是危險的。當然,我們可以使用 讀寫鎖信號量 或者 自旋鎖 來對公共數據進行保護。但這些鎖都有各自的弊端,比如:

那麼有沒有一種鎖機制,對系統的性能影響不大的呢?所以,Linux 內核黑客們就創造出 RCU鎖

RCU 鎖原理

如果能夠保證所有使用某個公共數據的線程不再使用它,那麼就可以安全刪除此公共數據。

1. 寬限期

在上面的例子中,如果能夠保證線程 A 和線程 B 不再使用舊數據,那麼線程 C 就能安全刪除舊數據。

如下圖所示(舊數據對應對象 A,新數據對應對象 B):

rcu-timeline

從上圖的時間線可以看出,線程 A 和線程 B 從 glb_foo 指針獲取的都是對象 A 的引用。

提示:因爲 glb_foo 指針在時間點 B 才被替換成對象 B,而線程 A 和線程 B 都是在時間點 B 前獲取 glb_foo 指針指向的對象,所以它們獲取到的都是對象 A 的引用。

而在 安全點 後,線程 A 和線程 B 便不再使用舊數據(對象 A)。所以此時,線程 C 便可以安全釋放舊數據(對象 A)。

線程 A 和線程 B 使用舊數據的這段期間,被稱爲 寬限期。如下圖所示:

grace-period

所以,RCU鎖 的核心思想就是怎麼確定 寬限期。因爲確定寬限期後,就可以隨心所欲地釋放舊數據。

2. 寬限期確認

RCU鎖 的原理雖然比較簡單,但是實現卻有點小複雜,主要是因爲 寬限期 的確定比較麻煩。

爲了能夠確認 寬限期,使用 RCU 鎖時有以下限制:

由於在 RCU 臨界區是禁止調度的,所以如果 CPU 發生了調度,就可以確定當前線程已經退出了臨界區(也就是說當前線程不再引用舊對象)。如果所有的 CPU 都至少發生過一次調度,那麼也就說明沒有任何線程引用舊對象,此時就可以安全釋放舊對象了。

所以,RCU 鎖的核心原理是:在釋放舊對象前,必須等待所有 CPU 核心至少調度一次。如下代碼所示:

void foo_update(struct foo *new_fp)
{
    // 1. 將 gbl_foo 指向新對象
    spin_lock(&foo_mutex);
    foo *old_fp = gbl_foo;
    gbl_foo = new_fp;
    spin_unlock(&foo_mutex);

    // 2. 等待所有 CPU 核心至少調度一次
    synchronize_kernel();

    // 3. 釋放舊對象
    free(old_fp);
}

foo_update() 函數釋放舊對象的步驟如下:

  1. 使用新對象替換舊對象,在替換前必須使用自旋鎖進行保護,避免多個 CPU 同時修改 gbl_foo 指針的值。

  2. 等待所有 CPU 核心至少調度一次。

  3. 由於所有 CPU 核心都至少調度過一次,那麼可以確認現在沒有線程引用舊對象,所以可以安全釋放舊對象。

3. RCU 臨界區

通過前面的分析可知,在 RCU 臨界區中是不能發生調度的。要保證臨界區不發生調度,首先要確保在臨界區中不能調用可能觸發調度的函數,如:alloc_pages()。這點需要 RCU 使用者自己保證。

另外一點要保證的是,內核不能發生搶佔,這點可以通過調用 preempt_disable() 函數實現。內核定義了一個名爲 rcu_read_lock() 的宏,如下所示:

#define rcu_read_lock()  preempt_disable()

可以看出, rcu_read_lock() 宏其實就是 preempt_disable() 函數的別名。所以,使用 RCU 鎖時,可以使用 rcu_read_lock() 宏對臨界區進行保護。

當退出臨界區時,需要調用 rcu_read_unlock() 把內核搶佔打開。rcu_read_unlock() 的定義如下:

#define rcu_read_unlock() preempt_enable()

可以看出,rcu_read_unlock() 宏就是 preempt_enable() 的別名。

所以,當我們使用 RCU 鎖對臨界區進行保護時,必須將需要保護的代碼放置在 rcu_read_lock() 和 rcu_read_unlock() 之間,如下所示:

void foo_read(void)
{
    // 1. 保護臨界區
    rcu_read_lock();
  
    foo *fp = gbl_foo;
    if (fp != NULL)
        do_something(fp->a, fp->b, fp->c);
  
    // 2. 退出臨界區
    rcu_read_unlock();
}
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Vw7VegcZ8NSnP-JGmBh3aQ