關於多執行緒同步的一切:偽共享
```c++ const size_t shm_size = 16*1024*1024; //16M static char shm[shm_size]; std::atomic<size_t> shm_offset{0}; void f() { for (;;) { auto off = shm_offset.fetch_add(sizeof(long)); if (off >= shm_size) break; *(long*)(shm + off) = off; } } ```
考察上面的程式,shm是一塊16M位元組的記憶體,我測試機器的L3 Cache是32M,所以挑選16M這個值確保shm陣列在Cache裡能存放得下。
f()函式在迴圈裡,把shm視為long型別的陣列,依次給每個元素賦值,shm_offset用於記錄偏移位置,shm_offset.fetch_add(sizeof(long))原子性的增加shm_offset的值(因為x86_64系統上long的長度為8,所以shm_offset每次增加8位元組),並返回增加前的值,對shm上long陣列的每個元素賦值後,結束迴圈從函式返回。
因為shm_offset是atomic型別變數,所以多執行緒呼叫f()依然能正常工作,雖然多個執行緒會競爭shm_offset,但每個執行緒會排他性的對各long元素賦值,多執行緒並行會加快對shm的賦值操作。
我們加上多執行緒呼叫,程式碼如下:
```c++ std::atomic<size_t> step{0}; const int THREAD_NUM = 2; void work_thread() { const int N = 10; for (int n = 1; n <= N; ++n) { f(); ++step; while (step.load() < n * THREAD_NUM) {} shm_offset = 0; } } int main() { std::thread threads[THREAD_NUM]; for (int i = 0; i < THREAD_NUM; ++i) { threads[i] = std::move(std::thread(work_thread)); } for (int i = 0; i < THREAD_NUM; ++i) { threads[i].join(); } return 0; } ```
- main函式裡啟動2個工作執行緒work_thread
- 工作執行緒對shm共計賦值N(10)輪,後面的每一輪會訪問Cache裡的shm資料,step用於work_thread之間每一輪的同步
- 工作執行緒呼叫完f()後會增加step,等2個工作執行緒都呼叫完之後,step的值增加到n * THREAD_NUM後,while()迴圈結束,重置shm_offset,重新開始新一輪對shm的賦值
編譯後執行上面的程式,產生如下的結果:
``` time ./a.out real 0m3.406s user 0m6.740s sys 0m0.040s ```
time命令用於時間測量,在a.out程式執行完成,會列印耗時,real行顯式耗時3.4秒。
改進版f_fast
我們稍微修改一下f函式,改進版f函式取名f_fast:
```c++ void f_fast() { for (;;) { const long inner_loop = 16; auto off = shm_offset.fetch_add(sizeof(long) * inner_loop); for (long j = 0; j < inner_loop; ++j) { if (off >= shm_size) return; *(long*)(shm + off) = j; off += sizeof(long); } } } ```
for迴圈裡,shm_offset不再是每次增加8位元組(sizeof(long)),而是8*16=128位元組,然後在內層的迴圈裡,依次對16個long連續元素賦值,然後下一輪迴圈又再次增加128位元組,直到完成對整個shm的賦值。
編譯後重新執行程式,結果顯示耗時降低到0.06秒,對比前一種耗時3.4秒,f_fast效能大幅提升。
``` time ./a.out real 0m0.062s user 0m0.110s sys 0m0.012s ```
f和f_fast的行為差異
shm陣列總共有2M個long元素,因為16M / sizeof(long) => 2M,
1. f()函式行為邏輯
執行緒1和執行緒2的work_thread裡會交錯地對shm元素賦值,shm的2M個long元素,會順序的一個接一個的派給2個執行緒去賦值。
例如:
- 可能元素1由執行緒1賦值,元素2由執行緒2賦值,然後元素3和元素4由執行緒1賦值,然後元素5由執行緒2賦值...
- 每次派元素的時候,shm_offset都會atomic的增加8位元組,所以不會出現2個執行緒給1個元素賦值的情況
2. f_fast()函式行為邏輯
- 每次派元素的時候,shm_offset原子性的增加128位元組(16個元素)
- 這16個位元組作為一個整體,派給執行緒1或者執行緒2;雖然執行緒1和執行緒2還是會交錯的操作shm元素,但是以16個元素(128位元組)為單元,這16個連續的元素不會被分派到不同執行緒
- 一次派發的16個元素,會在內部迴圈裡被一個接著一個的賦值,在一個執行緒裡執行
為什麼f_fast更快?
第一眼感覺是f_fast()裡shm_offset.fetch_add()呼叫頻次降低到了原來的1/16,我們有理由懷疑是原子變數的競爭減少導致程式執行速度加快。
為了驗證,讓我們在內層的迴圈里加一個原子變數test的fetch_add,test原子變數的競爭會像f()函式裡shm_offset.fetch_add()一樣被激烈競爭,修改後的f_fast程式碼變成下面這樣:
```c++ void f_fast() { for (;;) { const long inner_loop = 16; auto off = shm_offset.fetch_add(sizeof(long) * inner_loop); for (long j = 0; j < inner_loop; ++j) { test.fetch_add(1); if (off >= shm_size) return; *(long*)(shm + off) = j; off += sizeof(long); } } } ```
為了避免test.fetch_add(1)的呼叫被編譯器優化掉,我們在main函式的最後把test的值打印出來。
編譯後測試一下,結果顯示:執行時間只是稍微增加到`real 0m0.326s`。所以,很顯然,並不是atomic的呼叫頻次減少導致效能飆升。
我們重新審視f()迴圈裡的邏輯:f()迴圈裡的操作很簡單:原子增加、判斷、賦值。
會不會是賦值太慢?
我們把f()的裡賦值註釋掉,再測試一下,發現它的速度得到了很大提升,看來是`*(long*)(shm + off) = off;`這一行程式碼執行慢,但這明明只是一行賦值。
我們把它反彙編來看,它只是一個mov指令,源運算元是暫存器,目標運算元是記憶體地址,從暫存器拷貝資料到一個記憶體地址,而這個記憶體資料應該被cache住了,為什麼會這麼慢呢?
答案
現在揭曉原因,導致f()效能底下的元凶是偽共享(false sharing),那什麼是偽共享?
要說清這個問題,還得聯絡CPU的架構,以及CPU怎麼訪問資料,我們回顧一下關於多核Cache結構:
背景知識
我們知道現代CPU可以有多個核,每個核有自己的L1-L2快取,L1又區分資料快取(L1-DCache)和指令快取(L1-ICache),L2不區分資料和指令Cache,而L3跨核共享,L3通過記憶體匯流排連線到記憶體,記憶體被所有CPU所有Core共享。
CPU訪問L1 Cache的速度大約是訪問記憶體的100倍,Cache作為CPU與記憶體之間的快取,減少CPU對記憶體的訪問頻率。
從記憶體載入資料到Cache的時候,是以Cache Line為長度單位的,Cache Line的長度通常是64位元組。
所以,那怕只讀一個位元組,但是包含該位元組的整個Cache Line都會被載入到快取,同樣,如果修改一個位元組,那麼最終也會導致整個Cache Line被沖刷到記憶體。
如果一塊記憶體資料被多個執行緒訪問,假設多個執行緒在多個Core上並行執行,那麼它便會被載入到多個Core的的Local Cache中;這些執行緒在哪個Core上執行,就會被載入到哪個Core的Local Cache中,所以,記憶體中的一個數據,在不同Core的Cache裡會同時存在多份拷貝。
如果我們修改了Core1快取裡的某個資料,則該資料所在的Cache Line的狀態需要同步給其他Core的快取,Core之間可以通過核間訊息同步狀態,比如通過傳送Invalidate訊息給其他核,接收到該訊息的核會把對應Cache Line置為無效,然後重新從記憶體里加載最新資料。
被載入到多個Core快取中的同一Cache Line,會被標記為共享(Shared)狀態,對共享狀態的快取行進行修改,需要先獲取快取行的修改權(獨佔),MESI協議用來保證多核快取的一致性,更多的細節可以參考MESI資料。
示例分析
現在來看看我們的程式。
假設執行緒1執行在Core1,執行緒2執行在Core2。
因為shm被執行緒1和執行緒2這兩個執行緒併發訪問,所以shm的記憶體資料會以Cache Line粒度,被同時載入到2個Core的Cache,因為被多核共享,所以該Cache Line被標註為Shared狀態。
假設執行緒1在offset為64的位置寫入了一個8位元組的資料(sizeof(long)),要修改一個狀態為Shared的Cache Line,Core1會發送核間通訊訊息到Core2,去拿到該Cache Line的獨佔權,在這之後,Core1才能修改Local Cache。
執行緒1執行完`shm_offset.fetch_add(sizeof(long))`後,shm_offset會增加到72。
這時候Core2上執行的執行緒2也會執行`shm_offset.fetch_add(sizeof(long))`,它返回72並將shm_offset增加到80。
執行緒2接下來要修改shm[72]的記憶體位置,因為shm[64]和shm[72]在一個Cache Line,而這個Cache Line又被置為Invalidate,所以,它需要從記憶體裡重新載入這一個Cache Line,而在這之前,Core1上的執行緒1需要把Cache Line沖刷到記憶體,這樣執行緒2才能載入最新的資料。
這種交替執行模式,相當於Core1和Core2之間需要頻繁的傳送核間訊息,收到訊息的Core的對應Cache Line被置為無效,並重新從記憶體里加載資料到Cache,每次修改後都需要把Cache中的資料刷入記憶體。
這其實相當於廢棄掉了Cache,因為每次讀寫都直接跟記憶體打交道,Cache的作用不復存在,效能下降。
多核多執行緒程式,因為併發讀寫同一個Cache Line的資料(臨近位置的記憶體資料),導致Cache Line的頻繁失效,記憶體的頻繁Load/Store,從而導致效能急劇下降的現象叫偽共享,偽共享是效能殺手。
另一個偽共享的例子
假設執行緒x和y,分別修改Data的a和b變數,如果被頻繁呼叫,根據前面的分析,也會出現效能低下的情況,怎麼規避呢?
```c++ struct Data { int a; int b; }; Data data; // global void thread1() { data.a = 1; } void thread2() { data.b = 2; } ```
**空間換時間**
避免Cache偽共享導致效能下降的思路是用空間換時間,通過在a和b成員之間增加填充,讓a、b兩個變數分佈到不同的Cache Line,這樣對a和b的修改就會作用於不同Cache Line,就能避免Cache line失效的問題。
```c++ struct Data { int a; int padding[60]; int b; }; ```
在Linux kernel中存在__cacheline_aligned_in_smp巨集定義用於解決false sharing問題。
```c #ifdef CONFIG_SMP #define __cacheline_aligned_in_smp __cacheline_aligned #else #define __cacheline_aligned_in_smp #endif struct Data { int a; int b __cacheline_aligned_in_smp; }; ```
從上面的巨集定義,我們可以看到:
- 在多核(MP)系統裡,該巨集定義是 __cacheline_aligned,也就是Cache Line的大小
- 在單核系統裡,該巨集定義是空的
偽共享的疑問
既然多CPU多核併發讀寫一個Cache Line裡的記憶體資料,會出現偽共享,那麼,我們對`atomic<size_t> shm_offset`的fetch_add()操作也滿足這個條件,多個執行緒同時對同一個shm_offset變數併發讀寫,那為什麼效能不會很差呢?
我們反彙編發現`atomic.fetch_add`會被翻譯成`lock; xadd %rax (%rdx)`,lock是一個指令字首,配合其他指令使用。
bus lock做的事情就是鎖住匯流排,然後執行後面的xadd,在此期間,別的執行緒都不能訪問任何記憶體資料。
實際上,鎖匯流排的操作比較重,相當於全域性的記憶體匯流排鎖,lock字首之後的指令操作就直接作用於記憶體,bypass掉快取,lock也相當於記憶體屏障。
但翻看Intel手冊發現,執行lock指令,CPU會根據情況自行決定到底是鎖快取,還是assert #LOCK signal(鎖匯流排)。
如果訪問的記憶體區域已經快取在處理器的快取行中,Intel的現代處理器則不會assert #LOCK訊號,它會對CPU的快取中的快取行進行鎖定,在鎖定期間,其它CPU不能同時快取此資料,在修改之後,通過快取一致性協議來保證修改的原子性,這個操作被稱為“快取鎖”。
false sharing對應的是多執行緒同時讀寫一個Cache Line的多個數據,Core-A修改資料x後,會置Cache Line為Invalid,Core-B讀該快取行的另一個數據y,需要Core-A把Cache Line Store到記憶體,Core-B再從記憶體裡Load對應Cache Line,資料要過記憶體。
而atomic,多個執行緒修改的是同一個變數。lock指令字首,應該會用到快取鎖(鎖Cache Line),atomic在Cache Line裡的最新值通過核間訊息傳送給其他核就可以了,不需要頻繁的Store/Load,所以效能不會那麼糟。
- Spring中實現非同步呼叫的方式有哪些?
- 帶引數的全型別 Python 裝飾器
- 整理了幾個Python正則表示式,拿走就能用!
- SOLID:開閉原則Go程式碼實戰
- React中如何引入CSS呢
- 一個新視角:前端框架們都卷錯方向了?
- 編碼中的Adapter,不僅是一種設計模式,更是一種架構理念與解決方案
- 手寫程式語言-遞迴函式是如何實現的?
- 一文搞懂模糊匹配:定義、過程與技術
- 新來個阿里 P7,僅花 2 小時,做出一個多執行緒永動任務,看完直接跪了
- Puzzlescript,一種開發H5益智遊戲的引擎
- @Autowired和@Resource到底什麼區別,你明白了嗎?
- CSS transition 小技巧!如何保留 hover 的狀態?
- React如此受歡迎離不開這4個主要原則
- LeCun再炮轟Marcus: 他是心理學家,不是搞AI的
- Java保證執行緒安全的方式有哪些?
- 19個殺手級 JavaScript 單行程式碼,讓你看起來像專業人士
- Python 的"self"引數是什麼?
- 別整一坨 CSS 程式碼了,試試這幾個實用函式
- 再有人問你什麼是MVCC,就把這篇文章發給他!