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
}

下面是三個方法——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()

  1. 鎖定互斥鎖。

  2. 創建一個新的 "信號" 通道 (ch)。

  3. 將其附加到 c.chs

  4. 解鎖,然後阻塞在 <-ch

  5. 當有人調用 Signal() 或 Broadcast() 時,該通道被關閉(併發送一個值),讓這個 goroutine 恢復。

Signal()

  1. 鎖定互斥鎖。

  2. 如果至少有一個等待通道,選擇第一個。

  3. 在其上發送一個空結構體 struct{}{},然後 close(ch),這樣任何額外的 <-ch 接收都不會掛起。

  4. 從切片中刪除該通道。

Broadcast()

  1. 鎖定互斥鎖。

  2. 循環遍歷每個等待通道:發送一個空信號並關閉它。

  3. 將切片重置爲空,這樣未來的等待者可以重新開始。

這種簡單的方法展示了條件變量如何傳遞 "準備就緒" 信號而不傳遞實際負載——只是信號。

測試我們的自定義 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