操屁眼的视频在线免费看,日本在线综合一区二区,久久在线观看免费视频,欧美日韩精品久久综

新聞資訊

    章目錄:

    1、 CPU緩存

    2、 總線鎖和緩存鎖

    3、 緩存行

    4、 緩存一致性協(xié)議(如:MESI)

    5、 偽共享(false sharing)問(wèn)題

    6、 偽共享解決方案(如:緩存行填充)

    6.1 Disruptor為什么這么快?

    6.2 實(shí)驗(yàn)證明

    6.3 Jdk8中自帶注解@Contended

    7、 總結(jié)

    本篇文章主要介紹CPU緩存相關(guān)的內(nèi)容。 亦是上一篇文章干掉面試官-volatile底層原理詳解的延伸和補(bǔ)充。

    并發(fā)編程為何如此復(fù)雜?并發(fā)編程為什么會(huì)產(chǎn)生可見(jiàn)性、有序性、原子性的線程或內(nèi)存問(wèn)題?

    歸根結(jié)底,還是計(jì)算機(jī)硬件高速發(fā)展的原因。如果是單核的cpu,肯定不會(huì)出現(xiàn)多線程并發(fā)的安全問(wèn)題。正是因?yàn)槎嗪薈PU架構(gòu),以及CPU緩存才導(dǎo)致一系列的并發(fā)問(wèn)題。

    1、 CPU緩存

    相信大家都見(jiàn)過(guò)下面這張圖或類似的圖,計(jì)算機(jī)的存儲(chǔ)層次結(jié)構(gòu)像一座金字塔。越往上訪問(wèn)速度越快、成本更高,所以空間也越小。越往下訪問(wèn)速度越慢、成本越低,空間也就越大。

    CPU的運(yùn)算速度最快,內(nèi)存的讀寫(xiě)速度無(wú)法和其速度匹配。假如定義cpu的一次存儲(chǔ)或訪問(wèn)為一個(gè)時(shí)鐘周期,那么內(nèi)存的一次運(yùn)算通常需要幾十甚至幾百個(gè)始終周期。如果在CPU直接讀取內(nèi)存進(jìn)行運(yùn)算,那么CPU大部分時(shí)間都在等在內(nèi)存的訪問(wèn),利用率僅有幾十分之一甚至幾百分之一。為了解決CPU運(yùn)算速度與內(nèi)存讀寫(xiě)速度不匹配的矛盾,在CPU和內(nèi)存之間,引入了L1高速緩存、L2高速緩存、L3高速緩存,通過(guò)每一級(jí)緩存中所存儲(chǔ)的數(shù)據(jù)全部都是下一級(jí)緩存中的一部分,當(dāng)CPU需要數(shù)據(jù)時(shí),就從緩存中獲取,從而加快讀寫(xiě)速度,提高CPU利用率、提升整體效率。

    • L1高速緩存:也叫一級(jí)緩存。一般內(nèi)置在內(nèi)核旁邊,是與CPU結(jié)合最為緊密的CPU緩存。一次訪問(wèn)只需要2~4個(gè)時(shí)鐘周期
    • L2高速緩存:也叫二級(jí)緩存。空間比L1緩存大,速度比L1緩存略慢。一次訪問(wèn)約需要10多個(gè)時(shí)鐘周期
    • L3高速緩存:也叫三級(jí)緩存。部分單CPU多核心的才會(huì)有的緩存,介于多核和內(nèi)存之間。存儲(chǔ)空間已達(dá)Mb級(jí)別,一次訪問(wèn)約需要數(shù)十個(gè)時(shí)鐘周期。

    當(dāng)CPU要讀取一個(gè)數(shù)據(jù)時(shí),首先從L1緩存查找,命中則返回;若未命中,再?gòu)腖2緩存中查找,如果還沒(méi)有則從L3緩存查找(如果有L3緩存的話)。如果還是沒(méi)有,則從內(nèi)存中查找,并將讀取到的數(shù)據(jù)逐級(jí)放入緩存。

    2、 總線鎖和緩存鎖

    上一篇文章講到過(guò)lock前綴

    lock前綴,會(huì)保證某個(gè)處理器對(duì)共享內(nèi)存(一般是緩存行cacheline,這里記住緩存行概念,后續(xù)重點(diǎn)介紹)的獨(dú)占使用。它將本處理器緩存寫(xiě)入內(nèi)存,該寫(xiě)入操作會(huì)引起其他處理器或內(nèi)核對(duì)應(yīng)的緩存失效。通過(guò)獨(dú)占內(nèi)存、使其他處理器緩存失效,達(dá)到了“指令重排序無(wú)法越過(guò)內(nèi)存屏障”的作用

    總線鎖 :顧名思義就是,鎖住總線。通過(guò)處理器發(fā)出lock指令,總線接受到指令后,其他處理器的請(qǐng)求就會(huì)被阻塞,直到此處理器執(zhí)行完成。這樣,處理器就可以獨(dú)占共享內(nèi)存的使用。但是,總線鎖存在較大的缺點(diǎn),一旦某個(gè)處理器獲取總線鎖,其他處理器都只能阻塞等待,多處理器的優(yōu)勢(shì)就無(wú)法發(fā)揮

    于是,經(jīng)過(guò)發(fā)展、優(yōu)化,又產(chǎn)生了緩存鎖。

    緩存鎖:不需鎖定總線,只需要“鎖定”被緩存的共享對(duì)象(實(shí)際為:緩存行)即可,接受到lock指令,通過(guò)緩存一致性協(xié)議,維護(hù)本處理器內(nèi)部緩存和其他處理器緩存的一致性。相比總線鎖,會(huì)提高cpu利用率。

    但是緩存鎖也不是萬(wàn)能,有些場(chǎng)景和情況依然必須通過(guò)總線鎖才能完成。

    這里又出現(xiàn)了兩個(gè)新概念:緩存行緩存一致性協(xié)議

    3、 緩存行

    上一小章節(jié)中提到,緩存鎖會(huì)“鎖定”共享對(duì)象,如果僅鎖定所用對(duì)象,那么有大有小、隨用隨取,對(duì)于CPU來(lái)說(shuō)利用率還達(dá)不到最大化。所以采用,一次獲取一整塊的內(nèi)存數(shù)據(jù),放入緩存。那么這一塊數(shù)據(jù),通常稱為緩存行(cache line)。緩存行(cache line)是CPU緩存中可分配、操作的最小存儲(chǔ)單元。與CPU架構(gòu)有關(guān),通常有32字節(jié)、64字節(jié)、128字節(jié)不等。目前64位架構(gòu)下,64字節(jié)最為常用。

    4、 緩存一致性協(xié)議(如:MESI)

    每個(gè)處理器都有自己的高速緩存,而又共享同一主內(nèi)存。當(dāng)多個(gè)處理器都涉及同一塊主內(nèi)存區(qū)域的更改時(shí),將導(dǎo)致各自的的緩存數(shù)據(jù)不一致。那同步到主內(nèi)存時(shí)該以誰(shuí)的緩存數(shù)據(jù)為準(zhǔn)呢?為了解決一致性的問(wèn)題,需要各個(gè)處理器訪問(wèn)緩存時(shí)都遵循一些協(xié)議,在讀寫(xiě)時(shí)要根據(jù)協(xié)議來(lái)進(jìn)行操作,來(lái)保證處理器間緩存的一致性。這類協(xié)議有MSI、MESI、MOSI等。

    下面重點(diǎn)介紹應(yīng)用較為廣泛的MESI協(xié)議。MESI是Modified(修改)、Exclusive(獨(dú)占)、Shared(共享)、Invaild(失效)四種狀態(tài)的縮寫(xiě),是用來(lái)修飾緩存行的狀態(tài)。在每個(gè)緩存行前額外使用2bit,來(lái)表示此四種狀態(tài)。

    • Modified(修改):該緩存行僅出現(xiàn)在此cpu緩存中,緩存已被修改,和內(nèi)存中不一致,等待同步至內(nèi)存。
    • Exclusive(獨(dú)占):該緩存行僅出現(xiàn)在此cpu緩存中,緩存和內(nèi)存中保持一致。
    • Shared(共享):該緩存行可能出現(xiàn)在多個(gè)cpu緩存中,且多個(gè)cpu緩存的緩存行和內(nèi)存中的數(shù)據(jù)一致。
    • Invalid(失效):由于其他cpu修改了緩存行,導(dǎo)致本cpu中的緩存行失效。

    在MESI協(xié)議中,每個(gè)緩存行不僅知道自己的讀寫(xiě)操作,而且也監(jiān)聽(tīng)其它緩存行的讀寫(xiě)操作。每個(gè)緩存行的狀態(tài)根據(jù)本cpu和其它c(diǎn)pu的讀寫(xiě)操作在4個(gè)狀態(tài)間進(jìn)行遷移。

    它的監(jiān)聽(tīng)(嗅探)機(jī)制:

    • 當(dāng)緩存行處于Modified狀態(tài)時(shí),會(huì)時(shí)刻監(jiān)聽(tīng)其他cpu對(duì)該緩存行對(duì)應(yīng)主內(nèi)存地址的讀取操作,一旦監(jiān)聽(tīng)到,將本cpu的緩存行寫(xiě)回內(nèi)存,并標(biāo)記為Shared狀態(tài)
    • 當(dāng)緩存行處于Exclusive狀態(tài)時(shí),會(huì)時(shí)刻監(jiān)聽(tīng)其他cpu對(duì)該緩存行對(duì)應(yīng)主內(nèi)存地址的讀取操作,一旦監(jiān)聽(tīng)到,將本cpu的緩存行標(biāo)記為Shared狀態(tài)
    • 當(dāng)緩存行處于Shared狀態(tài)時(shí),會(huì)時(shí)刻監(jiān)聽(tīng)其他cpu對(duì)使緩存行失效的指令(即其他cpu的寫(xiě)入操作),一旦監(jiān)聽(tīng)到,將本cpu的緩存行標(biāo)記為Invalid狀態(tài)(其他cpu進(jìn)入Modified狀態(tài))
    • 當(dāng)緩存行處于Invalid狀態(tài)時(shí),從內(nèi)存中讀取,否則直接從緩存讀取

    總結(jié):當(dāng)某個(gè)cpu修改緩存行數(shù)據(jù)時(shí),其他的cpu通過(guò)監(jiān)聽(tīng)機(jī)制獲悉共享緩存行的數(shù)據(jù)被修改,會(huì)使其共享緩存行失效。本cpu會(huì)將修改后的緩存行寫(xiě)回到主內(nèi)存中。此時(shí)其他的cpu如果需要此緩存行共享數(shù)據(jù),則從主內(nèi)存中重新加載,并放入緩存,以此完成了緩存一致性

    5、 偽共享(false sharing)問(wèn)題

    緩存一致性協(xié)議針對(duì)的是最小存取單元:緩存行。依照64字節(jié)的緩存行為例,內(nèi)存中連續(xù)的64字節(jié)都會(huì)被加載到緩存行中,除了目標(biāo)數(shù)據(jù)還會(huì)有其他數(shù)據(jù)。

    如下圖所示,假如變量x和變量y共處在同一緩存行中,core1需要操作變量x,core2需要操作變量y。

    • core1修改緩存行內(nèi)的變量x后,按照緩存一致性協(xié)議,core2需將緩存行置為失效,core1將最新緩存行數(shù)據(jù)寫(xiě)回內(nèi)存。
    • core2需重新從內(nèi)存中加載包含變量y的緩存行數(shù)據(jù),并放置緩存。如果core2修改變量y,需要core1將緩存行置為失效,core2將最新緩存寫(xiě)回內(nèi)存。
    • core1或其他處理器如需操作同一緩存行內(nèi)的其他數(shù)據(jù),同上述步驟。

    上述例子,就是緩存行的偽共享問(wèn)題。總結(jié)來(lái)說(shuō),就是多核多線程并發(fā)場(chǎng)景下,多核要操作的不同變量處于同一緩存行,某cpu更新緩存行中數(shù)據(jù),并將其寫(xiě)回緩存,同時(shí)其他處理器會(huì)使該緩存行失效,如需使用,還需從內(nèi)存中重新加載。這對(duì)效率產(chǎn)生了較大的影響。

    6、 偽共享解決方案(如:緩存行填充)

    偽共享問(wèn)題的解決思路有也很典型:空間換時(shí)間

    以64字節(jié)的緩存行為例,偽共享問(wèn)題產(chǎn)生的前提是,并發(fā)情況下,不同cpu對(duì)緩存行中不同變量的操作引起的。那么,如果把緩存行中僅存儲(chǔ)目標(biāo)變量,其余空間采用“無(wú)用”數(shù)據(jù)填充補(bǔ)齊64字節(jié),就不會(huì)才產(chǎn)生偽共享問(wèn)題。這種方式就是:緩存行填充(也稱緩存行對(duì)齊)

    Talk is cheap,show me the code.

    下面,從三個(gè)實(shí)例去給大家解釋完緩存行填充,讓大家也能應(yīng)用到自己的代碼中去。

    6.1 Disruptor為什么這么快?

    Disruptor是一個(gè)性能極強(qiáng)的開(kāi)源的無(wú)鎖并發(fā)框架,基于Disruptor的LMAX架構(gòu)交易平臺(tái),號(hào)稱單線程內(nèi)每秒可處理600萬(wàn)筆訂單。簡(jiǎn)直是一個(gè)不折不扣的性能小鋼炮。

    Disruptor框架的核心是它的Ringbuffer環(huán)形緩沖。這里不做框架的具體分析,有興趣可在github下載源碼(傳送門(mén))。推薦大家閱讀并發(fā)編程網(wǎng)對(duì)Disruptor框架的介紹(傳送門(mén))。

    它的定位是高性能并發(fā)框架,肯定也會(huì)遇到我們上述的緩存?zhèn)喂蚕韱?wèn)題,我們看一下Disruptor是怎么解決的?

    public long p1, p2, p3, p4, p5, p6, p7; // cache line padding

    private volatile long cursor=INITIAL_CURSOR_VALUE;

    public long p8, p9, p10, p11, p12, p13, p14; // cache line padding


    Disruptor源碼中,有大量類似于上述的代碼,在目標(biāo)變量前后定義多個(gè)"無(wú)實(shí)際含義的"變量進(jìn)行緩存行填充(cache line padding)。

    基礎(chǔ)類型long在java中占用8字節(jié),在額外填充7個(gè)long類型的變量,這樣在從內(nèi)存中獲取目標(biāo)變量放入緩存行時(shí),可以達(dá)到緩存行中除了目標(biāo)變量,剩下都是填充變量(由于無(wú)業(yè)務(wù)含義,其他cpu不會(huì)對(duì)其進(jìn)行修改)。曲線救國(guó),解決了緩存行偽共享的問(wèn)題。思想:空間換時(shí)間

    6.2 實(shí)驗(yàn)證明

    看了上述代碼,可能還有人心有存疑,搞出這么多無(wú)用的字段,效率能提高?我不信。

    下面我們就自己寫(xiě)一個(gè)demo來(lái)證明:緩存行填充能提高并發(fā)效率

    例1:不填充緩存行

    public class CacheLinePaddingBefore {

    private static class Entity {

    public volatile long x=1L;

    }

    public static Entity[] arr=new Entity[2];

    static {

    arr[0]=new Entity();

    arr[1]=new Entity();

    }

    public static void main(String[] args) throws InterruptedException {

    Thread threadA=new Thread(() -> {

    for (long i=0; i < 1000_0000; i++) {

    arr[0].x=i;

    }

    }, "ThreadA");

    Thread threadB=new Thread(() -> {

    for (long i=0; i < 1000_0000; i++) {

    arr[1].x=i;

    }

    }, "ThreadB");

    final long start=System.nanoTime();

    threadA.start();

    threadB.start();

    threadA.join();

    threadB.join();

    final long end=System.nanoTime();

    System.out.println("耗時(shí):" + (end - start) / 100_0000);

    }

    }

    例1思路:

    1、定義一個(gè)長(zhǎng)度為2的數(shù)組arr,數(shù)組中是一個(gè)僅有一個(gè)long類型變量的對(duì)象;

    2、定義兩個(gè)線程A和B,線程A修改arr[0],線程B修改arr[1]。線程A和線程B并發(fā)修改1千萬(wàn)次;

    3、此處定義數(shù)組的目的是:保證線程A和線程B修改的變量盡可能是連續(xù)的,即兩個(gè)變量在同一緩存行中,以模擬偽共享問(wèn)題。

    測(cè)試結(jié)果:多次運(yùn)行上述demo,平均耗時(shí):240ms左右。

    例2:填充緩存行

    public class CacheLinePaddingAfter {

    // 定義7個(gè)long類型變量,進(jìn)行緩存行填充

    private static class Padding{

    public volatile long p1, p2, p3, p4, p5, p6, p7;

    }

    private static class Entity extends Padding{

    // 使用@sun.misc.Contended注解,必須添加此參數(shù):-XX:-RestrictContended

    // @sun.misc.Contended

    public volatile long x=0L;

    }

    public static Entity[] arr=new Entity[2];

    static {

    arr[0]=new Entity();

    arr[1]=new Entity();

    }

    public static void main(String[] args) throws InterruptedException {

    Thread threadA=new Thread(() -> {

    for (int i=0; i < 1000_0000; i++) {

    arr[0].x=i;

    }

    }, "ThreadA");

    Thread threadB=new Thread(() -> {

    for (int i=0; i < 1000_0000; i++) {

    arr[1].x=i;

    }

    }, "ThreadB");

    final long start=System.nanoTime();

    threadA.start();

    threadB.start();

    threadA.join();

    threadB.join();

    final long end=System.nanoTime();

    System.out.println("耗時(shí):" + (end - start)/100_0000);

    }

    }

    例2思路:

    ? 1、定義一個(gè)包含7個(gè)long類型的“無(wú)實(shí)際意義”字段的填充對(duì)象;

    ? 2、實(shí)際對(duì)象Entity繼承填充對(duì)象,達(dá)到7+1=8個(gè)long類型字段,可以填充一整個(gè)64字節(jié)的緩存行;

    ? 3、重復(fù)例1中的動(dòng)作。

    測(cè)試結(jié)果:多次運(yùn)行上述demo,平均耗時(shí):70ms左右。

    大家也可以直接拿上述兩個(gè)例子在自己的電腦進(jìn)行測(cè)試。例2的執(zhí)行效率遠(yuǎn)超超例1的執(zhí)行效率。我們通過(guò)實(shí)踐證明:緩存行填充顯著提高并發(fā)效率

    6.3 Jdk8中自帶注解@Contended

    Jdk8中引入了@sun.misc.Contended這個(gè)注解來(lái)解決緩存?zhèn)喂蚕韱?wèn)題。使用此注解有一個(gè)前提,必須開(kāi)啟JVM參數(shù)-XX:-RestrictContended,此注解才會(huì)生效。

    此注解在一定程度上同樣解決了緩存?zhèn)喂蚕韱?wèn)題。但底層原理并非緩存行填充,而是通過(guò)對(duì)對(duì)象頭內(nèi)存布局的優(yōu)化,將那些可能會(huì)被同一個(gè)線程幾乎同時(shí)寫(xiě)的字段分組到一起,避免形成競(jìng)爭(zhēng),來(lái)達(dá)到避免緩存?zhèn)喂蚕淼哪康摹4颂幉辉黉侀_(kāi)講述,有興趣的可閱讀文章:并發(fā)編程網(wǎng)-有助于減少偽共享的@Contended注解和此文開(kāi)頭提及Aleksey Shipilev的這封郵件

    Jdk內(nèi)部也大量使用了此注解

    例1:java.lang.Thread類,修飾字段

    例2:java.util.concurrent.ConcurrentHashMap類,修飾類

    例3:使用注解@Contended改造上一章中的例2:緩存行填充demo

    public class CacheLinePaddingAfter {

    private static class Entity{

    // 使用@sun.misc.Contended注解,必須添加此參數(shù):-XX:-RestrictContended

    @sun.misc.Contended

    public volatile long x=0L;

    }

    public static Entity[] arr=new Entity[2];

    static {

    arr[0]=new Entity();

    arr[1]=new Entity();

    }

    public static void main(String[] args) throws InterruptedException {

    Thread threadA=new Thread(() -> {

    for (int i=0; i < 1000_0000; i++) {

    arr[0].x=i;

    }

    }, "ThreadA");

    Thread threadB=new Thread(() -> {

    for (int i=0; i < 1000_0000; i++) {

    arr[1].x=i;

    }

    }, "ThreadB");

    final long start=System.nanoTime();

    threadA.start();

    threadB.start();

    threadA.join();

    threadB.join();

    final long end=System.nanoTime();

    System.out.println("耗時(shí):" + (end - start)/100_0000);

    }

    }

    測(cè)試結(jié)果:多次運(yùn)行上述demo,平均耗時(shí):70ms左右。

    7、 總結(jié)

    文章開(kāi)頭提及本篇文章是上一篇文章volatile底層原理詳解(上) 的延伸和補(bǔ)充。所以下面帶大家整體回顧下這兩張的內(nèi)容。如果沒(méi)看上篇文章或?qū)olatile暫無(wú)興趣,請(qǐng)直接看下面的第3點(diǎn)總結(jié)即可。

    我們對(duì)volatile關(guān)鍵字的作用和原理的了解,從Java代碼層面一路聊到計(jì)算機(jī)的硬件層面,硬件層面從cpu緩存到緩存行、緩存鎖,再到緩存一致性協(xié)議,最后分析了緩存行的偽共享問(wèn)題以及它的解決方案。希望看完整篇之后再?gòu)念^到尾串一遍,將其中的“點(diǎn)”串成“線”,做到舉一反三,能對(duì)日后的工作有所幫助。最后,通過(guò)幾個(gè)問(wèn)題,幫助大家回顧文章內(nèi)容:

    1、并發(fā)編程中的三大特性:原子性、可見(jiàn)性、有序性。volatile修飾的變量如何保證可見(jiàn)性、有序性?為何不能保證原子性?

    2、volatile的底層實(shí)現(xiàn):

    a. Java代碼中如何使用volatile關(guān)鍵字?

    b. volatile修飾的變量,編譯成class字節(jié)碼后,變量的訪問(wèn)標(biāo)志有什么變化?

    c. JVM運(yùn)行時(shí),是判斷變量是被的volatile修飾的?賦值和取值同普通變量有何區(qū)別的?orderAccess.hpp頭文件中是對(duì)內(nèi)存屏障的定義和作用的描述是怎樣的?lock前綴指令的作用是什么?

    d. 打印匯編輸出,可以看到JVM級(jí)別的實(shí)現(xiàn):lock前綴指令。

    3、可見(jiàn)性問(wèn)題,有序性問(wèn)題的產(chǎn)生一部分是cpu硬件架構(gòu)引起的,那么就有必要了解它的硬件原理,以及如何利用硬件寫(xiě)出高性能的并發(fā)程序。

    a. 金字塔的cpu存儲(chǔ)結(jié)構(gòu)要能直觀呈現(xiàn)在腦子中。Cpu為什么要有一、二、三級(jí)緩存?

    b. Lock前綴的指令,能保證某個(gè)處理器對(duì)共享內(nèi)存的獨(dú)占使用,并且達(dá)到指令重排序無(wú)法越過(guò)內(nèi)存屏障的目的。它是通過(guò)對(duì)緩存加鎖來(lái)實(shí)現(xiàn)。緩存鎖了解一下。

    c. 緩存鎖針對(duì)的是對(duì)緩存行加鎖。緩存行是什么?緩存一致性協(xié)議是什么?

    d. 緩存行偽共享問(wèn)題是如何出現(xiàn)的?

    e. 緩存行偽共享問(wèn)題的解決方案:緩存行填充JDK8中自帶的@Contented注解。并發(fā)編程中,我們可以嘗試應(yīng)用,來(lái)提高程序運(yùn)行效率。

    閱讀文本大概需要 17 分鐘。

    在并發(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ú)聲的性能殺手。

    本文僅針對(duì)目前所學(xué)進(jìn)行合并整理,目前并無(wú)非常深入地研究和實(shí)踐,希望對(duì)大家從零開(kāi)始理解偽共享提供一些幫助。

    偽共享的非標(biāo)準(zhǔn)定義為:緩存系統(tǒng)中是以緩存行(cache line)為單位存儲(chǔ)的,當(dāng)多線程修改互相獨(dú)立的變量時(shí),如果這些變量共享同一個(gè)緩存行,就會(huì)無(wú)意中影響彼此的性能,這就是偽共享。

    下面我們就來(lái)詳細(xì)剖析偽共享產(chǎn)生的前因后果。首先,我們要了解什么是緩存系統(tǒng)。

    一、CPU 緩存

    CPU 緩存的百度百科定義為:

    CPU 緩存(Cache Memory)是位于 CPU 與內(nèi)存之間的臨時(shí)存儲(chǔ)器,它的容量比內(nèi)存小的多但是交換速度卻比內(nèi)存要快得多。 高速緩存的出現(xiàn)主要是為了解決 CPU 運(yùn)算速度與內(nèi)存讀寫(xiě)速度不匹配的矛盾,因?yàn)?CPU 運(yùn)算速度要比內(nèi)存讀寫(xiě)速度快很多,這樣會(huì)使 CPU 花費(fèi)很長(zhǎng)時(shí)間等待數(shù)據(jù)到來(lái)或把數(shù)據(jù)寫(xiě)入內(nèi)存。 在緩存中的數(shù)據(jù)是內(nèi)存中的一小部分,但這一小部分是短時(shí)間內(nèi) CPU 即將訪問(wèn)的,當(dāng) CPU 調(diào)用大量數(shù)據(jù)時(shí),就可避開(kāi)內(nèi)存直接從緩存中調(diào)用,從而加快讀取速度。

    CPU 和主內(nèi)存之間有好幾層緩存,因?yàn)榧词怪苯釉L問(wèn)主內(nèi)存也是非常慢的。如果你正在多次對(duì)一塊數(shù)據(jù)做相同的運(yùn)算,那么在執(zhí)行運(yùn)算的時(shí)候把它加載到離 CPU 很近的地方就有意義了。

    按照數(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)如下圖所示:


    ?

    當(dāng) CPU 執(zhí)行運(yùn)算的時(shí)候,它先去 L1 查找所需的數(shù)據(jù),再去 L2,然后是 L3,最后如果這些緩存中都沒(méi)有,所需的數(shù)據(jù)就要去主內(nèi)存拿。走得越遠(yuǎn),運(yùn)算耗費(fèi)的時(shí)間就越長(zhǎng)。所以如果你在做一些很頻繁的事,你要確保數(shù)據(jù)在 L1 緩存中。

    Martin Thompson 給出了一些緩存未命中的消耗數(shù)據(jù),如下所示:


    ?

    二、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):

    1. M(修改,Modified):本地處理器已經(jīng)修改緩存行,即是臟行,它的內(nèi)容與內(nèi)存中的內(nèi)容不一樣,并且此 cache 只有本地一個(gè)拷貝(專有);
    2. E(專有,Exclusive):緩存行內(nèi)容和內(nèi)存中的一樣,而且其它處理器都沒(méi)有這行數(shù)據(jù);
    3. S(共享,Shared):緩存行內(nèi)容和內(nèi)存中的一樣, 有可能其它處理器也存在此緩存行的拷貝;
    4. I(無(wú)效,Invalid):緩存行失效, 不能使用。

    下面說(shuō)明這四個(gè)狀態(tài)是如何轉(zhuǎn)換的:

    1. 初始:一開(kāi)始時(shí),緩存行沒(méi)有加載任何數(shù)據(jù),所以它處于 I 狀態(tài)。
    2. 本地寫(xiě)(Local Write):如果本地處理器寫(xiě)數(shù)據(jù)至處于 I 狀態(tài)的緩存行,則緩存行的狀態(tài)變成 M。
    3. 本地讀(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ì)改變的)
    4. 遠(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ù)并保存。
    5. 遠(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)求呢?有以下兩種:

    1. 線程的工作從一個(gè)處理器移到另一個(gè)處理器, 它操作的所有緩存行都需要移到新的處理器上。此后如果再寫(xiě)緩存行,則此緩存行在不同核上有多個(gè)拷貝,需要發(fā)送 RFO 請(qǐng)求了。
    2. 兩個(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)和佐證。

    上述代碼的邏輯很簡(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];
    };
    

    除此之外,在網(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)題。

    References

    [1] 從Java視角理解偽共享(False Sharing): http://coderplay.iteye.com/blog/1486649

    [2] 【翻譯】線程間偽共享的避免和識(shí)別: http://www.cnblogs.com/apprentice89/p/3347720.html

    [3] 一種利用數(shù)據(jù)融合來(lái)提高局部性和減少偽共享的方法: http://wenku.baidu.com/link?url=wjtfxpXedl67KSxiaPfOLR0leyHD0ONmKbsyScj3wXt7_IGiPmRFPJSVlcDcSEkgPqDbJdAv1thtoAeN3ZSk6ezlFr7p57t7BNSrkfq2-W7

網(wǎng)站首頁(yè)   |    關(guān)于我們   |    公司新聞   |    產(chǎn)品方案   |    用戶案例   |    售后服務(wù)   |    合作伙伴   |    人才招聘   |   

友情鏈接: 餐飲加盟

地址:北京市海淀區(qū)    電話:010-     郵箱:@126.com

備案號(hào):冀ICP備2024067069號(hào)-3 北京科技有限公司版權(quán)所有