寫在前面的話
數據競態:當存在兩個或以上的goroutine,對一個數據,同時進行讀寫操作時(非原子操作),我們稱之為該數據存在竟態。在竟態條件下,讀者可能讀到非一致性的數據:比如一個結構體數據,goroutine1 正在讀v1版的數據;此時goroutine2 進行數據更新到v2;導致goroutine1讀取到的數據部分是v1版的數據,部分是v2版的數據。
當多個goroutine操作存在竟態的數據時,必須將并發操作進行順序化,避免出現數據不一致。在golang中可以實現操作順序化的方法有:基于通道(channel)通信,使用同步原語(如sync包中的Mutex、RWMutex等),使用原子操作(sync/atomic)等。
另一方面,Golang內存模型,定義了一些限制條件,當程序遵循這些限制條件時,就可以實現數據免竟態。這些限制條件被稱之為“Hanpens Before”規則。我們將在下一章重點說明。
警告:
理解和掌握golang 的 Hanpens Before規則,有助于我們了解哪些情況下,數據的可見性是有保障,而哪些情況下是不確定的的,從而避免跌入數據不可見的迷障。另一方面,我們在設計和實現程序時,不要過度依賴golang的Hanpens Before規則,而使程序變得晦澀難懂。
Hanpens Before (先行發生)
理解Hanpens Before
為了提高程序執行時的效率,編譯器和處理器常常會對指令做重排序。重排序分三種類型:
(1)編譯器優化的重排序:編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序。
(2)指令級并行的重排序:現代處理器采用了指令級并行技術(Instruction-Level Parallelism, ILP)來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。
(3)內存系統的重排序: 由于處理器使用緩存和讀/寫緩沖區,這使得加載和存儲操作看上去可能是在亂序執行。
在一個goroutine中,讀和寫必須按照程序指定的順序執行。也就是說,只有當指令重排沒有改變既定的代碼的邏輯順序時,編譯器和處理器才可以重新排序在單個goroutine中執行的讀寫操作。反過來講,只要程序的既定邏輯沒有被改變,那么編譯器和處理器就可以按需要對指令進行重新排序。由于這種重新排序,一個goroutine觀察到的執行順序可能與另一個goroutine感知到的執行順序不同。例如:
有全局變量a 和b,在goroutine1 中有如下代碼:
// in goroutine1
a = 10 // action 1
b = 1 // action 2
c = a + b // action 3
fmt.Println(c) // action 4
同時,在goroutine2 中,讀取a和b的內容:
// in goroutine2
for {
if b == 1 {
fmt.Println(a)
break
}
}
按程序表達,goroutine2中,當 b == 1 達成時,a應該已經被賦值了,程序期望輸出 a的值是10。 但實際運行時,卻發現輸出a 的值有時是10,有時是0。 這是因為,在goroutine1 中,a和b的賦值操作,并沒有邏輯依賴關系,所以編譯器和處理器可以對該兩條指令進行重新排序,導致實際b=1 可能先于a =10 被執行;此時goroutine2中,看到b==1 已經達成,但此時a卻依舊保持初始值0(這里有兩種情況會導致看見a==0,其一,goroutine1 尚未執行a=10;其二,goroutine1執行了a=10,但由于CPU cache 緩存,goroutine2 并未取到更新后的a的值)。
這種場景下,我們說:
“action 1 Hanpens Before action 2 條件不必要”。
當 Hanpens Before 條件不滿足,且沒有額外引入同步機制確保操作順序時,程序就可能出現未知狀態。例如一個經典范式:
var(
inited = false
config *Config
)
func doInit() {
c := loadConfig()
if c == nil {
panic("load config error")
}
config =c
inited = true
}
func main() {
go doInit()
for !inited {
// wait init done
}
doWithConfig(config)
}
上面的例子,是一個經典的異步初始化的模型范例,甚至在一些生產代碼也可以見到同樣的應用。但是,上面的程序,有可能出現在main流程中,等待初始化成功后,執行后面操作時,卻得到config是一個空指針而引發程序崩潰。
繼續回到本節開篇的例子,在goroutine1 中對 a,b,c 進行賦值,現在有goroutine3 ,代碼如下:
// in goroutine 3
for {
if c == 11 {
fmt.Println(a)
break
}
}
運行程序發現,goroutine3 打印a 的值是明確的,都是10。這是因為,在goroutine1 中, c= a+b 要求在對c 賦值之前,a 和b 必須要完成賦值。即:
action 1 和action 2 Hanpens Before action 3 條件必要
總結
Hanpens Before 描述了兩個行為的明確的先后關系: actionA 先于 actionB 發生。這種關系,通常是有一些基礎策略,邏輯依賴,實現原理所決定的。比如,golang中,程序啟動的流程為:
(1)從main 包開始執行,先引入所依賴的包;所述依賴包還有依賴,則遞歸引入依賴;
(2)引入依賴包后,執行該依賴包的init函數(如果有);
(3)從main 包的main函數開始執行。
通過以上流程,不難得出:
(1)加載程序所有依賴包 Hanpens Before main()函數開始執行;
(2)依賴包的init函數執行完畢 Hanpens Before main() 函數開始執行;
(3)在加載過程中,軟件包完成其依賴包的加載 Hanpens Before 該軟件包init()函數開始執行。
另一方面,當兩個行為或過程滿足 Hanpens Before 的必要條件時,說明他們的執行順序是明確有序的,它們就不會出現數據靜態,無需額外的同步機制。
下面我們將詳細說明,golang中那些過程是滿足Hanpens Before 條件的。
golang 中符合Hanpens Before的過程組合
Go的初始化
程序初始化運行在單個goroutine(主協程)中,并且該goroutine能夠創立其余并發運行的goroutine。
- 如果包p導入了包q,則q包init函數執行完結 先于 p包init函數的執行。
- main函數的執行發生在所有init函數執行完成之后。
goroutine的創建和退出
- goroutine的創建先于goroutine的執行。
- (注意)goroutine的退出不與程序中的任何過程形成Hanpens Before必要條件;即goroutine的退出是無法預測的。如果用一個goroutine察看另一個goroutine的退出,請應用鎖(waitgroup)或者Channel來保障絕對有序。
關于goroutine的創建 先于 goroutine 的執行,感覺上像句廢話;個人理解是:創建goroutine 的語句(尤其是復合語句)的執行,一定先于goroutine開始執行之前。舉例:
func delay( sec int) int {
time.Sleep(time.Second * sec)
return sec
}
go fmt.Println(delay(1))
上述代碼執行時,創建goroutine的過程會因為調用delay 函數而阻塞 main goroutine 一秒鐘。
channel的發送和接收
- 對channel 的send 動作開始 先于 對channel的接收完成
見下面的示例代碼:
var c = make(chan int, 10)
var a string
func f() {
a = "hello, world"
c <- 0
}
func main() {
go f()
<-c
print(a)
}
上面的程序 打印的輸出一定是“heallo,world”。因為主協程是在讀取到管道返回值后,才讀取a的值;當管道能夠讀取到值時,寫管道的動作一定先前發生了;f協程中,對a賦值先于寫channel(這點是程序的檢測點機制,不展開);因而可以推論出:對a的賦值 先于 對a的讀取。因此該程序的讀寫流程是嚴格同步的。
- 對channel的關閉動作 先于 讀端因為channel已關閉而讀到一個零值
見下面的示例代碼:
var c = make(chan int, 10)
var a string
func f() {
a = "hello, world"
close(c)
}
func main() {
go f()
<-c
print(a)
}
上面的程序 打印的輸出一定是“heallo,world”。因為讀取端獲取到返回值時,f協程的close動作一定先發生了。
- 在一個無緩存的channel上,接收完成 先于 發送完成
var c = make(chan int)
var a string
func f() {
a = "hello, world"
<-c
}
func main() {
go f()
c <- 0
print(a)
}
- 容量為C的channel,第k個接收完成 先于 第k +C個發送完成前
本質上,是帶緩沖的channel當buffer滿后,發生端會阻塞等待一個buffer空閑后、完成寫入后返回。應用該原理,一個限制并發數的調度隊列可以簡單實現為:
var limit = make(chan int, 3)
func scheduleWorker(w interface{}) {
limit <- 1
w()
<-limit
}
func main() {
for _, w := range work {
go scheduleWorker(w)
}
select{}
}
上面代碼, scheduleWorker中,worker 被調度后,先嘗試往channel中寫入一個標記;如果此時buffer 未滿則可以順利寫入(類似于獲取令牌),程序繼續執行真正的worker任務;相反,如果channel的buffer已滿(已有三個任務正在執行),那么當前任務會阻塞在channel的寫入上,直到其他任務執行完畢后,從channel中讀取一個數據(類似于補充令牌),觸發寫端繼續寫入。
鎖
- 對于任何sync.Mutex或sync.RWMutex變量l以及n < m,第n次l.Unlock()的調用先于第m次l.Lock()的調用返回。
見下面示例代碼:
var l sync.Mutex
var a string
func f() {
a = "hello, world"
l.Unlock()
}
func main() {
l.Lock() // 第一次鎖
go f() // 執行一次解鎖
l.Lock() // 第二次鎖
print(a)
}
上面程序輸出a 的值“hello, world”。主協程中,第一次獲取鎖成功,當第二次再嘗試獲取鎖時,程序被阻塞,直到鎖狀態被Unlock解除。這樣,a的寫入和讀取是嚴格有序的。
- 對于讀寫鎖l(sync.RWMutex),當有寫鎖存在時,任意的讀加鎖(l.RLock)返回 在 所有寫鎖解鎖(l.Unlock)完成之后
- 對于讀寫鎖l(sync.RWMutex), 當有讀鎖存在時,任意的寫加鎖(l.Lock)返回 在 所有的讀鎖解鎖(l.RUnlock)完成之后
總結讀寫鎖的持鎖規則:
(1)當存在讀加鎖(l.Rlock)時,新的讀加鎖無需等待之前的讀鎖解鎖,即可并行持鎖(相較與互斥鎖的主要改進);
(2)當存在讀加鎖(l.Rlock)時,寫鎖(l.Lock)必須等待所有的讀鎖解鎖(l.RUnlock)完成后,方能持鎖;
(3)當存在寫加鎖(l.Lock)時,讀鎖(l.Rlock)必須等待所有的寫鎖解鎖(l.Unlock)完成后,方能持鎖;
(4)當存在寫加鎖(l.Lock)時,寫鎖(l.Lock)必須等待所有的寫鎖解鎖(l.Unlock)完成后,方能持鎖。
Once
- 使用once進行初始化,once.do(f)的返回,一定在 f函數執行完成之后。
once本質上是一個封裝的單例模式,內部采用了互斥鎖,確保多個goroutine 并發調用once.do進行初始化,其中只有一個協程可以獲取到執行f的權限;其余協程都等待f執行完成后方可以返回。
原子操作
- sync/atomic中提供的API 可以在并發協程中調用而保持嚴格的可見性。
Finalizers
- 通過runtime包對實例設置 finalizers函數,SetFinalizer(x, f) 完成 先于 f 函數的執
這個與創建協程的邏輯一致。