偽共享 FalseSharing (CacheLine,MESI) 淺析以及解決方案
起因
在閱讀百度的發號器 uid-generator 原始碼的過程中,發現了一段很奇怪的程式碼:
/** * Represents a padded {@link AtomicLong} to prevent the FalseSharing problem<p> * * The CPU cache line commonly be 64 bytes, here is a sample of cache line after padding:<br> * 64 bytes = 8 bytes (object reference) + 6 * 8 bytes (padded long) + 8 bytes (a long value) * * @author yutianbao */ public class PaddedAtomicLong extends AtomicLong { private static final long serialVersionUID = -3415778863941386253L; /** Padded 6 long (48 bytes) */ public volatile long p1, p2, p3, p4, p5, p6 = 7L; /** * Constructors from {@link AtomicLong} */ public PaddedAtomicLong() { super(); } public PaddedAtomicLong(long initialValue) { super(initialValue); } }
這裡面有6個看上去毫無作用的volatile long變數(標紅)。如果這是我自己寫的程式碼,我肯定會認為是我自己手抖寫多了。
但是作為百度的發號器,開源了這麼久,如果是手抖早被fix了。肯定還是有深意的。於是閱讀了一些類註釋,看到了這句話:
to prevent the FalseSharing problem
果然,這幾個變數不是毫無作用的,是為了解決FalseSharing問題。
但是轉念一想,我好像不知道什麼是FalseSharing?解決了一個問題,又陷入了另一個更大的問題。
於是就上網查了很多資料,閱讀了很多部落格,算是對FalseSharing有了一個初步的瞭解。在這裡寫出來也為了希望能幫到有同樣困惑的人。
背景知識
要說清楚FalseSharing,不是一兩句話能做到的事,有一些必須瞭解的背景知識需要補充一下。
計算機儲存架構
上圖展示的是不同層級的硬體和cpu之間的互動延遲。越靠近CPU,速度越快。
計算機執行時,CPU是執行指令的地方,而指令會需要一些資料的讀寫。程式的執行時資料都是存放在主存的,而主存又特別慢(相對),所以為了解決CPU和主存之間的速度差異,現代計算機都引入了快取記憶體(L1L2L3)。
現代計算機對快取/記憶體的設計一般如下:
L1和L2由CPU的每個核心獨享,而L3則被整個CPU裡所有核心共享(僅指單CPU架構)。
CPU訪問資料時,按照先去L1,查不到去L2,再L3->主存的順序來查詢。
Cache Line
在上述CPU和快取的資料交換過程中,並不是以位元組為單位的。而是每次都會以Cache Line為單位來進行存取。
Cache Line其實就是一段固定大小的記憶體空間,一般為64位元組。
MESI
這個東西研究過 volatile的同學可能會比較熟悉,這個就是各個告訴快取之間的一個一致性協議。
因為L1 L2是每個核心自己使用,而不同核心又可能涉及共享變數問題,所以各個快取記憶體間勢必會有一致性的問題。MESI就是解決這些問題的一種方式。
MESI大致原理如下圖:
我這裡就摘抄一下網上搜到的解釋:
在MESI協議中,每個Cache line有4個狀態,可用2個bit表示,它們分別是:
M(Modified):這行資料有效,資料被修改了,和記憶體中的資料不一致,資料只存在於本Cache中;
E(Exclusive):這行資料有效,資料和記憶體中的資料一致,資料只存在於本Cache中;
S(Shared):這行資料有效,資料和記憶體中的資料一致,資料存在於很多Cache中;
I(Invalid):這行資料無效。
通俗一點說,就是如果Core0和Core1都在使用一個共享變數變數A,則0,1都會在自己的Cache裡有一份A的副本,分佈在不同的CacheLine。
如果大家都沒有修改A,則Core0和Core1裡變數A所在的Cache Line的狀態都是S。
如果Core0修改了A的值,則此時Core0的Cache Line變為M,Core1 的Cache Line變為I。
這樣CPU就可以通過CacheLine的狀態,來決定是刪除快取,還是直接讀取什麼的。
偽共享
背景知識介紹完畢了,這樣再說偽共享就不會顯得太難以理解了。
先說一個場景:
你的程式碼裡需要使用一個volatile的Bool變數,當做多執行緒行為的一個開關:
static volatile boolean flag = true; public static void main(String[] args) { for (int i = 0; i < 10; i++) { new Thread(() -> { Integer count = 0; while (flag) { ++count; System.out.println(Thread.currentThread().getName() + ":" + count); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } }).start(); } new Thread(() -> { try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } flag = false; }).start(); }
這段程式碼會宣告一個flag為true,然後有10個工作執行緒會在flag為true時沒100ms對count做個自增操作,然後輸出。當flag為false時,就會結束執行緒。
還有一個執行緒A,會在1000ms後將flag置為false。
這裡就是volatile的一個經典用法,可以保證多個執行緒對flag的可見性,不會因為執行緒A修改了flag的值,但是工作執行緒讀取到的不是最新值而額外執行一些工作。
這段程式碼看起來是沒有任何問題的,實際上跑起來也沒有問題。
但是結合之前的背景知識,考慮一下flag所在的cache line,肯定還會有其他的變數(cache line 64位元組,bool無法完整填充一個CacheLine)。
如果flag所在的CacheLine裡還有一個頻繁修改的共享變數,這時會發生什麼?
很簡單,就是flag所在的CacheLine被頻繁置為不可用,需要清除快取重新讀取。flag在工作狀態並沒有被修改,但是仍然會被其他頻繁修改的共享變數所影響。
這樣就會帶來一個問題,即使flag並沒有被修改,但我們的工作執行緒很多時間都等於是在主存中讀取flag的值,這樣在高併發時會帶來很大的效率問題。
以上就是所謂的 “FalseSharing” 問題。
解決辦法
FalseSharing對於普通業務應用,基本沒什麼實際影響。但是對於很多超高併發的中介軟體(例如發號器),可能就會帶來一定的效能瓶頸。所以這類專案都是需要關注這個問題的。
出現原因已經說清楚了,那麼該如何解決呢?
其實答案就在文章的開頭,那6個看上去沒有任何含義的volatile long變數,就是用來解決這個問題的。
The CPU cache line commonly be 64 bytes, here is a sample of cache line after padding:64 bytes = 8 bytes (object reference) + 6 * 8 bytes (padded long) + 8 bytes (a long value)
這行註釋就說明了這6個變數是如何解決FalseSharing問題的:
CacheLine一般是64位元組,64 = 8(物件本身的屬性資訊)+ 6*8(long佔用8個位元組) + 8 (AtomicLong本身帶有一個long) 。
寫了這6個看著無效的變數後,PaddedAtomicLong就會佔用64個位元組,正好填滿一個CacheLine,這樣就會被獨自分配到一個CacheLine,這樣就不存在FalseSharing問題了。
需要注意的是本來AtomicLong僅佔用不到20位元組,但是為了解決FalseSharing做了填充之後就佔用64位元組了,這樣就會導致空間會膨脹很多。所以即使用的時候也要做好取捨。
&n