關(guān)鍵要點(diǎn)
- RCU在讀取路徑上完全消除了鎖相關(guān)開銷,因此其讀取性能是傳統(tǒng)鎖機(jī)制的10到30倍;不過(guò)這種機(jī)制會(huì)以占用更多內(nèi)存和影響數(shù)據(jù)最終一致性為代價(jià)。
- RCU的工作流程分為三個(gè)階段:讀者可以無(wú)鎖地訪問(wèn)數(shù)據(jù),而寫入操作則會(huì)原子性地完成數(shù)據(jù)的復(fù)制、修改和替換操作,并將內(nèi)存回收操作推遲到一定時(shí)間之后進(jìn)行,以確保所有讀者都已經(jīng)完成讀取操作。
- RCU通過(guò)犧牲數(shù)據(jù)的一致性來(lái)提升系統(tǒng)的可擴(kuò)展性。因此,在那些以讀取操作為主的工作負(fù)載中,如果能夠接受數(shù)據(jù)最終出現(xiàn)不一致的情況,RCU就是一種理想的選擇。
- 當(dāng)讀寫操作的比率超過(guò)10:1,并且可以容忍短暫的數(shù)據(jù)不一致現(xiàn)象時(shí),就應(yīng)該使用RCU。例如,Kubernetes的API服務(wù)、PostgreSQL的MVCC機(jī)制、Envoy代理以及DNS服務(wù)器等都采用了RCU這種設(shè)計(jì)模式。
- 然而,RCU也存在一定的風(fēng)險(xiǎn)——在退出臨界區(qū)后仍然使用指針可能會(huì)導(dǎo)致“使用已釋放的資源”這類錯(cuò)誤;因此,對(duì)于那些需要強(qiáng)數(shù)據(jù)一致性或必須立即獲取最新數(shù)據(jù)的系統(tǒng)來(lái)說(shuō),RCU并不適用。
引言
讀寫鎖機(jī)制似乎是那些以讀取操作為主的工作負(fù)載的理想解決方案:多個(gè)讀者可以同時(shí)進(jìn)行讀取操作,而寫入操作則需要獨(dú)占訪問(wèn)權(quán)限。這種鎖機(jī)制允許并發(fā)讀取,但寫入操作必須獨(dú)占資源。讀者們共享同一個(gè)鎖,而寫入者則會(huì)獨(dú)占這個(gè)鎖,不過(guò)這種方式其實(shí)隱藏著一定的成本。
我最近在一臺(tái)M4 MacBook上對(duì)一個(gè)以讀取操作為主的工作負(fù)載進(jìn)行了測(cè)試——該工作負(fù)載的讀寫操作比率約為1000:1。使用pthread提供的rwlock實(shí)現(xiàn)時(shí),我在5秒鐘內(nèi)完成了2340萬(wàn)次讀取操作;而使用RCU機(jī)制后,我竟然完成了4920萬(wàn)次讀取操作,性能提升了110%,而且工作負(fù)載本身并沒有發(fā)生任何變化。

圖1:RCU與讀寫鎖機(jī)制的性能對(duì)比圖。
那么,是什么導(dǎo)致了這種性能瓶頸呢?在讀寫鎖機(jī)制中,讀者們必須獲取共享訪問(wèn)權(quán)限,這會(huì)觸發(fā)原子操作以及跨CPU核心的緩存行失效處理。隨著CPU核心數(shù)量的增加,這種開銷也會(huì)呈指數(shù)級(jí)增長(zhǎng)。而RCU通過(guò)完全消除讀取路徑中的鎖相關(guān)機(jī)制,有效地解決了這個(gè)問(wèn)題。
這并不是某種小眾的內(nèi)核優(yōu)化技術(shù)。你每天使用的生產(chǎn)系統(tǒng),比如Kubernetes的etcd、PostgreSQL的MVCC機(jī)制以及Envoy代理,都是依靠RCU的原理來(lái)實(shí)現(xiàn)可擴(kuò)展性的。隨著C++26標(biāo)準(zhǔn)對(duì)RCU的正式規(guī)范化(P2545R4),這種設(shè)計(jì)模式正在從一種特定于內(nèi)核的技術(shù),逐漸發(fā)展成為一種通用的編程機(jī)制。
本文解釋了RCU的工作原理、它在何時(shí)能夠帶來(lái)顯著的性能提升,以及如何判斷在自己的系統(tǒng)中應(yīng)用RCU的時(shí)機(jī)。
名稱的含義:讀取、復(fù)制、更新
“讀取-復(fù)制-更新”這一名稱準(zhǔn)確地描述了這種機(jī)制的工作過(guò)程。讓我們來(lái)詳細(xì)分析一下。
基本架構(gòu)
存在某種被多個(gè)線程同時(shí)訪問(wèn)的共享數(shù)據(jù)結(jié)構(gòu),比如配置文件、數(shù)據(jù)庫(kù)記錄或一組標(biāo)志位。其中一些線程負(fù)責(zé)讀取數(shù)據(jù),而另一些線程則負(fù)責(zé)寫入數(shù)據(jù)。關(guān)鍵在于,讀取操作的次數(shù)遠(yuǎn)遠(yuǎn)多于寫入操作——通常比例為百比一甚至千比一。對(duì)于大多數(shù)系統(tǒng)來(lái)說(shuō),這種比例都是現(xiàn)實(shí)存在的。畢竟,我們并不會(huì)每秒鐘都更新那些標(biāo)志位,而且配置信息的修改頻率也遠(yuǎn)低于針對(duì)這些配置數(shù)據(jù)的讀取請(qǐng)求次數(shù)。

圖2:讀取者和寫入者訪問(wèn)共享資源。
讀取操作
讀取者在訪問(wèn)共享數(shù)據(jù)時(shí)不會(huì)獲取任何鎖。因此不存在等待、競(jìng)爭(zhēng)或額外的開銷——這就是這種機(jī)制的無(wú)鎖優(yōu)勢(shì)所在。

圖3:讀取者可以在不獲取任何鎖的情況下訪問(wèn)資源。
復(fù)制操作
當(dāng)寫入者需要修改數(shù)據(jù)時(shí),他們并不會(huì)直接修改原始數(shù)據(jù),而是先創(chuàng)建一份數(shù)據(jù)的副本,然后對(duì)這份副本進(jìn)行修改。這樣一來(lái),讀取者仍然能夠看到未修改的、一致的舊版本,而寫入者則可以專注于準(zhǔn)備新的版本。

圖4:寫入者創(chuàng)建副本以進(jìn)行數(shù)據(jù)修改。
更新操作
一旦副本準(zhǔn)備就緒,寫入者就會(huì)原子性地將指針指向新版本。所謂“原子性”是指這個(gè)操作要么完全完成,要么根本不會(huì)執(zhí)行;既不會(huì)出現(xiàn)部分更新的情況,也不會(huì)出現(xiàn)讀取到舊版本和新版本混合的情況。從這一刻起,新的讀取者將會(huì)看到已更新的版本,而現(xiàn)有的讀取者則可以繼續(xù)使用舊的版本而不會(huì)遇到任何問(wèn)題。

圖5:全局指針已更新為新數(shù)據(jù),但舊的讀取器仍會(huì)繼續(xù)讀取舊數(shù)據(jù)。
這種三階段機(jī)制正是RCU實(shí)現(xiàn)無(wú)鎖讀取功能的關(guān)鍵所在:由于寫入者永遠(yuǎn)不會(huì)修改當(dāng)前正在使用的數(shù)據(jù),因此讀取器根本不需要等待。
問(wèn)題所在:為什么傳統(tǒng)的鎖定機(jī)制在大規(guī)模應(yīng)用中會(huì)失效
想象一下,你正在構(gòu)建一個(gè)高流量的API網(wǎng)關(guān)。該網(wǎng)關(guān)需要配置路由規(guī)則,以便將傳入的請(qǐng)求分配給相應(yīng)的后端服務(wù)——比如哪個(gè)服務(wù)負(fù)責(zé)處理/api/users請(qǐng)求,哪個(gè)服務(wù)負(fù)責(zé)處理/api/orders請(qǐng)求,同時(shí)還需要設(shè)置每個(gè)路由的超時(shí)時(shí)間以及重試策略等等。這種配置機(jī)制通常如下:
- 每條請(qǐng)求都會(huì)被處理(每秒鐘可能有成千上萬(wàn)甚至數(shù)百萬(wàn)條請(qǐng)求被處理)。
- 但這類配置很少會(huì)被修改(除非你部署了新的服務(wù)或更改了路由規(guī)則,可能每小時(shí)才會(huì)修改一次)。
當(dāng)使用傳統(tǒng)的讀寫鎖機(jī)制時(shí),會(huì)發(fā)生以下情況:
pthread_rwlock_t config_lock;
config_t *global_config;
// 每條請(qǐng)求都會(huì)執(zhí)行以下操作:
void handle_request() {
pthread_rwlock_rdlock(&config_lock); // 獲取讀鎖
route_t *route = lookup_route(global_config, request_path);
int timeout = route->timeout_ms;
pthread_rwlock_unlock(&config_lock); // 釋放讀鎖
// ... 將請(qǐng)求轉(zhuǎn)發(fā)給后端服務(wù) ...
}
// 管理員需要修改配置:
void update_config(config_t *new_config) {
pthread_rwlock_wrlock(&config_lock); // 獲取寫鎖
global_config = new_config;
pthread_rwlock_unlock(&config_lock);
}
從表面上看,讀寫鎖機(jī)制似乎非常適合這種應(yīng)用場(chǎng)景:多個(gè)讀取器可以同時(shí)進(jìn)行操作,而寫入者則擁有對(duì)數(shù)據(jù)的獨(dú)占訪問(wèn)權(quán)。但實(shí)際上,這種機(jī)制存在隱藏的性能開銷,在大規(guī)模應(yīng)用中這些問(wèn)題會(huì)變得非常嚴(yán)重。
鎖定操作的開銷
盡管讀寫鎖允許多個(gè)讀取器同時(shí)執(zhí)行操作,但它們?nèi)匀恍枰ㄟ^(guò)原子操作來(lái)獲取鎖。在一臺(tái)擁有多顆CPU核心的繁忙服務(wù)器上:
- 每個(gè)讀取器都必須執(zhí)行原子級(jí)的比較并交換操作才能獲取讀鎖。
- 這些原子操作會(huì)導(dǎo)致所有CPU核心中的緩存行內(nèi)容失效。
- 包含鎖信息的緩存行會(huì)在不同的CPU核心之間來(lái)回“跳躍”,每秒鐘會(huì)發(fā)生數(shù)千次這樣的操作。
- 當(dāng)CPU核心的數(shù)量增加時(shí)(8核、16核、32核及以上),這種競(jìng)爭(zhēng)現(xiàn)象會(huì)呈指數(shù)級(jí)加劇。
最終的結(jié)果就是:雖然各個(gè)讀取器之間不會(huì)互相阻塞,但它們都會(huì)爭(zhēng)奪同一條緩存行。本應(yīng)在納秒級(jí)別完成的路由查找操作,實(shí)際上會(huì)被鎖定操作的開銷所拖累,從而導(dǎo)致性能大幅下降。
這種情況就好比在一個(gè)圖書館里,讀者們不需要互相等待,但在借書之前都必須先在一本共享的登記簿上簽名。然而,這本登記簿本身卻成了性能瓶頸,因?yàn)闆]有人會(huì)阻止其他人借書。
理解性能瓶頸的本質(zhì)
<要理解為什么基于讀寫鎖的機(jī)制在大規(guī)模應(yīng)用中會(huì)遇到種種問(wèn)題,我們就需要研究那些困擾所有多核系統(tǒng)中基于鎖的同步機(jī)制的基本性缺陷。>
緩存一致性帶來(lái)的開銷
現(xiàn)代CPU的每個(gè)核心都擁有獨(dú)立的緩存。當(dāng)某個(gè)讀操作獲取鎖時(shí),它會(huì)執(zhí)行一個(gè)原子操作來(lái)更新鎖的狀態(tài)(例如,增加讀操作的數(shù)量)。這種狀態(tài)更新會(huì)迫使CPU同步所有核心中的緩存數(shù)據(jù),這個(gè)過(guò)程被稱為“緩存行失效”。每當(dāng)有讀操作獲取或釋放鎖時(shí),包含該鎖的緩存行就會(huì)在各個(gè)核心之間來(lái)回“跳躍”。在一個(gè)擁有十個(gè)核心、每秒要處理數(shù)千個(gè)請(qǐng)求的系統(tǒng)里,每次這樣的“跳躍”都會(huì)耗費(fèi)10到100納秒的時(shí)間。隨著核心數(shù)量的增加,這種開銷會(huì)呈指數(shù)級(jí)增長(zhǎng)。

圖6:緩存行跳躍帶來(lái)的開銷。
寫入操作時(shí)的競(jìng)爭(zhēng)問(wèn)題
在讀寫鎖機(jī)制中,寫操作需要獲取獨(dú)占鎖,以防止讀操作讀取到部分更新的數(shù)據(jù)。一旦寫操作獲得了獨(dú)占鎖,所有讀操作都必須等待,即使它們之間并不會(huì)發(fā)生沖突。在那些讀操作非常頻繁的場(chǎng)景中,這種獨(dú)占鎖的獲取機(jī)制會(huì)導(dǎo)致“雷鳴獸群”現(xiàn)象——成千上萬(wàn)的讀操作會(huì)排成一隊(duì),等待某個(gè)寫操作完成操作。
優(yōu)先級(jí)反轉(zhuǎn)
由于“優(yōu)先級(jí)反轉(zhuǎn)”的存在,高優(yōu)先級(jí)的讀操作可能會(huì)被持有鎖的低優(yōu)先級(jí)寫操作阻塞,從而導(dǎo)致性能不可預(yù)測(cè),甚至引發(fā)系統(tǒng)不穩(wěn)定。
排隊(duì)現(xiàn)象
如果操作系統(tǒng)強(qiáng)行中斷了一個(gè)正在持有鎖的線程的執(zhí)行,那么所有等待該線程的線程都會(huì)被阻塞,直到它重新開始執(zhí)行。在負(fù)載較大的情況下,這種問(wèn)題會(huì)嚴(yán)重降低系統(tǒng)的吞吐量。

圖7:正常的多線程運(yùn)行情況。

圖8:被操作系統(tǒng)中斷的多線程運(yùn)行情況。
圖7和圖8中提到的這些問(wèn)題,其實(shí)是基于鎖的機(jī)制所固有的局限性。無(wú)論你如何優(yōu)化讀寫鎖的設(shè)計(jì),都無(wú)法避免緩存一致性帶來(lái)的開銷、寫入操作時(shí)的競(jìng)爭(zhēng)問(wèn)題,以及排隊(duì)現(xiàn)象和優(yōu)先級(jí)反轉(zhuǎn)所帶來(lái)的風(fēng)險(xiǎn)。
重新思考這個(gè)問(wèn)題:我們真的需要鎖嗎?
解決復(fù)雜問(wèn)題的關(guān)鍵在于提出正確的問(wèn)題。根據(jù)我們對(duì)基于鎖的瓶頸現(xiàn)象所了解到的知識(shí),我們應(yīng)該問(wèn)問(wèn)自己:我們是否用對(duì)了方法來(lái)解決當(dāng)前面臨的問(wèn)題?
更直接地說(shuō),我們可以這樣問(wèn):鎖真的是我們解決這個(gè)問(wèn)題的唯一工具嗎?
讓我們想想,在那些以讀操作為主的系統(tǒng)中,我們實(shí)際上想要實(shí)現(xiàn)什么目標(biāo):
- 讀者需要獲取一致的數(shù)據(jù)(不能出現(xiàn)數(shù)據(jù)不一致的情況,也不能進(jìn)行部分更新)。
- 讀操作的數(shù)量遠(yuǎn)遠(yuǎn)多于寫操作的數(shù)量(通常是千比一甚至更高)。
- 寫操作必須能夠安全地更新數(shù)據(jù)。
- 更新操作的頻率很低(比如每小時(shí)只進(jìn)行一次,而讀操作的速度卻可能達(dá)到每秒數(shù)百萬(wàn)次)。
傳統(tǒng)的鎖機(jī)制是通過(guò)阻止并發(fā)訪問(wèn)來(lái)解決這些問(wèn)題的。它們要求所有程序在進(jìn)行讀寫操作時(shí)都必須進(jìn)行協(xié)調(diào),但實(shí)際上很多時(shí)候這種協(xié)調(diào)并不是必需的。那么,如果我們換一種方法來(lái)解決這個(gè)問(wèn)題會(huì)怎么樣呢?如果讀者根本不需要進(jìn)行任何協(xié)調(diào),會(huì)怎么樣呢?
如果我們能夠保證讀者在任何情況下都能看到有效且一致的數(shù)據(jù),而無(wú)需獲取任何鎖,會(huì)怎么樣呢?如果把所有的協(xié)調(diào)工作都交給那些數(shù)量稀少的寫操作者,而不是數(shù)量眾多的讀者,會(huì)怎么樣呢?
正是為了解決這些問(wèn)題,RCU應(yīng)運(yùn)而生。
RCU——三階段機(jī)制
RCU代表了一種與傳統(tǒng)基于鎖的并發(fā)模型截然不同的解決方案。它不允許使用鎖來(lái)保護(hù)共享數(shù)據(jù),而是讓讀者能夠在不獲取任何鎖的情況下直接訪問(wèn)這些數(shù)據(jù)。這一機(jī)制通過(guò)三個(gè)關(guān)鍵概念徹底顛覆了傳統(tǒng)的處理方式。
第一階段:無(wú)鎖讀取
讀者在訪問(wèn)數(shù)據(jù)時(shí)不需要獲取任何鎖,只需獲取當(dāng)前數(shù)據(jù)的指針,然后就可以自由地使用這些數(shù)據(jù),而無(wú)需擔(dān)心數(shù)據(jù)會(huì)在他們使用期間被修改。這種無(wú)鎖機(jī)制之所以可行,是因?yàn)閷懖僮髡邚牟粫?huì)直接修改共享數(shù)據(jù)。
為了參與基于RCU的系統(tǒng),讀者需要在開始使用受RCU保護(hù)的數(shù)據(jù)時(shí)進(jìn)行標(biāo)記:
rcu_read_lock() ; // 標(biāo)記:進(jìn)入臨界區(qū)
p = rcu_dereference(global_ptr); // 獲取當(dāng)前數(shù)據(jù)的指針,然后開始讀取臨界區(qū)的內(nèi)容
rcu_read_unlock() ; // 標(biāo)記:離開臨界區(qū)
盡管這些函數(shù)的名稱中包含了“鎖”這個(gè)詞,但實(shí)際上它們的實(shí)現(xiàn)非常輕量級(jí)——通常只是禁用內(nèi)核的搶占機(jī)制,或者在用戶空間中增加一個(gè)線程局部的計(jì)數(shù)器而已。根據(jù)具體的RCU實(shí)現(xiàn)方式,這些函數(shù)可能會(huì)使用原子操作,也可能會(huì)不使用;例如,在某些不允許搶占的內(nèi)核配置中,它們就不會(huì)使用原子操作。
需要注意的一些重要規(guī)則:
- 指針僅在臨界區(qū)內(nèi)有效:
通過(guò)rcu_dereference()獲取的指針,在rcu_read_unlock()之后就不能再使用了。一旦離開了臨界區(qū),這個(gè)指針可能會(huì)指向無(wú)效的內(nèi)存地址——因?yàn)橄嚓P(guān)數(shù)據(jù)可能已經(jīng)被釋放了。 - 在臨界區(qū)內(nèi),數(shù)據(jù)不能被釋放
只要至少還有一個(gè)讀者處于臨界區(qū)內(nèi)(即在調(diào)用rcu_read_lock()和rcu_read_unlock()之間),那么該讀者正在訪問(wèn)的數(shù)據(jù)就不能被釋放。這就引出了一個(gè)重要的問(wèn)題:RCU是如何判斷何時(shí)可以安全地釋放舊數(shù)據(jù)的呢?答案就在于“寬限期”,我們稍后會(huì)詳細(xì)討論這一點(diǎn)。 - 禁止阻塞或進(jìn)入睡眠狀態(tài)
>讀者在臨界區(qū)內(nèi)時(shí)絕對(duì)不能阻塞或進(jìn)入睡眠狀態(tài):既不能獲取鎖,也不能進(jìn)行I/O操作,更不能調(diào)用sleep函數(shù)。違反這一規(guī)則會(huì)導(dǎo)致RCU無(wú)法判斷何時(shí)可以安全地釋放內(nèi)存。
第二階段:復(fù)制與更新
當(dāng)寫入者需要修改數(shù)據(jù)時(shí),它會(huì)創(chuàng)建一份新副本,對(duì)這份副本進(jìn)行修改,然后通過(guò)原子操作交換全局指針,從而發(fā)布新的數(shù)據(jù):
// 1. 分配新內(nèi)存空間
config_t *new_config = malloc(sizeof(config_t));
// 2. 復(fù)制當(dāng)前數(shù)據(jù)
config_t *old_config = global_config;
*new_config = *old_config;
// 3. 修改副本中的數(shù)據(jù)
new_config->max_connections = new_max_connections;
// 4. 進(jìn)行原子指針交換
__atomic_store_n(&global_config, new_config, __ATOMIC_RELEASE);
// 舊的讀取者仍然使用舊的數(shù)據(jù)指針
// 新的讀取者則會(huì)使用新的數(shù)據(jù)指針
完成這種原子操作交換后:
- 新的讀取者會(huì)立即看到更新后的數(shù)據(jù)。
- 現(xiàn)有的讀取者可以繼續(xù)安全地使用他們?cè)械臄?shù)據(jù)指針。
- 兩個(gè)版本的數(shù)據(jù)會(huì)暫時(shí)共存。
第三階段:寬限期與內(nèi)存回收
在寫入者發(fā)布了新數(shù)據(jù)版本之后,舊版本的數(shù)據(jù)不能立即被釋放。為什么呢?因?yàn)槟切┰诟掳l(fā)生之前就進(jìn)入了臨界區(qū)的讀取者可能仍在使用舊數(shù)據(jù)。如果直接釋放這些舊數(shù)據(jù),就會(huì)導(dǎo)致“使用已釋放內(nèi)存”的錯(cuò)誤。
因此,寫入者必須等待一個(gè)“寬限期”結(jié)束后,才能回收這些舊內(nèi)存。
寫-寫同步:多個(gè)寫入者怎么辦?
你可能會(huì)想知道:如果有兩個(gè)寫入者同時(shí)嘗試修改數(shù)據(jù),會(huì)發(fā)生什么?它們需要使用鎖嗎?數(shù)據(jù)更新會(huì)不會(huì)丟失呢?答案是:RCU確實(shí)能夠處理讀寫并發(fā)問(wèn)題,但多個(gè)寫入者之間仍然需要通過(guò)傳統(tǒng)的同步機(jī)制來(lái)進(jìn)行協(xié)調(diào)。
上述三個(gè)階段說(shuō)明了RCU是如何避免讀取者與寫入者之間的沖突的。然而,RCU并不能自動(dòng)解決多個(gè)寫入者同時(shí)修改數(shù)據(jù)時(shí)可能產(chǎn)生的沖突。當(dāng)有兩個(gè)寫入者試圖同時(shí)進(jìn)行更新時(shí),它們通常會(huì)使用傳統(tǒng)的鎖(互斥鎖或自旋鎖)來(lái)確保更新的順序性。
盡管如此,使用RCU仍然是一種可行的解決方案,因?yàn)椋?/p>
- 在以讀取操作為主的工作負(fù)載中,寫入者之間很少會(huì)發(fā)生競(jìng)爭(zhēng)。
- 與讀操作帶來(lái)的巨大性能提升相比,寫入鎖所帶來(lái)的開銷可以忽略不計(jì)。
- 讀取操作完全不需要使用鎖,而這正是RCU帶來(lái)性能優(yōu)勢(shì)的關(guān)鍵所在。
寬限期的檢測(cè)機(jī)制
什么是寬限期?
寬限期是指在這段時(shí)間內(nèi),所有在寬限期開始時(shí)正處于臨界區(qū)的讀取者都已經(jīng)完成了對(duì)那些臨界區(qū)的操作并退出了它們。換句話說(shuō),這個(gè)寬限期就是為了確保每一個(gè)可能還持有舊數(shù)據(jù)指針的讀取者都已經(jīng)停止使用這些舊數(shù)據(jù)而設(shè)定的。
__atomic_store_n(&global_config, new_config, __ATOMIC_RELEASE);
synchronize_rcu(); // 等待寬限期結(jié)束
free(old_data);
關(guān)鍵要點(diǎn)
雖然這一點(diǎn)可能不太明顯,但寬限期并不會(huì)等待所有讀者完成讀取操作——在一個(gè)處于持續(xù)高負(fù)載狀態(tài)的系統(tǒng)里,這樣做是不可能的。寬限期只會(huì)等待那些在更新發(fā)生時(shí)仍在執(zhí)行讀取操作的讀者。而在更新之后才開始閱讀的讀者會(huì)自動(dòng)看到新版本的內(nèi)容,因此無(wú)需對(duì)這些讀者進(jìn)行特別跟蹤。
寬限期的工作原理:靜止?fàn)顟B(tài)
要理解RCU機(jī)制,關(guān)鍵在于了解系統(tǒng)是如何判斷寬限期是否已經(jīng)結(jié)束的。這一判斷過(guò)程依賴于“靜止?fàn)顟B(tài)”這一概念。
所謂“靜止?fàn)顟B(tài)”,是指線程在執(zhí)行過(guò)程中處于這樣一個(gè)階段:此時(shí)可以確定該線程不會(huì)持有任何對(duì)受RCU保護(hù)的數(shù)據(jù)結(jié)構(gòu)的引用。換句話說(shuō),從RCU的角度來(lái)看,這個(gè)線程此時(shí)處于“靜止”狀態(tài)。

圖9:讀者線程、寫入線程以及寬限期的時(shí)間線概覽。
內(nèi)核RCU機(jī)制:上下文切換作為判斷靜止?fàn)顟B(tài)的手段
Linux內(nèi)核的實(shí)現(xiàn)方式是利用上下文切換來(lái)檢測(cè)線程是否處于靜止?fàn)顟B(tài)。當(dāng)調(diào)度器將控制權(quán)移交給另一個(gè)線程時(shí),那個(gè)被移交控制的線程肯定不可能正在執(zhí)行RCU保護(hù)區(qū)內(nèi)的代碼。需要記住的是,RCU保護(hù)區(qū)內(nèi)不允許線程進(jìn)行阻塞操作或進(jìn)入睡眠狀態(tài),因此每次上下文切換都意味著該線程已經(jīng)離開了可能位于RCU保護(hù)區(qū)內(nèi)的任何代碼段。
一旦系統(tǒng)中的所有CPU自更新發(fā)生以來(lái)都至少執(zhí)行過(guò)一次上下文切換,那么之前存在的所有RCU保護(hù)區(qū)間就已經(jīng)結(jié)束,此時(shí)寬限期也就宣告結(jié)束了。
用戶空間RCU機(jī)制:其他檢測(cè)方式
對(duì)于用戶空間環(huán)境而言,由于無(wú)法控制調(diào)度器或可靠地檢測(cè)上下文切換,因此它們會(huì)采用其他方法來(lái)檢測(cè)線程是否處于靜止?fàn)顟B(tài)。這些方法包括:
- 基于時(shí)間段的檢測(cè)機(jī)制(如URCU、crossbeam-epoch),在這種機(jī)制中,線程會(huì)定期聲明自己已經(jīng)進(jìn)入了一個(gè)新的“時(shí)間段”,這個(gè)時(shí)間戳表示它們已經(jīng)安全地通過(guò)了某個(gè)關(guān)鍵點(diǎn)。
- 基于信號(hào)的通知機(jī)制:在RCU的信號(hào)通知版本中,寫入線程會(huì)向所有讀取線程發(fā)送信號(hào)(例如SIGUSR1),并等待這些線程確認(rèn)收到信號(hào)。當(dāng)有讀取線程處理了這個(gè)信號(hào)時(shí),就說(shuō)明該線程當(dāng)前沒有處于RCU保護(hù)區(qū)內(nèi),此時(shí)就可以認(rèn)為它處于靜止?fàn)顟B(tài)。
- 顯式跟蹤機(jī)制:在這種機(jī)制中,線程會(huì)在進(jìn)入或離開RCU子系統(tǒng)時(shí)主動(dòng)進(jìn)行注冊(cè),這樣寬限期機(jī)制就能直接對(duì)這些線程進(jìn)行跟蹤。當(dāng)某個(gè)線程的讀取計(jì)數(shù)器值為零時(shí),就說(shuō)明該線程當(dāng)前沒有處于RCU保護(hù)區(qū)內(nèi),因此可以認(rèn)為它處于靜止?fàn)顟B(tài)。
盡管實(shí)現(xiàn)方式各不相同,但所有實(shí)現(xiàn)方案都遵循同一個(gè)基本原則:必須等到所有線程都到達(dá)一個(gè)安全狀態(tài),即它們不再持有舊的引用。具體的實(shí)現(xiàn)機(jī)制會(huì)根據(jù)不同環(huán)境中的可用資源而有所差異。
現(xiàn)在我們已經(jīng)了解了核心問(wèn)題以及RCU背后的原理,接下來(lái)讓我們看看那些采用類似RCU機(jī)制的實(shí)際系統(tǒng),并探討它們是如何確定“寬限期”結(jié)束時(shí)間的。
生產(chǎn)環(huán)境中的RCU:實(shí)際應(yīng)用案例
RCU的概念已經(jīng)存在了大約二十年,其原理被應(yīng)用于世界上一些最為關(guān)鍵、性能要求最高的系統(tǒng)中。
Linux內(nèi)核
Linux內(nèi)核是RCU的發(fā)源地,在過(guò)去二十多年里,RCU機(jī)制在Linux內(nèi)核中得到了廣泛的應(yīng)用。該機(jī)制將原子的指針更新技術(shù)與基于調(diào)度器的寬限期檢測(cè)機(jī)制相結(jié)合,利用上下文切換來(lái)判斷何時(shí)可以安全地回收內(nèi)存。
在Linux內(nèi)核中,RCU機(jī)制在各種以讀操作為主的數(shù)據(jù)結(jié)構(gòu)中都表現(xiàn)出了出色的性能,這些數(shù)據(jù)結(jié)構(gòu)包括:
- 網(wǎng)絡(luò)路由表
路由查找過(guò)程是不需要鎖保護(hù)的,因此數(shù)據(jù)包可以以最高速度被轉(zhuǎn)發(fā)。正是這種設(shè)計(jì)使得Linux能夠以線速處理數(shù)據(jù)包發(fā)送請(qǐng)求;每秒鐘會(huì)有數(shù)百萬(wàn)次路由查找操作發(fā)生,而且整個(gè)過(guò)程都不需要使用鎖。 - 文件系統(tǒng)的元數(shù)據(jù),主要包括緩存的目錄查詢操作、掛載點(diǎn)的讀取操作以及inode緩存相關(guān)的操作
- 那些能夠在多顆CPU上安全地管理設(shè)備狀態(tài)的設(shè)備驅(qū)動(dòng)程序
PostgreSQL的多版本并發(fā)控制機(jī)制
PostgreSQL的多版本并發(fā)控制機(jī)制體現(xiàn)了RCU原理在數(shù)據(jù)庫(kù)事務(wù)層面的應(yīng)用。雖然有些專家會(huì)爭(zhēng)論是否應(yīng)該將MVCC稱為“真正的RCU”,因?yàn)閮烧咴跁r(shí)間尺度(秒與微秒)和跟蹤機(jī)制(事務(wù)ID與靜態(tài)狀態(tài))上存在差異,但它們的核心設(shè)計(jì)思路是相同的:通過(guò)版本控制實(shí)現(xiàn)無(wú)鎖讀取,并延遲內(nèi)存回收操作。
當(dāng)某條記錄被更新時(shí),PostgreSQL并不會(huì)覆蓋原有的數(shù)據(jù),而是會(huì)創(chuàng)建這條記錄的新版本,并將舊版本標(biāo)記為過(guò)時(shí)狀態(tài)。每個(gè)事務(wù)都會(huì)獲得一個(gè)“快照”,這個(gè)快照反映了數(shù)據(jù)庫(kù)在某個(gè)特定時(shí)間點(diǎn)的狀態(tài),而這個(gè)時(shí)間點(diǎn)是由該事務(wù)的事務(wù)ID決定的。通過(guò)這種方式,不同事務(wù)可以看到不同的記錄版本,從而避免了讀操作阻塞寫操作的情況發(fā)生。
舊的記錄版本會(huì)不斷累積,直到有一個(gè)名為VACUUM的后臺(tái)進(jìn)程判斷出這些舊版本不再對(duì)任何活躍的事務(wù)可見——這一過(guò)程類似于RCU中的“寬限期”。只有當(dāng)所有可能看到這些舊版本的事務(wù)都完成操作后,VACUUM才會(huì)回收這些舊版本。不過(guò),這種機(jī)制也會(huì)導(dǎo)致某些事務(wù)在并發(fā)更新進(jìn)行期間看到過(guò)時(shí)的數(shù)據(jù)。
Kubernetes與Etcd
Kubernetes這一廣受歡迎的容器編排系統(tǒng),將其主要數(shù)據(jù)存儲(chǔ)機(jī)制設(shè)置為etcd。Etcd是一種分布式鍵值存儲(chǔ)系統(tǒng),它采用了與PostgreSQL類似的多版本并發(fā)控制機(jī)制。
當(dāng)你更新Kubernetes中的某個(gè)資源時(shí),比如一個(gè)Deployment或Service,實(shí)際上你并不是在修改etcd中已存在的數(shù)據(jù),而是創(chuàng)建了這些數(shù)據(jù)的新版本。通過(guò)這種方式,API服務(wù)器等組件能夠在處理更新請(qǐng)求的同時(shí),始終使用數(shù)據(jù)的一致性快照來(lái)響應(yīng)讀請(qǐng)求。
然而,這樣的歷史記錄可能會(huì)變得非常龐大,因此etcd會(huì)定期“壓縮”這些歷史數(shù)據(jù),刪除那些不再需要的舊版本。這一過(guò)程與RCU的數(shù)據(jù)復(fù)制機(jī)制有些類似,但也存在區(qū)別:與RCU不同,在RCU中寫入操作會(huì)等待所有讀取操作完成后再釋放內(nèi)存,而etcd則采用了一種混合機(jī)制。對(duì)于那些持續(xù)運(yùn)行的讀取操作,etcd會(huì)保護(hù)它們不被壓縮(類似于RCU的緩沖期),但對(duì)于一次性進(jìn)行的歷史數(shù)據(jù)讀取操作,則不會(huì)提供這種保護(hù)。如果客戶端嘗試讀取已被壓縮的數(shù)據(jù)版本,就會(huì)收到錯(cuò)誤提示,必須重新嘗試使用更新后的數(shù)據(jù)版本。這種設(shè)計(jì)將責(zé)任從系統(tǒng)層面轉(zhuǎn)移到了客戶端層面,即由客戶端來(lái)處理因數(shù)據(jù)壓縮而產(chǎn)生的錯(cuò)誤。
服務(wù)網(wǎng)格:Envoy
Envoy是一種高性能代理組件,它是許多服務(wù)網(wǎng)格架構(gòu)中的關(guān)鍵組成部分。Envoy的配置具有高度動(dòng)態(tài)性,可以頻繁地進(jìn)行更新。為了防止在配置更新過(guò)程中阻塞網(wǎng)絡(luò)流量,Envoy采用了類似RCU的機(jī)制。
當(dāng)收到新的配置信息時(shí),Envoy會(huì)首先在內(nèi)存中創(chuàng)建該配置的新版本,然后原子性地替換掉原有的配置指針。這種處理方式使得代理能夠在舊配置仍然有效的情況下繼續(xù)轉(zhuǎn)發(fā)請(qǐng)求,直到所有工作線程都切換到新配置后,才會(huì)安全地釋放舊配置所占用的內(nèi)存。
線程本地配置指針
每個(gè)工作線程都會(huì)維護(hù)自己獨(dú)立的配置指針:
thread_local Config* my_config = nullptr;
void worker_main() {
while (true) {
// 檢查全局配置是否發(fā)生了變化
Config* global = global_config.load(memory_order_acquire);
if (global != my_config) {
my_config = global; // 切換到新的配置版本
}
// 使用my_config來(lái)處理請(qǐng)求
process_requests(my_config);
}
}
這種設(shè)計(jì)模式帶來(lái)了兩個(gè)重要的好處。首先,它避免了在高頻執(zhí)行的代碼路徑中使用代價(jià)高昂的原子操作:每個(gè)工作線程在每次迭代中只進(jìn)行一次原子加載操作(即global_config.load()),用于檢查配置是否更新;之后,在該迭代過(guò)程中所有的請(qǐng)求都會(huì)使用緩存的my_config指針來(lái)處理。否則,如果每次調(diào)用process_requests()都需要進(jìn)行原子加載操作,那么每秒鐘可能會(huì)執(zhí)行數(shù)百萬(wàn)次這樣的操作。而通過(guò)線程本地緩存機(jī)制,工作線程每秒鐘只需要執(zhí)行幾十次原子操作而已。
其次,對(duì)于RCU語(yǔ)義而言,線程局部指針起到了標(biāo)識(shí)版本的作用——它告訴配置管理器每個(gè)工作進(jìn)程當(dāng)前使用的是哪個(gè)配置版本。當(dāng)my_config仍然指向舊配置時(shí),說(shuō)明該工作進(jìn)程仍在使用舊配置,因此無(wú)法釋放該配置。而當(dāng)my_config切換到新配置時(shí),說(shuō)明該工作進(jìn)程已經(jīng)完成了配置版本的切換,從而使系統(tǒng)更接近“寬限期”的結(jié)束。這種情況與RCU中的臨界區(qū)機(jī)制完全類似:線程局部指針就是工作進(jìn)程對(duì)某個(gè)配置版本的穩(wěn)定引用。
基于年代值的寬限期檢測(cè)機(jī)制
Envoy會(huì)記錄每個(gè)工作進(jìn)程當(dāng)前所處的“版本階段”或“年代值”:
struct Worker {
? ? ? ? ? ? ? ? ? ? ? atomic config_epoch;
? ? ? ? ? ? ? ? ? ? ? // ...
};
void waitForAllWorkersToTransition() {
? ? ? ? int64_t target_epoch = global_epoch.load();
? ? ? ? // 等待所有工作進(jìn)程都進(jìn)入新的版本階段
? ? ? ? for (auto& worker : workers) {
? ? ? ? ? ? ?while (worker.config_epoch.load() < target_epoch) {
? ? ? ? ? ? ? ? ? ? ? this_thread::yield(); // 等待
? ? ? ? ? ? ? }
? ? ? ? ? }
? ? ? ? ?
// 所有工作進(jìn)程都完成了版本切換 → 可以安全地釋放舊配置了
}
“年代值”本質(zhì)上就是一個(gè)版本號(hào),它是一個(gè)會(huì)隨著每次配置更新而遞增的計(jì)數(shù)器。每個(gè)工作進(jìn)程的config_epoch記錄著該工作進(jìn)程所使用且已確認(rèn)為有效版本的配置信息。這種機(jī)制為檢測(cè)寬限期提供了簡(jiǎn)單高效的方式:當(dāng)配置發(fā)生更新時(shí),全局年代值會(huì)增加(例如從5增加到6)。那些仍在使用舊配置的工作進(jìn)程,其config_epoch值為5;而已經(jīng)切換到新配置的工作進(jìn)程,其config_epoch值為6。
配置管理器可以通過(guò)檢查所有工作進(jìn)程是否都達(dá)到了目標(biāo)年代值來(lái)確定是否可以安全地釋放舊配置:一旦所有工作進(jìn)程的config_epoch值都大于或等于6,我們就可以確定沒有工作進(jìn)程還在使用版本號(hào)為5的配置了。這就是Envoy在用戶空間實(shí)現(xiàn)的RCU寬限期機(jī)制:與Linux RCU依賴內(nèi)核上下文切換不同,Envoy使用了顯式的年代值跟蹤機(jī)制,而且這些數(shù)值可以由工作進(jìn)程在用戶空間中進(jìn)行更新。
如果選擇另一種方法——即追蹤每個(gè)工作進(jìn)程持有的具體配置指針——那么就需要使用復(fù)雜的內(nèi)存屏障和指針比較操作;而年代值計(jì)數(shù)器則提供了一種更簡(jiǎn)單、更清晰的解決方案,因?yàn)樗鼉H需要進(jìn)行整數(shù)比較即可完成相關(guān)判斷。
主線程中的延遲刪除機(jī)制
負(fù)責(zé)處理配置更新的主線程會(huì)將刪除操作推遲到寬限期結(jié)束之后再進(jìn)行:
void updateConfig(Config* new_config) {
? ? ? ? Config* old_config = current_config.exchange(new_config);
? ? ? ? global_epoch.fetch_add(1); // 遞增年代值
? ? ? ?// 將刪除操作推遲,直到所有工作進(jìn)程都完成更新
? ? ? ?main_thread.post([old_config, epoch = global_epoch.load()] {
? ? ? ? ?waitForAllWorkersToAcknowledge(epoch);
? ? ? ? ?delete old_config; // 現(xiàn)在可以安全地刪除舊配置了
? ? ? ? ?});
關(guān)鍵在于那個(gè)匿名函數(shù)——也就是傳遞給 `main_thread.post(): [old_config, epoch = global_epoch.load()] { ... }` 的 lambda 函數(shù)。這個(gè) lambda 函數(shù)會(huì)保存對(duì)舊配置的引用以及當(dāng)前的時(shí)期值,然后確定如何處理這些數(shù)據(jù):等待所有工作線程確認(rèn)新的時(shí)期值后,再刪除舊配置。`main_thread.post()` 會(huì)安排這個(gè) lambda 函數(shù)在主線程的事件循環(huán)中異步執(zhí)行,而不會(huì)立即運(yùn)行它。
這種非阻塞機(jī)制至關(guān)重要:配置更新會(huì)立即完成,而不需要等待所有工作線程完成狀態(tài)切換。Envoy 會(huì)繼續(xù)使用新配置處理請(qǐng)求,而 lambda 函數(shù)則會(huì)在后臺(tái)等待規(guī)定的寬限期結(jié)束。只有當(dāng)所有工作線程都完成了狀態(tài)切換(通過(guò)它們的時(shí)期值計(jì)數(shù)器可以判斷)后,lambda 函數(shù)才會(huì)執(zhí)行并安全地刪除舊配置。這種延遲刪除的方式能夠防止配置更新導(dǎo)致請(qǐng)求處理出現(xiàn)延遲。
一致性 trade-off
RCU 的高性能是有代價(jià)的——它放棄了即時(shí)一致性,以換取讀操作的擴(kuò)展性。要有效使用 RCU,就必須理解這一根本性的權(quán)衡。
當(dāng)寫入者使用 RCU 更新數(shù)據(jù)結(jié)構(gòu)時(shí),這些變更并不會(huì)立即被所有讀取者看到。在更新發(fā)生時(shí)正在執(zhí)行臨界操作的讀取者,會(huì)繼續(xù)看到舊版本的數(shù)據(jù),直到他們退出臨界區(qū)域。因此,在任何時(shí)刻,不同的讀取者都可能看到不同版本的數(shù)據(jù)。
這就是所謂的“最終一致性”:系統(tǒng)最終會(huì)達(dá)到一致的狀態(tài),但在這一過(guò)程中,讀取者可能會(huì)看到過(guò)時(shí)的數(shù)據(jù)。對(duì)于許多應(yīng)用來(lái)說(shuō),這種權(quán)衡是可以接受的。例如,如果你正在更新路由表,那么讓少量數(shù)據(jù)包仍然使用舊路由表進(jìn)行傳輸也是可以接受的,因?yàn)橄到y(tǒng)很快就會(huì)切換到新的路由表。
然而,如果你的應(yīng)用要求嚴(yán)格的一致性,那么 RCU 就不是合適的選擇。比如在開發(fā)銀行應(yīng)用程序時(shí),你絕對(duì)不能允許不同的線程看到客戶賬戶余額的不同版本。
總之,RCU 并非萬(wàn)能的解決方案。它確實(shí)是一種強(qiáng)大的工具,但并不能適用于所有情況。在決定使用 RCU 之前,你必須仔細(xì)考慮你的應(yīng)用對(duì)一致性的要求。
常見的誤區(qū)與操作注意事項(xiàng)
盡管 RCU 是一種非常有效的機(jī)制,但它也存在一些需要注意的問(wèn)題。以下是一些常見的錯(cuò)誤及操作建議:
在臨界區(qū)域之外使用指針
使用 RCU 時(shí)最常出現(xiàn)的錯(cuò)誤就是在讀操作的臨界區(qū)域內(nèi)獲取某個(gè)數(shù)據(jù)結(jié)構(gòu)的指針,然后在臨界區(qū)域之外使用這個(gè)指針。這種做法非常危險(xiǎn),因?yàn)橐坏┩顺雠R界區(qū)域,該數(shù)據(jù)結(jié)構(gòu)可能會(huì)被立即釋放,從而導(dǎo)致“使用已釋放的指針”這類難以調(diào)試的錯(cuò)誤。
// 錯(cuò)誤的做法
rcu_read_lock();
p = rcu_dereference(global_ptr);
rcu_read_unlock();
// 這里p可能會(huì)被釋放!
use(p->data); // 這是一種“使用已釋放內(nèi)存”的錯(cuò)誤做法!
// 正確的做法
rcu_read_lock();
p = rcu_dereference(global_ptr);
use(p->data); // 是安全的——此時(shí)仍在臨界區(qū)內(nèi)
rcu_read_unlock();
讀側(cè)臨界區(qū)的阻塞問(wèn)題
讀側(cè)臨界區(qū)絕對(duì)不能造成阻塞。如果某個(gè)讀取操作在臨界區(qū)內(nèi)被阻塞,就會(huì)導(dǎo)致“寬限期”永遠(yuǎn)無(wú)法結(jié)束。這樣一來(lái),任何內(nèi)存都不會(huì)被釋放,最終會(huì)導(dǎo)致內(nèi)存不足的問(wèn)題。
// 錯(cuò)誤的做法——可能會(huì)導(dǎo)致系統(tǒng)掛起!
rcu_read_lock();
p = rcu_dereference(global_ptr);
sleep(1); // 這會(huì)阻止寬限期的檢測(cè)機(jī)制正常工作!
rcu_readunlock();
內(nèi)存開銷問(wèn)題
RCU的“寫時(shí)復(fù)制”機(jī)制可能會(huì)導(dǎo)致內(nèi)存消耗增加。如果數(shù)據(jù)結(jié)構(gòu)很大,或者需要進(jìn)行大量的更新操作,那么維護(hù)多個(gè)版本的數(shù)據(jù)所帶來(lái)的人工成本就會(huì)很高。
寫側(cè)的復(fù)雜性
雖然RCU簡(jiǎn)化了讀側(cè)的操作流程,但卻會(huì)增加寫側(cè)的復(fù)雜性。寫入方需要確保正確地復(fù)制數(shù)據(jù),而“寬限期”機(jī)制的實(shí)現(xiàn)也可能會(huì)遇到很多麻煩。
選擇合適的寬限期機(jī)制
實(shí)現(xiàn)寬限期的方法有很多種。正確的選擇取決于應(yīng)用程序的具體需求。有些機(jī)制雖然簡(jiǎn)單,但性能較差;而另一些機(jī)制則較為復(fù)雜,但能提供更好的性能。
需要注意的是,了解這些潛在的問(wèn)題是避免它們發(fā)生的第一步。在使用RCU時(shí),仔細(xì)的設(shè)計(jì)和測(cè)試是非常必要的。幸運(yùn)的是,內(nèi)核提供了一些基于斷言的調(diào)試機(jī)制,但完全依賴這些機(jī)制是很危險(xiǎn)的。
決策框架:何時(shí)使用RCU
現(xiàn)在我們已經(jīng)很好地了解了RCU是什么、它是如何工作的,以及它有哪些優(yōu)缺點(diǎn),因此我們可以建立一個(gè)決策框架,幫助你們決定在自己的系統(tǒng)中何時(shí)使用RCU。
以下是一些需要你自己思考的問(wèn)題:
你的數(shù)據(jù)中讀操作與寫操作的比例是多少?RCU在以讀操作為主的系統(tǒng)中效果最佳。一個(gè)常見的經(jīng)驗(yàn)法則是:當(dāng)讀操作與寫操作的比例達(dá)到10:1或更高時(shí),使用RCU會(huì)比使用讀寫鎖帶來(lái)顯著的性能提升;而在5:1到10:1之間,讀寫鎖可能更為合適,而且實(shí)現(xiàn)起來(lái)也更加簡(jiǎn)單。當(dāng)寫操作非常頻繁、比例低于5:1時(shí),RCU的“寫時(shí)復(fù)制”機(jī)制所帶來(lái)的額外開銷可能就不值得了——此時(shí)使用標(biāo)準(zhǔn)的讀寫鎖或簡(jiǎn)單的互斥鎖可能會(huì)更加合適,因?yàn)楫?dāng)寫操作占主導(dǎo)地位時(shí),性能差異會(huì)變得很小。
你的應(yīng)用程序是否能夠接受最終一致性?到目前為止,我們已經(jīng)知道RCU能夠提供最終一致性。但如果你的應(yīng)用程序要求強(qiáng)一致性,那么RCU就不是合適的選擇。
你的數(shù)據(jù)是否可以被指向新的版本,或者其版本信息能否被記錄下來(lái)?RCU的工作原理是通過(guò)原子級(jí)別地更新指向數(shù)據(jù)新版本的指針來(lái)實(shí)現(xiàn)的。換句話說(shuō),你的數(shù)據(jù)必須以某種能夠支持這種機(jī)制的方式來(lái)進(jìn)行結(jié)構(gòu)化設(shè)計(jì)。如果你的數(shù)據(jù)只是一個(gè)連續(xù)的、整體性的內(nèi)存塊,那么使用RCU可能會(huì)遇到困難。
你的數(shù)據(jù)結(jié)構(gòu)是否復(fù)雜?數(shù)據(jù)結(jié)構(gòu)越復(fù)雜,正確實(shí)現(xiàn)“寫時(shí)復(fù)制”機(jī)制就越困難。對(duì)于列表和樹這類簡(jiǎn)單的數(shù)據(jù)結(jié)構(gòu)來(lái)說(shuō),實(shí)現(xiàn)RCU相對(duì)比較容易;而對(duì)于更復(fù)雜的數(shù)據(jù)結(jié)構(gòu)而言,實(shí)現(xiàn)過(guò)程則會(huì)充滿挑戰(zhàn)。
你是否愿意承擔(dān)定制RCU實(shí)現(xiàn)所帶來(lái)的復(fù)雜性呢?雖然RCU的基本原理很簡(jiǎn)單,但一個(gè)可用于生產(chǎn)環(huán)境的完整實(shí)現(xiàn)方案可能會(huì)非常復(fù)雜。如果你不熟悉低層并發(fā)編程,那么使用現(xiàn)成的RCU庫(kù)或那些內(nèi)置了RCU功能的系統(tǒng)可能會(huì)更加合適。
C++26標(biāo)準(zhǔn)對(duì)RCU進(jìn)行了規(guī)范,這一規(guī)定使得更多開發(fā)者能夠使用RCU,因此RCU在更廣泛的應(yīng)用場(chǎng)景中得到采用的可能性也會(huì)大大增加。通過(guò)仔細(xì)考慮這些問(wèn)題,你可以做出明智的決定,從而判斷RCU是否適合解決你所面臨的問(wèn)題。
常見的RCU實(shí)現(xiàn)方案
如果你認(rèn)為RCU適用于你的需求,那么目前已有幾種可供使用的、成熟度較高的RCU實(shí)現(xiàn)方案。
適用于C/C++語(yǔ)言:
- liburcu庫(kù)
這是最為成熟且應(yīng)用最廣泛的C/C++語(yǔ)言RCU庫(kù)。Knot DNS、Netsniff-ng、GlusterFS以及ISC BIND等項(xiàng)目都在生產(chǎn)環(huán)境中使用了它。該庫(kù)提供了多種針對(duì)不同使用場(chǎng)景優(yōu)化的RCU實(shí)現(xiàn)版本,支持在Linux、FreeBSD、macOS等平臺(tái)上運(yùn)行。 - C++26標(biāo)準(zhǔn)庫(kù)中的P2545R4模塊
適用于Rust語(yǔ)言:
- crossbeam-epoch
它提供了基于“時(shí)代劃分”的垃圾回收機(jī)制,有助于構(gòu)建無(wú)鎖數(shù)據(jù)結(jié)構(gòu)。雖然這個(gè)庫(kù)并未被明確標(biāo)榜為RCU實(shí)現(xiàn)方案,但它采用了類似的原理,并提供了適合Rust語(yǔ)言使用的API,在Rust并發(fā)開發(fā)生態(tài)系統(tǒng)中得到了廣泛的應(yīng)用。
適用于Linux內(nèi)核:
- 內(nèi)核內(nèi)置的RCU功能
Linux內(nèi)核中內(nèi)置了多種RCU實(shí)現(xiàn)版本,包括vanilla RCU、SRCU以及Tasks RCU等。這些機(jī)制在網(wǎng)絡(luò)協(xié)議棧、文件系統(tǒng)以及設(shè)備驅(qū)動(dòng)程序等領(lǐng)域得到了廣泛的應(yīng)用。詳情請(qǐng)參閱內(nèi)核文檔。
對(duì)于大多數(shù)應(yīng)用來(lái)說(shuō),從liburcu(適用于C/C++)或crossbeam-epoch(適用于Rust)開始使用RCU,就能夠獲得一個(gè)穩(wěn)定且經(jīng)過(guò)充分測(cè)試的并發(fā)處理基礎(chǔ)。
結(jié)論
RCU是一種強(qiáng)大的并發(fā)處理機(jī)制,它通過(guò)放棄即時(shí)一致性來(lái)?yè)Q取極高的讀操作性能。由于消除了讀路徑中的鎖機(jī)制,RCU使得系統(tǒng)能夠在不降低性能的情況下處理海量的讀操作負(fù)載。
需要記住的關(guān)鍵點(diǎn):
- 讀者通過(guò)訪問(wèn)不可變的版本來(lái)執(zhí)行操作,因此無(wú)需使用鎖。
- 寫入者會(huì)創(chuàng)建新的版本,而不是直接修改原有數(shù)據(jù)。
- 通過(guò)設(shè)置緩沖期,可以確保在所有讀者完成操作之后再回收資源,從而保障系統(tǒng)安全性。
- 為了獲得無(wú)鎖機(jī)制帶來(lái)的高性能,就必須犧牲一定的一致性。
RCU并不適用于所有場(chǎng)景;在使用它之前,你需要仔細(xì)考慮自己的數(shù)據(jù)一致性要求、讀寫操作模式,以及對(duì)實(shí)現(xiàn)復(fù)雜性的容忍度。但是,當(dāng)正確應(yīng)用時(shí),RCU確實(shí)能夠顯著提升以讀取操作為主的應(yīng)用系統(tǒng)的可擴(kuò)展性。
無(wú)論你是在使用PostgreSQL、Kubernetes、Envoy,還是在開發(fā)自己的高性能系統(tǒng),了解RCU的原理都能幫助你更有效地運(yùn)用這些技術(shù)機(jī)制。