競態
在串行程序中,步驟執行順序由程序邏輯決定;而在有多個 goroutine 的并發程序中,不同 goroutine 的事件先后順序不確定,若無法確定兩個事件先后,它們就是并發的。若一個函數在并發調用時能正確工作,稱其為并發安全。當類型的所有可訪問方法和操作都是并發安全時,該類型為并發安全類型。并發安全的類型并非普遍存在,若要在并發中安全訪問變量,需限制變量僅在一個 goroutine 內存在,或維護更高層的互斥不變量。
package bankvar balance intfunc Deposit(amount int) { balance = balance + amount }
func Balance() int { return balance }// Alice:
go func() {bank.Deposit(200) // A1fmt.Println("=", bank.Balance()) // A2
}()
// Bob:
go func() {bank.Deposit(100) // B
}()
競態是指多個 goroutine 按交錯順序執行時,程序無法給出正確結果的情形。它對程序是致命的,可能潛伏在程序中,出現頻率低,且難以再現和分析。以銀行賬戶程序為例,在并發調用Deposit
和Balance
函數時,若多個 goroutine 交錯執行,可能出現數據競態,導致賬戶余額計算錯誤,如出現存款丟失等情況。數據競態發生在兩個或多個 goroutine 并發讀寫同一個變量,且至少其中一個是寫入時。當變量類型大于機器字長(如接口、字符串或 slice)時,數據競態問題會更復雜。
避免數據競態的方法
- 不修改變量:對于延遲初始化的 map,若并發調用訪問可能存在數據競態。但如果在創建其他 goroutine 之前,用完整數據初始化 map 且不再修改,那么多個 goroutine 可安全并發調用相關函數讀取 map。
package bankvar deposits = make(chan int) // 發送存款額
var balances = make(chan int) // 接收余額func Deposit(amount int) { deposits <- amount }
func Balance() int { return <-balances }func teller() {var balance intfor {select {case amount := <-deposits:balance += amountcase balances <- balance:}}
}func init() {go teller() // 啟動監控goroutine
}
- 避免多個 goroutine 訪問同一變量:通過將變量限制在單個 goroutine 內部訪問來避免競態。如 Web 爬蟲中主 goroutine 是唯一能訪問
seen
map 的,消息服務器中broadcaster
goroutine 是唯一能訪問clients
map 的。還可通過監控 goroutine 來限制對共享變量的訪問,如銀行案例中用teller
goroutine 限制balance
變量的并發訪問 。 - 允許多個 goroutine 訪問,但同一時間只有一個可訪問:通過互斥機制實現。
互斥鎖:sync.Mutex
// 使用通道實現二進制信號量保護balance
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
}
為保證同一時間最多有一個 goroutine 能訪問共享變量,可使用容量為 1 的通道作為二進制信號量。
由于互斥鎖模式應用廣泛,Go 語言sync
包提供了Mutex
類型來支持這種模式,Lock
方法用于獲取令牌(上鎖),Unlock
方法用于釋放令牌(解鎖)。
// 使用sync.Mutex實現互斥鎖保護balance
import "sync"
var (mu sync.Mutex // 保護 balancebalance int
)
func Deposit(amount int) {mu.Lock()balance = balance + amountmu.Unlock()
}
func Balance() int {mu.Lock()b := balancemu.Unlock()return b
}
示例:以銀行賬戶程序為例,定義mu
為sync.Mutex
類型來保護balance
變量 。在Deposit
和Balance
函數中,通過先調用mu.Lock()
獲取互斥鎖,訪問或修改balance
變量,最后調用mu.Unlock()
釋放鎖 ,確保共享變量不會被并發訪問 。這種函數、互斥鎖、變量的組合方式稱為監控(monitor)模式。
func Balance() int {mu.Lock()defer mu.Unlock()return balance
}
在Lock
和Unlock
之間的代碼區域稱為臨界區域,此區域內可自由讀寫共享變量 。一個 goroutine 在使用完互斥鎖后應及時釋放,對于有多個分支(尤其是錯誤分支)的復雜函數,可使用defer
語句延遲執行Unlock
,將臨界區域擴展到函數結尾,保證鎖能正確釋放 ,即使在臨界區域崩潰時也能正常執行解鎖操作 。
原子操作與互斥鎖的應用
// 不正確的Withdraw實現示例
func Withdraw(amount int) bool {Deposit(-amount)if Balance() < 0 {Deposit(amount)return false // 余額不足}return true
}// 錯誤的Withdraw加鎖嘗試示例
func Withdraw(amount int) bool {mu.Lock()defer mu.Unlock()Deposit(-amount)if Balance() < 0 {Deposit(amount)return false // 余額不足}return true
}// 正確的Withdraw實現示例
func Withdraw(amount int) bool {mu.Lock()defer mu.Unlock()deposit(-amount)if balance < 0 {deposit(amount)return false // 余額不足}return true
}func Deposit(amount int) {mu.Lock()defer mu.Unlock()deposit(amount)
}func Balance() int {mu.Lock()defer mu.Unlock()return balance
}// 這個函數要求已獲取互斥鎖
func deposit(amount int) { balance += amount }
以Withdraw
函數為例,最初版本因不是原子操作(包含多個串行操作且未對整個操作上鎖)存在問題,在嘗試超額提款時可能導致余額異常 。改進版本應在整個操作開始時申請一次互斥鎖 ,但直接在Withdraw
中嵌套調用已使用互斥鎖的Deposit
函數會因互斥鎖不可再入導致死鎖 。最終解決方案是將Deposit
函數拆分為不導出的deposit
函數(假定已獲取互斥鎖并完成業務邏輯)和導出的Deposit
函數(負責獲取鎖并調用deposit
),從而正確實現Withdraw
函數 。使用互斥鎖時,應確保互斥鎖本身及被保護的變量都不被導出 ,以維持并發中的不變性 。
讀寫互斥鎖:sync.RWMutex
var mu sync.RWMutex
var balance intfunc Balance() int {mu.RLock() // 讀鎖defer mu.RUnlock()return balance
}
以 Bob 頻繁查詢賬戶余額為例,銀行的Balance
函數只是讀取變量狀態,多個Balance
請求可并發運行,只要Deposit
和Withdraw
請求不同時運行即可 。為滿足這種場景需求,需要一種特殊的鎖,即多讀單寫鎖,Go 語言中的sync.RWMutex
可提供此功能。
- 讀鎖操作:定義
mu
為sync.RWMutex
類型 ,在Balance
函數中,通過調用mu.RLock()
獲取讀鎖(共享鎖),使用defer mu.RUnlock()
延遲釋放讀鎖,確保在函數結束時釋放鎖 ,這樣多個讀操作可并發進行。 - 寫鎖操作:
Deposit
函數等寫操作函數,仍通過調用mu.Lock()
獲取寫鎖(互斥鎖),mu.Unlock()
釋放寫鎖 ,保證寫操作時的獨占訪問權限。
注意事項
RLock
僅適用于臨界區域內對共享變量無寫操作的情形 ,因為有些看似只讀的函數可能會更新內部變量,若不確定應使用獨占版本的Lock
。- 當絕大部分 goroutine 都在獲取讀鎖且鎖競爭激烈時,
RWMutex
才有優勢,因為其內部簿記工作更復雜,在競爭不激烈時比普通互斥鎖慢 。
內存同步
以銀行賬戶的Balance
函數為例,其需要互斥鎖不僅是防止操作交錯,還涉及內存同步問題。現代計算機多處理器有本地內存緩存,寫操作先緩存在處理器中,刷回內存順序可能與 goroutine 寫入順序不一致。通道通信、互斥鎖等同步原語可使處理器將累積寫操作刷回內存并提交,保證執行結果對其他處理器上的 goroutine 可見。
var x, y int
go func() {x = 1fmt.Print("y:", y, " ")
}()
go func() {y = 1fmt.Print("x:", x, " ")
}()
通過代碼示例,兩個 goroutine 并發訪問共享變量x
和y
,在未使用互斥鎖時存在數據競態,預期輸出為y:0 x:1
、x:0 y:1
、x:1 y:1
、y:1 x:1
這四種情況之一 。但實際可能出現x:0 y:0
、y:0 x:0
這種意外輸出 。原因在于單個 goroutine 內語句執行順序一致,但在無同步措施時,不同 goroutine 間無法保證事件順序一致 。編譯器可能因賦值和打印對應不同變量,交換語句執行順序,CPU 也可能因緩存等問題導致一個 goroutine 的寫入操作對另一個 goroutine 的Print
語句不可見 。
解決:為避免這些并發問題,可采用成熟模式,將變量限制在單個 goroutine 中;對于其他變量,使用互斥鎖進行同步 。
延遲初始化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 {if icons == nil {loadIcons() // 一次性地初始化}return icons[name]
}
延遲昂貴的初始化步驟到實際需要時進行,可避免增加程序啟動延時。以icons
變量為例,初始版本在Icon
函數中檢測icons
是否為空,若為空則調用loadIcons
進行一次性初始化 ,但此方式在并發調用Icon
時不安全。
var mu sync.Mutex // 保護 icons
var icons map[string]image.Image// 并發安全版本(使用普通互斥鎖)
func Icon(name string) image.Image {mu.Lock()defer mu.Unlock()if icons == nil {loadIcons()}return icons[name]
}var mu sync.RWMutex // 保護 icons
var icons map[string]image.Image// 并發安全版本(使用讀寫互斥鎖)
func Icon(name string) image.Image {mu.RLock()if icons!= nil {icon := icons[name]mu.RUnlock()return icon}mu.RUnlock()mu.Lock()if icons == nil { // 必須重新檢查nil值loadIcons()}icon := icons[name]mu.Unlock()return icon
}
在無顯式同步情況下,編譯器和 CPU 可能重排loadIcons
語句執行順序,導致一個 goroutine 發現icons
不為nil
時,初始化可能尚未真正完成 。使用互斥鎖可解決同步問題,如用sync.Mutex
保護icons
變量 ,但這會限制并發訪問,即使初始化完成且不再更改,也會阻止多個 goroutine 并發讀取 。使用sync.RWMutex
雖能改善并發讀問題,但代碼復雜且易出錯 。
var loadIconsOnce sync.Once
var icons map[string]image.Image// 并發安全版本(使用sync.Once)
func Icon(name string) image.Image {loadIconsOnce.Do(loadIcons)return icons[name]
}
sync.Once
為一次性初始化問題提供簡化方案 。它包含布爾變量記錄初始化是否完成,以及互斥量保護相關數據 。Once
的Do
方法以初始化函數為參數 ,首次調用Do
時,鎖定互斥量并檢查布爾變量,若為假則調用初始化函數并將變量設為真,后續調用相當于空操作 。通過使用sync.Once
,可確保變量在正確構造之前不被其他 goroutine 訪問,避免競態問題 。
競態檢測器
Go 語言運行時和工具鏈提供競態檢測器,用于檢測并發編程中的數據競態問題。在go build
、go run
、go test
命令中添加-race
參數即可啟用 。啟用后,編譯器會構建修改后的版本,記錄運行時對共享變量的訪問,以及讀寫變量的 goroutine 標識,還會記錄同步事件(如go
語句、通道操作、互斥鎖調用、WaitGroup
調用等 )。
競態檢測器通過研究事件流,找出一個 goroutine 寫入變量后,無同步操作時另一個 goroutine 讀寫該變量的情況,即數據競態 。檢測到競態后,會輸出包含變量標識、讀寫 goroutine 調用棧的報告,幫助定位問題 。
它只能檢測運行時發生的競態,無法保證程序絕對不會發生競態 。為獲得最佳檢測效果,測試應包含并發使用包的場景 。由于增加了額外簿記工作,帶競態檢測功能的程序運行時需更長時間和更多內存,但對于排查不常發生的競態,能節省大量調試時間 。
goroutine 和線程
可增長的棧
每個 OS 線程都有固定大小的棧內存,通常為 2MB ,用于保存在函數調用期間正在執行或臨時暫停函數中的局部變量。但這個固定大小存在弊端,對于簡單的 goroutine(如僅等待WaitGroup
或關閉通道 ),2MB 棧內存浪費;對于復雜深度遞歸函數,固定大小棧又不夠用,且無法兼顧空間效率和支持更深遞歸。
goroutine 在生命周期開始時棧很小,典型為 2KB ,也用于存放局部變量。與 OS 線程不同,goroutine 的棧可按需增大和縮小,大小限制可達 1GB ,比線程棧大幾個數量級,能更靈活適應不同場景,極少的 goroutine 才會用到這么大棧。
goroutine調度
OS 線程由 OS 內核調度。每隔幾毫秒,硬件時鐘中斷觸發 CPU 調用調度器內核函數 。該函數暫停當前運行線程,保存寄存器信息到內存,選擇下一個運行線程,恢復其注冊表信息后繼續執行 。此過程涉及完整上下文切換,包括保存和恢復線程狀態、更新調度器數據結構,因內存訪問及 CPU 周期消耗,操作較慢 。
Go 運行時有自己的調度器,采用 m:n 調度技術(將 m 個 goroutine 復用 / 調度到 n 個 OS 線程 )。與內核調度器不同,Go 調度器不由硬件時鐘定期觸發,而是由特定 Go 語言結構觸發 ,如 goroutine 調用time.Sleep
、被通道阻塞或進行互斥量操作時,調度器將其設為休眠模式,轉而運行其他 goroutine,直到可喚醒該 goroutine 。由于無需切換到內核語境,調度 goroutine 成本比調度線程低很多 。
GOMAXPROCS
Go 調度器通過GOMAXPROCS
參數確定同時執行 Go 代碼所需的 OS 線程數量 ,默認值為機器上的 CPU 數量 。例如在 8 核 CPU 機器上,調度器會將 Go 代碼調度到 8 個 OS 線程上執行(它是 m:n 調度中的 n )。處于休眠、被通道阻塞的 goroutine 不占用線程,阻塞在 I/O 及系統調用或調用非 Go 語言函數的 goroutine 雖需獨立 OS 線程,但該線程不計入GOMAXPROCS
。
for {go fmt.Print(0)fmt.Print(1)
}
// $ GOMAXPROC=1 go run hacker-cliche.go 11111111111111111118008000000000000001111...
// $ GOMAXPROCS=2 go run hacker-cliche.go 01010101010101010101100110010101101001010...
可通過GOMAXPROCS
環境變量或runtime.GOMAXPROCS
函數顯式控制該參數 。文中通過一個不斷輸出 0 和 1 的小程序示例展示其效果 ,當GOMAXPROCS=1
時,每次最多一個 goroutine 運行,主 goroutine 和輸出 0 的 goroutine 交替執行;當GOMAXPROCS=2
時,兩個 goroutine 可同時運行 。由于影響 goroutine 調度因素眾多且運行時不斷變化,實際結果可能不同。
goroutine沒有標識
在多數支持多線程的操作系統和編程語言中,當前線程有獨特標識,通常為整數或指針 。利用此標識可構建線程局部存儲,即一個以線程標識為鍵的全局 map,使每個線程能獨立存儲和獲取值,不受其他線程干擾 。
goroutine 沒有可供程序員訪問的標識 ,這是設計選擇。因為線程局部存儲易被濫用,如 Web 服務器使用支持線程局部存儲的語言時,很多函數通過訪問該存儲查找 HTTP 請求信息,會導致類似過度依賴全局變量的 “超距作用”,使函數行為不僅取決于參數,還與運行線程標識有關,在需要改變線程標識(如使用工作線程 )時,函數行為會變得不可預測 。
Go 語言鼓勵簡單編程風格,函數行為應僅由顯式指定參數決定,這樣程序更易閱讀,且在將函數子任務分發到多個 goroutine 時,無需考慮 goroutine 標識問題 。
參考資料:《Go程序設計語言》