軟硬件協同編程 - C#玩轉CPU高速緩存(附示例)

寫在前面

很久沒有寫博客了,一直在不斷地探索響應式DDD,又get到了不少新知識,解惑了不少老問題,最近讀了Martin Fowler大師一篇很是精彩的博客The LMAX Architecture,裏面有一個術語Mechanical Sympathy,姑且翻譯成軟硬件協同編程(Hardware and software working together in harmony),頗有感悟,說的是要把編程與底層硬件協同起來,這樣對於開發低延遲、高併發的系統特別地重要,爲何呢,今天咱們就來說講CPU的高速緩存。html

電腦的緩存系統


電腦的緩存系統分了不少層級,從外到內依次是主內存、三級高速緩存、二級高速緩存、一級高速緩存,因此,在咱們的腦海裏,覺點磁盤的讀寫速度是很慢的,而內存的讀寫速度確是快速的,的確如此,從上圖磁盤和內存距離CPU的遠近距離就看出來。這裏先說明一個概念,主內存被全部CPU共享;三級緩存被同一個插槽內的CPU所共享;單個CPU獨享本身的一級、二級緩存,即高速緩存。CPU是真正作事情的地方,它會先從高速緩存中去獲取所需的數據,若是找不到,再去三級緩存中查找,若是仍是找不到最終就去會主內存查找,而且找到數據後,先要複製到緩存(L一、L二、L3),而後在返回數據;若是每一次都這樣來來回回地複製和讀取數據,那麼無疑是很是耗時。若是可以把數據緩存到高速緩存中就行了,這樣不只CPU第一次就能夠直接從高速緩存中命中數據,並且每一個CPU都獨佔本身的高速緩存,多線程下也不存在臨界資源的問題,這纔是真正的低延遲,可是這個地方對高層開發人員而言根本不透明,腫麼辦?git

對於CPU而言,只有第1、2、三級纔是緩存區,主內存不是,若是須要到主內存讀取數據,這種狀況稱爲緩存未命中(cache miss)。程序員

探索高速緩存的構造

咱們先來看一張使用魯大師檢測的處理器信息截圖,以下:

從上圖能夠看到,CPU高速緩存(1、二級)的存儲單元爲Line,大小爲64 bytes,也就是說不管咱們的數據大小是多少,高速緩存都是以64 bytes爲單位緩存數據,好比一個8位的long類型數組,即便只有第一位有數據,每次高速緩存加載數據的時候,都會順帶把後面7位數據也一塊兒加載(由於數組內元素的內存地址是連續的),這就是底層硬件CPU的工做機制,因此咱們要利用這個自然的優點,讓數據獨佔整個緩存行,這樣CPU命中的緩存行中就必定有咱們的數據。github

示例

使用不一樣的線程數,對一個long類型的數值計數500億次。編程

備註:統計分析圖表和總結在最後。數組

1. 通常的實現方式

大多數程序員都會這樣子構造數據,老鐵沒毛病。緩存

代碼

///// <summary>
///// CPU僞共享高速緩存行條目(僞共享)
///// </summary>
public class FalseSharingCacheLineEntry
{
    public long Value = 0L;
}

單線程


平均響應時間 = 1508.56 毫秒。多線程

雙線程


平均響應時間 = 4460.40 毫秒。併發

三線程


平均響應時間 = 7719.02 毫秒。socket

四線程


平均響應時間 = 10404.30 毫秒。

2. 獨佔緩存行,直接命中高速緩存。

2.1 直接填充

代碼

/// <summary>
/// CPU高速緩存行條目(直接填充)
/// </summary>
public class CacheLineEntry
{
    protected long P1, P2, P3, P4, P5, P6, P7;
    public long Value = 0L;
    protected long P9, P10, P11, P12, P13, P14, P15;
}

爲了保證高速緩存行中必定有咱們的數據,因此先後都填充7個long。

單線程


平均響應時間 = 1516.33 毫秒。

雙線程


平均響應時間 = 1529.97 毫秒。

三線程


平均響應時間 = 1563.65 毫秒。

四線程


平均響應時間 = 1616.12 毫秒。

2.2 內存佈局填充

做爲一個C#程序員,必須寫出優雅的代碼,可使用StructLayout、FieldOffset來控制class、struct的內存佈局。

備註:就是上面直接填充的優雅實現方式而已。

代碼

/// <summary>
/// CPU高速緩存行條目(控制內存佈局)
/// </summary>
[StructLayout(LayoutKind.Explicit, Size = 120)]
public class CacheLineEntryOne
{
    [FieldOffset(56)]
    private long _value;

    public long Value
    {
        get => _value;
        set => _value = value;
    }
}

單線程


平均響應時間 = 2008.12 毫秒。

雙線程


平均響應時間 = 2046.33 毫秒。

三線程


平均響應時間 = 2081.75 毫秒。

四線程


平均響應時間 = 2163.092 毫秒。

3. 統計分析


上面的圖表已經一目瞭然了吧,通常實現方式的持續時間隨線程數呈線性增加,多線程下表現的很是糟糕,而經過直接、內存佈局方式填充了數據後,響應時間與線程數的多少沒有無關,達到了真正的低延遲。其中直接填充數據的方式,效率最高,內存佈局方式填充次之,在四線程的狀況下,通常實現方式持續時間爲10.4秒多,直接填充數據的方式爲1.6秒,內存佈局填充方式爲2.2秒,延遲仍是比較明顯,爲何會有這麼大的差距呢?

刨根問底

在C#下,一個long類型佔8 byte,對於通常的實現方式,在多線程的狀況下,隸屬於每一個獨立線程的數據會共用同一個緩存行,因此只要有一個線程更新了緩存行的數據,那麼整個緩存行就自動失效,這樣就致使CPU永遠沒法直接從高速緩存中命中數據,每次都要通過1、2、三級緩存到主內存中從新獲取數據,時間就是被浪費在了這樣的來來回回中。而對數據進行填充後,隸屬於每一個獨立線程的數據不只被緩存到了CPU的高速緩存中,並且每一個數據都獨佔整個緩存行,其餘的線程更新數據,並不會致使本身的緩存行失效,因此每次CPU均可以直接命中,不論是單線程也好,仍是多線程也好,只要線程數小於等於CPU的核數都和單線程同樣的快速,正如咱們常常在一些性能測試軟件,都會看到的建議,線程數最好小於等於CPU核數,最多爲CPU核數的兩倍,這樣壓測的結果纔是比較準確的,如今明白了吧。

最後來看一下大師們總結的未命中緩存的測試結果

從CPU到 大約須要的 CPU 週期 大約須要的時間
主存 約60-80納秒
QPI 總線傳輸 (between sockets, not drawn) 約20ns
L3 cache 約40-45 cycles 約15ns
L2 cache 約10 cycles, 約3ns
L1 cache 約3-4 cycles 約1ns
寄存器 寄存器

每個開發人員都應該知道計算機硬件IO的延遲數傳送門

源碼參考:
https://github.com/justmine66/Disruptor/blob/master/tests/Disruptor.ConsoleTest/FalseSharingTest.cs

延伸閱讀

Magic cache line padding
The LMAX Architecture

補充

感謝@ firstrose同窗主動測試後的提醒,你們應該向他學習,帶着疑惑看博客,不明白的本身動手測試。對於內存佈局填充方式,去掉屬性後,通過測試性能與直接填充方式幾乎無差異了,不過本示例代碼僅僅做爲一個測試參考,主要目的是給你們佈道如何利用CPU高速緩存工做機制,經過緩存行的填充來避免假共享,從而寫出真正低延遲的代碼。

/// <summary>
/// CPU高速緩存行條目(控制內存佈局)
/// </summary>
[StructLayout(LayoutKind.Explicit, Size = 120)]
public class CacheLineEntryOne
{
    [FieldOffset(56)]
    public long Value;
}

總結

編寫單、多線程下表現都相同的代碼,從來都是很是困難的,須要不斷地從深度、廣度上積累知識,學無止境,無癡迷,不成功,但願你們能有所收穫。

寫在最後

若是有什麼疑問和看法,歡迎評論區交流。
若是你以爲本篇文章對您有幫助的話,感謝您的【推薦】。
若是你對.NET高性能編程感興趣的話能夠【關注我】,我會按期的在博客分享個人學習心得。
歡迎轉載,請在明顯位置給出出處及連接

相關文章
相關標籤/搜索