天天看點

Go和Rust的高并發程式設計中,為什麼要特别注意對齊?

從傳統意義上講,對齊是指将變量的存儲按照計算機的字長進行邊界對章,這裡字長一般是指一個WORD的位數,也就是現代計算機中一次IO的資料處理長度,通過計算機的字長與CPU的寄存器長度相等。現代的CPU一般都不是按位進行記憶體通路,而是按照字長來通路記憶體,當CPU從記憶體或者磁盤中将讀變量載入到寄存器時,每次操作的最小機關一般是取決于CPU的字長。比如8位字是1位元組,那麼至少由記憶體載入1位元組也就是8位長的資料,再比如32位CPU每次就至少載入4位元組資料, 64位系統8位元組以此類推。

對齊詳解

那麼以8位機為例咱們來看一下這個問題。假如變量1是個bool類型的變量,它占用1位空間,而變量2為byte類型占用8位空間,假如程式目前要通路變量2那麼,第一次讀取CPU會從開始的0x00位置讀取8位,也就是将bool型的變量1與byte型變量2的高7位全部讀入記憶體,但是byte變量的最低位卻沒有被讀進來,還需要第二次的讀取才能把完整的變量2讀入,詳見下圖:

Go和Rust的高并發程式設計中,為什麼要特别注意對齊?

也就是說變量的存儲應該按照CPU的字長進行對齊,當通路的變量長度不足CPU字長的整數倍時,需要對變量的長度進行補齊。這樣才能提升CPU與記憶體間的通路效率,避免額外的記憶體讀取操作。

一般來說隻要保證變量存儲的首位址恰好是CPU字長的整數倍就能做到按照字長對齊了。這方面絕大多數編譯器都做得很好,在預設情況下,C編譯器為每一個變量或是資料單元按其自然對界條件配置設定空間邊界。也可以通過pragma pack(n)調用來改變預設的對界條件指令,調用後C編譯器将按照pack(n)中指定的n來進行n個位元組的對齊,這其實也對應着彙編語言中的.align。以上這些工作現代的編譯器都做得很好了。

我們可以來比較下面兩段代碼,由于我測試的平台是64位的機器,是以我選擇的占位變量1是bool類型,變量2為int64類型,如果沒有做對齊的話那麼變量2在實際中需要讀取兩次,不過這些優化編譯器和CPU都會幫你做好,以下兩段代碼的執行效率并沒有明顯不同。

fn main() {

let j=true;

let mut i:u64=0;

while i < 100000000 {

i += 1

}

println!("{}", j);

println!("{}", i);

}

fn main() {

//let j=true;

let mut i:u64=0;

while i < 100000000 {

i += 1

}

//println!("{}", j);

println!("{}", i);

}

并發環境要按照緩存行對齊

在沒有并發競争的情況下,按照CPU字長進行對齊就完全可以了,但是如果在并發的情況下,即使沒有共享變量,也可能會造成僞共享的問題,我們來看下面的代碼,代碼示例一中四個個goroutine分别操作slicea中的前四個元素,

package main

import (

"fmt"

"time"

)

func main() {

s1icea := []int64{0, 1, 2, 3, 4, 5, 6, 7}

//s1iceb := []int64{0, 1, 2, 3, 4, 5, 6, 7}

//s1icec := []int64{0, 1, 2, 3, 4, 5, 6, 7}

//s1iced := []int64{0, 1, 2, 3, 4, 5, 6, 7}

go func() {

for {

s1icea[0]++

}

}()

go func() {

for {

s1icea[1]++

}

}()

go func() {

for {

s1icea[2]++

}

}()

go func() {

for {

s1icea[3]++

}

}()

time.Sleep(time.Second)

fmt.Println(s1icea)

}

運作結果如下:

[269164771 265021684 258089104 267919418 4 5 6 7]

而代碼示例二中兩個goroutine分别操作slicea和sliceb,

package main

import (

"fmt"

"time"

)

func main() {

s1icea := []int64{0, 1, 2, 3, 4, 5, 6, 7}

s1iceb := []int64{0, 1, 2, 3, 4, 5, 6, 7}

s1icec := []int64{0, 1, 2, 3, 4, 5, 6, 7}

s1iced := []int64{0, 1, 2, 3, 4, 5, 6, 7}

go func() {

for {

s1icea[0]++

}

}()

go func() {

for {

s1iceb[1]++

}

}()

go func() {

for {

s1icec[2]++

}

}()

go func() {

for {

s1iced[3]++

}

}()

time.Sleep(time.Second)

fmt.Println(s1icea, s1iceb, s1icec, s1iced)

}

運作結果如下:

[399287607 1 2 3 4 5 6 7] [0 406576583 2 3 4 5 6 7] [0 1 403888391 3 4 5 6 7] [0 1 2 396400686 4 5 6 7]

這兩段代碼在我四核的機器上測試,性能差距至少相差近一倍。這個問題本質是由于多核競争造成的,雖然每個雖然在例程一中每個goroutine都在操作不同的對象,但是這些對象處于同一個記憶體緩存行上,這就會造成本來沒有并發競争的程式,也産生了并發競争問題。

MESI協定簡介

現代的CPU除了多核心之外,還給每個核心都配備了獨享的高速緩存,按照多核高速緩存同步的MESI協定約定,每個緩存行都有四個狀态,分别是E(exclusive)、M(modified)、S(shared)、I(invalid),其中:

M:代表該緩存行中的内容被修改,并且該緩存行隻被緩存在該CPU中。這個狀态代表緩存行的資料和記憶體中的資料不同。

E:代表該緩存行對應記憶體中的内容隻被該CPU緩存,其他CPU沒有緩存該緩存對應記憶體行中的内容。這個狀态的緩存行中的資料與記憶體的資料一緻。

I:代表該緩存行中的内容無效。

S:該狀态意味着資料不止存在本地CPU緩存中,還存在其它CPU的緩存中。這個狀态的資料和記憶體中的資料也是一緻的。不過隻要有CPU修改該緩存行都會使該行狀态變成 I 。

但是在上面的例程一當中,四個goroutine操作的對象本質上處于同一個記憶體緩存行上,這也會造成S共享态到無效态遷移的頻繁出現,進而影響效率。

繼續閱讀