sync-Cond :無需通道的高效 Goroutine 信號傳遞
介紹
Go 語言的併發通常讓我們想到通道(channels),但還有另一個同步原語可能在某些場景下是:sync.Cond。。文章結束時,我們將看到一個簡單的自定義實現,理解真正的 sync.Cond 如何在底層工作,並知道何時在自己的項目中選擇它。
爲什麼使用 sync.Cond?
大多數 Go 開發者本能地使用通道來協調 goroutine:發送值、等待結果等。然而,通道也會攜帶數據。如果您只需要一個簡單的 "喚醒" 信號,而不需要任何負載呢?這正是 sync.Cond 發揮作用的地方。它是一種輕量級方式,可以阻塞一個或多個 goroutine,直到條件變爲真,而無需傳輸實際數據。
可以將其視爲廣播系統:goroutine 可以調用 Wait() 並掛起,直到有人調用 Signal()(喚醒單個等待者)或 Broadcast()(喚醒所有等待者)。在底層,sync.Cond 不會爲每個 goroutine 分配通道;相反,它維護一個等待 goroutine 的小型鏈表,當您只需要信號傳遞時,這使其更加內存高效。
爲了說明這一點,讓我們使用通道構建自己的 "簡易版" 條件變量。一旦您看到這個類比,切換到 sync.Cond 就變得簡單明瞭。
使用通道構建自定義 Cond
這是一個最小的結構體,通過使用通道切片和互斥鎖來模仿 sync.Cond:
type MyCond struct {
chs []chan struct{}
mu sync.Mutex
}
-
chs爲每個等待的 goroutine 持有一個通道。 -
mu確保向chs添加或從中刪除元素是安全的。
下面是三個方法——Wait()、Signal() 和 Broadcast()——它們模擬了 sync.Cond 的核心行爲。
func (c *MyCond) Wait() {
c.mu.Lock()
ch := make(chanstruct{})
c.chs = append(c.chs, ch)
c.mu.Unlock()
// 等待信號
<-ch
}
func (c *MyCond) Signal() {
c.mu.Lock()
defer c.mu.Unlock()
iflen(c.chs) == 0 {
return
}
// 選擇第一個通道併發送信號
ch := c.chs[0]
ch <- struct{}{}
close(ch)
// 從切片中刪除該通道
c.chs = c.chs[1:]
}
func (c *MyCond) Broadcast() {
c.mu.Lock()
defer c.mu.Unlock()
for _, ch := range c.chs {
ch <- struct{}{}
close(ch)
}
// 重置切片,不保留過時的通道
c.chs = make([]chanstruct{}, 0)
}
這裏發生了什麼?
Wait()
-
鎖定互斥鎖。
-
創建一個新的 "信號" 通道 (
ch)。 -
將其附加到
c.chs。 -
解鎖,然後阻塞在
<-ch。 -
當有人調用
Signal()或Broadcast()時,該通道被關閉(併發送一個值),讓這個 goroutine 恢復。
Signal()
-
鎖定互斥鎖。
-
如果至少有一個等待通道,選擇第一個。
-
在其上發送一個空結構體
struct{}{},然後close(ch),這樣任何額外的<-ch接收都不會掛起。 -
從切片中刪除該通道。
Broadcast()
-
鎖定互斥鎖。
-
循環遍歷每個等待通道:發送一個空信號並關閉它。
-
將切片重置爲空,這樣未來的等待者可以重新開始。
這種簡單的方法展示了條件變量如何傳遞 "準備就緒" 信號而不傳遞實際負載——只是信號。
測試我們的自定義 MyCond
要看 MyCond 的實際效果,想象一下啓動多個等待信號的工作 goroutine。然後,從另一個 goroutine 一次發送一個信號。最後,切換到廣播以一次喚醒所有人。
func main() {
cond := &MyCond{}
wg := sync.WaitGroup{}
tasks := 5
wg.Add(tasks) // 將任務計數添加到等待組
for id := range tasks {
// 爲每個任務創建單獨的 goroutine
gofunc() {
defer wg.Done()
fmt.Println("Waiting", id)
cond.Wait()
fmt.Println("Done", id)
}()
}
gofunc() {
forrange tasks {
time.Sleep(1 * time.Second)
cond.Signal() // 每隔 1 秒向一個 routine 發送信號
}
}()
// 等待所有 routine 完成
wg.Wait()
}
輸出
當您運行該程序時,每個 goroutine 都會掛在 cond.Wait() 上。每隔一秒,Signal() 喚醒一個 goroutine,直到所有 5 個完成。
切換到 Broadcast
您可以在延遲後廣播以一次喚醒所有 goroutine,而不是一個一個地發信號:
// 只需更改信號到廣播的 go routine
go func() {
// - for range tasks {
// - time.Sleep(1 * time.Second)
// - cond.Signal() // 每隔 1 秒向一個 routine 發送信號
// - }
time.Sleep(2 * time.Second)
cond.Broadcast() // 2 秒後一次向所有 routine 發送信號
}()
輸出
通過這個修改,所有 5 個 goroutine 都在 cond.Wait() 中休眠。兩秒後,單個 Broadcast() 喚醒所有人,您將看到所有 "Done" 消息快速連續出現。
用 sync.Cond 替換 MyCond
一旦您驗證了自定義行爲,替換爲真正的 sync.Cond 就很簡單了。只要您原來寫了:
// cond := &MyCond{}
cond := sync.NewCond(&sync.Mutex{})
// cond.Wait()
cond.L.Lock()
cond.Wait()
cond.L.Unlock()
您將獲得與之前相同的 "Waiting ... Done" 行爲,但現在由官方的優化實現支持。
在底層,sync.Cond 不會爲每個等待者啓動一個通道。相反,它使用內部的 notifyList——一個等待 goroutine 的小型雙向鏈表,並使用低級運行時原語來暫停和喚醒 goroutine。每次調用 Wait() 都會將 goroutine 加入該列表。Signal() 移除一個鏈接並喚醒其 goroutine;Broadcast() 遍歷整個列表並喚醒每個等待者。在內存方面,這比爲每個等待者分配一個通道要便宜得多,特別是如果您有數百或數千個 goroutine 偶爾阻塞在同一個條件上。
要深入瞭解,查看 sync.Cond 的源代碼。
何時選擇 sync.Cond 而不是通道
以下是 sync.Cond 有意義的幾種場景:
簡單信號傳遞
如果 goroutine 只需要 "現在開始" 通知——不傳遞數據——sync.Cond 提供了比填充虛擬值的通道更清晰、更具表達意圖的 API。
廣播語義
通道缺乏內置的 "喚醒所有人" 原語。可以循環遍歷通道列表,但管理該列表是額外的樣板代碼。sync.Cond.Broadcast() 完全符合其名稱:一次喚醒所有等待者。
更低的內存開銷
每個 Go 通道都有內部緩衝區、互斥鎖等。如果您只需要一個 "信號",通道分配的資源超過必要的。sync.Cond 維護一個等待者的最小鏈表,如果您有數千個偶爾等待的 goroutine,這一點尤爲明顯。
基於條件的等待
通常,您將 sync.Cond 與單獨的共享值結合使用。例如:
mu.Lock()
for !conditionMet {
cond.Wait()
}
// 現在條件爲真;繼續
mu.Unlock()
這種 "在循環中等待" 的模式在併發結構(如池、隊列或緩衝區)中很常見。僅靠通道是不行的——您必須處理額外的變量或使用 select,這可能會變得混亂。
簡而言之,如果 goroutine 純粹基於布爾或數值條件進行協調——並且想喚醒一個等待者或所有等待者——sync.Cond 會大放異彩。如果需要發送實際數據,通道仍然是更常用的選擇。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/HPpWy1feICumat_whHnnHQ