并發(fā),如果無法確定一個事件先于另外一個事件,那么這兩個事件就是并發(fā)的。
并發(fā)安全(concurrency-safe),如果一個函數(shù)在并發(fā)調(diào)用時仍然能正確工作,那么這個函數(shù)就是并發(fā)安全的。如果一個類型的所有可訪問方法和操作都是并發(fā)安全的,則它可稱為并發(fā)安全的類型。
并發(fā)安全的類型是特例而不是普遍存在的,對于絕大部分變量,如要回避并發(fā)訪問,只有下面幾種辦法:
競態(tài)是指在多個 goroutine 按某些交錯順序執(zhí)行時程序無法給出正確的結(jié)果。
數(shù)據(jù)競態(tài)(data race)是競態(tài)的一種。數(shù)據(jù)競態(tài)發(fā)生于兩個 goroutine 并發(fā)讀寫同一個變量并且至少其中一個是寫入時。有三種方法來避免數(shù)據(jù)競態(tài):
Go 箴言:“不要通過共享內(nèi)存來通信,而應(yīng)該通過通信來共享內(nèi)存”。
使用緩沖通道可以實(shí)現(xiàn)一個計(jì)數(shù)信號量,可以用于同時發(fā)起的 goroutine 的數(shù)量。一個計(jì)數(shù)上限為 1 的信號量稱為二進(jìn)制信號量(binary semaphore)。
使用二進(jìn)制信號量就可以實(shí)現(xiàn)互斥鎖:
var (
sema = make(chan struct{}, 1) // 用來保護(hù) balance 的二進(jìn)制信號量
balance int
)
func Deposit(amount int) {
sema <- struct{}{} // 獲取令牌
balance = balance + amount
<-sema // 釋放令牌
}
func Balance() int {
sema <- struct{}{} // 獲取令牌
b := balance
<-sema // 釋放令牌
return b
}
互斥鎖模式應(yīng)用非常廣泛,所以 sync 包有一個單獨(dú)的 Mutex 類型來支持這種模式:
import "sync"
var (
mu sync.Mutex // 保護(hù) balance
balance int
)
func Deposit(amount int) {
mu.Lock()
balance = balance + amount
mu.Unlock()
}
func Balance() int {
mu.Lock()
b := balance
mu.Unlock()
return b
}
互斥量保護(hù)共享變量。按照慣例,被互斥量保護(hù)的變量聲明應(yīng)當(dāng)緊接在互斥量的聲明之后。如果實(shí)際情況不是如此,請加注釋說明。
臨界區(qū)域,在 Lock 和 Unlock 之間的代碼,可以自由地讀取和修改共享變量,這一部分稱為臨界區(qū)域。
封裝,即通過在程序中減少對數(shù)據(jù)結(jié)構(gòu)的非預(yù)期交互,來幫助我們保證數(shù)據(jù)結(jié)構(gòu)中的不變量。類似的原因,封裝也可以用來保持并發(fā)中的不變性。所以無論是為了保護(hù)包級別的變量,還是結(jié)構(gòu)中的字段,當(dāng)使用一個互斥量時,都請確?;コ饬勘旧硪约氨槐Wo(hù)的變量都沒有導(dǎo)出。
多讀單寫鎖,允許只讀操作可以并發(fā)執(zhí)行,但寫操作需要獲得完全獨(dú)享的訪問權(quán)限。Go 語言中的 sync.RWMutex 提供了這種功能:
var mu sync.RWMutex
var balance int
func Balance() int {
mu.RLock() // 讀取
defer mu.RUnlock()
return balance
}
Balance 函數(shù)可以調(diào)用 mu.RLock 和 mu.RUnlock 方法來分別獲取和釋放一個讀鎖(也稱為共享鎖)。而之前的 mu.Lock 和 mu.Unlock 方法則是分別獲取和釋放一個寫鎖(也稱為互斥鎖)。
一般情況下,不應(yīng)該假定那些邏輯上只讀的函數(shù)和方法不會更新一些變量。比如,一個看起來只是簡單訪問的方法,可能會遞增內(nèi)部使用的計(jì)數(shù)器,或者更新一個緩存來讓重復(fù)的調(diào)用更快。如果不確定,就應(yīng)該使用互斥鎖。
讀鎖的應(yīng)用場景
僅在絕大部分 goroutine 都在獲取讀鎖并且鎖競爭比較激烈時,RWMutex 才有優(yōu)勢。因?yàn)?RWMutex 需要更復(fù)雜的內(nèi)部實(shí)現(xiàn),所以在競爭不激烈時它比普通的互斥鎖慢。
現(xiàn)代的計(jì)算機(jī)一般會有多個處理器,每個處理器都有內(nèi)存的本地緩存。為了提高效率,對內(nèi)存的寫入是緩存在每個處理器中的,只在必要時才刷回內(nèi)存。甚至刷會內(nèi)存的順序都可能與 goroutine 的寫入順序不一致。像通道通信或者互斥鎖操作這樣的同步源語都會導(dǎo)致處理器把累積的寫操作刷回內(nèi)存并提交。但這個時刻之前 goroutine 的執(zhí)行結(jié)果就無法保證能被運(yùn)行在其他處理器的 goroutine 觀察到。
考慮如下的代碼片段可能的輸出:
var x, y int
go func() {
x = 1
fmt.Print("y:", y, " ")
}
go func() {
y = 1
fmt.Print("x:", x, " ")
}
下面4個是顯而易見的可能的輸出結(jié)果:
y:0 x:1
x:0 y:1
x:1 y:1
y:1 x:1
但是下面的輸出也是可能出現(xiàn)的:
x:0 y:0
y:0 x:0
在某些特定的編譯器、CPU 或者其他情況下,這些確實(shí)可能發(fā)生。
單個 goroutine 內(nèi),每個語句的效果保證按照執(zhí)行的順序發(fā)生,也就是說,goroutine 是串行一致的(sequentially consistent)。但在缺乏使用通道或者互斥量來顯式同步的情況下,并不能保證所有的 goroutine 看到的事件順序都是一致的。
上面的兩個 goroutine 盡管打印語句是在賦值另外一個變量之后,但是一個 goroutine 并不一定能觀察到另一個 goroutine 對變量的效果。所以可能輸出的是一個變量的過期值。
盡管很容易把并發(fā)簡單理解為多個 goroutine 中語句的某種交錯執(zhí)行方式。如果兩個 goroutine 在不同的 CPU 上執(zhí)行,每個 CPU 都有自己的緩存,那么一個 goroutine 的寫入操作在同步到內(nèi)存之前對另外一個 goroutine 的打印變量的語句是不可見的。
這些并發(fā)的問題都可以通過采用簡單、成熟的模式來避免,即在可能的情況下,把變量限制到單個 goroutine 中,對于其他變量,使用互斥鎖。
延遲一個昂貴的初始化步驟到有實(shí)際需求的時刻是一個很好的實(shí)踐。預(yù)先初始化一個變量會增加程序的啟動延遲,并且如果實(shí)際執(zhí)行時有可能根本用不上這個變量,那么初始化也不是必需的。
sync 包提供了針對一次性初始化問題的特化解決方案:sync.Once。從概念上來講,Once 包含一個布爾變量和一個互斥量,布爾變量記錄初始化是否已經(jīng)完成,互斥量則負(fù)責(zé)保護(hù)這個布爾變量和客戶端的數(shù)據(jù)結(jié)構(gòu)。Once 唯一的方法 Do 以初始化函數(shù)作為它的參數(shù):
var loadIconsOnce sync.Once
var icons map[string]image.Image
// 這是個昂貴的初始化步驟
func loadIcons() {
icons = map[string]image.Image{
"spades.png": loadIcon("spades.png"),
"hearts.png": loadIcon("hearts.png"),
"diamonds.png": loadIcon("diamonds.png"),
"clubs.png": loadIcon("clubs.png"),
}
}
// 并發(fā)安全
func Icon(name string) image.Image {
loadIconsOnce.Do(loadIcons)
return icons[name]
}
每次調(diào)用 Do 方法時,會先鎖定互斥量并檢查里邊的布爾變量。在第一次調(diào)用時,這個布爾變量為 false,Do 會調(diào)用它參數(shù)的方法,然后把布爾變量設(shè)置為 true。之后 DO 方法的調(diào)用相當(dāng)于空操作,只是通過互斥量的同步來保證初始化操作對內(nèi)存產(chǎn)生的效果對所有的 goroutine 可見。以這種方式來使用 sync.Once,可以避免變量在構(gòu)造完成之前就被其他 goroutine 訪問。
goroutine 與操作系統(tǒng)(OS)線程之間的差異本質(zhì)上屬于量變。但是足夠大的量變會變成質(zhì)變,所以還是要區(qū)分一下兩者的差異。
每個 OS 線程都有一個固定大小的棧內(nèi)存(通常為 2MB),棧內(nèi)存區(qū)域用戶保存在其他函數(shù)調(diào)用期間那些正在執(zhí)行或臨時暫停的函數(shù)中的局部變量。這個固定的大小對小的 goroutine 來說太大了,對于要創(chuàng)建數(shù)量巨大的 goroutine 來說,就會有巨大的浪費(fèi)。另外,對于更復(fù)雜或者深度遞歸的函數(shù),固定大小的棧又會不夠大。改變這個固定大小,調(diào)小了可以允許創(chuàng)建更多的線程,改大了則可以容許更深的遞歸,但兩者無法同時兼容。
gotouine 也用于存放那些正在執(zhí)行或臨時暫停的函數(shù)中的局部變量。但棧的大小不是固定的,它可與按需增大或縮小。goroutine 的棧大小限制可以達(dá)到 1GB。當(dāng)然,只有極少的 goroutine 會使用這么大的棧。
OS線程調(diào)度器
OS線程由OS內(nèi)核來調(diào)度。每隔幾毫秒,一個硬件時鐘中斷發(fā)送到CPU、CPU調(diào)用一個叫調(diào)度器的內(nèi)核函數(shù)。這個函數(shù)暫停當(dāng)前正在運(yùn)行的線程,把它的寄存器信息保存到內(nèi)存,查看線程列表并決定接下來運(yùn)行哪一個線程,再從內(nèi)存恢復(fù)線程的注冊表信息,最后繼續(xù)執(zhí)行選中的線程。因?yàn)镺S線程由內(nèi)核來調(diào)度,所以控制權(quán)限從一個線程到另外一個線程需要一個完整的上下文切換(context switch):即保存一個線程的狀態(tài)到內(nèi)存,再恢復(fù)另外一個線程的狀態(tài)、最后更新調(diào)度器的數(shù)據(jù)結(jié)構(gòu)??紤]這個操作涉及的內(nèi)存局域性以及涉及的內(nèi)存訪問數(shù)量,還有訪問內(nèi)存所需的CPU周期數(shù)量的增加,這個操作其實(shí)是很慢的。
Go調(diào)度器
Go 運(yùn)行時包含一個自己的調(diào)度器,這個調(diào)度器使用一個稱為m:n 調(diào)度的技術(shù)(因?yàn)樗梢詮?fù)用/調(diào)度 m 個 goroutine 到 n 個OS線程)。Go 調(diào)度器與內(nèi)核調(diào)度器的工作類似,但 Go 調(diào)度器值需關(guān)心單個 Go 程序的 goroutine 調(diào)度問題。
差別
與操作系統(tǒng)的線程調(diào)度器不同的是,Go 調(diào)度器不是由硬件時鐘來定期觸發(fā)的,而是由特定的 Go 語言結(jié)構(gòu)來觸發(fā)的。比如當(dāng)一個 goroutine 調(diào)用 time.Sleep 或被通道阻塞或?qū)コ饬坎僮鲿r,調(diào)度器就會將這個 goroutine 設(shè)為休眠模式,并運(yùn)行其他 goroutine 直到前一個可重新喚醒為止。因?yàn)樗恍枰袚Q到內(nèi)核語境,所以調(diào)用一個 goroutine 比調(diào)度一個線程成本低很多。
Go 調(diào)度器使用 GOMAXPROCS 參數(shù)來確定需要使用多少個OS線程來同時執(zhí)行 Go 代碼,默認(rèn)值是機(jī)器上的CPU數(shù)量(GOMAXPROCS 是 m:n 調(diào)度中的 n)。正在休眠或者正被通道通信阻塞的 goroutine 不需要占用線程。阻塞在 I\/O 和其他系統(tǒng)調(diào)用中或調(diào)用非 Go 語言寫的函數(shù)的 goroutine 需要一個獨(dú)立的OS線程,但這個線程不計(jì)算在 GOMAXPROCS 內(nèi)。
可以用 GOMAXPROCS 環(huán)境變量或者 runtime.GOMAXPROCS 函數(shù)來顯式控制這個參數(shù)。可以用一個小程序來看看 GOMAXPROCS 的效果,這個程序無止境地輸出0和1:
func main() {
var n int
flag.IntVar(&n, "n", 1, "GOMAXPROCS")
flag.Parse()
runtime.GOMAXPROCS(n)
for {
go fmt.Print(0)
fmt.Print(1)
}
}
這里使用命令行參數(shù)來控制線程數(shù)量。
Linux 中應(yīng)該可以直接設(shè)置 GOMAXPROCS 環(huán)境變量來運(yùn)行程序:
$ GOMAXPROCS=1 go run main.go
$ GOMAXPROCS=2 go run main.go
GOMAXPROCS 為1時,每次最多只能由一個 goroutine 運(yùn)行。最開始是主 goroutine,它會連續(xù)輸出很多1。在運(yùn)行了一段時間之后,Go 調(diào)度器讓主 goroutine 休眠,并喚醒另一個輸出0的 goroutine,讓它有機(jī)會執(zhí)行。所以執(zhí)行結(jié)果能看到大段的連續(xù)的0或1。
GOMAXPROCS 為2時,就有兩個可用的OS線程,所以兩個 goroutine 可以同時運(yùn)行,輸出的0和1就會交替出現(xiàn)(我看到的是小段小段的交替)。
在大部分支持多線程的操作系統(tǒng)和編程語言里,當(dāng)前線程都有一個獨(dú)特的標(biāo)識,它通常可以取一個整數(shù)或者指針。這個特性讓我們可以輕松構(gòu)建一個線程的局部存儲,它本質(zhì)上就是一個全局的 map,以線程的標(biāo)識為 key,這樣各個線程都可以獨(dú)立地用這個 map 存儲和獲取值,而不受其他線程的干擾。
goroutine 沒有可供程序員訪問的表示。這個是有設(shè)計(jì)來決定的,因?yàn)榫€程局部存儲有一個被濫用的的傾向。
Go 語言鼓勵一種更簡單的編程風(fēng)格。其中,能影響一個函數(shù)行為的參數(shù)應(yīng)當(dāng)是顯式指定的。
創(chuàng)新互聯(lián)www.cdcxhl.cn,專業(yè)提供香港、美國云服務(wù)器,動態(tài)BGP最優(yōu)骨干路由自動選擇,持續(xù)穩(wěn)定高效的網(wǎng)絡(luò)助力業(yè)務(wù)部署。公司持有工信部辦法的idc、isp許可證, 機(jī)房獨(dú)有T級流量清洗系統(tǒng)配攻擊溯源,準(zhǔn)確進(jìn)行流量調(diào)度,確保服務(wù)器高可用性。佳節(jié)活動現(xiàn)已開啟,新人活動云服務(wù)器買多久送多久。