一.前言
我們反覆提到了goroutine的建立時簡單的。 但是仍然要小心, 習慣總是會導致我們可能寫出一些bug.對於語言規範沒有定義的內容不要做任何的假設。
需要通過同步語義來控制程式碼的執行順序 這一點很重要。 這些包提供了一些基礎的同步語義,但是在實際的併發程式設計當中,我們應該使用 channel
來進行同步控制。
二. Mutex
2.1 案例
上一篇文章中有這樣一個例子
package main
import (
"fmt"
"sync"
)
var wg sync.WaitGroup
var counter int
func main() {
// 多跑幾次來看結果
for i := 0; i < 100000; i++ {
run()
}
fmt.Printf("Final Counter: %d\n", counter)
}
func run() {
// 開啟兩個 協程,操作
for i := 1; i <= 2; i++ {
wg.Add(1)
go routine(i)
}
wg.Wait()
}
func routine(id int) {
for i := 0; i < 2; i++ {
value := counter
value++
counter = value
}
wg.Done()
}
測試後我們會發現,每次執行的結果都不一樣, 那麼如何運用Mutex進行修改呢?
package main
import (
"fmt"
"sync"
)
var wg sync.WaitGroup
var counter int
var mu sync.Mutex
func main() {
// 多跑幾次來看結果
for i := 0; i < 100000; i++ {
run()
}
fmt.Printf("Final Counter: %d\n", counter)
}
func run() {
for i := 1; i <= 2; i++ {
wg.Add(1)
go routine(i)
}
wg.Wait()
fmt.Printf("Final Counter: %d\n", counter)
}
func routine(id int) {
for i := 0; i < 2; i++ {
// 加鎖
mu.Lock()
counter++
// 解鎖
mu.Unlock()
}
wg.Done()
}
這裡主要的目的就是為了保護我們臨界區的資料,通過鎖來進行保證。鎖的使用非常的簡單,但是還是有幾個需要注意的點
- 鎖的範圍要儘量的小,不要搞很多大鎖
- 用鎖一定要解鎖,小心產生死鎖
三. 實現原理
3.1 鎖的實現模式
- Barging: 這種模式是為了提高吞吐量,當鎖被釋放時,它會喚醒第一個等待者,然後把鎖給第一個等待者或者給第一個請求鎖的人
- Handoff: 當鎖釋放的時候, 鎖會一直持有直到第一個等待者準備好獲取鎖。 它降低了吞吐量,因為鎖被持有, 即使另一個 goroutine 準備獲取它。這種模式可以解決公平性的問題,因為在 Barging 模式下可能會存在被喚醒的 goroutine 永遠也獲取不到鎖的情況,畢竟一直在 cpu 上跑著的 goroutine 沒有上下文切換會更快一些。缺點就是效能會相對差一些
- Spining:自旋在等待佇列為空或者應用程式重度使用鎖時效果不錯。Parking 和 Unparking goroutines 有不低的效能成本開銷,相比自旋來說要慢得多。但是自旋是有成本的,所以在 go 的實現中進入自旋的條件十分的苛刻。
3.2 Go Mutex 實現原理
3.2.1 加鎖
- 首先如果當前鎖處於初始化狀態就直接用
CAS
方法嘗試獲取鎖,這是 Fast Path - 如果失敗就進入 Slow Path
- 會首先判斷當前能不能進入自旋狀態,如果可以就進入自旋,最多自旋 4 次
- 自旋完成之後,就會去計算當前的鎖的狀態
- 然後嘗試通過 CAS 獲取鎖
- 如果沒有獲取到就呼叫 runtime_SemacquireMutex 方法休眠當前 goroutine 並且嘗試獲取訊號量
- goroutine 被喚醒之後會先判斷當前是否處在飢餓狀態,(如果當前 goroutine 超過 1ms 都沒有獲取到鎖就會進飢餓模式)
- 如果處在飢餓狀態就會獲得互斥鎖,如果等待佇列中只存在當前 Goroutine,互斥鎖還會從飢餓模式中退出
- 如果不在,就會設定喚醒和飢餓標記、重置迭代次數並重新執行獲取鎖的迴圈
CAS 方法在這裡指的是 atomic.CompareAndSwapInt32(addr, old, new) bool 方法,這個方法會先比較傳入的地址的值是否是 old,如果是的話就嘗試賦新值,如果不是的話就直接返回 false,返回 true 時表示賦值成功
飢餓模式是 Go 1.9 版本之後引入的優化,用於解決公平性的問題[10]
3.2.2 解鎖
解鎖的流程相對於加鎖簡單很多,這裡直接上圖,過程不過多贅述
四. 原始碼分析
4.1 Mutex基本結構
Mutex是個結構體,原始碼如下
type Mutex struct {
state int32
sema uint32
}
Mutex
結構體由 state sema
兩個 4 位元組成員組成,其中 state
表示了當前鎖的狀態, sema
是用於控制鎖的訊號量
state
欄位的最低三位表示三種狀態,分別是 mutexLocked
mutexWoken
mutexStarving
,剩下的用於統計當前在等待鎖的 goroutine 數量
- mutexLocked 表示是否處於鎖定狀態
- mutexWoken 表示是否處於喚醒狀態
- mutexStarving 表示是否處於飢餓狀態
4.2 加鎖
互斥鎖加鎖邏輯如下
-
當呼叫
Lock
方法的時候,會先嚐試走Fast Path
,也就是如果當前互斥鎖如果處於未加鎖的狀態,嘗試加鎖,只要加鎖成功就直接返回func (m *Mutex) Lock() { // Fast path: grab unlocked mutex. if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) { return } // Slow path (outlined so that the fast path can be inlined) m.lockSlow() }
-
否則的話就進入 slow path
func (m *Mutex) lockSlow() { var waitStartTime int64 // 等待時間 starving := false // 是否處於飢餓狀態 awoke := false // 是否處於喚醒狀態 iter := 0 // 自旋迭代次數 old := m.state for { // Don't spin in starvation mode, ownership is handed off to waiters // so we won't be able to acquire the mutex anyway. if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) { // Active spinning makes sense. // Try to set mutexWoken flag to inform Unlock // to not wake other blocked goroutines. if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 && atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) { awoke = true } runtime_doSpin() iter++ old = m.state continue }
-
在
lockSlow
方法中可以看到,有一個大的for
迴圈,不斷的嘗試去獲取互斥鎖,在迴圈的內部,第一步就是判斷能否自旋狀態。
進入自旋狀態的判斷比較苛刻,具體需要滿足什麼條件呢? runtime_canSpin 原始碼見下方- 當前互斥鎖的狀態是非飢餓狀態,並且已經被鎖定了
- 自旋次數不超過 4 次
- cpu 個數大於一,必須要是多核 cpu
- 當前正在執行當中,並且佇列空閒的 p 的個數大於等於一
// Active spinning for sync.Mutex. //go:linkname sync_runtime_canSpin sync.runtime_canSpin //go:nosplit func sync_runtime_canSpin(i int) bool { // 自旋次數不超過4 // cpu個數大於1--所以必須是多核CPU // 佇列空閒的p的個數大於等於1 if i >= active_spin || ncpu <= 1 || gomaxprocs <= int32(sched.npidle+sched.nmspinning)+1 { return false } if p := getg().m.p.ptr(); !runqempty(p) { return false } return true }
-
如果可以進入自旋狀態之後就會呼叫
runtime_doSpin
方法進入自旋,doSpin
方法會呼叫procyield(30)
執行三十次 PAUSE 指令TEXT runtime·procyield(SB),NOSPLIT,$0-0 MOVL cycles+0(FP), AX again: PAUSE SUBL $1, AX JNZ again RET
為什麼使用 PAUSE 指令呢?
PAUSE 指令會告訴 CPU 我當前處於處於自旋狀態,這時候 CPU 會針對性的做一些優化,並且在執行這個指令的時候 CPU 會降低自己的功耗,減少能源消耗if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 && atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) { awoke = true }
-
在自旋的過程中會嘗試設定 mutexWoken 來通知解鎖,從而避免喚醒其他已經休眠的 goroutine 在自旋模式下,當前的 goroutine 就能更快的獲取到鎖
new := old // Don't try to acquire starving mutex, new arriving goroutines must queue. if old&mutexStarving == 0 { new |= mutexLocked } if old&(mutexLocked|mutexStarving) != 0 { new += 1 << mutexWaiterShift } // The current goroutine switches mutex to starvation mode. // But if the mutex is currently unlocked, don't do the switch. // Unlock expects that starving mutex has waiters, which will not // be true in this case. if starving && old&mutexLocked != 0 { new |= mutexStarving } if awoke { // The goroutine has been woken from sleep, // so we need to reset the flag in either case. if new&mutexWoken == 0 { throw("sync: inconsistent mutex state") } new &^= mutexWoken }
-
自旋結束之後就會去計算當前互斥鎖的狀態,如果當前處在飢餓模式下則不會去請求鎖,而是會將當前 goroutine 放到佇列的末端
if atomic.CompareAndSwapInt32(&m.state, old, new) { if old&(mutexLocked|mutexStarving) == 0 { break // locked the mutex with CAS } // If we were already waiting before, queue at the front of the queue. queueLifo := waitStartTime != 0 if waitStartTime == 0 { waitStartTime = runtime_nanotime() } runtime_SemacquireMutex(&m.sema, queueLifo, 1) starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs old = m.state if old&mutexStarving != 0 { // If this goroutine was woken and mutex is in starvation mode, // ownership was handed off to us but mutex is in somewhat // inconsistent state: mutexLocked is not set and we are still // accounted as waiter. Fix that. if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 { throw("sync: inconsistent mutex state") } delta := int32(mutexLocked - 1<<mutexWaiterShift) if !starving || old>>mutexWaiterShift == 1 { // Exit starvation mode. // Critical to do it here and consider wait time. // Starvation mode is so inefficient, that two goroutines // can go lock-step infinitely once they switch mutex // to starvation mode. delta -= mutexStarving } atomic.AddInt32(&m.state, delta) break } awoke = true iter = 0 }
狀態計算完成之後就會嘗試使用 CAS 操作獲取鎖,如果獲取成功就會直接退出迴圈
如果獲取失敗,則會呼叫 runtime_SemacquireMutex(&m.sema, queueLifo, 1)
方法保證鎖不會同時被兩個 goroutine 獲取。runtime_SemacquireMutex
方法的主要作用是:
- 不斷呼叫嘗試獲取鎖
- 休眠當前
goroutine
- 等待訊號量, 喚醒
goroutine
goroutine 被喚醒後就會去判斷當前是否處於飢餓模式,如果當前等待超過1ms
就會進入飢餓模式
- 飢餓模式下: 會獲得互斥鎖,如果等待佇列中只存在當前Goroutine, 互斥鎖還會從飢餓模式中退出
- 正常模式下: 會設定喚醒和飢餓標識, 重置迭代次數並重新執行獲取鎖的迴圈
4.3 解鎖
加鎖比解鎖簡單多了,原理直接參考原始碼的註釋
// 解鎖沒有繫結關係,可以一個 goroutine 鎖定,另外一個 goroutine 解鎖
func (m *Mutex) Unlock() {
// Fast path: 直接嘗試設定 state 的值,進行解鎖
new := atomic.AddInt32(&m.state, -mutexLocked)
// 如果減去了 mutexLocked 的值之後不為零就會進入慢速通道,這說明有可能失敗了,或者是還有其他的 goroutine 等著
if new != 0 {
// Outlined slow path to allow inlining the fast path.
// To hide unlockSlow during tracing we skip one extra frame when tracing GoUnblock.
m.unlockSlow(new)
}
}
func (m *Mutex) unlockSlow(new int32) {
// 解鎖一個沒有鎖定的互斥量會報執行時錯誤
if (new+mutexLocked)&mutexLocked == 0 {
throw("sync: unlock of unlocked mutex")
}
// 判斷是否處於飢餓模式
if new&mutexStarving == 0 {
// 正常模式
old := new
for {
// 如果當前沒有等待者.或者 goroutine 已經被喚醒或者是處於鎖定狀態了,就直接返回
if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
return
}
// 喚醒等待者並且移交鎖的控制權
new = (old - 1<<mutexWaiterShift) | mutexWoken
if atomic.CompareAndSwapInt32(&m.state, old, new) {
runtime_Semrelease(&m.sema, false, 1)
return
}
old = m.state
}
} else {
// 飢餓模式,走 handoff 流程,直接將鎖交給下一個等待的 goroutine,注意這個時候不會從飢餓模式中退出
runtime_Semrelease(&m.sema, true, 1)
}
}
通過原始碼註解,總結解鎖需要注意以下
- 解鎖一個沒有鎖定的互斥量會報執行時錯誤
五. 讀寫鎖(RWMutex)
讀寫鎖相對於互斥鎖來說粒度更細,使用讀寫鎖可以併發讀,但是不能併發讀寫,或者併發寫寫
5.1 案例
大部分的業務應用都是讀多寫少的場景,這個時候使用讀寫鎖的效能就會比互斥鎖要好一些,例如下面的這個例子,是一個配置讀寫的例子,我們分別使用讀寫鎖和互斥鎖實現
- 讀寫鎖
// RWMutexConfig 讀寫鎖實現
type RWMutexConfig struct {
rw sync.RWMutex
data []int
}
// Get get config data
func (c *RWMutexConfig) Get() []int {
c.rw.RLock()
defer c.rw.RUnlock()
return c.data
}
// Set set config data
func (c *RWMutexConfig) Set(n []int) {
c.rw.Lock()
defer c.rw.Unlock()
c.data = n
}
- 互斥鎖
// MutexConfig 互斥鎖實現
type MutexConfig struct {
data []int
mu sync.Mutex
}
// Get get config data
func (c *MutexConfig) Get() []int {
c.mu.Lock()
defer c.mu.Unlock()
return c.data
}
// Set set config data
func (c *MutexConfig) Set(n []int) {
c.mu.Lock()
defer c.mu.Unlock()
c.data = n
}
併發基準測試,測試兩種鎖的效能
type iConfig interface {
Get() []int
Set([]int)
}
func bench(b *testing.B, c iConfig) {
b.RunParallel(func(p *testing.PB) {
for p.Next() {
c.Set([]int{100})
c.Get()
c.Get()
c.Get()
c.Set([]int{100})
c.Get()
c.Get()
}
})
}
func BenchmarkMutexConfig(b *testing.B) {
conf := &MutexConfig{data: []int{1, 2, 3}}
bench(b, conf)
}
func BenchmarkRWMutexConfig(b *testing.B) {
conf := &RWMutexConfig{data: []int{1, 2, 3}}
bench(b, conf)
}
執行測試結果如下
root@failymao:/mnt/d/gopath/src/Go_base/daily_test/mutex# go test -race -bench=.
goos: linux
goarch: amd64
pkg: Go_base/daily_test/mutex
BenchmarkMutexConfig-8 179932 5820 ns/op
BenchmarkRWMutexConfig-8 279578 3939 ns/op
PASS
ok Go_base/daily_test/mutex 3.158s
可以看到首先是沒有 data race 問題,其次讀寫鎖的效能幾乎是互斥鎖的一倍
5.2 原始碼解析
5.2.1 基本結構
type RWMutex struct {
w Mutex // 複用互斥鎖
writerSem uint32 // 訊號量,用於寫等待讀
readerSem uint32 // 訊號量,用於讀等待寫
readerCount int32 // 當前執行讀的 goroutine 數量
readerWait int32 // 寫操作被阻塞的準備讀的 goroutine 的數量
}
由於複用了互斥鎖的程式碼,讀寫鎖的原始碼很簡單
5.2.2 讀鎖
加鎖
func (rw *RWMutex) RLock() {
// 直接對讀的goroutine記錄加1,並判斷當前執行的讀的goroutines數量是否為空
if atomic.AddInt32(&rw.readerCount, 1) < 0 {
// A writer is pending, wait for it.
runtime_SemacquireMutex(&rw.readerSem, false, 0)
}
}
首先是讀鎖, atomic.AddInt32(&rw.readerCount, 1)
呼叫這個原子方法,對當前在讀的數量加一,如果返回負數,那麼說明當前有其他寫鎖,這時候就呼叫 runtime_SemacquireMutex
休眠 goroutine 等待被喚醒
解鎖
func (rw *RWMutex) RUnlock() {
if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 {
// Outlined slow-path to allow the fast-path to be inlined
rw.rUnlockSlow(r)
}
}
解鎖的時候對正在讀的操作減一,如果返回值小於 0 那麼說明當前有在寫的操作,這個時候呼叫 rUnlockSlow
進入慢速通道
func (rw *RWMutex) rUnlockSlow(r int32) {
if r+1 == 0 || r+1 == -rwmutexMaxReaders {
race.Enable()
throw("sync: RUnlock of unlocked RWMutex")
}
// A writer is pending.
if atomic.AddInt32(&rw.readerWait, -1) == 0 {
// The last reader unblocks the writer.
runtime_Semrelease(&rw.writerSem, false, 1)
}
}
5.2.3 寫鎖
寫鎖
func (rw *RWMutex) Lock() {
// First, resolve competition with other writers.
rw.w.Lock()
// Announce to readers there is a pending writer.
r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders
// Wait for active readers.
if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 {
runtime_SemacquireMutex(&rw.writerSem, false, 0)
}
}
首先呼叫互斥鎖的 lock,獲取到互斥鎖之後
atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders)
呼叫這個函式阻塞後續的讀操作- 如果計算之後當前仍然有其他 goroutine 持有讀鎖,那麼就呼叫
runtime_SemacquireMutex
休眠當前的 goroutine 等待所有的讀操作完成
解鎖
func (rw *RWMutex) Unlock() {
// Announce to readers there is no active writer.
r := atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders)
if r >= rwmutexMaxReaders {
race.Enable()
throw("sync: Unlock of unlocked RWMutex")
}
// Unblock blocked readers, if any.
for i := 0; i < int(r); i++ {
runtime_Semrelease(&rw.readerSem, false, 0)
}
}
解鎖的操作,會先呼叫 atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders)
將恢復之前寫入的負數,然後根據當前有多少個讀操作在等待,迴圈喚醒