很久沒有寫博客了,一直在不斷地探索響應式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億次。編程
備註:統計分析圖表和總結在最後。數組
大多數程序員都會這樣子構造數據,老鐵沒毛病。緩存
///// <summary> ///// CPU僞共享高速緩存行條目(僞共享) ///// </summary> public class FalseSharingCacheLineEntry { public long Value = 0L; }
平均響應時間 = 1508.56 毫秒。多線程
平均響應時間 = 4460.40 毫秒。併發
平均響應時間 = 7719.02 毫秒。socket
平均響應時間 = 10404.30 毫秒。
/// <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 毫秒。
做爲一個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 毫秒。
上面的圖表已經一目瞭然了吧,通常實現方式的持續時間隨線程數呈線性增加,多線程下表現的很是糟糕,而經過直接、內存佈局方式填充了數據後,響應時間與線程數的多少沒有無關,達到了真正的低延遲。其中直接填充數據的方式,效率最高,內存佈局方式填充次之,在四線程的狀況下,通常實現方式持續時間爲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 |
寄存器 | 寄存器 |
源碼參考:
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高性能編程感興趣的話能夠【關注我】,我會按期的在博客分享個人學習心得。
歡迎轉載,請在明顯位置給出出處及連接。