面向并發的內存模型
在早期,CPU都是以單核的形式順序執行機器指令。Go語言的祖先C語言正是這種順序編程語言的代表。順序編程語言中的順序是指:所有的指令都是以串行的方式執行,在相同的時刻有且僅有一個CPU在順序執行程序的指令。
隨著處理器技術的發展,單核時代以提升處理器頻率來提高運行效率的方式遇到了瓶頸,目前各種主流的CPU頻率基本被鎖定在了3GHZ附近。單核CPU的發展的停滯,給多核CPU的發展帶來了機遇。相應地,編程語言也開始逐步向并行化的方向發展。Go語言正是在多核和網絡化的時代背景下誕生的原生支持并發的編程語言。
常見的并行編程有多種模型,主要有多線程、消息傳遞等。從理論上來看,多線程和基于消息的并發編程是等價的。由于多線程并發模型可以自然對應到多核的處理器,主流的操作系統因此也都提供了系統級的多線程支持,同時從概念上講多線程似乎也更直觀,因此多線程編程模型逐步被吸納到主流的編程語言特性或語言擴展庫中。而主流編程語言對基于消息的并發編程模型支持則相比較少,Erlang語言是支持基于消息傳遞并發編程模型的代表者,它的并發體之間不共享內存。Go語言是基于消息并發模型的集大成者,它將基于CSP模型的并發編程內置到了語言中,通過一個go關鍵字就可以輕易地啟動一個Goroutine,與Erlang不同的是Go語言的Goroutine之間是共享內存的。
Goroutine和系統線程
Goroutine是Go語言特有的并發體,是一種輕量級的線程,由go關鍵字啟動。在真實的Go語言的實現中,goroutine和系統線程也不是等價的。盡管兩者的區別實際上只是一個量的區別,但正是這個量變引發了Go語言并發編程質的飛躍。
首先,每個系統級線程都會有一個固定大小的棧(一般默認可能是2MB),這個棧主要用來保存函數遞歸調用時參數和局部變量。固定了棧的大小導致了兩個問題:一是對于很多只需要很小的棧空間的線程來說是一個巨大的浪費,二是對于少數需要巨大棧空間的線程來說又面臨棧溢出的風險。針對這兩個問題的解決方案是:要么降低固定的棧大小,提升空間的利用率;要么增大棧的大小以允許更深的函數遞歸調用,但這兩者是沒法同時兼得的。相反,一個Goroutine會以一個很小的棧啟動(可能是2KB或4KB),當遇到深度遞歸導致當前棧空間不足時,Goroutine會根據需要動態地伸縮棧的大小(主流實現中棧的最大值可達到1GB)。因為啟動的代價很小,所以我們可以輕易地啟動成千上萬個Goroutine。
Go的運行時還包含了其自己的調度器,這個調度器使用了一些技術手段,可以在n個操作系統線程上多工調度m個Goroutine。Go調度器的工作和內核的調度是相似的,但是這個調度器只關注單獨的Go程序中的Goroutine。Goroutine采用的是半搶占式的協作調度,只有在當前Goroutine發生阻塞時才會導致調度;同時發生在用戶態,調度器會根據具體函數只保存必要的寄存器,切換的代價要比系統線程低得多。運行時有一個runtime.GOMAXPROCS
變量,用于控制當前運行正常非阻塞Goroutine的系統線程數目。
在Go語言中啟動一個Goroutine不僅和調用函數一樣簡單,而且Goroutine之間調度代價也很低,這些因素極大地促進了并發編程的流行和發展。
原子操作
所謂的原子操作就是并發編程中“最小的且不可并行化”的操作。通常,如果多個并發體對同一個共享資源進行的操作是原子的話,那么同一時刻最多只能有一個并發體對該資源進行操作。從線程角度看,在當前線程修改共享資源期間,其它的線程是不能訪問該資源的。原子操作對于多線程并發編程模型來說,不會發生有別于單線程的意外情況,共享資源的完整性可以得到保證。
一般情況下,原子操作都是通過“互斥”訪問來保證的,通常由特殊的CPU指令提供保護。當然,如果僅僅是想模擬下粗粒度的原子操作,我們可以借助于sync.Mutex
來實現:
import (
"sync"
)
var total struct {
sync.Mutex
value int
}
func worker(wg *sync.WaitGroup) {
defer wg.Done()
for i := 0; i <= 100; i++ {
total.Lock()
total.value += i
total.Unlock()
}
}
func main() {
var wg sync.WaitGroup
wg.Add(2)
go worker(&wg)
go worker(&wg)
wg.Wait()
fmt.Println(total.value)
}
在worker
的循環中,為了保證total.value += i
的原子性,我們通過sync.Mutex
加鎖和解鎖來保證該語句在同一時刻只被一個線程訪問。對于多線程模型的程序而言,進出臨界區前后進行加鎖和解鎖都是必須的。如果沒有鎖的保護,total
的最終值將由于多線程之間的競爭而可能會不正確。
用互斥鎖來保護一個數值型的共享資源,麻煩且效率低下。標準庫的sync/atomic
包對原子操作提供了豐富的支持。我們可以重新實現上面的例子:
import (
"sync"
"sync/atomic"
)
var total uint64
func worker(wg *sync.WaitGroup) {
defer wg.Done()
var i uint64
for i = 0; i <= 100; i++ {
atomic.AddUint64(&total, i)
}
}
func main() {
var wg sync.WaitGroup
wg.Add(2)
go worker(&wg)
go worker(&wg)
wg.Wait()
}
atomic.AddUint64
函數調用保證了total
的讀取、更新和保存是一個原子操作,因此在多線程中訪問也是安全的。
原子操作配合互斥鎖可以實現非常高效的單件模式。互斥鎖的代價比普通整數的原子讀寫高很多,在性能敏感的地方可以增加一個數字型的標志位,通過原子檢測標志位狀態降低互斥鎖的使用次數來提高性能。
type singleton struct {}
var (
instance *singleton
initialized uint32
mu sync.Mutex
)
func Instance() *singleton {
if atomic.LoadUint32(&initialized) == 1 {
return instance
}
mu.Lock()
defer mu.Unlock()
if instance == nil {
defer atomic.StoreUint32(&initialized, 1)
instance = &singleton{}
}
return instance
}
我們可以將通用的代碼提取出來,就成了標準庫中sync.Once
的實現:
type Once struct {
m Mutex
done uint32
}
func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 1 {
return
}
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 {
defer atomic.StoreUint32(&o.done, 1)
f()
}
}
基于sync.Once
重新實現單件模式:
var (
instance *singleton
once sync.Once
)
func Instance() *singleton {
once.Do(func() {
instance = &singleton{}
})
return instance
}
sync/atomic
包對基本的數值類型及復雜對象的讀寫都提供了原子操作的支持。atomic.Value
原子對象提供了Load
和Store
兩個原子方法,分別用于加載和保存數據,返回值和參數都是interface{}
類型,因此可以用于任意的自定義復雜類型。
var config atomic.Value // 保存當前配置信息
// 初始化配置信息
config.Store(loadConfig())
// 啟動一個后臺線程, 加載更新后的配置信息
go func() {
for {
time.Sleep(time.Second)
config.Store(loadConfig())
}
}()
// 用于處理請求的工作者線程始終采用最新的配置信息
for i := 0; i < 10; i++ {
go func() {
for r := range requests() {
c := config.Load()
// ...
}
}()
}
這是一個簡化的生產者消費者模型:后臺線程生成最新的配置信息;前臺多個工作者線程獲取最新的配置信息。所有線程共享配置信息資源。
順序一致性內存模型
如果只是想簡單地在線程之間進行數據同步的話,原子操作已經為編程人員提供了一些同步保障。不過這種保障有一個前提:順序一致性的內存模型。要了解順序一致性,我們先看看一個簡單的例子:
var a string
var done bool
func setup() {
a = "hello, world"
done = true
}
func main() {
go setup()
for !done {}
print(a)
}
我們創建了setup
線程,用于對字符串a
的初始化工作,初始化完成之后設置done
標志為true
。main
函數所在的主線程中,通過for !done {}
檢測done
變為true
時,認為字符串初始化工作完成,然后進行字符串的打印工作。
但是Go語言并不保證在main
函數中觀測到的對done
的寫入操作發生在對字符串a
的寫入的操作之后,因此程序很可能打印一個空字符串。更糟糕的是,因為兩個線程之間沒有同步事件,setup
線程對done
的寫入操作甚至無法被main
線程看到,main
函數有可能陷入死循環中。
在Go語言中,同一個Goroutine線程內部,順序一致性內存模型是得到保證的。但是不同的Goroutine之間,并不滿足順序一致性內存模型,需要通過明確定義的同步事件來作為同步的參考。如果兩個事件不可排序,那么就說這兩個事件是并發的。為了最大化并行,Go語言的編譯器和處理器在不影響上述規定的前提下可能會對執行語句重新排序(CPU也會對一些指令進行亂序執行)。
因此,如果在一個Goroutine中順序執行a = 1; b = 2;
兩個語句,雖然在當前的Goroutine中可以認為a = 1;
語句先于b = 2;
語句執行,但是在另一個Goroutine中b = 2;
語句可能會先于a = 1;
語句執行,甚至在另一個Goroutine中無法看到它們的變化(可能始終在寄存器中)。也就是說在另一個Goroutine看來, a = 1; b = 2;
兩個語句的執行順序是不確定的。如果一個并發程序無法確定事件的順序關系,那么程序的運行結果往往會有不確定的結果。比如下面這個程序:
func main() {
go println("你好, 世界")
}
根據Go語言規范,main
函數退出時程序結束,不會等待任何后臺線程。因為Goroutine的執行和main
函數的返回事件是并發的,誰都有可能先發生,所以什么時候打印,能否打印都是未知的。
用前面的原子操作并不能解決問題,因為我們無法確定兩個原子操作之間的順序。解決問題的辦法就是通過同步原語來給兩個事件明確排序:
func main() {
done := make(chan int)
go func(){
println("你好, 世界")
done <- 1
}()
<-done
}
當<-done
執行時,必然要求done <- 1
也已經執行。根據同一個Gorouine依然滿足順序一致性規則,我們可以判斷當done <- 1
執行時,println("你好, 世界")
語句必然已經執行完成了。因此,現在的程序確保可以正常打印結果。
當然,通過sync.Mutex
互斥量也是可以實現同步的:
func main() {
var mu sync.Mutex
mu.Lock()
go func(){
println("你好, 世界")
mu.Unlock()
}()
mu.Lock()
}
可以確定后臺線程的mu.Unlock()
必然在println("你好, 世界")
完成后發生(同一個線程滿足順序一致性),main
函數的第二個mu.Lock()
必然在后臺線程的mu.Unlock()
之后發生(sync.Mutex
保證),此時后臺線程的打印工作已經順利完成了。
初始化順序
前面函數章節中我們已經簡單介紹過程序的初始化順序,這是屬于Go語言面向并發的內存模型的基礎規范。
Go程序的初始化和執行總是從main.main
函數開始的。但是如果main
包里導入了其它的包,則會按照順序將它們包含進main
包里(這里的導入順序依賴具體實現,一般可能是以文件名或包路徑名的字符串順序導入)。如果某個包被多次導入的話,在執行的時候只會導入一次。當一個包被導入時,如果它還導入了其它的包,則先將其它的包包含進來,然后創建和初始化這個包的常量和變量。然后就是調用包里的init
函數,如果一個包有多個init
函數的話,實現可能是以文件名的順序調用,同一個文件內的多個init
則是以出現的順序依次調用(init
不是普通函數,可以定義有多個,所以不能被其它函數調用)。最終,在main
包的所有包常量、包變量被創建和初始化,并且init
函數被執行后,才會進入main.main
函數,程序開始正常執行
要注意的是,在main.main
函數執行之前所有代碼都運行在同一個Goroutine中,也是運行在程序的主系統線程中。如果某個init
函數內部用go關鍵字啟動了新的Goroutine的話,新的Goroutine和main.main
函數是并發執行的。
因為所有的init
函數和main
函數都是在主線程完成,它們也是滿足順序一致性模型的。
Goroutine的創建
go
語句會在當前Goroutine對應函數返回前創建新的Goroutine. 例如:
var a string
func f() {
print(a)
}
func hello() {
a = "hello, world"
go f()
}
執行go f()
語句創建Goroutine和hello
函數是在同一個Goroutine中執行, 根據語句的書寫順序可以確定Goroutine的創建發生在hello
函數返回之前, 但是新創建Goroutine對應的f()
的執行事件和hello
函數返回的事件則是不可排序的,也就是并發的。調用hello
可能會在將來的某一時刻打印"hello, world"
,也很可能是在hello
函數執行完成后才打印。
基于Channel的通信
Channel通信是在Goroutine之間進行同步的主要方法。在無緩存的Channel上的每一次發送操作都有與其對應的接收操作相配對,發送和接收操作通常發生在不同的Goroutine上(在同一個Goroutine上執行2個操作很容易導致死鎖)。無緩存的Channel上的發送操作總在對應的接收操作完成前發生.
var done = make(chan bool)
var msg string
func aGoroutine() {
msg = "你好, 世界"
done <- true
}
func main() {
go aGoroutine()
<-done
println(msg)
}
可保證打印出“hello, world”。該程序首先對msg
進行寫入,然后在done
管道上發送同步信號,隨后從done
接收對應的同步信號,最后執行println
函數。
若在關閉Channel后繼續從中接收數據,接收者就會收到該Channel返回的零值。因此在這個例子中,用close(c)
關閉管道代替done <- false
依然能保證該程序產生相同的行為。
var done = make(chan bool)
var msg string
func aGoroutine() {
msg = "你好, 世界"
close(done)
}
func main() {
go aGoroutine()
<-done
println(msg)
}
對于從無緩沖Channel進行的接收,發生在對該Channel進行的發送完成之前。
基于上面這個規則可知,交換兩個Goroutine中的接收和發送操作也是可以的(但是很危險):
var done = make(chan bool)
var msg string
func aGoroutine() {
msg = "hello, world"
<-done
}
func main() {
go aGoroutine()
done <- true
println(msg)
}
也可保證打印出“hello, world”。因為main
線程中done <- true
發送完成前,后臺線程<-done
接收已經開始,這保證msg = "hello, world"
被執行了,所以之后println(msg)
的msg已經被賦值過了。簡而言之,后臺線程首先對msg
進行寫入,然后從done
中接收信號,隨后main
線程向done
發送對應的信號,最后執行println
函數完成。但是,若該Channel為帶緩沖的(例如,done = make(chan bool, 1)
),main
線程的done <- true
接收操作將不會被后臺線程的<-done
接收操作阻塞,該程序將無法保證打印出“hello, world”。
對于帶緩沖的Channel,對于Channel的第K
個接收完成操作發生在第K+C
個發送操作完成之前,其中C
是Channel的緩存大小。 如果將C
設置為0自然就對應無緩存的Channel,也即使第K個接收完成在第K個發送完成之前。因為無緩存的Channel只能同步發1個,也就簡化為前面無緩存Channel的規則:對于從無緩沖Channel進行的接收,發生在對該Channel進行的發送完成之前。
我們可以根據控制Channel的緩存大小來控制并發執行的Goroutine的最大數目, 例如:
var limit = make(chan int, 3)
func main() {
for _, w := range work {
go func() {
limit <- 1
w()
<-limit
}()
}
select{}
}
最后一句select{}
是一個空的管道選擇語句,該語句會導致main
線程阻塞,從而避免程序過早退出。還有for{}
、<-make(chan int)
等諸多方法可以達到類似的效果。因為main
線程被阻塞了,如果需要程序正常退出的話可以通過調用os.Exit(0)
實現。
不靠譜的同步
前面我們已經分析過,下面代碼無法保證正常打印結果。實際的運行效果也是大概率不能正常輸出結果。
func main() {
go println("你好, 世界")
}
剛接觸Go語言的話,可能希望通過加入一個隨機的休眠時間來保證正常的輸出:
func main() {
go println("hello, world")
time.Sleep(time.Second)
}
因為主線程休眠了1秒鐘,因此這個程序大概率是可以正常輸出結果的。因此,很多人會覺得這個程序已經沒有問題了。但是這個程序是不穩健的,依然有失敗的可能性。我們先假設程序是可以穩定輸出結果的。因為Go線程的啟動是非阻塞的,main
線程顯式休眠了1秒鐘退出導致程序結束,我們可以近似地認為程序總共執行了1秒多時間。現在假設println
函數內部實現休眠的時間大于main
線程休眠的時間的話,就會導致矛盾:后臺線程既然先于main
線程完成打印,那么執行時間肯定是小于main
線程執行時間的。當然這是不可能的。
嚴謹的并發程序的正確性不應該是依賴于CPU的執行速度和休眠時間等不靠譜的因素的。嚴謹的并發也應該是可以靜態推導出結果的:根據線程內順序一致性,結合Channel或sync
同步事件的可排序性來推導,最終完成各個線程各段代碼的偏序關系排序。如果兩個事件無法根據此規則來排序,那么它們就是并發的,也就是執行先后順序不可靠的。
解決同步問題的思路是相同的:使用顯式的同步。