稍微有編碼常識的同學,都會意識到程式並非完全按照純程式碼邏輯順序執行。有多執行緒多程式經驗,知道程式執行往往表現的像無規律的交叉,而且每次重新來過,還體現不一樣。 本文以通道為引子,意直白講述併發同步
記憶體順序
編譯器(在編譯時刻)最佳化和CPU處理器最佳化(執行時),會調整指令的執行地順序。這導致指令執行順序與程式碼指定的順序不完全一致。所以當你認為你的程式碼是按照書寫的邏輯執行時,事實有可能並非如此,尤其是在高併發的情況下。在go語言中表現為,指令執行順序(指令順序,稱之為記憶體順序)的調整,可能會影響到其它協程行為。
併發同步
為了應對這些調整,需要同步技術。換而言之,對於某些調整程式碼會影響到最終輸出結果的情況,必須作出記憶體順序保證。非go語言通常會用到鎖和原子操作,以及適用於多個程式之間或多個主機之間,用網路或檔案讀寫來實現併發同步。
鎖基本上有這幾種。互斥鎖有點獨的味道,無論是讀還是寫,都會阻塞,即有人在讀其它人別說寫了,想看看(讀)都沒門。讀寫鎖,單寫多讀模型,除了寫時阻塞,你讀時,我可讀,他可讀,大家誰讀都沒問題,不阻塞。更進一步,其實站在鎖的立場,它需要知道,誰在用他,用完後給哪些需要他的人。換而言之,得通知可能得鎖人,讓那些沒得鎖的人繼續等待。條件鎖出現了,它依賴於前面兩種鎖,通常用來協調想要訪問共享資源的執行緒,在對應的共享資源狀態傳送變化時,通知其它因此而阻塞(在等待)的執行緒。
中斷 程式碼從執行狀態切換到非執行狀態稱之為中斷
原子操作通常是 CPU 和作業系統提供支援,執行過程中不會中斷
原子操作 互斥鎖只能保證臨界區程式碼的序列執行,不能保證程式碼執行的原子性。
而原子執行過程中不中斷,可以完全消除競態條件,從而絕對保證併發安全性,無鎖直接透過CPU指令直接實現。
順序保證
上面說了那麼多,就一目的實現記憶體模型(指令執行)順序保證,即確保某些情況下順序不被調整(或即便調整了也不影響最終的結果正確性)。Go記憶體模型除了提供主流的鎖或原子操作做出順序保證外,還提供了通道操作順序保證。
通道
通道簡單理解,就是由讀,寫,緩衝三個佇列組成的資料型別。其實它的設計邏輯,以無緩衝通道為例,假定有一空杯,大家喝水都用它,加鎖那套是要喝水的人時不時要看看,有沒有人在用那個杯子,沒有人用它用完則放回原地。而通道不一樣,它是杯子到手了,我用完了,直接傳遞給下一個要用的人,當然你得保證喝完之後杯子裡有水。二者的區別在於,前者需要鎖防止大家爭搶水杯,而後者則你不需要去找水,你只需要告訴水杯我要喝水,上一個喝水的人,喝完之後,會灌滿遞給你。前者強調共享,後者重在傳遞。所以不要讓計算透過共享記憶體來通訊,而應該讓它們透過通訊來共享記憶體。
最快到達
現實生活中,發出請求並不總是及時響應,有時面對多源資料,我們會發出多個請求,只採用其中響應最快的那個。
import (
"fmt"
"math/rand"
"time"
)
func main() {
rand.Seed(time.Now().UnixNano())
startTime := time.Now()
// 採用緩衝通道,模擬同步發出多個請求
c := make(chan int32, 5)
for i := 0; i < cap(c); i++ {
go source(c)
}
// 只取一個最快的響應結果
rnd := <-c
// 測量最快時間差
fmt.Println(time.Since(startTime))
fmt.Println(rnd)
}
func source(c chan<- int32) {
ra, rb := rand.Int31(), rand.Intn(3)+1
// 隨機模擬請求的響應時間
time.Sleep(time.Duration(rb) * time.Second)
c <- ra
}
Future/Promise
Future/promise 常常用在請求/回應場合,以下示例 sumSquares
函式呼叫的兩個實參請求併發進行。 每個通道讀取操作將阻塞到請求返回結果為止。 兩個實參總共需要大約3秒鐘(而不是6秒鐘) 準備完畢(以較慢的一個為準)
package main
import (
"fmt"
"math/rand"
"time"
)
func longTimeRequest() <-chan int32 {
r := make(chan int32)
go func() {
time.Sleep(time.Second * 3) // 模擬一個工作負載
r <- rand.Int31n(100) // 隨機正整數範圍
}()
return r
}
func sumSquares(a, b int32) int32 {
return a*a + b*b
}
func main() {
rand.Seed(time.Now().UnixNano()) // 準備隨機初始種子
start := time.Now() // 計時
a, b := longTimeRequest(), longTimeRequest() // goroutine分發,併發執行
fmt.Println(sumSquares(<-a, <-b), time.Since(start)) // 輸出類似 10084 3.000541298s
}
- 通知
互斥鎖
將容量為1的緩衝通道,作為互斥鎖,下面示例傳送操作加鎖
package main
import "fmt"
func main() {
mutex := make(chan struct{}, 1) // 容量必須為1,二元訊號
counter := 0
increase := func() {
mutex <- struct{}{} // 傳送通道加鎖
counter++
<-mutex // 解鎖
}
increase10 := func(done chan<- struct{}) {
for i := 0; i < 10; i++ {
increase()
}
done <- struct{}{}
}
done := make(chan struct{})
go increase10(done)
go increase10(done)
<-done; <-done
fmt.Println(counter) // 20
}
計數訊號量
計數訊號量經常被使用於限制最大併發數,下面以酒吧喝酒示例
package main
import (
"log"
"math/rand"
"time"
)
type Seat int
type Bar chan Seat
func (bar Bar) ServeCustomer(c int, seat Seat) {
log.Print("顧客#", c, "進酒吧了")
log.Print("++ 顧客", c, "坐在",seat,"號位開始喝酒#", )
time.Sleep(time.Second * time.Duration(2+rand.Intn(6)))
log.Print("-- 顧客#", c, "離開了", seat, "號座位")
bar <- seat // 離開座位
}
func main() {
rand.Seed(time.Now().UnixNano())
bar24x7 := make(Bar, 3) // 此酒吧最多能同時服務3個客人
for seatId := 0; seatId < cap(bar24x7); seatId++ {
bar24x7 <- Seat(seatId) // 酒吧放置3把椅子,此處不會阻塞
}
for customerId := 0; ; customerId++ {
time.Sleep(time.Second)
seat := <-bar24x7 // 等待,當有空位時允許進
go bar24x7.ServeCustomer(customerId, seat)
}
select {} // 主協程永久阻塞,防止退出
}
- 對戰
其它
用通道實現請求/應答模式,使用緩衝,並不能保證結果順序與分發順序一致。道理很簡單,在同步發出多個請求,最先響應的並不一定是第一個請求(各個請求響應耗時不一),你不能根據響應的結果來斷定通道是否讀取完畢。讀寫一致是最好的保證。
本作品採用《CC 協議》,轉載必須註明作者和本文連結