寫在前面
好久沒有寫博客了,一直在不斷地探索響應式DDD,又get到了很多新知識,解惑了很多老問題,最近讀了Martin Fowler大師一篇非常精彩的博客The LMAX Architecture,里面有一個術語Mechanical Sympathy,姑且翻譯成軟硬件協同編程(Hardware and software working together in harmony),很有感悟,說的是要把編程與底層硬件協同起來,這樣對於開發低延遲、高並發的系統特別地重要,為什么呢,今天我們就來講講CPU的高速緩存。
電腦的緩存系統
電腦的緩存系統分了很多層級,從外到內依次是主內存、三級高速緩存、二級高速緩存、一級高速緩存,所以,在我們的腦海里,覺點磁盤的讀寫速度是很慢的,而內存的讀寫速度確是快速的,的確如此,從上圖磁盤和內存距離CPU的遠近距離就看出來。這里先說明一個概念,主內存被所有CPU共享;三級緩存被同一個插槽內的CPU所共享;單個CPU獨享自己的一級、二級緩存,即高速緩存。CPU是真正做事情的地方,它會先從高速緩存中去獲取所需的數據,如果找不到,再去三級緩存中查找,如果還是找不到最終就去會主內存查找,並且找到數據后,先要復制到緩存(L1、L2、L3),然后在返回數據;如果每一次都這樣來來回回地復制和讀取數據,那么無疑是非常耗時。如果能夠把數據緩存到高速緩存中就好了,這樣不僅CPU第一次就可以直接從高速緩存中命中數據,而且每個CPU都獨占自己的高速緩存,多線程下也不存在臨界資源的問題,這才是真正的低延遲,但是這個地方對高層開發人員而言根本不透明,腫么辦?
對於CPU而言,只有第一、二、三級才是緩存區,主內存不是,如果需要到主內存讀取數據,這種情況稱為緩存未命中(cache miss)。
探索高速緩存的構造
我們先來看一張使用魯大師檢測的處理器信息截圖,如下:
從上圖可以看到,CPU高速緩存(一、二級)的存儲單元為Line,大小為64 bytes,也就是說無論我們的數據大小是多少,高速緩存都是以64 bytes為單位緩存數據,比如一個8位的long類型數組,即使只有第一位有數據,每次高速緩存加載數據的時候,都會順帶把后面7位數據也一起加載(因為數組內元素的內存地址是連續的),這就是底層硬件CPU的工作機制,所以我們要利用這個天然的優勢,讓數據獨占整個緩存行,這樣CPU命中的緩存行中就一定有我們的數據。
示例
使用不同的線程數,對一個long類型的數值計數500億次。
備注:統計分析圖表和總結在最后。
1. 一般的實現方式
大多數程序員都會這樣子構造數據,老鐵沒毛病。
代碼
///// <summary>
///// CPU偽共享高速緩存行條目(偽共享)
///// </summary>
public class FalseSharingCacheLineEntry
{
public long Value = 0L;
}
單線程
平均響應時間 = 1508.56 毫秒。
雙線程
平均響應時間 = 4460.40 毫秒。
三線程
平均響應時間 = 7719.02 毫秒。
四線程
平均響應時間 = 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永遠無法直接從高速緩存中命中數據,每次都要經過一、二、三級緩存到主內存中重新獲取數據,時間就是被浪費在了這樣的來來回回中。而對數據進行填充后,隸屬於每個獨立線程的數據不僅被緩存到了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高性能編程感興趣的話可以【關注我】,我會定期的在博客分享我的學習心得。
歡迎轉載,請在明顯位置給出出處及鏈接。