起因
在閱讀百度的發號器 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字節了,這樣就會導致空間會膨脹很多。所以即使用的時候也要做好取舍。