網(wǎng)站首頁 編程語言 正文
1. 簡介(Introduction)
官方原文
本文翻譯了原文并加入了自己的理解。
主要介紹多個 Go協(xié)程之間對同一個變量并發(fā)讀寫時需要注意的同步措施和執(zhí)行順序問題。并列出幾個常見錯誤。
Go 內(nèi)存模型涉及到多個 Go協(xié)程之間對同一個變量的讀寫。
假如有一個變量,其中一個?Go協(xié)程(a)?寫這個變量,另一個?Go協(xié)程(b)?讀這個變量;Go 內(nèi)存模型定義了什么情況下?Go協(xié)程(b)?能夠確保讀取到由?Go協(xié)程(a)?寫入的值。
2. 建議(Advice)
- 如果多協(xié)程并發(fā)修改數(shù)據(jù),必須保證各個步驟串行執(zhí)行(序列化訪問)。
- 為了串行執(zhí)行,可以使用?
channel
?或其他同步原語( 如?sync
?和?sync/atomic
?兩個包里的那些)來保護被共享的數(shù)據(jù)。
3. 發(fā)生在…之前(Happens Before)
除了重排序需要理解,其余概念其實沒那么重要,看后面的例子就懂了。
3.1 重排序
當只有一個 Go協(xié)程時,對同一個變量的讀寫必然是按照代碼編寫的順序來執(zhí)行的。對于多個變量的讀寫,如果重新排序不影響代碼邏輯的正常執(zhí)行,編譯器和處理器可能會對多個變量的讀寫過程重新排序。
比如對于?a = 1; b = 2
?這兩個語句,在同一個 Go協(xié)程里先執(zhí)行 哪個其實是沒有區(qū)別的,只要最后執(zhí)行結(jié)果正確就行。
a := 1//1 b := 2//2 c := a + b //3
但是,因為重新排列執(zhí)行順序的情況的存在,會導致**某個 Go協(xié)程所觀察到的執(zhí)行順序可能與另一個 Go協(xié)程觀察到的執(zhí)行順序不一樣。**可能另一個 Go協(xié)程 觀察到的事實是 b 的值先被更新,而 a 的值被后更新。
3.2 happens-before
為了表征讀寫需求,我們可以定義?happens-before,用來表示 Go 語言中某一小段內(nèi)存命令的執(zhí)行順序。
- 如果事件 e1 發(fā)生在事件 e2 之前,此時我們就認為 e2 發(fā)生在 e1 之后。
- 如果事件 e1 既不發(fā)生在事件 e2 之前,也不發(fā)生在 e2 之后,此時我們就認為 e1 和 e2 同時發(fā)生(并發(fā))(并發(fā) ≠ 并行)。
3.3 規(guī)則
在只有一個 Go協(xié)程的內(nèi)部,happens-before的順序就是代碼顯式定義的順序。當 Go協(xié)程 不僅僅局限在一個的時候,存在下面兩個規(guī)則:
- 如果存在一個變量?
v
,下面的兩個條件都滿足,則讀操作?r
?允許觀察到(可能觀察到,也可能觀察不到)寫操作?w
?寫入的值。
-
r
?不在?w
?之前發(fā)生; - 不存在其他的?
w’
?在?w
?之后發(fā)生,也不存在?w’
?在?r?
之前發(fā)生。
- 為了保證讀操作?
r
?讀取到的是寫操作?w
?寫入的值,需要確保?w
?是唯一允許被?r
?觀察到的寫操作。如果下面的兩個條件都滿足,則?r
?保證能夠觀察到?w
?寫入的值:
-
w
?發(fā)生在?r
?之前; - 其他對共享變量?
v
?的寫操作要么發(fā)生在?w
?之前,要么發(fā)生在?r
?之后。
規(guī)則二的條件比規(guī)則一的條件更為嚴格,它要求沒有其他的寫操作和 w、r 并發(fā)地發(fā)生。
在一個 Go協(xié)程 里是不存在并發(fā)的,因此規(guī)則一和規(guī)則二是等效的:讀操作 r 可以觀察到最近一次寫操作 w 寫入的值。
但是,當多個協(xié)程訪問一個共享變量時,就必須使用同步事件來構(gòu)建 happens-before 的條件,從而保證讀操作觀察到的一定是想要的寫操作。
在內(nèi)存模型中,變量?v
?的零值初始化操作等同于一個寫操作。
如果變量的值大于單機器字(CPU 從內(nèi)存單次讀取的字節(jié)數(shù)),那么 CPU 在讀和寫這個變量的時候是以一種不可預知順序的多次執(zhí)行單機器字的操作,這也是?sync/atomic?包存在的價值。
4. 同步(Synchronization)
4.1 初始化(Initialization)
程序的初始化是在一個單獨的 Go協(xié)程 中進行的,但是這個協(xié)程可以創(chuàng)建其他的 Go協(xié)程 并且二者并發(fā)執(zhí)行。
每個包都允許有一個?init
?函數(shù),當這個包被導入時,會執(zhí)行該包的這個?init
?函數(shù),做一些初始化任務。
- 如果一個包?
p
?導入了包?q
, 那么?q
?的?init
?函數(shù)的執(zhí)行發(fā)生在?p
的所有?init
?函數(shù)的執(zhí)行之前。(即包的引用鏈) - 函數(shù)?
main.main
?的執(zhí)行發(fā)生在所有的?init
?函數(shù)執(zhí)行完成之后。
4.2 Go協(xié)程的創(chuàng)建(Goroutine creation)
通過?go
?語句啟動新的 Go協(xié)程這個動作,發(fā)生在新的 Go協(xié)程的執(zhí)行之前。比如下面的例子:
var a string func f() { print(a) } func hello() { a = "hello, world" go f() }
調(diào)用函數(shù)?hello
?會在調(diào)用后的某個時間點打印 “hello, world” ,這個時間點可能在 hello 函數(shù)返回之前,也可能在 hello 函數(shù)返回之后。
4.3 Go協(xié)程的銷毀(Goroutine destruction)
Go協(xié)程的退出無法確保發(fā)生在程序的某個事件之前。比如下面的例子:
var a string func hello() { go func() { a = "hello" }() print(a) }
其中 a 的賦值語句沒有任何的同步措施,因此無法保證被其他任意的 Go 協(xié)程(例如?hello
?函數(shù)本身)觀察到這個賦值事件的存在。
一些激進的編譯器可能會在編譯階段刪除上面代碼中的整個 go 語句。
如果某個 Go協(xié)程 里發(fā)生的事件必須要被另一個 Go協(xié)程 觀察到,需要使用同步機制進行保證,比如使用鎖或者信道(channel)通信來構(gòu)建一個相對的事件發(fā)生順序。
4.4 信道通信(Channel communication)
這部分介紹通過 channel 實現(xiàn)并發(fā)順序控制。
有緩存channel
信道通信是多個 Go協(xié)程 間事件同步的主要方式。在某個特定的信道上發(fā)送一個數(shù)據(jù),則對應地可以在這個信道上接收一個數(shù)據(jù),一般情況下是在不同的 Go協(xié)程 間發(fā)送與接收。
- 規(guī)則一:在某個信道上發(fā)送數(shù)據(jù)的事件發(fā)生在相應的接收事件之前。
即一定是先發(fā)送數(shù)據(jù),才能接收到數(shù)據(jù)這個順序。
var c = make(chan int, 10) var a string func f() { a = "hello, world" c <- 0 } func main() { go f() <-c print(a) }
?上面這段代碼保證了 `hello, world` 的打印。因為信道的寫入事件 `c <- 0` 發(fā)生在讀取事件 `<-c` 之前,而 `<-c` 發(fā)生在 `print(a)`之前。信道未被讀取時協(xié)程會阻塞。
- 規(guī)則二:信道的關(guān)閉事件發(fā)生在從信道接收到零值(由信道關(guān)閉觸發(fā))之前。
即一定是先關(guān)閉 channel,才能接收到零值。
在前面的例子中,可以使用?close(c)
?來替代?c <- 0
?語句來保證同樣的效果。
無緩存 channel
- 規(guī)則三:對于沒有緩存的信道,數(shù)據(jù)的接收事件發(fā)生在數(shù)據(jù)發(fā)送完成之前。
即信道容量為0時,只有發(fā)送的信息被讀取了才算發(fā)送成功,否則阻塞。
比如下面的代碼(類似上面給出的代碼,但是使用了沒有緩存的信道,且發(fā)送和接收的語句交換了一下):
var c = make(chan int) //容量為0,無緩存 var a string func f() { a = "hello, world" <-c } func main() { go f() c <- 0 print(a) }
上面這段代碼依然可以保證可以打印 `hello, world`。因為信道的寫入事件 `c <- 0` 發(fā)生在讀取事件 `<-c` 之前,而 `<-c` 發(fā)生在寫入事件 `c <- 0` 完成之前,同時寫入事件 `c <- 0` 的完成發(fā)生在 `print` 之前。
上面的代碼,如果信道是帶緩存的(比如 `c = make(chan int, 1)`),程序?qū)⒉荒鼙WC會打印出 `hello, world`,它可能會打印出空字符串,也可能崩潰退出,或者表現(xiàn)出一些其他的癥狀。
規(guī)則抽象
- 規(guī)則四:對于容量為 C 的信道,接收第 k 個元素的事件發(fā)生在第 k+C 個元素的發(fā)送之前。
規(guī)則四是規(guī)則三在帶緩存的信道上的推廣。 - 它使得帶緩存的信道可以模擬出計數(shù)信號量:**信道中元素的個數(shù)表示活躍數(shù),信道的容量表示最大的可并發(fā)數(shù);發(fā)送一個元素意味著獲取一個信號量,接收一個元素意味著釋放這個信號量。**這是一種常見的限制并發(fā)的用法。
- 下面的代碼給工作列表中的每個入口都開啟一個 Go協(xié)程,但是通過配合一個固定長度的信道保證了同時最多有 3 個運行的工作(最多 3 個并發(fā))。
var limit = make(chan int, 3) func main() { for _, w := range work { go func(w func()) { limit <- 1 // channel里達到3個即阻塞 w() <-limit // 取出后channel里小于3個即可繼續(xù) }(w) } select{} }
4. 鎖
包?sync
?實現(xiàn)了兩類鎖數(shù)據(jù)類型,分別是?sync.Mutex
?和?sync.RWMutex
,即互斥鎖和讀寫鎖。
- 規(guī)則一:對于類型為?
sync.Mutex
?和?sync.RWMutex
?的變量?l
,如果存在 n 和 m 且滿足?n < m
,則?l.Unlock()
?的第 n 次調(diào)用返回發(fā)生在l.Lock()
?的第 m 次調(diào)用返回之前。
即先解開上一次鎖才能上這一次鎖。
比如下面的代碼:
var l sync.Mutex var a string func f() { a = "hello, world" l.Unlock() } func main() { l.Lock() go f() l.Lock() print(a) }
上面這段代碼保證能夠打印 `hello, world`。`l.Unlock()`的第 1 次調(diào)用返回(在函數(shù) f 內(nèi)部)發(fā)生在 `l.Lock()` 的第 2 次調(diào)用返回之前,后者發(fā)生在 `print` 之前。?
- 規(guī)則二:存在類型?
sync.RWMutex
?的變量?l
,如果?l.RLock
?的調(diào)用返回發(fā)生在?l.Unlock
?的第 n 次調(diào)用返回之后,那么其對應的?l.RUnlock
?發(fā)生在?l.Lock
?的第 n+1 次調(diào)用返回之前。
即讀鎖可以上多次,但是只要沒有全解開就不能上寫鎖,寫鎖只能上一個,不解開讀寫鎖都不能上。
5. 單次運行
包?sync
?還提供了?Once
?類型用來保證多協(xié)程的初始化的安全。
多個 Go協(xié)程 可以并發(fā)執(zhí)行?once.Do(f)
?來執(zhí)行函數(shù)?f
, 且只會有一個 Go協(xié)程會運行?f()
,其他的 Go 協(xié)程會阻塞到?f()
?運行結(jié)束(不再執(zhí)行?f
,但能得到運行結(jié)果)
- 規(guī)則一:函數(shù)?
f()
?在?once.Do(f)
?的單次調(diào)用返回發(fā)生在其他所有的?once.Do(f)
?調(diào)用返回之前。
比如下面的代碼:
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
方法只運行了一次 - 函數(shù)?
setup
?函數(shù)的執(zhí)行返回發(fā)生在所有的?print
?調(diào)用之前,同時會打印出兩次?hello, world
,即當一個goroutine在執(zhí)行setup
方法時候,另外一個在阻塞。
6. 不正確的同步方式
6.1 案例一
對某個變量的讀操作 r 一定概率可以觀察到對同一個變量的并發(fā)寫操作 w,但是即使這件事情發(fā)生了,也并不意味著發(fā)生在 r 之后的其他讀操作可以觀察到發(fā)生在 w 之前的其他寫操作。(這里的先后指的是代碼里面聲明的操作的先后順序,而不是實際執(zhí)行時候的)
比如下面的代碼:
var a, b int func f() { a = 1 b = 2 } func g() { print(b) print(a) } func main() { go f() g() }
上面的代碼里函數(shù)?g
?可能會先打印 2(b的值),然后打印 0(a的值)。可能大家會認為既然 b 的值已經(jīng)被賦值為 2 了,那么 a 的值肯定被賦值為 1 了,但事實是兩個事件的先后在這里是沒有辦法確定的,因為編譯器會改變執(zhí)行順序。
上面的事實可以證明下面的幾個常見的錯誤。
6.2 案例二
雙重檢查鎖定嘗試避免同步帶來的開銷。比如下面的例子,twoprint 函數(shù)可能會被錯誤地編寫為:
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() }
在?doprint
?函數(shù)中,觀察到對?done
?的寫操作并不意味著能夠觀察到對?a
?的寫操作。上面的寫法依然有可能打印出空字符串。
6.3 案例三
另一個常見的錯誤用法是對某個值的循環(huán)檢查,比如下面的代碼:
var a string var done bool func setup() { a = "hello, world" done = true } func main() { go setup() for !done { } print(a) }
和上一個例子類似,main
函數(shù)中觀察到對?done
?的寫操作并不意味著可以觀察到對?a
?的寫操作,因此上面的代碼依然可能會打印出空字符串。
更糟糕的是,由于兩個 Go協(xié)程之間缺少同步事件,main
?函數(shù)甚至可能永遠無法觀察到對?done
?變量的寫操作,導致?main
?中的?for
?循環(huán)永遠執(zhí)行下去。
上面這個錯誤有一種變體,如下面的代碼所示:
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
?函數(shù)觀察到?g != nil
并且退出了它的?for
?循環(huán),依然沒有辦法保證它可以觀察到被初始化的?g.msg
?值。
避免上面幾個錯誤用法的方式是一樣的:顯式使用同步語句。
7. 總結(jié)
通過上面所有的例子,不難看出解決多goroutine下共享數(shù)據(jù)可見性問題的方法是在訪問共享數(shù)據(jù)時候施加一定的同步措施。
原文鏈接:https://zhuanlan.zhihu.com/p/506088064
相關(guān)推薦
- 2022-03-16 C#?使用Fluent?API?創(chuàng)建自己的DSL(推薦)_C#教程
- 2023-10-25 el-tree設置選中高亮/焦點高亮、選中的節(jié)點加深背景,更改字體顏色等
- 2023-06-04 pandas.DataFrame?Series排序的使用(sort_values,sort_inde
- 2022-12-09 ReactQuery系列之數(shù)據(jù)轉(zhuǎn)換示例詳解_React
- 2022-05-08 使用Visual?Studio編寫單元測試_實用技巧
- 2022-05-28 python非單一.py文件用Pyinstaller打包發(fā)布成exe_python
- 2024-03-23 css動態(tài)樣式的幾種常見寫法
- 2024-07-18 restTemplate使用總結(jié)
- 最近更新
-
- window11 系統(tǒng)安裝 yarn
- 超詳細win安裝深度學習環(huán)境2025年最新版(
- Linux 中運行的top命令 怎么退出?
- MySQL 中decimal 的用法? 存儲小
- get 、set 、toString 方法的使
- @Resource和 @Autowired注解
- Java基礎(chǔ)操作-- 運算符,流程控制 Flo
- 1. Int 和Integer 的區(qū)別,Jav
- spring @retryable不生效的一種
- Spring Security之認證信息的處理
- Spring Security之認證過濾器
- Spring Security概述快速入門
- Spring Security之配置體系
- 【SpringBoot】SpringCache
- Spring Security之基于方法配置權(quán)
- redisson分布式鎖中waittime的設
- maven:解決release錯誤:Artif
- restTemplate使用總結(jié)
- Spring Security之安全異常處理
- MybatisPlus優(yōu)雅實現(xiàn)加密?
- Spring ioc容器與Bean的生命周期。
- 【探索SpringCloud】服務發(fā)現(xiàn)-Nac
- Spring Security之基于HttpR
- Redis 底層數(shù)據(jù)結(jié)構(gòu)-簡單動態(tài)字符串(SD
- arthas操作spring被代理目標對象命令
- Spring中的單例模式應用詳解
- 聊聊消息隊列,發(fā)送消息的4種方式
- bootspring第三方資源配置管理
- GIT同步修改后的遠程分支