如履薄冰 —— Redis懶惰刪除的巨大犧牲
之前我們介紹了Redis懶惰刪除的特性,它是使用非同步執行緒對已經刪除的節點進行延後記憶體回收。但是還不夠深入,所以本節我們要對非同步執行緒邏輯處理的細節進行分析,看看Antirez是如何實現非同步執行緒處理的。非同步執行緒在Redis內部有一個特別的名稱,它就是BIO,全稱是Background IO,意思是在背後默默幹活的IO執行緒。不過記憶體回收本身並不是什麼IO操作,只是CPU的計算消耗可能會比較大而已。

懶惰刪除的最初實現不是非同步執行緒
Antirez實現懶惰刪除時,它並不是一開始就想到了非同步執行緒。最初的嘗試是使用類似於字典漸進式搬遷那樣來實現漸進式刪除回收,在主執行緒裡。比如對於一個非常大的字典來說,懶惰刪除是採用類似於scan操作的方法,通過遍歷第一維陣列來逐步刪除回收第二維連結串列的內容,等到所有連結串列都回收完了,再一次性回收第一維陣列。這樣也可以達到刪除大物件時不阻塞主執行緒的效果。
但是說起來容易做起來卻很難,漸進式回收需要仔細控制回收頻率,它不能回收的太猛,這會導致CPU資源佔用過多,也不能回收的蝸牛慢,記憶體回收不及時可能導致記憶體持續增長。Antirez需要採用合適的自適應演算法來控制回收頻率。他首先想到的是檢測記憶體增長的趨勢是增長(+1)還是下降(-1)來漸進式調整回收頻率係數,這樣的自適應演算法實現也很簡單。但是測試後發現在服務繁忙的時候,QPS會下降到正常情況下65%的水平,這點非常致命。
所以Antirez才使用瞭如今使用的方案——非同步執行緒,這套方案就簡單多了,釋放記憶體不用為每種資料結構適配一套漸進式釋放策略,也不用搞個自適應演算法來仔細控制回收頻率。將物件從全域性字典中摘掉,然後往佇列裡一扔,主執行緒就去幹別的去了。非同步執行緒從佇列裡取出物件來,直接走正常的同步釋放邏輯就可以了。
不過使用非同步執行緒也是有代價的,主執行緒和非同步執行緒之間在記憶體回收器(jemalloc)的使用上存在競爭。這點競爭消耗是可以忽略不計的,因為Redis的主執行緒在記憶體的分配與回收上花的時間相對整體運算時間而言是極少的。
非同步執行緒方案其實也相當複雜
剛才上面說非同步執行緒方案很簡單,為什麼這裡又說它很複雜呢?因為有一點我們沒有想到,這點非常可怕,嚴重阻礙了非同步執行緒方案的改造。那就是Redis的內部物件有共享機制。
比如集合的並集操作sunionstore用來將多個集合合併成一個新集合

我們看到新的集合包含了舊集合的所有元素。但是這裡有一個我們沒看到的trick。那就是底層的字串物件被共享了。

為什麼物件共享是懶惰刪除的巨大障礙呢?因為懶惰刪除相當於徹底砍掉某個樹枝,將它扔到非同步刪除佇列裡去。注意這裡必須是徹底刪除,而不能藕斷絲連。如果底層物件是共享的,那就做不到徹底刪除。

所以antirez為了支援懶惰刪除,將物件共享機制徹底拋棄,它將這種物件結構稱為「share-nothing」,也就是無共享設計。但是甩掉物件共享談何容易!這種物件共享機制散落在原始碼的各個角落,牽一髮而動全身,改起來猶如在佈滿地雷的道路上小心翼翼地行走。
不過antirez還是決心改了,他將這種改動描述為「絕望而瘋狂」,可見改動之大之深之險,前後花了好幾周的時間才改完。不過效果也是很明顯的,物件的刪除操作再也不會導致主執行緒卡頓了。
非同步刪除的實現
主執行緒需要將刪除任務傳遞給非同步執行緒,它是通過一個普通的雙向連結串列來傳遞的。因為連結串列需要支援多執行緒併發操作,所以它需要有鎖來保護。
執行懶惰刪除時,redis將刪除操作的相關引數封裝成一個bio_job結構,然後追加到連結串列尾部。非同步執行緒通過遍歷連結串列摘取job元素來挨個執行非同步任務。

我們注意到這個job結構有三個引數,為什麼刪除物件需要三個引數呢?我們繼續看程式碼

可以看到通過組合這三個引數可以實現不同結構的釋放邏輯。接下來我們繼續追蹤普通物件的非同步刪除lazyfreeFreeObjectFromBioThread是如何進行的,請仔細閱讀程式碼註釋


這些程式碼散落在多個不同的檔案,我將它們湊到了一塊便於讀者閱讀。從程式碼中我們可以看到釋放一個物件要深度呼叫一系列函式,每種物件都有它獨特的記憶體回收邏輯。
佇列安全
前面提到任務佇列是一個不安全的雙向連結串列,需要使用鎖來保護它。當主執行緒將任務追加到佇列之前它需要加鎖,追加完畢後,再釋放鎖,還需要喚醒非同步執行緒,如果它在休眠的話。

非同步執行緒需要對任務佇列進行輪訓處理,依次從連結串列表頭摘取元素逐個處理。摘取元素的時候也需要加鎖,摘出來之後再解鎖。如果一個元素的沒有,它需要等待,直到主執行緒來喚醒它繼續工作。

研究完這些加鎖解鎖的程式碼後,我開始有點當心主執行緒的效能。我們都知道加鎖解鎖是一個相對比較耗時的操作,尤其是悲觀鎖最為耗時。如果刪除很頻繁,主執行緒豈不是要頻繁加鎖解鎖。所以這裡肯定還有優化空間,Java的ConcurrentLinkQueue就沒有使用這樣粗粒度的悲觀鎖,它優先使用cas來控制併發。
思考
Redis還有其它地方用到了物件共享機制麼?
Java的ConcurrentLinkQueue具體是如何實現的?
歡迎工作一到五年的Java工程師朋友們加入Java填坑之路:860113481
群內提供免費的Java架構學習資料(裡面有高可用、高併發、高效能及分散式、Jvm效能調優、Spring原始碼,MyBatis,Netty,Redis,Kafka,Mysql,Zookeeper,Tomcat,Docker,Dubbo,Nginx等多個知識點的架構資料)合理利用自己每一分每一秒的時間來學習提升自己,不要再用"沒有時間“來掩飾自己思想上的懶惰!趁年輕,使勁拼,給未來的自己一個交代!