圖解 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()
,那麼會出現以下幾種情況:
-
線程 A 和線程 B 同時讀取到舊的 gbl_foo 的指針。
-
線程 A 和線程 B 同時讀取到新的 gbl_foo 的指針。
-
線程 A 和線程 B 有一個讀取到新的 gbl_foo 的指針,另外一個讀取到舊的 gbl_foo 的指針。
如果線程 A 或線程 B 在讀取舊的 gbl_foo 數據還沒完成時,線程 C 釋放了舊的 gbl_foo 指針,那麼將會導致程序奔潰。
也就是說,在不加鎖的情況下,對公共數據的訪問是危險的。當然,我們可以使用 讀寫鎖
、信號量
或者 自旋鎖
來對公共數據進行保護。但這些鎖都有各自的弊端,比如:
-
讀寫鎖
:對於寫操作較多的場景,性能會非常差。 -
信號量
:上鎖失敗的進程將會切換上下文,從而導致系統的性能下降。 -
自旋鎖
:獲得鎖的 CPU 將會阻塞其他 CPU 的允許,從而導致系統的並行能力下降。
那麼有沒有一種鎖機制,對系統的性能影響不大的呢?所以,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 鎖前,必須禁止內核搶佔。
-
在 RCU 鎖保護的臨界區中,不能使用可能觸發調度的函數(如不能調用 alloc_pages 函數)。
由於在 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()
函數釋放舊對象的步驟如下:
-
使用新對象替換舊對象,在替換前必須使用自旋鎖進行保護,避免多個 CPU 同時修改 gbl_foo 指針的值。
-
等待所有 CPU 核心至少調度一次。
-
由於所有 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