原文:https://mp.weixin.qq.com/s/LXt24f9Qw2fAQtyPNg-a9A
在并發(fā)編程過(guò)程中,我們大部分的焦點(diǎn)都放在如何控制共享變量的訪問(wèn)控制上(代碼層面)盯另,但是很少人會(huì)關(guān)注系統(tǒng)硬件及 JVM 底層相關(guān)的影響因素蛙吏。前段時(shí)間學(xué)習(xí)了一個(gè)牛X的高性能異步處理框架 Disruptor,它被譽(yù)為“最快的消息框架”稽物,其 LMAX 架構(gòu)能夠在一個(gè)線程里每秒處理 6百萬(wàn) 訂單奄毡!在講到 Disruptor 為什么這么快時(shí),接觸到了一個(gè)概念——偽共享( false sharing )贝或,其中提到:緩存行上的寫(xiě)競(jìng)爭(zhēng)是運(yùn)行在 SMP 系統(tǒng)中并行線程實(shí)現(xiàn)可伸縮性最重要的限制因素吼过。由于從代碼中很難看出是否會(huì)出現(xiàn)偽共享,有人將其描述成無(wú)聲的性能殺手咪奖。
偽共享的非標(biāo)準(zhǔn)定義為:緩存系統(tǒng)中是以緩存行(cache line)為單位存儲(chǔ)的盗忱,當(dāng)多線程修改互相獨(dú)立的變量時(shí),如果這些變量共享同一個(gè)緩存行赡艰,就會(huì)無(wú)意中影響彼此的性能售淡,這就是偽共享。
一慷垮、CPU 緩存
CPU 緩存(Cache Memory)是位于 CPU 與內(nèi)存之間的臨時(shí)存儲(chǔ)器揖闸,它的容量比內(nèi)存小的多但是交換速度卻比內(nèi)存要快得多。
按照數(shù)據(jù)讀取順序和與 CPU 結(jié)合的緊密程度料身,CPU 緩存可以分為一級(jí)緩存汤纸,二級(jí)緩存,部分高端 CPU 還具有三級(jí)緩存芹血。每一級(jí)緩存中所儲(chǔ)存的全部數(shù)據(jù)都是下一級(jí)緩存的一部分贮泞,越靠近 CPU 的緩存越快也越小。所以 L1 緩存很小但很快(譯注:L1 表示一級(jí)緩存)幔烛,并且緊靠著在使用它的 CPU 內(nèi)核啃擦。L2 大一些,也慢一些饿悬,并且仍然只能被一個(gè)單獨(dú)的 CPU 核使用令蛉。L3 在現(xiàn)代多核機(jī)器中更普遍,仍然更大,更慢珠叔,并且被單個(gè)插槽上的所有 CPU 核共享蝎宇。最后,你擁有一塊主存祷安,由全部插槽上的所有 CPU 核共享姥芥。擁有三級(jí)緩存的的 CPU,到三級(jí)緩存時(shí)能夠達(dá)到 95% 的命中率汇鞭,只有不到 5% 的數(shù)據(jù)需要從內(nèi)存中查詢凉唐。
多核機(jī)器的存儲(chǔ)結(jié)構(gòu)如下圖所示:
二、MESI 協(xié)議及 RFO 請(qǐng)求
從上一節(jié)中我們知道虱咧,每個(gè)核都有自己私有的 L1,熊榛、L2 緩存。那么多線程編程時(shí), 另外一個(gè)核的線程想要訪問(wèn)當(dāng)前核內(nèi) L1腕巡、L2 緩存行的數(shù)據(jù), 該怎么辦呢玄坦?
有人說(shuō)可以通過(guò)第 2 個(gè)核直接訪問(wèn)第 1 個(gè)核的緩存行,這是當(dāng)然是可行的绘沉,但這種方法不夠快煎楣。跨核訪問(wèn)需要通過(guò) Memory Controller(內(nèi)存控制器车伞,是計(jì)算機(jī)系統(tǒng)內(nèi)部控制內(nèi)存并且通過(guò)內(nèi)存控制器使內(nèi)存與 CPU 之間交換數(shù)據(jù)的重要組成部分)择懂,典型的情況是第 2 個(gè)核經(jīng)常訪問(wèn)第 1 個(gè)核的這條數(shù)據(jù),那么每次都有跨核的消耗.另玖。更糟的情況是困曙,有可能第 2 個(gè)核與第 1 個(gè)核不在一個(gè)插槽內(nèi),況且 Memory Controller 的總線帶寬是有限的谦去,扛不住這么多數(shù)據(jù)傳輸慷丽。所以,CPU 設(shè)計(jì)者們更偏向于另一種辦法: 如果第 2 個(gè)核需要這份數(shù)據(jù)鳄哭,由第 1 個(gè)核直接把數(shù)據(jù)內(nèi)容發(fā)過(guò)去要糊,數(shù)據(jù)只需要傳一次。
那么什么時(shí)候會(huì)發(fā)生緩存行的傳輸呢妆丘?答案很簡(jiǎn)單:當(dāng)一個(gè)核需要讀取另外一個(gè)核的臟緩存行時(shí)發(fā)生锄俄。但是前者怎么判斷后者的緩存行已經(jīng)被弄臟(寫(xiě))了呢?
下面將詳細(xì)地解答以上問(wèn)題. 首先我們需要談到一個(gè)協(xié)議—— MESI 協(xié)議∩准穑現(xiàn)在主流的處理器都是用它來(lái)保證緩存的相干性和內(nèi)存的相干性奶赠。M、E药有、S 和 I 代表使用 MESI 協(xié)議時(shí)緩存行所處的四個(gè)狀態(tài):
M(修改车柠,Modified):本地處理器已經(jīng)修改緩存行,即是臟行,它的內(nèi)容與內(nèi)存中的內(nèi)容不一樣竹祷,并且此 cache 只有本地一個(gè)拷貝(專有);
E(專有羊苟,Exclusive):緩存行內(nèi)容和內(nèi)存中的一樣塑陵,而且其它處理器都沒(méi)有這行數(shù)據(jù);
S(共享蜡励,Shared):緩存行內(nèi)容和內(nèi)存中的一樣, 有可能其它處理器也存在此緩存行的拷貝令花;
I(無(wú)效,Invalid):緩存行失效, 不能使用凉倚。
下面說(shuō)明這四個(gè)狀態(tài)是如何轉(zhuǎn)換的:
初始:一開(kāi)始時(shí)兼都,緩存行沒(méi)有加載任何數(shù)據(jù),所以它處于 I 狀態(tài)稽寒。
本地寫(xiě)(Local Write):如果本地處理器寫(xiě)數(shù)據(jù)至處于 I 狀態(tài)的緩存行扮碧,則緩存行的狀態(tài)變成 M。
本地讀(Local Read):如果本地處理器讀取處于 I 狀態(tài)的緩存行杏糙,很明顯此緩存沒(méi)有數(shù)據(jù)給它慎王。此時(shí)分兩種情況:(1)其它處理器的緩存里也沒(méi)有此行數(shù)據(jù),則從內(nèi)存加載數(shù)據(jù)到此緩存行后宏侍,再將它設(shè)成 E 狀態(tài)赖淤,表示只有我一家有這條數(shù)據(jù),其它處理器都沒(méi)有谅河;(2)其它處理器的緩存有此行數(shù)據(jù)咱旱,則將此緩存行的狀態(tài)設(shè)為 S 狀態(tài)。(備注:如果處于M狀態(tài)的緩存行绷耍,再由本地處理器寫(xiě)入/讀出吐限,狀態(tài)是不會(huì)改變的)
遠(yuǎn)程讀(Remote Read):假設(shè)我們有兩個(gè)處理器 c1 和 c2,如果 c2 需要讀另外一個(gè)處理器 c1 的緩存行內(nèi)容锨天,c1 需要把它緩存行的內(nèi)容通過(guò)內(nèi)存控制器 (Memory Controller) 發(fā)送給 c2毯盈,c2 接到后將相應(yīng)的緩存行狀態(tài)設(shè)為 S。在設(shè)置之前病袄,內(nèi)存也得從總線上得到這份數(shù)據(jù)并保存搂赋。
遠(yuǎn)程寫(xiě)(Remote Write):其實(shí)確切地說(shuō)不是遠(yuǎn)程寫(xiě),而是 c2 得到 c1 的數(shù)據(jù)后益缠,不是為了讀脑奠,而是為了寫(xiě)。也算是本地寫(xiě)幅慌,只是 c1 也擁有這份數(shù)據(jù)的拷貝宋欺,這該怎么辦呢?c2 將發(fā)出一個(gè) RFO (Request For Owner) 請(qǐng)求,它需要擁有這行數(shù)據(jù)的權(quán)限齿诞,其它處理器的相應(yīng)緩存行設(shè)為 I酸休,除了它自已,誰(shuí)不能動(dòng)這行數(shù)據(jù)祷杈。這保證了數(shù)據(jù)的安全斑司,同時(shí)處理 RFO 請(qǐng)求以及設(shè)置I的過(guò)程將給寫(xiě)操作帶來(lái)很大的性能消耗。
狀態(tài)轉(zhuǎn)換由下圖做個(gè)補(bǔ)充:
我們從上節(jié)知道但汞,寫(xiě)操作的代價(jià)很高宿刮,特別當(dāng)需要發(fā)送 RFO 消息時(shí)。我們編寫(xiě)程序時(shí)私蕾,什么時(shí)候會(huì)發(fā)生 RFO 請(qǐng)求呢僵缺?有以下兩種:
線程的工作從一個(gè)處理器移到另一個(gè)處理器, 它操作的所有緩存行都需要移到新的處理器上。此后如果再寫(xiě)緩存行踩叭,則此緩存行在不同核上有多個(gè)拷貝磕潮,需要發(fā)送 RFO 請(qǐng)求了。
兩個(gè)不同的處理器確實(shí)都需要操作相同的緩存行
接下來(lái)懊纳,我們要了解什么是緩存行揉抵。
三、緩存行
在文章開(kāi)頭提到過(guò)嗤疯,緩存系統(tǒng)中是以緩存行(cache line)為單位存儲(chǔ)的冤今。緩存行通常是 64 字節(jié)(譯注:本文基于 64 字節(jié),其他長(zhǎng)度的如 32 字節(jié)等不適本文討論的重點(diǎn))茂缚,并且它有效地引用主內(nèi)存中的一塊地址戏罢。一個(gè) Java 的 long 類型是 8 字節(jié),因此在一個(gè)緩存行中可以存 8 個(gè) long 類型的變量脚囊。所以龟糕,如果你訪問(wèn)一個(gè) long 數(shù)組,當(dāng)數(shù)組中的一個(gè)值被加載到緩存中悔耘,它會(huì)額外加載另外 7 個(gè)讲岁,以致你能非常快地遍歷這個(gè)數(shù)組衬以。事實(shí)上缓艳,你可以非常快速的遍歷在連續(xù)的內(nèi)存塊中分配的任意數(shù)據(jù)結(jié)構(gòu)看峻。而如果你在數(shù)據(jù)結(jié)構(gòu)中的項(xiàng)在內(nèi)存中不是彼此相鄰的(如鏈表)阶淘,你將得不到免費(fèi)緩存加載所帶來(lái)的優(yōu)勢(shì),并且在這些數(shù)據(jù)結(jié)構(gòu)中的每一個(gè)項(xiàng)都可能會(huì)出現(xiàn)緩存未命中互妓。
如果存在這樣的場(chǎng)景溪窒,有多個(gè)線程操作不同的成員變量坤塞,但是相同的緩存行,這個(gè)時(shí)候會(huì)發(fā)生什么澈蚌?摹芙。沒(méi)錯(cuò),偽共享(False Sharing)問(wèn)題就發(fā)生了惜浅!有張 Disruptor 項(xiàng)目的經(jīng)典示例圖瘫辩,如下:
上圖中,一個(gè)運(yùn)行在處理器 core1上的線程想要更新變量 X 的值坛悉,同時(shí)另外一個(gè)運(yùn)行在處理器 core2 上的線程想要更新變量 Y 的值。但是承绸,這兩個(gè)頻繁改動(dòng)的變量都處于同一條緩存行裸影。兩個(gè)線程就會(huì)輪番發(fā)送 RFO 消息,占得此緩存行的擁有權(quán)军熏。當(dāng) core1 取得了擁有權(quán)開(kāi)始更新 X轩猩,則 core2 對(duì)應(yīng)的緩存行需要設(shè)為 I 狀態(tài)。當(dāng) core2 取得了擁有權(quán)開(kāi)始更新 Y荡澎,則 core1 對(duì)應(yīng)的緩存行需要設(shè)為 I 狀態(tài)(失效態(tài))均践。輪番奪取擁有權(quán)不但帶來(lái)大量的 RFO 消息,而且如果某個(gè)線程需要讀此行數(shù)據(jù)時(shí)摩幔,L1 和 L2 緩存上都是失效數(shù)據(jù)彤委,只有 L3 緩存上是同步好的數(shù)據(jù)。從前一篇我們知道或衡,讀 L3 的數(shù)據(jù)非常影響性能焦影。更壞的情況是跨槽讀取,L3 都要 miss封断,只能從內(nèi)存上加載斯辰。
表面上 X 和 Y 都是被獨(dú)立線程操作的,而且兩操作之間也沒(méi)有任何關(guān)系坡疼。只不過(guò)它們共享了一個(gè)緩存行彬呻,但所有競(jìng)爭(zhēng)沖突都是來(lái)源于共享。
四柄瑰、遭遇偽共享
好的闸氮,那么接下來(lái)我們就用 code 來(lái)進(jìn)行實(shí)驗(yàn)和佐證。
public class FalseShareTest implements Runnable {
public static int NUM_THREADS = 4;
public final static long ITERATIONS = 500L * 1000L * 1000L;
private final int arrayIndex;
private static VolatileLong[] longs;
public static long SUM_TIME = 0l;
public FalseShareTest(final int arrayIndex) {
this.arrayIndex = arrayIndex;
}
public static void main(final String[] args) throws Exception {
Thread.sleep(10000);
for(int j=0; j<10; j++){
System.out.println(j);
if (args.length == 1) {
NUM_THREADS = Integer.parseInt(args[0]);
}
longs = new VolatileLong[NUM_THREADS];
for (int i = 0; i < longs.length; i++) {
longs[i] = new VolatileLong();
}
final long start = System.nanoTime();
runTest();
final long end = System.nanoTime();
SUM_TIME += end - start;
}
System.out.println("平均耗時(shí):"+SUM_TIME/10);
}
private static void runTest() throws InterruptedException {
Thread[] threads = new Thread[NUM_THREADS];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(new FalseShareTest(i));
}
for (Thread t : threads) {
t.start();
}
for (Thread t : threads) {
t.join();
}
}
public void run() {
long i = ITERATIONS + 1;
while (0 != --i) {
longs[arrayIndex].value = i;
}
}
public final static class VolatileLong {
public volatile long value = 0L;
public long p1, p2, p3, p4, p5, p6; //屏蔽此行
}
}
上述代碼的邏輯很簡(jiǎn)單狱意,就是四個(gè)線程修改一數(shù)組不同元素的內(nèi)容湖苞。元素的類型是 VolatileLong,只有一個(gè)長(zhǎng)整型成員 value 和 6 個(gè)沒(méi)用到的長(zhǎng)整型成員详囤。value 設(shè)為 volatile 是為了讓 value 的修改對(duì)所有線程都可見(jiàn)财骨。程序分兩種情況執(zhí)行镐作,第一種情況為不屏蔽倒數(shù)第三行(見(jiàn)”屏蔽此行”字樣),第二種情況為屏蔽倒數(shù)第三行隆箩。為了”保證”數(shù)據(jù)的相對(duì)可靠性该贾,程序取 10 次執(zhí)行的平均時(shí)間。執(zhí)行情況如下(執(zhí)行環(huán)境:32位 windows捌臊,四核杨蛋,8GB 內(nèi)存):
兩個(gè)邏輯一模一樣的程序,前者的耗時(shí)大概是后者的 2.5 倍理澎,這太不可思議了逞力!那么這個(gè)時(shí)候,我們?cè)儆脗喂蚕恚‵alse Sharing)的理論來(lái)分析一下糠爬。前者 longs 數(shù)組的 4 個(gè)元素寇荧,由于 VolatileLong 只有 1 個(gè)長(zhǎng)整型成員,所以整個(gè)數(shù)組都將被加載至同一緩存行执隧,但有4個(gè)線程同時(shí)操作這條緩存行揩抡,于是偽共享就悄悄地發(fā)生了。
基于此镀琉,我們有理由相信峦嗤,在一定線程數(shù)量范圍內(nèi)(注意思考:為什么強(qiáng)調(diào)是一定線程數(shù)量范圍內(nèi)),隨著線程數(shù)量的增加屋摔,偽共享發(fā)生的頻率也越大烁设,直觀體現(xiàn)就是執(zhí)行時(shí)間越長(zhǎng)。為了證實(shí)這個(gè)觀點(diǎn)凡壤,本人在同樣的機(jī)器上分別用單線程署尤、2、4亚侠、8個(gè)線程曹体,對(duì)有填充和無(wú)填充兩種情況進(jìn)行測(cè)試。執(zhí)行場(chǎng)景是取 10 次執(zhí)行的平均時(shí)間硝烂,結(jié)果如下所示:
五箕别、如何避免偽共享
其中一個(gè)解決思路,就是讓不同線程操作的對(duì)象處于不同的緩存行即可滞谢。
那么該如何做到呢串稀?其實(shí)在我們注釋的那行代碼中就有答案,那就是緩存行填充(Padding) ∈ㄑ睿現(xiàn)在分析上面的例子母截,我們知道一條緩存行有 64 字節(jié),而 Java 程序的對(duì)象頭固定占 8 字節(jié)(32位系統(tǒng))或 12 字節(jié)( 64 位系統(tǒng)默認(rèn)開(kāi)啟壓縮, 不開(kāi)壓縮為 16 字節(jié))橄教,所以我們只需要填 6 個(gè)無(wú)用的長(zhǎng)整型補(bǔ)上6*8=48字節(jié)清寇,讓不同的 VolatileLong 對(duì)象處于不同的緩存行喘漏,就避免了偽共享( 64 位系統(tǒng)超過(guò)緩存行的 64 字節(jié)也無(wú)所謂,只要保證不同線程不操作同一緩存行就可以)华烟。
偽共享在多核編程中很容易發(fā)生翩迈,而且非常隱蔽。例如盔夜,在 JDK 的 LinkedBlockingQueue 中负饲,存在指向隊(duì)列頭的引用 head 和指向隊(duì)列尾的引用 tail 。而這種隊(duì)列經(jīng)常在異步編程中使有喂链,這兩個(gè)引用的值經(jīng)常的被不同的線程修改返十,但它們卻很可能在同一個(gè)緩存行,于是就產(chǎn)生了偽共享椭微。線程越多吧慢,核越多,對(duì)性能產(chǎn)生的負(fù)面效果就越大赏表。
由于某些 Java 編譯器的優(yōu)化策略,那些沒(méi)有使用到的補(bǔ)齊數(shù)據(jù)可能會(huì)在編譯期間被優(yōu)化掉匈仗,我們可以在程序中加入一些代碼防止被編譯優(yōu)化瓢剿。如下:
public static long preventFromOptimization(VolatileLong v) {
return v.p1 + v.p2 + v.p3 + v.p4 + v.p5 + v.p6;
}
另外一種技術(shù)是使用編譯指示,來(lái)強(qiáng)制使每一個(gè)變量對(duì)齊悠轩。
下面的代碼顯式了編譯器使用__declspec( align(n) ) 此處 n=64间狂,按照 cache line 邊界對(duì)齊。
__declspec (align(64)) int thread1_global_variable;
__declspec (align(64)) int thread2_global_variable;
當(dāng)使用數(shù)組時(shí)火架,在 cache line 尾部填充 padding 來(lái)保證數(shù)據(jù)元素在 cache line 邊界開(kāi)始鉴象。如果不能夠保證數(shù)組按照 cache line 邊界對(duì)齊,填充數(shù)據(jù)結(jié)構(gòu)【數(shù)組元素】使之是 cache line 大小的兩倍何鸡。下面的代碼顯式了填充數(shù)據(jù)結(jié)構(gòu)使之按照 cache line 對(duì)齊纺弊。并且通過(guò) __declspec( align(n) ) 語(yǔ)句來(lái)保證數(shù)組也是對(duì)齊的。如果數(shù)組是動(dòng)態(tài)分配的骡男,你可以增加分配的大小淆游,并調(diào)整指針來(lái)對(duì)其到 cache line 邊界。
struct ThreadParams
{
// For the following 4 variables: 4*4 = 16 bytes
unsigned long thread_id;
unsigned long v; // Frequent read/write access variable
unsigned long start;
unsigned long end;
// expand to 64 bytes to avoid false-sharing
// (4 unsigned long variables + 12 padding)*4 = 64
int padding[12];
};
在jdk1.8中隔盛,有專門的注解@Contended來(lái)避免偽共享犹菱,更優(yōu)雅地解決問(wèn)題。
除此之外吮炕,在網(wǎng)上還有很多對(duì)偽共享的研究腊脱,提出了一些基于數(shù)據(jù)融合的方案,有興趣的同學(xué)可以了解下龙亲。
六陕凹、對(duì)于偽共享悍抑,我們?cè)趯?shí)際開(kāi)發(fā)中該怎么做?
通過(guò)上面大篇幅的介紹捆姜,我們已經(jīng)知道偽共享的對(duì)程序的影響传趾。那么,在實(shí)際的生產(chǎn)開(kāi)發(fā)過(guò)程中泥技,我們一定要通過(guò)緩存行填充去解決掉潛在的偽共享問(wèn)題嗎浆兰?
其實(shí)并不一定。
首先就是多次強(qiáng)調(diào)的珊豹,偽共享是很隱蔽的簸呈,我們暫時(shí)無(wú)法從系統(tǒng)層面上通過(guò)工具來(lái)探測(cè)偽共享事件。其次店茶,不同類型的計(jì)算機(jī)具有不同的微架構(gòu)(如 32 位系統(tǒng)和 64 位系統(tǒng)的 java 對(duì)象所占自己數(shù)就不一樣)蜕便,如果設(shè)計(jì)到跨平臺(tái)的設(shè)計(jì),那就更難以把握了贩幻,一個(gè)確切的填充方案只適用于一個(gè)特定的操作系統(tǒng)轿腺。還有,緩存的資源是有限的丛楚,如果填充會(huì)浪費(fèi)珍貴的 cache 資源族壳,并不適合大范圍應(yīng)用。最后趣些,目前主流的 Intel 微架構(gòu) CPU 的 L1 緩存仿荆,已能夠達(dá)到 80% 以上的命中率。
綜上所述坏平,并不是每個(gè)系統(tǒng)都適合花大量精力去解決潛在的偽共享問(wèn)題拢操。
參考文章:
- 《從Java視角理解偽共享(False Sharing)》
- 《【翻譯】線程間偽共享的避免和識(shí)別》
- 一種利用數(shù)據(jù)融合來(lái)提高局部性和減少偽共享的方法》