基於共享內存的HashMap的思考

 

 

基於mmap共享內存實現既可用於多進程又可用於多線程的無需持久化的併發HashMap,我們就叫它SharedHashMap吧!

 

基於mmap共享內存實現既可用於多進程又可用於多線程的無需持久化的併發SharedHashMap

 

使用mmap把文件內容映射到進程的虛擬地址空間,在這塊虛擬地址空間中實現一個HashMap

 

  1. 每個進程都會使用mmap把Nodes文件做爲內存池映射到自己的虛擬地址空間,內存池由一個userspace spinLocker進行保護,任何一個進程檢測到內存池已使用完時,都會發起一次內存池的擴容操作,擴容首先會調用ftruncate對Nodes文件擴展到當前大小2倍,然後使用mremap進行重映射,完成一次內存池的擴容,只有第一個發起擴容的進程才能初始化內存池,後來的進程直接擴容後就可以使用內存池,擴容期間可以對map,進行get,update操作,進行set,delete操作將會自旋,因爲當set,delete時要訪問內存池,獲取內存池的鎖,而此時內存池正在擴容,鎖被髮起擴容的進程lock着。當一個進程進行擴容後,其它進程要及時,感知到這種變化,自己也發起一次擴容,否則在一個未及時擴容的進程的HashMap中訪問已擴容後的進程插入的從擴容後的虛擬地址空間中分配出去的節點,未擴容的進程將崩潰
  2. 當進程重啓後所有的數據將變的無法訪問,因爲所有的數據的操作,都是基於之前進程的虛擬地址空間的,對於map中的每一個node,在不同的進程中有不同的虛擬地址,當內存池進行擴容後,已經從內存池中分配出去的node都將失效,擴容後再通過擴容前的地址去訪問擴容前的node,進程將收到一個段錯誤,然後崩掉,因爲擴容前的虛擬地址空間有可能已不存在於進程可訪問的虛擬地址段中,因此在內存池擴容後,每一個進程都要修正自已的map中每一個的node的虛擬地址,爲了解決這種問題,在從內存池分配node時只能分配相對地址,每次獲取對象時使用當前的Nodes文件被映射的首地址+相對地址去訪問node
  3. 由於擴容導致虛擬地址的改變,在上一行代碼中可以正常訪問的節點,在下一行代碼中可能已經失效,因此代碼調試不是像多線程環境中那樣順利,要詳細考慮每一行代碼中的數據是否已經失效,否則進程將崩潰,一種解決辦法就是擴容時強制從上一次映射的虛擬地址開始映射
  4. 多進程併發rehash問題,多進程併發rehash是一種協作關係,由於多進程通信及SharedHashMap設計實現原因,實現併發rehash不是那麼容易,所以在此會有一進程專門負責rehash,rehash進程會把Slots文件當做哈希槽mmap到自己的虛擬地址空間,redis使用漸進式rehash,ConcurrentHashMap使用多線程併發rehash, SharedHashMap使用全量rehash,一次性把原map中的數據移動到擴容後的map中,在數據量大的情況下可能會產生一小段時間的不可用,但rehash在整個map的生命週期中並不常見,如果我們能預估數據集大小rehash可能永遠不會發生,rehash進程與其它進程使用同一把UserSpace讀寫自旋鎖,通過獲取寫鎖來取得rehash的機會,因此在進行rehash過程中其它進程都將因獲取不到讀鎖而自旋,在rehash後其它進程要及時感知到已經rehash了要進行一次哈希槽的切換,在進行操作map前其它進程會獲取到共享的讀鎖讓rehash進程無法進行下一次rehash。因爲進程1,2,3可能率先完成哈希槽的切換,此時插入了大量數據,而rehash進程可能會又發起多次rehash,而進程4,5,6可能還正在進行第一次的哈希槽的切換
  5. 鎖的粒度控制,jdk1.8的ConcurrentHashMap,已經不再使用分段鎖,採用每個哈希槽一把鎖的及CAS的方式,進一步減小鎖的粒度,SharedHashMap,也採用一個哈希槽一把UserSpace 自旋鎖,在高併發訪問情況下了可根據編譯參數使用讀寫自旋鎖進一步減少鎖衝突,當hash衝突足夠小時,map的訪問速度已經很快了,而一個自旋鎖在不開啓死鎖檢測的情況下只需一個字節就可以了,而讀寫自旋鎖需要4字節,在開啓了死鎖檢測,由於字節對齊,在32位系統下兩者都會使用8字節
  6. 自旋鎖導致cpu長時間空轉問題,任何一進程在在進行初始化map時都會獲取讀鎖,去初始化哈希槽的鎖,同時任一進程在初始化內存池時,其它進程如果要申請釋放Node都將自旋,如果map的哈希槽比較大,內存池比較大,每次的擴容後其它進程將有一段長時間的自旋,對自旋鎖進行改造,當自旋一定次數後,將升級爲互斥鎖,給每個哈希槽分配一把互斥鎖不太現實,這裏的互斥鎖,只是爲了解決進程的park與unpark問題,並不是真的要獲取鎖,鎖是在用戶空間實現的,在此將使用Java Synchronizor及AQS的park/unpark底層實現方式來解決,進程的park與unpark,因此將會使用一個全局的互斥量和條件變量,和一個原子變量,用來 park/unpark相關的進程,當擁有鎖的進程釋放鎖時會判斷是否有進程sleep在此條件變量上如有則喚醒正在睡眠在此條件變量上的所有進程
  7. 一個進程進行了哈希槽或內存池的擴容後,其它進程如何感知?在此使用 kafka 解決controller腦列問題,及consumerGroup解決因rebalance後已被踢出consumerGroup的consumer提交無效offset問題所使用的方式,kafka每輪controllor選舉後都會產生一個epochId下發到每個broker,consumerGroup的每次rebalance,都會產生一個generatorId下發到consumerGroup中的每個consumer,而generatorId與epochId都是自增的。在SharedHashMap中 每一個進程的本地都有一個local expandId,在共享內存中都有一個共享的expandId,當擴容進程完成擴容後會對local expandId自增同時更新共享的expandId,每個進程在操作map及內存池前都會檢測local expandId與共享的expandId是否一致,如不一致將進行擴容操作,完成擴容操作後更新local expandId與共享expandId一致
  8. Java的ConcurrentHashMap的get是一個無鎖操作,當一個線程在get,一個線程在put時,get線程可能在此次get並不能拿到新插入的Node,也可能拿不到最新update的值,當一個線程在remove時get線程可能會拿到一個已刪除的Node,當在SharedHashMap中去除鎖時由於沒有引用計數,當一個進程在get,一個進程在del,剛好get訪問到一Node,del同時刪除了此Node,而此Node被回收後又立即分配出去插到另一個slot中導致get時出現slot跳越問題,爲了解決此問題可在Node回收時置next值爲0(SharedHashMap中0代表NULL)解決此種問題,但會造成鏈表訪問不完整,另一種方法就是對回收到內存池中的Node設置閒置期,如採用鏈表尾插入法進行Node回收,並設置回收時的時間只有脫離閒置期的Node才能從內存池中再分配出去,否則擴容內存池,但此種方法很難設定出一個合適的閒置時間,要徹底解決這種問題就是採用Java 解決非線程安全的容器被併發修改的fail-fast機制,在此SharedHashMap並不會拋出 ConcurrentModificationException 異常,這在SharedHashMap中當做一種正常現象,只用來告訴正在遍歷的進程,鏈表已改變可能有Node加入,也有可能有Node刪除,更新。爲了實現這種機制需要在SharedHashMap的slot中加入一個無符號共享的char 類型的changes變量,每次對slot,進行寫操作時都會加1,當到256時回到0,在鏈表的一次遍歷中,如:changes從26增加到256再回到26,並被正在遍歷的進程檢測到,這應該是一種小概率事件,由於字節對齊的原因在此使用1字節與4字節都會佔用相同的空間,索性就用4字節了。當get進程對slot進行遍歷時先讀取changs到本地副本,在遍歷過程中爲了不漏掉新插入的Node,不誤拿到已刪除的Node,保證拿到訪問進程中update的Node,訪問每一個Node前都先判斷本地副本與slot中的changs中的是否一致,遍歷結束後還要再判斷一次,如不一致則從頭開始重新遍歷,這兩種方法只能去除slot鎖而並不能去除與rehash進程共用的讀寫鎖
  9. 如何去除與rehash進程共用的讀寫鎖?去除get方法中的rehash相關的擴容方法的調用,get方法不參與檢測發起slot切換,就無需使用讀寫鎖了,但此處存在一個問題,就是如果一個進程一直不進行,set,del,操作,只進行get操作那麼此進程就一直得不到最新的slot,get將會一直使用一個過期的slot,最終導致無法獲取到新插入的Node,在此get方法將先檢測slot切換條件,如果條件成立纔去獲取讀鎖,完成slot切換,而set,del方法卻不能這樣,因爲在執行set,del的任何時候,都有可能發生rehash,要保證,set,del操作的是切換後的slotB而不是rehash前的slotA,而這樣會造成get取不到已經被rehash進程移動到slotB中的Node

 

class MetaData{

  public:

        unsigned int curMovingIdx;

        int rehashing;

        int status;

        unsigned int slots;

        int expandId;

        char curUsingSlotFile[256];

        char curExingSlotFile[256];

        int rehashDone;   

};

 

 

 

10.如何去除與rehash進程共用的讀寫鎖?當rehash進程正在進行rehash時, rehash進程會把slotA中的Node移動到slotB中,這時set,del,get,進程要感知到正在rehash,從而也把slotB映射到自己的虛擬地址空間,在set,get,del時先操作slotA如果失敗就操作slotB,以此保證數據的一致與完整性,當在rehash完畢時,rehash進程要在共享內存中設置rehashOver標識,讓其它進程進行slot切換,而其它進程也要在共享內存中設置全部完成switchOver標識slot切換完成,當rehash進程檢測到switchOver時才能進行下一次rehash,這裏有一個問題就是當一個進程在rehash過程中進行了一次操作後就再也沒進行過操作,會導致無法完成switchOver,因此我不得不去除rehash進程,讓每個進程都能rehash,實現如ConcurrentHashMap同樣的rehash方式,多進程併發rehash,任何進程一旦檢測到正在rehash就立馬加入一起rehash直到rehash完畢才能返回繼續進行set,get,del操作,這時我們要換一種策略來進行多進程間的rehash協作,去除rehashOver,switchOver標識,用rehashDone==0來表明所有參與rehash的進程都已完成rehash,當調用doRehash函數時會對rehashDone原子增1,當在rehash過程中檢測到已完成rehash時對rehashDone原子減1,然後循環判斷rehashDone是否是0,如果是0則代表,所有參與rehash的進程都已完成了rehash,此次rehash結束,可以進行清理切換工作了,刪除各自己的slotA,切換到各自的slotB,由於slot鎖是所有進程共享的所以不能重複刪除,只有識別到curMovingIdx==slotsA的進程才能進行鎖的清理,這個進程同時會設置rehashing=0(第一個發起slot擴容,rehash的進程會設置rehashing=1),其它的進程識別到rehash結束的條件是curMovingIdx>slotsA||rehashing==0

 

11.對於多進程併發rehash時,進程奔潰問題導致無法完成rehashDone問題解決,當一個進程參與rehash後會對rehashDone原子增1,當進程在rehash過程中被誤殺或奔潰退出時,會導致rehashDone永遠無法爲0,進而導致死鎖。在共享內存實現一RehashProcessQueue隊列,當rehash進程加入時把自己的pid放入此隊列,當自己完成rehash後,在對rehashDone原子減1後從隊列中移除自己的pid,因此只要在循環檢測rehashDone時,順帶檢測RehashProcessQueue中的進程是否都alive,否則清空RehashProcessQueue,置rehashDone爲0

 

12.當我試着去除get方法中的讀寫鎖時,我對之前的讀寫鎖的使用進行了調整,把 rehash過程中持有的寫鎖,換成了讀鎖,把讀鎖加在了擴容slotB哈希槽時使用,因爲多個進程在set時可能同時檢測到了需要擴容哈希槽,rehash過程中持有讀鎖,可以使多個進程在併發rehash中,可以阻止先完成rehash的進程,再次發起下一輪擴容哈希槽。由於去除鎖後沒有保護,導致變量被多個進程同時修改刪除,進程頻頻奔潰,這簡直是一個災難,當我終於解決這個問題後又出現了死鎖問題,由於鎖帶有membar的功能,當去除鎖後,在修改一個多進程共享的變量時,不像使用鎖時那麼容易,修改一個變量,首先要從內存中讀取這個變量,而這個變量可能被加載到了多個cpu核心的cachline中,當一個運行的進程使用這個共享變量時它所運行的cpu核心的cacheline中的數據可能不是最新的,這時就需要使本地這個變量所在的cacheline無效,以便重新從內存取得其它cpu核心修改的最新數據,cpu提供了讀內存欄柵來解決這種問題,當修改完一個變量後要把數據同步到內存並告訴其它cpu核心,它們的cacheline中的數據已經無效了,cpu提供了寫內存欄柵來解決這種問題,因此在沒有鎖的情況下,在修改一個變量前後要自己加上內存欄柵,cpu之所以會提供內存欄柵是由於,cpu在修改完一個被多個cpu cacheline cache的變量時需要徵求所有擁有此變量的cpu核心的同意才能修改,所以要向其它cpu廣播這個請求,在等待所有cpu響應期間,發出請求的cpu會暫停,因此cpu搞了一個storeQueue,invalidQueue來異步完成數據的修改與發佈,又提供了membar來flush這兩個隊列使隊列的請求立馬完成。這也不是一個問題,加唄。但由於進程死鎖的出現讓我以爲,是共享的變量沒有及時同步到各個cpu核心導致,以致於我在接下來的一個星期的時間裏,時分疲倦的我像無頭蒼蠅一樣,在代碼裏到處加membar,這時我把所有的共享變量全部加上了volatile因爲我已經開始懷疑,我那個狗屁“頓悟”的正確性了,期間我有過幾次想要放棄去除get中的鎖,但最後在星期5的晚上也就是2019.08.16,這天下班的路上我對自己說,不行這個週末一定要調通,回到出租房的後我放下了電腦,然後從上社走到了華景,途中經過大王鴨貨由於下雨路上堵車現在已經7:30,有點餓就買了一個鴨頭,一個鴨爪,一個鴨翅,到了華景後叫了一碗羊肉面,並加了面,今天這碗麪煮的特別的久,期間我發現煮麪的小夥子還了玩手機,等了10幾分鐘的面終於煮好了,真是個實稱的小夥子,真的是加了3塊錢的面,多到我看了心慌,我趕忙,把面從碗裏撈了一大半出來,放到裝鴨骨頭的朔料袋中,吃了一口實在是難喫,面都被他給煮坨了,真是十分的難喫,算了回去吧,又TM開始下雨了,廣州的雨說來就來說走就走像風一樣,無耐單身狗的我出門從來都不會帶傘,只好在門口坐了一會,誰知遇到兩個哥們一個喝醉了,坐在地上,靠着牆,另一個坐在旁邊的凳子上,沒過一會地上那哥們開始吐了,吐了一地,流了一身,這還沒完,旁邊那哥們看這哥們吐了,竟被噁心吐了,真是受不了了哦,等不到雨停了,冒着雨也要回去了,可能是吃了一碗羊肉面和淋了雨的原故吧!回到出租房我特別的精神,完全沒了以前昏昏欲睡的感覺,沒一會兒就發現導致的死鎖的原因,只因一個共享變量被另一進程提前修改而導致在rehash過程中slotA越界訪問,去獲得一把不存在的鎖,而導致了死鎖,今天果真是個好日子

13.關於進程的park與unpark,由於多個進程會共用一把讀寫自旋鎖來保證哈希槽的擴容及初始化與rehash的正確性,當已經啓動了多個進程,系統已經正常的運行了一段時間,哈希槽已經擴容的很大了,這時由於數據量增加又啓動了更多的進程加入,或正在運行的個別進程崩潰重啓,會遇到初始化哈希槽過久,長時間持有寫鎖導致其它進程長時間自旋導致cpu空轉的問題,在此使用Java 鎖的底屋實現方式來解決cpu空轉問題,Java的鎖是由AQS實現,AQS使用LockSupport類解決線程的park與unpark問題,並在park線程時把線程掛到用戶空間鎖的等待隊列,因此在unpark時可以選擇要喚醒那個線程,從而可以實現公平鎖。在linux下JVM使用pthread_mutex與pthread_cond,來實現LockSuppport,解決線程的park與unpark,在此我也使用pthread_mutex與pthread_cond來實現多進程的park與unpark也可以使用信號量,由於pthread_mutex與pthread_cond 在APUE中只被提及了是可以用於多進程間的同步,但並沒有用例 ,網上也僅有幾個pthread_mutex的例子,根本沒有pthread_cond的,於是我按着APUE介紹在多進程中使用pthread_mutex與pthread_cond,但卻在pthread_broad_cast時出現了死鎖,掙扎了幾天仍無法解決,於是我去掉了pthread_cond,因爲我根本不需要,AQS那麼多的功能,我只需在進程在獲取用戶空間鎖時當自旋了一定次數後就自己把自己掛起,當一個進程釋放了用戶空間鎖時就喚醒所有被掛起的進程就行了,於是我只使用了一個pthread_mutex,完成了多進程的park與unpark,當pthred_mutex,被創建後就進行tryLock對其上鎖保證pthread_mutxt被上鎖,

1:當一個進程在獲取不到用戶空間的自旋鎖而park自已時,就對waiters變量原子增1然後對pthread_mutex上鎖,由於此時鎖已被佔用,所以當前進程無法獲取鎖而被掛起,後來的進程同樣被掛起,

2:當一個進程釋放用戶空間的鎖時,對pthread_mutex解鎖,

3:此時掛起並睡眠在此鎖上的進程中的一個會被OS喚醒,並獲得鎖

4:由於我們要喚醒所有的進程,所以當一個進程被喚醒並獲得到鎖後會判斷,waiters是否大於1,如是則原子減1,釋放自己持有的鎖然後返回,此時另一個進程會來到步驟3

5:如不是則直接返回,此時鎖還是未被釋放的,這樣當最後一個進程被喚醒持有鎖時所有的進程都因爲獲取了一次鎖而被unpark

 

 

14.關於內存分配器的實現,SharedHashMap的內存分配器只支持一種類型size對象的分配與回收,並不是一種通用的內存分配器,可以適配任何size對象的分配。在此借鑑linux內核1.0.0對物理內存的分配與回收實現,內存分配器在創建時會創建Nodes文件,並使用fallocate擴展到固定大小,使用mmap映射到進程的虛擬地址空間,mmap會返回虛擬地址的首地址,然後對這塊內存進行初始化,其實就是用一個靜態鏈表把空閒的內存塊串起來,分配時從鏈表開頭取一個鏈表節點,回收時就再插到鏈表的開頭,可以在O(1)的時間開銷完成內存的分配與回收

 

 

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