Go 語言 sync-Cond 使用詳解

【導讀】go 語言的 sync.Cond 在什麼場景下使用?本文對 sync.Cond 的使用做了梳理。

爲等待 / 通知場景下的併發問題提供支持。Cond 通常應用於等待某個條件的一組 goroutine,等條件變爲 true 的時候,其中一個 goroutine 或者所有的 goroutine 都會被喚醒執行。

說點人話吧……

cond 分析

我們來看一下 Cond 提供的方法

func NewCond(l Locker) *Cond {} // 創建一個 cond
func (c *Cond) Wait() {}        // 阻塞,等待喚醒
func (c *Cond) Signal() {}      // 喚醒一個等待者
func (c *Cond) Broadcast() {}   // 喚醒所有等待者

第一步:創建一個 cond

c := sync.NewCond(&sync.Mutex{})

第二步:將 goroutine 阻塞在 c 上

// 這裏會有坑,下文再討論
c.Wait()

第三步:喚醒

// 喚醒所有等待者
c.Broadcast()

// 喚醒一個等待者
c.Signal()

這裏再回過頭去看 cond 的作用,應該清晰了不少, 我們再結合一個例子來看一下。

場景是百米賽跑,10 個運動員,進場以後做熱身運動,運動員熱身完成後示意裁判,10 個運動員都熱身完成,裁判發令起跑。

func main() {
    c := sync.NewCond(&sync.Mutex{})
    var readyCnt int

    for i := 0; i < 10; i++ {
        go func(i int) {
            // 模擬熱身
            time.Sleep(time.Duration(rand.Int63n(10)) * time.Second)

            // 熱身結束,加鎖更改等待條件
            c.L.Lock()
            readyCnt++
            c.L.Unlock()

            fmt.Printf("運動員#%d 已準備就緒\n", i)
            c.Signal()    // 示意裁判員
        }(i)
    }

    c.L.Lock()
    for readyCnt != 10 {    // 每次 c.Signal() 都會喚醒一次,喚醒 10 次才能開始比賽
        c.Wait()    // c.Wait() 調用後,會阻塞在這裏,直到被喚醒
        fmt.Printf("裁判員被喚醒一次\n")
    }
    c.L.Unlock()

    fmt.Println("所有運動員都準備就緒。比賽開始,3,2,1, ......")
}

這裏你可能會說,使用 sync.WaitGroup{}channel 也可以實現,甚至比 cond 的實現還要簡單,的確如此,這也從側面說明 cond 的應用場景少之又少。

sync.WaitGroup{}channel 這種併發原語適用的情況時,等待者只有一個,如果等待者有多個,cond 比較擅長。

我們來改一下場景,假設有兩個裁判,一個發令裁判,一個計時裁判,看代碼實現:

func main() {
    c := sync.NewCond(&sync.Mutex{})
    var readyCnt int

    for i := 0; i < 10; i++ {
        go func(i int) {
            // 模擬熱身
            time.Sleep(time.Duration(rand.Int63n(10)) * time.Second)

            // 熱身結束,加鎖更改等待條件
            c.L.Lock()
            readyCnt++
            c.L.Unlock()

            fmt.Printf("運動員#%d 已準備就緒\n", i)
            c.Broadcast()    // 示意所有裁判員
        }(i)
    }

    var wg sync.WaitGroup
    wg.Add(2)
    for i:=0; i<2; i++ {
        go func(i int) {
            defer wg.Done()
            c.L.Lock()
            for readyCnt != 10 {
                c.Wait()
                fmt.Printf("裁判員 %d 被喚醒一次\n", i)
            }
            c.L.Unlock()
        }(i)
    }
    wg.Wait()

    fmt.Println("所有運動員都準備就緒。比賽開始,3,2,1, ......")
}

關於代碼裏的一些細節,我們有必要說明一下,readyCnt++ 需要加鎖,這個很明顯。對於 c.Wait() 的操作,需要先獲取鎖,這是由它的實現來決定的。

// Wait atomically unlocks c.L and suspends execution
// of the calling goroutine. After later resuming execution,
// Wait locks c.L before returning. Unlike in other systems,
// Wait cannot return unless awoken by Broadcast or Signal.
//
// Because c.L is not locked when Wait first resumes, the caller
// typically cannot assume that the condition is true when
// Wait returns. Instead, the caller should Wait in a loop:
//
//    c.L.Lock()
//    for !condition() {
//        c.Wait()
//    }
//    ... make use of condition ...
//    c.L.Unlock()
//
func (c *Cond) Wait() {
    c.checker.check()
    t := runtime_notifyListAdd(&c.notify)   // 加入到等待隊列
    c.L.Unlock()                            // 解鎖
    runtime_notifyListWait(&c.notify, t)    // 阻塞等待直到被喚醒
    c.L.Lock()                              // 加鎖
}

調用 Wait() 時,它會把當前 goroutine 放入等待隊列,然後解鎖,將自己阻塞等待喚醒,當有其它 goroutine 執行了喚醒操作時,會先獲取鎖,然後執行 Wait 後面的代碼。這裏需要注意的是,任何 goroutine 都能執行喚醒操作,但並不是每次喚醒都滿足了條件,比如說上述的 demo,每個運動員熱身完成後,都會示意裁判(執行一次喚醒),但是要等 10 個運動員都熱身完成後,比賽才能開始。所以官方的註釋裏給我們的建議是使用 for 能夠確保條件符合要求後,再執行後續的代碼

c.L.Lock()
for !condition() {
    c.Wait()
}
... make use of condition ...
c.L.Unlock()

對應到我們的 demo 就是

for readyCnt != 10 {
    c.Wait()
    fmt.Printf("裁判員 %d 被喚醒一次\n", i)
}
c.L.Unlock()

那我們再反問下自己,Wait() 爲什麼要如此設計:解鎖在前,加鎖在後?我們來改一下 Wait()

func (c *Cond) Wait() {
    c.L.Lock()  
    c.checker.check()
    t := runtime_notifyListAdd(&c.notify)   // 更新操作加鎖保護
    c.L.Unlock()                         
    runtime_notifyListWait(&c.notify, t)                     
}

撇開其它業務邏輯不談,這樣子是完全沒有問題的,需要併發安全的,我們加鎖保護來起來,runtime_notifyListWait(&c.notify, t) 是一個耗時的阻塞操作,不在鎖的保護區,也不會有性能問題。

這個時候我們再看外層的業務邏輯,condition 的檢查涉及到併發訪問資源的問題,我們需要加鎖對其保護,那就需要

var mutex sync.Mutex
mutex.Lock()    // 加鎖訪問 condition
for !condition() {
    mutex.Unlock()  // 釋放掉鎖,防止其它 goroutine 阻塞
    c.Wait()        // 這個是業務上的阻塞操作,等待喚醒
    mutex.Lock()    // 到這裏時,被喚醒了,需要加鎖訪問 condition,進行 !condition 判斷
}
... make use of condition ...
mutex.Unlock()

我們把 c.Wait() 的代碼組合進來再看

var mutex sync.Mutex
mutex.Lock()    // 加鎖訪問 condition
for !condition() {
    mutex.Unlock()  // 釋放掉鎖,防止其它 goroutine 阻塞

    // c.Wait() 源碼
    c.L.Lock()  
    c.checker.check()
    t := runtime_notifyListAdd(&c.notify)   // 更新操作加鎖保護
    c.L.Unlock()                         
    runtime_notifyListWait(&c.notify, t)

    mutex.Lock()    // 到這裏時,被喚醒了,需要加鎖訪問 condition,進行 !condition 判斷
}
... make use of condition ...
mutex.Unlock()

你會發現 mutex.Unlock 和 c.L.Lock 中間什麼也沒發生,那如果 mutex 和 c.L 是同一把鎖的話,這兩個操作可以直接去掉了。

事實是它們就是一把鎖,因爲 condition 就是和 這個 c 綁定的,那通過 c.L 來控制 condition 的併發訪問,是理所應當的。

把兩把鎖換成同一把,去掉多餘的代碼

c.L.Lock()
for !condition() {
    // c.Wait() 源碼
    c.checker.check()
    t := runtime_notifyListAdd(&c.notify)
    c.L.Unlock()                         
    runtime_notifyListWait(&c.notify, t)
    c.L.Lock().Lock()   
}
... make use of condition ...
mutex.Unlock()

這不就變成了

func (c *Cond) Wait() {
    c.checker.check()
    t := runtime_notifyListAdd(&c.notify)
    c.L.Unlock()
    runtime_notifyListWait(&c.notify, t)
    c.L.Lock()
}

c.L.Lock()
for !condition() {
    c.Wait()
}
... make use of condition ...
c.L.Unlock()

妙哉妙哉~~~

易錯分析

錯誤寫法

for !condition() {
    c.Wait()
}
... make use of condition ...

錯誤寫法

c.L.Lock()
c.Wait()
... make use of condition ...
c.L.Unlock()

轉自:

zero-tt.fun/go/cond/

Go 開發大全

參與維護一個非常全面的 Go 開源技術資源庫。日常分享 Go, 雲原生、k8s、Docker 和微服務方面的技術文章和行業動態。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/AECG9FiydmELH9s_prWLaQ