Go語言 內存模型

2018-07-25 16:28 更新

內存模型是非常重要的,理解Go的內存模型會就可以明白很多奇怪的競態(tài)條件問題,"The Go Memory Model"的原文在這里,讀個四五遍也不算多。

這里并不是要翻譯這篇文章,英文原文是精確的,但讀起來卻很晦澀,尤其是happens-before的概念本身就是不好理解的,很容易跟時序問題混淆。大多數(shù)讀者第一遍讀Go的內存模型時基本上看不懂它在說什么。所以我要做的事情用不怎么精確但相對通俗的語言解釋一下。

先用一句話總結,Go的內存模型描述的是"在一個groutine中對變量進行讀操作能夠偵測到在其他goroutine中對該變量的寫操作"的條件。

內存模型相關bug一例

為了證明這個重要性,先看一個例子。下面一小段代碼:

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

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ā)生。它就是一個術語,就像光年不是時間單位一樣。具體地說:

  1. A happens-before B并不意味著A在B之前發(fā)生。
  2. A在B之前發(fā)生并不意味著A happens-before B。

這兩個陳述看似矛盾,其實并不是。如果你覺得很困惑,可以多讀幾篇它的定義。后面我會試著解釋這點。記住,happens-before 是一系列語言規(guī)范中定義的操作間的關系。它和時間的概念獨立。這和我們通常說”A在B之前發(fā)生”時表達的真實世界中事件的時間順序不同。

A happens-before B并不意味著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ī)則。

A在B之前發(fā)生并不意味著A happens-before B

下面這個例子中,所有的操作按照指定的順序發(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“。

Go關于同步的規(guī)則

我們回過頭來再看看"The Go Memory Model"中關于happens-before的部分。

如果滿足下面條件,對變量v的讀操作r可以偵測到對變量v的寫操作w:

  1. r does not happen before w.
  2. There is no other write w to v that happens after w but before r.

為了保證對變量v的讀操作r可以偵測到某個對v的寫操作w,必須確保w是r可以偵測到的唯一的寫操作。也就是說當滿足下面條件時可以保證讀操作r能偵測到寫操作w:

  1. w happens-before r.
  2. Any other write to the shared variable v either happens-before w or after r.

關于channel的happens-before在Go的內存模型中提到了三種情況:

  1. 對一個channel的發(fā)送操作 happens-before 相應channel的接收操作完成
  2. 關閉一個channel happens-before 從該Channel接收到最后的返回值0
  3. 不帶緩沖的channel的接收操作 happens-before 相應channel的發(fā)送操作完成

先看一個簡單的例子:

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條件。

links

  • 目錄
  • 上一節(jié): [雜項]
  • 下一節(jié): [pprof]


以上內容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號