1. 程式人生 > >偽共享的產生原因和優化方案

偽共享的產生原因和優化方案

  一 flase sharing產生原因

     在談到false sharing問題之前我們先說cpu快取的問題。

    CPU 快取(Cache Memory)是位於 CPU 與記憶體之間的臨時儲存器,它的容量比記憶體小的多但是交換速度卻比記憶體要快得多。快取記憶體的出現主要是為了解決 CPU 運算速度與記憶體讀寫速度不匹配的矛盾,因為 CPU 運算速度要比記憶體讀寫速度快很多,這樣會使 CPU 花費很長時間等待資料到來或把資料寫入記憶體。在快取中的資料是記憶體中的一小部分,但這一小部分是短時間內 CPU 即將訪問的,當 CPU呼叫大量資料時,就可避開記憶體的開銷直接從快取中呼叫,從而加快讀取速度。     

    按照資料讀取順序和與 CPU 結合的緊密程度,CPU 快取可以分為一級快取,二級快取,部分高階 CPU 還具有三級快取。每一級快取中所儲存的全部資料都是下一級快取的一部分,越靠近 CPU 的快取越快也越小。所以 L1 快取很小但很快(譯註:L1 表示一級快取),並且緊靠著在使用它的 CPU 核心。L2 大一些,也慢一些,並且仍然只能被一個單獨的 CPU 核使用。L3 在現代多核機器中更普遍,仍然更大,更慢,並且被單個插槽上的所有 CPU 核共享。

   這種快取策略在單核環境下近乎完美,可以大大的提高資料的訪問速度,但是現在的cpu架構發展到今天的多核,就會又問題。先看一下多核環境下的快取結構:

                                                                                         

 我們可以看到在多核環境下,共用了L3 ,cpu的快取以快取行(cache line)為單位(通常是64位元組,因系統而定),當我們從記憶體中讀取資料是,先從各級快取中檢視資料地址是否在快取中,如果在直接從快取中讀取,如果不在把資料從記憶體中放入快取中,每次放入的最小單位為一個快取行64個位元組。當我們改變了快取中的資料後,會回寫到記憶體中,但是回寫策略有很多:

1 每次更新都回寫. write-through cache,

2 更新後不回寫,標記為dirty, 僅當cache entry被evict時才回寫 

3 更新後, 把cache entry送如回寫佇列, 待佇列收集到多個entry時批量回寫.

    問題就出在這裡,就算寫回到記憶體,如果其他處理器快取的值還是舊的,再執行計算操作就會有問題,所以在多處理器下,為了保證各個處理器的快取是一致的,就會實現快取一致性協議,每個處理器通過嗅探在總線上傳播的資料來檢查自己快取的值是不是過期了,當處理器發現自己快取行對應的記憶體地址被修改,就會將當前處理器的快取行設定成無效狀態,當處理器要對這個資料進行修改操作的時候,會強制重新從系統記憶體裡把資料讀到處理器快取裡,這也是voliate關鍵字存在的意義。

   看下面的情況:

                                                                                               

       當執行緒1要訪問某個資料時,系統選擇了記憶體中的一段資料讀到一個快取行中,假如thread1訪問的時快取行中的第二個元素,此時該快取行鎖在的cache entry被標記為dirty,並且導致了thread0的快取失效,此時thread0要訪問快取行中的第1個數據,即便不是同一個資料也因為快取失效(cache miss)而影響了cpu的正常處理過程,反過來thread0的操作也會導致thread1的cache miss,這就是所謂的false sharing。當發生這種情況時會大大降低cpu的處理速度,此時的處理速度大概為正常速度的十分之1,我們怎麼避免這種情況呢。

二 相關問題的優化方案

    緩衝行補充:最終的目標就是避免不同執行緒訪問的變數在一個快取行中,所以我們要進行快取行填充(Padding)操作 。我們知道一條快取行有 64 位元組,所以我們只要補充物件的位元組數超過64個位元組,就避免了偽共享。對於這種緩衝行補充我們可以用程式碼來實現,也可以在編譯的時候通過相關的操作來實現。還有一種解決方案:點選開啟連結,我也不是很懂,這裡不在多說。

三 問題擴充套件

       我們分析一個經典的生產者消費者問題,一個生產者執行緒thread1和一個消費者執行緒thread2。他們之間有一個數據佇列,通常我們的做法是給佇列加鎖來保證佇列資料的同步,這種做法除了鎖的效能消耗外,還有什麼缺點呢,我們來分析一下。

       假設我們的計算機是雙核,core1和core2,thread1執行在core1中,當thread1執行時遇到lock,進入sleep狀態,此時thread2啟用,此時系統給thread2分配cpu時間片,此時core1和core2都空閒假如此時系統把core1分配給了thread2,此時core1中的快取對於thread2來說是無用的,所以重新衝記憶體中讀取快取,當thread2遇到lock時,thread1啟用,假如又分配給了core1,此時快取又是無用的。這種情況就會導致頻繁的讀取快取降低cpu的效率。我們能不能讓快取的有效時間長一點呢,最直接最簡單的辦法就是不使用鎖。  

        這裡可以參考java的併發框架Disruptor的實現原理。不得不說java社群還是有很多值得學習的優秀的框架的。

       簡單說下吧,瞭解的也不是很多。

        

       首先Disruptor有一個ringbuffer,是一個迴圈佇列如上圖,有個cursor,裡面有sequencenumber,資料型別是long。如果不考慮consumer,只有一個producer在寫,就是不停的往entry裡寫東西,然後增加cursor上的sequence number。為了避免cursor裡的sequence number和其他variable變數產生false sharing,disruptor定義了7個long型,並沒有給它們賦值,然後再定義cursor,這樣cursor就不會和其他variable同時出現在一個cache line裡,這就是我們上面提到的在程式層進行快取行補齊。

     我們首先看有一個生產者,一個或多個消費者的情況, 當我們的消費者讀到5,7時,生產者寫道18,此時18會被標記為inavalid,Consumer每次在訪問時需要先檢查sequence number是否available,當讀到18時發現此位置不可用,此時會有多種策略,latency最高的一種是盲等。producer在寫的時候,需要檢查最低的sequence number在哪兒,判斷當前位置是否有消費者在讀取資料,如果沒有則修改當前位置的資料,並且把當前位置的sequencenumber增加(上圖如果修改了3位置的資料,3會變成19)。

    如果有多個生產者呢,怎麼保證資料的同步呢,我還沒有徹底搞懂他的實現機制,推薦一篇文章吧,裡面詳細講述了它如何應對多個生產者的情況:點選開啟連結,文章下面有很多關於該框架的文章可以看一下,最近我也在看,搞java的應該對這個很熟悉。

    最後說一下我對於cache line優化的觀點吧,這種優化我認為是在你係統對於處理效能和併發等方面有一個特別高的要求,並且你的系統優化到了極致,這種時候你再考慮做著這方面的優化,比如你的系統子io讀取,cpu計算,還有網路等方面有很大的優化空間你首先要解決這些問題,而不是直接去優化cpu 的cache問題,否則會得不償失。