您好,登錄后才能下訂單哦!
并發,如果無法確定一個事件先于另外一個事件,那么這兩個事件就是并發的。
并發安全(concurrency-safe),如果一個函數在并發調用時仍然能正確工作,那么這個函數就是并發安全的。如果一個類型的所有可訪問方法和操作都是并發安全的,則它可稱為并發安全的類型。
并發安全的類型是特例而不是普遍存在的,對于絕大部分變量,如要回避并發訪問,只有下面幾種辦法:
競態是指在多個 goroutine 按某些交錯順序執行時程序無法給出正確的結果。
數據競態(data race)是競態的一種。數據競態發生于兩個 goroutine 并發讀寫同一個變量并且至少其中一個是寫入時。有三種方法來避免數據競態:
Go 箴言:“不要通過共享內存來通信,而應該通過通信來共享內存”。
使用緩沖通道可以實現一個計數信號量,可以用于同時發起的 goroutine 的數量。一個計數上限為 1 的信號量稱為二進制信號量(binary semaphore)。
使用二進制信號量就可以實現互斥鎖:
var (
sema = make(chan struct{}, 1) // 用來保護 balance 的二進制信號量
balance int
)
func Deposit(amount int) {
sema <- struct{}{} // 獲取令牌
balance = balance + amount
<-sema // 釋放令牌
}
func Balance() int {
sema <- struct{}{} // 獲取令牌
b := balance
<-sema // 釋放令牌
return b
}
互斥鎖模式應用非常廣泛,所以 sync 包有一個單獨的 Mutex 類型來支持這種模式:
import "sync"
var (
mu sync.Mutex // 保護 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
}
互斥量保護共享變量。按照慣例,被互斥量保護的變量聲明應當緊接在互斥量的聲明之后。如果實際情況不是如此,請加注釋說明。
臨界區域,在 Lock 和 Unlock 之間的代碼,可以自由地讀取和修改共享變量,這一部分稱為臨界區域。
封裝,即通過在程序中減少對數據結構的非預期交互,來幫助我們保證數據結構中的不變量。類似的原因,封裝也可以用來保持并發中的不變性。所以無論是為了保護包級別的變量,還是結構中的字段,當使用一個互斥量時,都請確保互斥量本身以及被保護的變量都沒有導出。
多讀單寫鎖,允許只讀操作可以并發執行,但寫操作需要獲得完全獨享的訪問權限。Go 語言中的 sync.RWMutex 提供了這種功能:
var mu sync.RWMutex
var balance int
func Balance() int {
mu.RLock() // 讀取
defer mu.RUnlock()
return balance
}
Balance 函數可以調用 mu.RLock 和 mu.RUnlock 方法來分別獲取和釋放一個讀鎖(也稱為共享鎖)。而之前的 mu.Lock 和 mu.Unlock 方法則是分別獲取和釋放一個寫鎖(也稱為互斥鎖)。
一般情況下,不應該假定那些邏輯上只讀的函數和方法不會更新一些變量。比如,一個看起來只是簡單訪問的方法,可能會遞增內部使用的計數器,或者更新一個緩存來讓重復的調用更快。如果不確定,就應該使用互斥鎖。
讀鎖的應用場景
僅在絕大部分 goroutine 都在獲取讀鎖并且鎖競爭比較激烈時,RWMutex 才有優勢。因為 RWMutex 需要更復雜的內部實現,所以在競爭不激烈時它比普通的互斥鎖慢。
現代的計算機一般會有多個處理器,每個處理器都有內存的本地緩存。為了提高效率,對內存的寫入是緩存在每個處理器中的,只在必要時才刷回內存。甚至刷會內存的順序都可能與 goroutine 的寫入順序不一致。像通道通信或者互斥鎖操作這樣的同步源語都會導致處理器把累積的寫操作刷回內存并提交。但這個時刻之前 goroutine 的執行結果就無法保證能被運行在其他處理器的 goroutine 觀察到。
考慮如下的代碼片段可能的輸出:
var x, y int
go func() {
x = 1
fmt.Print("y:", y, " ")
}
go func() {
y = 1
fmt.Print("x:", x, " ")
}
下面4個是顯而易見的可能的輸出結果:
y:0 x:1
x:0 y:1
x:1 y:1
y:1 x:1
但是下面的輸出也是可能出現的:
x:0 y:0
y:0 x:0
在某些特定的編譯器、CPU 或者其他情況下,這些確實可能發生。
單個 goroutine 內,每個語句的效果保證按照執行的順序發生,也就是說,goroutine 是串行一致的(sequentially consistent)。但在缺乏使用通道或者互斥量來顯式同步的情況下,并不能保證所有的 goroutine 看到的事件順序都是一致的。
上面的兩個 goroutine 盡管打印語句是在賦值另外一個變量之后,但是一個 goroutine 并不一定能觀察到另一個 goroutine 對變量的效果。所以可能輸出的是一個變量的過期值。
盡管很容易把并發簡單理解為多個 goroutine 中語句的某種交錯執行方式。如果兩個 goroutine 在不同的 CPU 上執行,每個 CPU 都有自己的緩存,那么一個 goroutine 的寫入操作在同步到內存之前對另外一個 goroutine 的打印變量的語句是不可見的。
這些并發的問題都可以通過采用簡單、成熟的模式來避免,即在可能的情況下,把變量限制到單個 goroutine 中,對于其他變量,使用互斥鎖。
延遲一個昂貴的初始化步驟到有實際需求的時刻是一個很好的實踐。預先初始化一個變量會增加程序的啟動延遲,并且如果實際執行時有可能根本用不上這個變量,那么初始化也不是必需的。
sync 包提供了針對一次性初始化問題的特化解決方案:sync.Once。從概念上來講,Once 包含一個布爾變量和一個互斥量,布爾變量記錄初始化是否已經完成,互斥量則負責保護這個布爾變量和客戶端的數據結構。Once 唯一的方法 Do 以初始化函數作為它的參數:
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"),
}
}
// 并發安全
func Icon(name string) image.Image {
loadIconsOnce.Do(loadIcons)
return icons[name]
}
每次調用 Do 方法時,會先鎖定互斥量并檢查里邊的布爾變量。在第一次調用時,這個布爾變量為 false,Do 會調用它參數的方法,然后把布爾變量設置為 true。之后 DO 方法的調用相當于空操作,只是通過互斥量的同步來保證初始化操作對內存產生的效果對所有的 goroutine 可見。以這種方式來使用 sync.Once,可以避免變量在構造完成之前就被其他 goroutine 訪問。
goroutine 與操作系統(OS)線程之間的差異本質上屬于量變。但是足夠大的量變會變成質變,所以還是要區分一下兩者的差異。
每個 OS 線程都有一個固定大小的棧內存(通常為 2MB),棧內存區域用戶保存在其他函數調用期間那些正在執行或臨時暫停的函數中的局部變量。這個固定的大小對小的 goroutine 來說太大了,對于要創建數量巨大的 goroutine 來說,就會有巨大的浪費。另外,對于更復雜或者深度遞歸的函數,固定大小的棧又會不夠大。改變這個固定大小,調小了可以允許創建更多的線程,改大了則可以容許更深的遞歸,但兩者無法同時兼容。
gotouine 也用于存放那些正在執行或臨時暫停的函數中的局部變量。但棧的大小不是固定的,它可與按需增大或縮小。goroutine 的棧大小限制可以達到 1GB。當然,只有極少的 goroutine 會使用這么大的棧。
OS線程調度器
OS線程由OS內核來調度。每隔幾毫秒,一個硬件時鐘中斷發送到CPU、CPU調用一個叫調度器的內核函數。這個函數暫停當前正在運行的線程,把它的寄存器信息保存到內存,查看線程列表并決定接下來運行哪一個線程,再從內存恢復線程的注冊表信息,最后繼續執行選中的線程。因為OS線程由內核來調度,所以控制權限從一個線程到另外一個線程需要一個完整的上下文切換(context switch):即保存一個線程的狀態到內存,再恢復另外一個線程的狀態、最后更新調度器的數據結構。考慮這個操作涉及的內存局域性以及涉及的內存訪問數量,還有訪問內存所需的CPU周期數量的增加,這個操作其實是很慢的。
Go調度器
Go 運行時包含一個自己的調度器,這個調度器使用一個稱為m:n 調度的技術(因為它可以復用/調度 m 個 goroutine 到 n 個OS線程)。Go 調度器與內核調度器的工作類似,但 Go 調度器值需關心單個 Go 程序的 goroutine 調度問題。
差別
與操作系統的線程調度器不同的是,Go 調度器不是由硬件時鐘來定期觸發的,而是由特定的 Go 語言結構來觸發的。比如當一個 goroutine 調用 time.Sleep 或被通道阻塞或對互斥量操作時,調度器就會將這個 goroutine 設為休眠模式,并運行其他 goroutine 直到前一個可重新喚醒為止。因為它不需要切換到內核語境,所以調用一個 goroutine 比調度一個線程成本低很多。
Go 調度器使用 GOMAXPROCS 參數來確定需要使用多少個OS線程來同時執行 Go 代碼,默認值是機器上的CPU數量(GOMAXPROCS 是 m:n 調度中的 n)。正在休眠或者正被通道通信阻塞的 goroutine 不需要占用線程。阻塞在 I\/O 和其他系統調用中或調用非 Go 語言寫的函數的 goroutine 需要一個獨立的OS線程,但這個線程不計算在 GOMAXPROCS 內。
可以用 GOMAXPROCS 環境變量或者 runtime.GOMAXPROCS 函數來顯式控制這個參數。可以用一個小程序來看看 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)
}
}
這里使用命令行參數來控制線程數量。
Linux 中應該可以直接設置 GOMAXPROCS 環境變量來運行程序:
$ GOMAXPROCS=1 go run main.go
$ GOMAXPROCS=2 go run main.go
GOMAXPROCS 為1時,每次最多只能由一個 goroutine 運行。最開始是主 goroutine,它會連續輸出很多1。在運行了一段時間之后,Go 調度器讓主 goroutine 休眠,并喚醒另一個輸出0的 goroutine,讓它有機會執行。所以執行結果能看到大段的連續的0或1。
GOMAXPROCS 為2時,就有兩個可用的OS線程,所以兩個 goroutine 可以同時運行,輸出的0和1就會交替出現(我看到的是小段小段的交替)。
在大部分支持多線程的操作系統和編程語言里,當前線程都有一個獨特的標識,它通常可以取一個整數或者指針。這個特性讓我們可以輕松構建一個線程的局部存儲,它本質上就是一個全局的 map,以線程的標識為 key,這樣各個線程都可以獨立地用這個 map 存儲和獲取值,而不受其他線程的干擾。
goroutine 沒有可供程序員訪問的表示。這個是有設計來決定的,因為線程局部存儲有一個被濫用的的傾向。
Go 語言鼓勵一種更簡單的編程風格。其中,能影響一個函數行為的參數應當是顯式指定的。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。