IPC 選型
說到 IPC贩据,首要的問題就是架構(gòu)選型官帘,不同的架構(gòu)效果大相徑庭。
CS 架構(gòu) vs 去中心化架構(gòu)
Android 平臺(tái)第一個(gè)想到的就是 ContentProvider:一個(gè)單獨(dú)進(jìn)程管理數(shù)據(jù),數(shù)據(jù)同步不易出錯(cuò)家淤,簡單好用易上手。然而它的問題也很明顯愚屁,就是一個(gè)字慢:啟動(dòng)慢济竹,訪問也慢。這個(gè)可以說是 Android 下基于 Binder 的 CS 架構(gòu)組件的通用痛點(diǎn)霎槐。至于其他的 CS 架構(gòu)送浊,例如經(jīng)典的 socket、PIPE栽燕、message queue罕袋,因?yàn)橐辽?2 次的內(nèi)存拷貝,就更加慢了碍岔。
MMKV 追求的是極致的訪問速度浴讯,我們要盡可能地避免進(jìn)程間通信,CS 架構(gòu)是不可取的蔼啦。再考慮到 MMKV 底層使用 mmap 實(shí)現(xiàn)榆纽,采用去中心化的架構(gòu)是很自然的選擇。我們只需要將文件 mmap 到每個(gè)訪問進(jìn)程的內(nèi)存空間捏肢,加上合適的進(jìn)程鎖奈籽,再處理好數(shù)據(jù)的同步,就能夠?qū)崿F(xiàn)多進(jìn)程并發(fā)訪問鸵赫。
挑選進(jìn)程鎖
然而去中心化的架構(gòu)實(shí)現(xiàn)起來并不簡單衣屏,Android 是個(gè)閹割版的 Linux,IPC 組件的支持比較殘缺辩棒。例如狼忱,說到進(jìn)程鎖第一個(gè)想到的就是 pthread 庫的 pthread_mutex,創(chuàng)建于共享內(nèi)存的 pthread_mutex 是可以用作進(jìn)程鎖的一睁,然而 Android 版的 pthread_mutex 并不保證robust钻弄,亦即對(duì) pthread_mutex 加了鎖的進(jìn)程被 kill,系統(tǒng)不會(huì)進(jìn)行清理工作者吁,這個(gè)鎖會(huì)一直存在下去窘俺,那么其他等鎖的進(jìn)程就會(huì)永遠(yuǎn)餓死。其他的 IPC 組件复凳,例如信號(hào)量瘤泪、條件變量,也有同樣問題育八,Android 為了能夠盡快關(guān)閉進(jìn)程均芽,真是無所不用其極。
找了一圈单鹿,能夠保證 robust 的掀宋,只有已打開的文件描述符,以及基于文件描述符的文件鎖和 Binder 組件的死亡通知(是的,Binder 也是依賴這個(gè)清理機(jī)制運(yùn)作劲妙,打開的文件是 /dev/binder)湃鹊。
我們有兩個(gè)選擇:
- 文件鎖,優(yōu)點(diǎn)是天然 robust镣奋,缺點(diǎn)是不支持遞歸加鎖币呵,也不支持讀寫鎖升級(jí)/降級(jí),需要自行實(shí)現(xiàn)侨颈。
- pthread_mutex余赢,優(yōu)點(diǎn)是 pthread 庫支持遞歸加鎖,也支持讀寫鎖升級(jí)/降級(jí)哈垢,缺點(diǎn)是不 robust妻柒,需要自行清理。
關(guān)于 mutex 清理耘分,有個(gè)可能的方案是基于 Binder 死亡通知進(jìn)行清理:A举塔、B進(jìn)程相互注冊對(duì)方的死亡通知,在對(duì)方死亡的時(shí)候進(jìn)行清理求泰。但有個(gè)比較棘手的場景:只有 A 進(jìn)程存在央渣,那么他的死亡通知就沒人處理,留下一個(gè)永遠(yuǎn)加鎖的 mutex渴频。Binder 規(guī)定死亡通知不能本進(jìn)程自行處理芽丹,必須由其他進(jìn)程處理,所以這個(gè)問題不好解決卜朗。
綜合各種考慮志衍,我們先將文件鎖作為一個(gè)簡單的互斥鎖,進(jìn)行 MMKV 的多進(jìn)程開發(fā)聊替,稍后再回頭解決遞歸鎖和讀寫鎖升級(jí)/降級(jí)的問題。
多進(jìn)程實(shí)現(xiàn)細(xì)節(jié)
首先我們簡單回顧一下 MMKV 原來的邏輯培廓。MMKV 本質(zhì)上是將文件 mmap 到內(nèi)存塊中惹悄,將新增的 key-value 統(tǒng)統(tǒng) append 到內(nèi)存中;到達(dá)邊界后肩钠,進(jìn)行重整回寫以騰出空間泣港,空間還是不夠的話,就 double 內(nèi)存空間价匠;對(duì)于內(nèi)存文件中可能存在的重復(fù)鍵值当纱,MMKV 只選用最后寫入的作為有效鍵值。那么其他進(jìn)程為了保持?jǐn)?shù)據(jù)一致踩窖,就需要處理這三種情況:寫指針增長坡氯、內(nèi)存重整、內(nèi)存增長。但首先還得解決一個(gè)問題:怎么讓其他進(jìn)程感知這三種情況箫柳?
狀態(tài)同步
寫指針的同步
我們可以在每個(gè)進(jìn)程內(nèi)部緩存自己的寫指針手形,然后在寫入鍵值的同時(shí),還要把最新的寫指針位置也寫到 mmap 內(nèi)存中悯恍;這樣每個(gè)進(jìn)程只需要對(duì)比一下緩存的指針與 mmap 內(nèi)存的寫指針库糠,如果不一樣,就說明其他進(jìn)程進(jìn)行了寫操作涮毫。事實(shí)上 MMKV 原本就在文件頭部保存了有效內(nèi)存的大小瞬欧,這個(gè)數(shù)值剛好就是寫指針的內(nèi)存偏移量,我們可以重用這個(gè)數(shù)值來校對(duì)寫指針罢防。內(nèi)存重整的感知
考慮使用一個(gè)單調(diào)遞增的序列號(hào)艘虎,每次發(fā)生內(nèi)存重整,就將序列號(hào)遞增篙梢。將這個(gè)序列號(hào)也放到 mmap 內(nèi)存中顷帖,每個(gè)進(jìn)程內(nèi)部也緩存一份,只需要對(duì)比序列號(hào)是否一致渤滞,就能夠知道其他進(jìn)程是否觸發(fā)了內(nèi)存重整贬墩。內(nèi)存增長的感知
事實(shí)上 MMKV 在內(nèi)存增長之前,會(huì)先嘗試通過內(nèi)存重整來騰出空間妄呕,重整后還不夠空間才申請(qǐng)新的內(nèi)存陶舞。所以內(nèi)存增長可以跟內(nèi)存重整一樣處理。至于新的內(nèi)存大小绪励,可以通過查詢文件大小來獲得肿孵,無需在 mmap 內(nèi)存另外存放。
狀態(tài)同步邏輯用偽碼表達(dá)大概是這個(gè)樣子:
void checkLoadData() {
if (m_sequence != mmapSequence()) {
m_sequence = mmapSequence();
if (m_size != fileSize()) {
m_size = fileSize();
// 處理內(nèi)存增長
} else {
// 處理內(nèi)存重整
}
} else if (m_actualSize != mmapActualSize()) {
auto lastPosition = m_actualSize;
m_actualSize = mmapActualSize();
// 處理寫指針增長
} else {
// 什么也沒發(fā)生
return;
}
}
寫指針增長
當(dāng)一個(gè)進(jìn)程發(fā)現(xiàn) mmap 寫指針增長疏魏,就意味著其他進(jìn)程寫入了新鍵值停做。這些新的鍵值都 append 在原有寫指針后面,可能跟前面的 key 重復(fù)大莫,也可能是全新的 key蛉腌,而原寫指針前面的鍵值都是有效的。那么我們就要把這些新鍵值都讀出來只厘,插入或替換原有鍵值烙丛,并將寫指針同步到最新位置。
auto lastPosition = m_actualSize;
m_actualSize = mmapActualSize();
// 處理寫指針增長
auto bufferSize = m_actualSize - lastPosition;
auto buffer = Buffer(lastPosition, bufferSize);
map<string, Buffer> dictionary = decodeMap(buffer);
for (auto& itr : dictionary) {
// m_cache 還是有效的
m_cache[itr.first] = itr.second;
}
內(nèi)存重整
當(dāng)一個(gè)進(jìn)程發(fā)現(xiàn)內(nèi)存被重整了羔味,就意味著原寫指針前面的鍵值全部失效河咽,那么最簡單的做法是全部拋棄掉,從頭開始重新加載一遍赋元。
// 處理內(nèi)存重整
m_actualSize = mmapActualSize();
auto buffer = Buffer(0, m_actualSize);
m_cache = decodeMap(buffer);
內(nèi)存增長
正如前文所述忘蟹,發(fā)生內(nèi)存增長的時(shí)候飒房,必然已經(jīng)先發(fā)生了內(nèi)存重整,那么原寫指針前面的鍵值也是統(tǒng)統(tǒng)失效寒瓦,處理邏輯跟內(nèi)存重整一樣情屹。
文件鎖
到這里我們已經(jīng)完成了數(shù)據(jù)的多進(jìn)程同步工作,是時(shí)候回頭處理鎖事了杂腰,亦即前面提到的遞歸鎖和鎖升級(jí)/降級(jí)垃你。
遞歸鎖
意思是如果一個(gè)進(jìn)程/線程已經(jīng)擁有了鎖,那么后續(xù)的加鎖操作不會(huì)導(dǎo)致卡死喂很,并且解鎖也不會(huì)導(dǎo)致外層的鎖被解掉惜颇。對(duì)于文件鎖來說,前者是滿足的少辣,后者則不然凌摄。因?yàn)?strong>文件鎖是狀態(tài)鎖,沒有計(jì)數(shù)器漓帅,無論加了多少次鎖锨亏,一個(gè)解鎖操作就全解掉。只要用到子函數(shù)忙干,就非常需要遞歸鎖器予。鎖升級(jí)/降級(jí)
鎖升級(jí)是指將已經(jīng)持有的共享鎖,升級(jí)為互斥鎖捐迫,亦即將讀鎖升級(jí)為寫鎖乾翔;鎖降級(jí)則是反過來。文件鎖支持鎖升級(jí)施戴,但是容易死鎖:假如 A反浓、B 進(jìn)程都持有了讀鎖,現(xiàn)在都想升級(jí)到寫鎖赞哗,就會(huì)陷入相互等待的困境雷则,發(fā)生死鎖。另外肪笋,由于文件鎖不支持遞歸鎖月劈,也導(dǎo)致了鎖降級(jí)無法進(jìn)行,一降就降到?jīng)]有鎖涂乌。
為了解決這兩個(gè)難題,需要對(duì)文件鎖進(jìn)行封裝英岭,增加讀鎖湾盒、寫鎖計(jì)數(shù)器。處理邏輯如下表:
讀鎖計(jì)數(shù)器 | 寫鎖計(jì)數(shù)器 | 加讀鎖 | 加寫鎖 | 解讀鎖 | 解寫鎖 |
---|---|---|---|---|---|
0 | 0 | 加讀鎖 | 加寫鎖 | - | - |
0 | 1 | +1 | +1 | - | 解寫鎖 |
0 | N | +1 | +1 | - | -1 |
1 | 0 | +1 | 解讀鎖再加寫鎖 | 解讀鎖 | - |
1 | 1 | +1 | +1 | -1 | 加讀鎖 |
1 | N | +1 | +1 | -1 | -1 |
N | 0 | +1 | 解讀鎖再加寫鎖 | -1 | - |
N | 1 | +1 | +1 | -1 | 加讀鎖 |
N | N | +1 | +1 | -1 | -1 |
需要注意的地方有兩點(diǎn):
- 加寫鎖時(shí)诅妹,如果當(dāng)前已經(jīng)持有讀鎖罚勾,那么先嘗試加寫鎖毅人,try_lock 失敗說明其他進(jìn)程持有了讀鎖,我們需要先將自己的讀鎖釋放掉尖殃,再進(jìn)行加寫鎖操作丈莺,以避免死鎖的發(fā)生。
- 解寫鎖時(shí)送丰,假如之前曾經(jīng)持有讀鎖缔俄,那么我們不能直接釋放掉寫鎖,這樣會(huì)導(dǎo)致讀鎖也解了器躏。我們應(yīng)該加一個(gè)讀鎖俐载,將鎖降級(jí)。