一、前言
Go語言的內存模型規定了一個goroutine可以看到另外一個goroutine修改同一個變量的值的條件,這類似java內存模型中內存可見性問題(Java內存可見性問題可以參考拙作:Java併發編程之美一書)。
當多個goroutine併發同時存取同一個數據時候必須把併發的存取的操作順序化,在go中可以實現操作順序化的工具有高級的通道(channel)通信和同步原語比如sync包中的Mutex(互斥鎖)、RWMutex(讀寫鎖)或者和sync/atomic中的原子操作。
二、Happens Before原則
當程序裏面只有一個goroutine時候,雖然編譯器和CPU由於開啓了優化功能可能調整讀寫操作的順序,但是這個調整是不會影響程序的執行正確性:
a := 1//1
b := 2//2
c := a + b //3
...
如上代碼由於編譯器和cpu的優化,實際運行時候可能代碼(2)先運行,然後代碼(1)後執行,但是由於代碼(3)依賴代碼(1)和代碼(2)創建的變量,所以代碼(1)和(2)不會被放到代碼(3)後運行,也就是說編譯器和CPU在不改變程序正確性的前提下才會對指令進行重排序,所以上面代碼在單一goroutine時候並不會存在問題,也就是在單一goroutine 中Happens Before所要表達的順序就是程序執行的順序。
但是在多個goroutine時候就可能存在問題,比如下面代碼:
//變量b初始化爲0
var b int
//goroutine A
go func() {
a := 1 //1
b := 2 //2
c := a + b //3
}()
//goroutine B
go func() {
if 2 == b {//4
fmt.Println(a)//5
}
}()
- 如上代碼變量b是一個全局變量,初始化爲0值
- 下面開啓了兩個goroutine,假設goroutine B有機會輸出值時候,那麼它可能輸出的值是多少那?其實可能是0也可能是1,輸出1大家可能會感到很直觀,那麼爲何會輸出0 了?
- 這是因爲編譯器或者CPU可能會對goroutine A中的指令做重排序,可能先執行了代碼(2),然後在執行了代碼(1)。假設當goroutine A執行代碼(2)後,調度器調度了goroutine B執行,則goroutine B這時候會輸出0。
爲了保證多goroutine下讀取共享數據的正確性,go中引入happens before原則,即在go程序中定義了多個內存操作執行的一種偏序關係。如果操作e1先於e2發生,我們說e2 happens after e1,如果e1操作既不先於e2發生又不晚於e2發生,我們說e1操作與e2操作併發發生。
在單一goroutine 中Happens Before所要表達的順序就是程序執行的順序,happens before原則指出在單一goroutine 中當滿足下面條件時候,對一個變量的寫操作w1對讀操作r1可見:
- 讀操作r1沒有發生在寫操作w1前
- 在讀操作r1之前,寫操作w1之後沒有其他的寫操作w2對變量進行了修改
在一個goroutine裏面,不存在併發,所以對變量的讀操作r1總是對最近的一個寫操作w1的內容可見,但是在多goroutine下則需要滿足下面條件才能保證寫操作w1對讀操作r1可見:
- 寫操作w1先於讀操作r1
- 任何對變量的寫操作w2要先於寫操作w1或者晚於讀操作r1
這兩條條件相比第一組的兩個條件更加嚴格,因爲它要求沒有任何寫操作與w1或者讀操作r1併發的運行,而是要求在w1操作前或讀操作r1後發生。
在一個goroutine時候,不存在與w1或者r1併發的寫操作,所以前面兩種定義是等價的:一個讀操作r1總是對最近的一個對寫操作w1的內容可見。但是當有多個goroutines併發訪問變量時候,就需要引入同步機制來建立happen-before條件來確保讀操作r1對寫操作w1寫的內容可見。
需要注意的是在go內存模型中將多個goroutine中用到的全局變量初始化爲它的類型零值在內被視爲一次寫操作,另外當讀取一個類型大小比機器字長大的變量的值時候表現爲是對多個機器字的多次讀取,這個行爲是未知的,go中使用sync/atomic包中的Load和Store操作可以解決這個問題。
解決多goroutine下共享數據可見性問題的方法是在訪問共享數據時候施加一定的同步措施,比如sync包下的鎖或者通道。
三、同步(Synchronization)
3.1初始化(Initialization)
程序的初始化是發生在一個goroutine內的,這個goroutine可以創建多個新的goroutine,創建的goroutine和當前的goroutine可以併發的運行。
如果在一個goroutine所在的源碼包p裏面通過import命令導入了包q,那麼q包裏面go文件的初始化方法的執行會happens before 於包p裏面的初始化方法執行:
package main
import (
"fmt"
"main/hello"
)
func init() {
fmt.Println("--main thread init---")
}
func main() {
fmt.Println("---main func start----")
hello.SayHello()
}
- 如上代碼main包裏面導入了main/hello包,後者裏面含有一個hello.go的文件,內容如下:
package hello
import (
"fmt"
)
func init() {
fmt.Println("--hello pkg init---")
}
func SayHello() {
fmt.Println("--hello jiaduo---")
}
- main包的main裏面調用了包hello的SayHello 方法。
運行上面代碼會輸出:
--hello pkg init---
--main thread init---
---main func start----
--hello jiaduo---
可知hello包的init方法happen before main包的init執行,main包的init方法happen berfore main函數執行。
3.2 創建goroutine(Goroutine creation)
go語句啓動一個新的goroutine的動作 happen before 該新goroutine的運行,例如下面程序:
package main
import (
"fmt"
"sync"
)
var a string
var wg sync.WaitGroup
func f() {
fmt.Print(a)
wg.Done()
}
func hello() {
a = "hello, world"
go f()
}
func main() {
wg.Add(1)
hello()
wg.Wait()
}
如上代碼調用hello方法後肯定會輸出"hello,world",可能等hello方法執行完畢後才輸出(由於調度的原因)。
3.3 銷燬goroutine(Goroutine destruction)
一個goroutine的銷燬操作並不能確保 happen before 程序中的任何事件,比如下面例子:
var a string
func hello() {
go func() { a = "hello" }()
print(a)
}
如上代碼 goroutine內對變量a的賦值並沒有加任何同步措施,所以並能不保證hello函數所在的goroutine對變量a的賦值可見。如果要確保一個goroutine對變量的修改對其他goroutine可見,必須使用一定的同步機制,比如鎖、通道來建立對同一個變量讀寫的偏序關係。
3.4 通道通信(Channel communication)
在go中通道是用來解決多個goroutines之間進行同步的主要措施,在多個goroutines中,每個對通道進行寫操作的goroutine都對應着一個從通道讀操作的goroutine。
3.4.1 有緩衝通道
在有緩衝的通道時候向通道寫入一個數據總是 happen before 這個數據被從通道中讀取完成,如下例子:
package main
import (
"fmt"
)
var c = make(chan int, 10)
var a string
func f() {
a = "hello, world" //1
c <- 0 //2
}
func main() {
go f() //3
<-c //4
fmt.Print(a) //5
}
如上代碼運行後可以確保輸出"hello, world",這裏對變量a的寫操作(1) happen before 向通道寫入數據的操作(2),而向通道寫入數據的操作(2)happen before 從通道讀取數據完成的操作(4),而步驟(4)happen before 步驟(5)的打印輸出。
另外關閉通道的操作 happen before 從通道接受0值(關閉通道後會向通道發送一個0值),修改上面代碼(2)如下:
package main
import (
"fmt"
)
var c = make(chan int, 10)
var a string
func f() {
a = "hello, world" //1
close(c) //2
}
func main() {
go f() //3
<-c //4
fmt.Print(a) //5
}
然後在運行也可以確保輸出"hello, world"。
注:在有緩衝通道中通過向通道寫入一個數據總是 happen before 這個數據被從通道中讀取完成,這個happen before規則使多個goroutine中對共享變量的併發訪問變成了可預見的串行化操作。
3.4.2 無緩衝通道
對應無緩衝的通道來說從通道接受(獲取叫做讀取)元素 happen before 向通道發送(寫入)數據完成,看下下面代碼:
package main
import (
"fmt"
)
var c = make(chan int)
var a string
func f() {
a = "hello, world" //1
<-c //2
}
func main() {
go f() //3
c <- 0 //4
fmt.Print(a) //5
}
如上代碼運行也可保證輸出"hello, world",注意改程序相比上一個片段,通道改爲了無緩衝,並向通道發送數據與讀取數據的步驟(2)(4)調換了位置。
在這裏寫入變量a的操作(1)happen before 從通道讀取數據完畢的操作(2),而從通道讀取數據的操作 happen before 向通道寫入數據完畢的操作(4),而步驟(4) happen before 打印輸出步驟(5)。
注:在無緩衝通道中從通道讀取數據的操作 happen before 向通道寫入數據完畢的操作,這個happen before規則使多個goroutine中對共享變量的併發訪問變成了可預見的串行化操作。
如上代碼如果換成有緩衝的通道,比如c = make(chan int, 1)則就不能保證一定會輸出"hello, world"。
3.4.3 規則抽象
從容量爲C的通道接受第K個元素 happen before 向通道第k+C次寫入完成,比如從容量爲1的通道接受第3個元素 happen before 向通道第3+1次寫入完成。
這個規則對有緩衝通道和無緩衝通道的情況都適用,有緩衝的通道可以實現信號量計數的功能,比如通道的容量可以認爲是最大信號量的個數,通道內當前元素個數可以認爲是剩餘的信號量個數,向通道寫入(發送)一個元素可以認爲是獲取一個信號量,從通道讀取(接受)一個元素可以認爲是釋放一個信號量,所以有緩衝的通道可以作爲限制併發數的一個通用手段:
package main
import (
"fmt"
"time"
)
var limit = make(chan int, 3)
func sayHello(index int){
fmt.Println(index )
}
var work []func(int)
func main() {
work := append(work,sayHello,sayHello,sayHello,sayHello,sayHello,sayHello)
for i, w := range work {
go func(w func(int),index int) {
limit <- 1
w(index)
<-limit
}(w,i)
}
time.Sleep(time.Second * 10)
}
如上代碼main goroutine裏面爲work列表裏面的每個方法的執行開啓了一個單獨的goroutine,這裏有6個方法,正常情況下這7個goroutine可以併發運行,但是本程序使用緩存大小爲3的通道來做併發控制,導致同時只有3個goroutine可以併發運行。
四、鎖(locks)
sync包實現了兩個鎖類型,分別爲 sync.Mutex(互斥鎖)和 sync.RWMutex(讀寫鎖)。
對應任何sync.Mutex or sync.RWMutex類型的遍歷I來說調用n次 l.Unlock() 操作 happen before 調用m次l.Lock()操作返回,其中n<m,我們看下面程序:
package main
import (
"fmt"
"sync"
)
var l sync.Mutex
var a string
func f() {
a = "hello, world" //1
l.Unlock() //2
}
func main() {
l.Lock() //3
go f() //4
l.Lock() //5
fmt.Print(a) //6
}
運行上面代碼可以確保輸出"hello, world",其中對變量a的賦值操作(1) happen before 步驟(2),第一次調用 l.Unlock()的操作(2) happen before 第二次調用l.Lock()的操作(5),操作(5) happen before 打印輸出操作(6)
另外對任何一個sync.RWMutex類型的變量l來說,存在一個次數n,調用 l.RLock操作happens after 調用n次 l. Unlock(釋放寫鎖)並且相應的 l.RUnlock happen before 調用n+1次 l.Lock(寫鎖)
package main
import (
"fmt"
"sync"
)
var l sync.RWMutex
var a string
func unlock() {
a = "unlock" //1
l.Unlock() //2
}
func runlock() {
a = "runlock" //3
l.RUnlock() //4
}
func main() {
l.Lock() //5
go unlock() //6
l.RLock() //7
fmt.Println(a) //8
go runlock() //9
l.Lock() //10
fmt.Print(a) //11
l.Unlock()
}
- 運行上面代碼一定會輸出如下:
unlock
runlock
- 如上代碼 (1)對a的賦值 happen before 代碼(2),而對l.RLock() (代碼7) 的調用happen after對l.Unlock()(代碼2)的第1次調用,所以代碼(8)輸出unlock。
- 而對代碼(7)l.RLock() 的調用happen after對l.Unlock()(代碼2) 的第1次調用,相應的有對l.RUnlock() (代碼4)的調用happen before 第2次對l.Lock()(代碼4)的調用,所以代碼(11)輸出runlock
也就是這裏對任何一個sync.RWMutex類型的變量l來說,存在一個次數1,調用 l.RLock操作happens after 調用1次 l. Unlock(釋放寫鎖)並且相應的 l.RUnlock happen before 調用2次 l.Lock(寫鎖)
五、一次執行(Once)
sync包提供了在多個goroutine存在的情況下進行安全初始化的一種機制,這個機制也就是提供的Once類型。多(goroutine)下多個goroutine可以同時執行once.Do(f)方法,其中f是一個函數,但是同時只有一個goroutine可以真正運行傳遞的f函數,其他的goroutine則會阻塞直到運行f的goroutine運行f完畢。
多goroutine下同時調用once.Do(f)時候,真正執行f()函數的goroutine, happen before 任何其他由於調用once.Do(f)而被阻塞的goroutine返回:
package main
import (
"fmt"
"sync"
"time"
)
var a string
var once sync.Once
var wg sync.WaitGroup
func setup() {
time.Sleep(time.Second * 2) //1
a = "hello, world"
fmt.Println("setup over") //2
}
func doprint() {
once.Do(setup) //3
fmt.Println(a) //4
wg.Done()
}
func twoprint() {
go doprint()
go doprint()
}
func main() {
wg.Add(2)
twoprint()
wg.Wait()
}
如上代碼運行會輸出:
setup over
hello, world
hello, world
- 上面代碼使用wg sync.WaitGroup等待兩個goroutine運行完畢,由於 setup over只輸出一次,所以setup方法只運行了一次
- 由於輸出了兩次hello, world說明當一個goroutine在執行setup方法時候,另外一個在阻塞。
六、不正確的同步(Incorrect synchronization)
6.1 不正確的同步案例(一)
需要注意的是雖然一個goroutine對一個變量的讀取操作r,可以觀察到另外一個goroutine的寫操作w對變量的修改,但是這不意味着happening after 讀操作r的讀操作可以看到 happen before寫操作w的寫操作對變量的修改(需要注意這裏的先後指的是代碼裏面聲明的操作的先後順序,而不是實際執行時候的):
var a, b int
func f() {
a = 1//1
b = 2//2
}
func g() {
print(b)//3
print(a)//4
}
func main() {
go f()//5
g()//6
}
- 比如上面代碼一個可能的輸出爲先打印2,然後打印0
- 由於代碼(1)(2)沒有有任何同步措施,所以經過重排序後可能先執行代碼(2),然後執行代碼(1)。
- 另外由於步驟(5)開啓了一個新goroutine來執行f函數,所以f函數和g函數是併發運行,並且兩個goroutine沒做任何同步。
- 假設f函數先執行,並且由於重排序限制性了步驟(2),然後g函數執行了步驟(3)則這時候會打印出2,然後執行步驟(4)則打印出0,然後執行步驟(1)給變量a賦值。
也就是說這裏即使假設步驟(3)的讀取操作r 對步驟(2)的寫操作w的內容可見,但是還沒不能保證步驟(3)的讀取操作後面的讀取操作步驟(4)可以看到 先於代碼中聲明的在步驟(2)前面的代碼(1)對變量a賦值的內容。
6.2 不正確的同步案例(二)
使用雙重檢查機制來避免使用同步帶來的開銷,如下代碼:
var a string
var done bool
func setup() {
a = "hello, world"
done = true
}
func doprint() {
if !done {
once.Do(setup)
}
print(a)
}
func twoprint() {
go doprint()
go doprint()
}
如上代碼並不能保證一定輸出hello, world,而可能輸出空字符串,這是因爲在doPrint函數內即使可以能夠看到setup中對done變量的寫操作,也不能保證在doPrint裏面看到對變量a的寫操作。
6.3 不正確的同步案例(三)
另外一個常見的不正確的同步是等待某個變量的值滿足一定條件:
var a string
var done bool
func setup() {
a = "hello, world"
done = true
}
func main() {
go setup()
for !done {
}
print(a)
}
該案例同理,並不能確保在main函數內即使可以看到對變量done的寫操作,也可以看到對變量a的操作,所以main函數還是可能會輸出空串。更糟糕的是由於兩個goroutine沒有對變量done做同步措施,main函數所在goroutine可能看不到對done的寫操作,從而導致main函數所在goroutine一直運行在for循環出。
這種不正常的同步方式有更微妙的變體,例如這個程序:
type T struct {
msg string
}
var g *T
func setup() {
t := new(T)
t.msg = "hello, world"
g = t
}
func main() {
go setup()
for g == nil {
}
print(g.msg)
}
如上代碼即使main函數內可以看到setup函數內對g的賦值,從而讓main函數退出,但是也不能保證main函數可以看到對 g.msg的賦值,也就是可能輸出空串
七、總結
通過上面所有的例子,不難看出解決多goroutine下共享數據可見性問題的方法是在訪問共享數據時候施加一定的同步措施。本文翻譯自https://golang.org/ref/mem ,並融入作者自己理解。