內存模型是非常重要的,理解Go的內存模型會就可以明白很多奇怪的競態(tài)條件問題,"The Go Memory Model"的原文在這里,讀個四五遍也不算多。
這里并不是要翻譯這篇文章,英文原文是精確的,但讀起來卻很晦澀,尤其是happens-before的概念本身就是不好理解的,很容易跟時序問題混淆。大多數(shù)讀者第一遍讀Go的內存模型時基本上看不懂它在說什么。所以我要做的事情用不怎么精確但相對通俗的語言解釋一下。
先用一句話總結,Go的內存模型描述的是"在一個groutine中對變量進行讀操作能夠偵測到在其他goroutine中對該變量的寫操作"的條件。
為了證明這個重要性,先看一個例子。下面一小段代碼:
package main
import (
"sync"
"time"
)
func main() {
var wg sync.WaitGroup
var count int
var ch = make(chan bool, 1)
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
ch <- true
count++
time.Sleep(time.Millisecond)
count--
<-ch
wg.Done()
}()
}
wg.Wait()
}
以上代碼有沒有什么問題?這里把buffered channel作為semaphore來使用,表面上看最多允許一個goroutine對count進行++和--,但其實這里是有bug的。根據(jù)Go語言的內存模型,對count變量的訪問并沒有形成臨界區(qū)。編譯時開啟競態(tài)檢測可以看到這段代碼有問題:
go run -race test.go
編譯器可以檢測到16和18行是存在競態(tài)條件的,也就是count并沒像我們想要的那樣在臨界區(qū)執(zhí)行。繼續(xù)往下看,讀完這一節(jié),回頭再來看就可以明白為什么這里有bug了。
happens-before是一個術語,并不僅僅是Go語言才有的。簡單的說,通常的定義如下:
假設A和B表示一個多線程的程序執(zhí)行的兩個操作。如果A happens-before B,那么A操作對內存的影響 將對執(zhí)行B的線程(且執(zhí)行B之前)可見。
無論使用哪種編程語言,有一點是相同的:如果操作A和B在相同的線程中執(zhí)行,并且A操作的聲明在B之前,那么A happens-before B。
int A, B;
void foo()
{
// This store to A ...
A = 5;
// ... effectively becomes visible before the following loads. Duh!
B = A * A;
}
還有一點是,在每門語言中,無論你使用那種方式獲得,happens-before關系都是可傳遞的:如果A happens-before B,同時B happens-before C,那么A happens-before C。當這些關系發(fā)生在不同的線程中,傳遞性將變得非常有用。
剛接觸這個術語的人總是容易誤解,這里必須澄清的是,happens-before并不是指時序關系,并不是說A happens-before B就表示操作A在操作B之前發(fā)生。它就是一個術語,就像光年不是時間單位一樣。具體地說:
這兩個陳述看似矛盾,其實并不是。如果你覺得很困惑,可以多讀幾篇它的定義。后面我會試著解釋這點。記住,happens-before 是一系列語言規(guī)范中定義的操作間的關系。它和時間的概念獨立。這和我們通常說”A在B之前發(fā)生”時表達的真實世界中事件的時間順序不同。
這里有個例子,其中的操作具有happens-before關系,但是實際上并不一定是按照那個順序發(fā)生的。下面的代碼執(zhí)行了(1)對A的賦值,緊接著是(2)對B的賦值。
int A = 0;
int B = 0;
void main()
{
A = B + 1; // (1)
B = 1; // (2)
}
根據(jù)前面說明的規(guī)則,(1) happens-before (2)。但是,如果我們使用gcc -O2編譯這個代碼,編譯器將產生一些指令重排序。有可能執(zhí)行順序是這樣子的:
將B的值取到寄存器
將B賦值為1
將寄存器值加1后賦值給A
也就是到第二條機器指令(對B的賦值)完成時,對A的賦值還沒有完成。換句話說,(1)并沒有在(2)之前發(fā)生!
那么,這里違反了happens-before關系了嗎?讓我們來分析下,根據(jù)定義,操作(1)對內存的影響必須在操作(2)執(zhí)行之前對其可見。換句話說,對A的賦值必須有機會對B的賦值有影響.
但是在這個例子中,對A的賦值其實并沒有對B的賦值有影響。即便(1)的影響真的可見,(2)的行為還是一樣。所以,這并不能算是違背happens-before規(guī)則。
下面這個例子中,所有的操作按照指定的順序發(fā)生,但是并能不構成happens-before 關系。假設一個線程調用pulishMessage,同時,另一個線程調用consumeMessage。 由于我們并行的操作共享變量,為了簡單,我們假設所有對int類型的變量的操作都是原子的。
int isReady = 0;
int answer = 0;
void publishMessage()
{
answer = 42; // (1)
isReady = 1; // (2)
}
void consumeMessage()
{
if (isReady) // (3) <-- Let's suppose this line reads 1
printf("%d\n", answer); // (4)
}
根據(jù)程序的順序,在(1)和(2)之間存在happens-before 關系,同時在(3)和(4)之間也存在happens-before關系。
除此之外,我們假設在運行時,isReady讀到1(是由另一個線程在(2)中賦的值)。在這中情形下,我們可知(2)一定在(3)之前發(fā)生。但是這并不意味著在(2)和(3)之間存在happens-before 關系!
happens-before 關系只在語言標準中定義的地方存在,這里并沒有相關的規(guī)則說明(2)和(3)之間存在happens-before關系,即便(3)讀到了(2)賦的值。
還有,由于(2)和(3)之間,(1)和(4)之間都不存在happens-before關系,那么(1)和(4)的內存交互也可能被重排序 (要不然來自編譯器的指令重排序,要不然來自處理器自身的內存重排序)。那樣的話,即使(3)讀到1,(4)也會打印出“0“。
我們回過頭來再看看"The Go Memory Model"中關于happens-before的部分。
如果滿足下面條件,對變量v的讀操作r可以偵測到對變量v的寫操作w:
為了保證對變量v的讀操作r可以偵測到某個對v的寫操作w,必須確保w是r可以偵測到的唯一的寫操作。也就是說當滿足下面條件時可以保證讀操作r能偵測到寫操作w:
關于channel的happens-before在Go的內存模型中提到了三種情況:
先看一個簡單的例子:
var c = make(chan int, 10)
var a string
func f() {
a = "hello, world" // (1)
c <- 0 // (2)
}
func main() {
go f()
<-c // (3)
print(a) // (4)
}
上述代碼可以確保輸出"hello, world",因為(1) happens-before (2),(4) happens-after (3),再根據(jù)上面的第一條規(guī)則(2)是 happens-before (3)的,最后根據(jù)happens-before的可傳遞性,于是有(1) happens-before (4),也就是a = "hello, world" happens-before print(a)。
再看另一個例子:
var c = make(chan int)
var a string
func f() {
a = "hello, world" // (1)
<-c // (2)
}
func main() {
go f()
c <- 0 // (3)
print(a) // (4)
}
根據(jù)上面的第三條規(guī)則(2) happens-before (3),最終可以保證(1) happens-before (4)。
如果我把上面的代碼稍微改一點點,將c變?yōu)橐粋€帶緩存的channel,則print(a)打印的結果不能夠保證是"hello world"。
var c = make(chan int, 1)
var a string
func f() {
a = "hello, world" // (1)
<-c // (2)
}
func main() {
go f()
c <- 0 // (3)
print(a) // (4)
}
因為這里不再有任何同步保證,使得(2) happens-before (3)??梢曰仡^分析一下本節(jié)最前面的例子,也是沒有保證happens-before條件。
更多建議: