Go:無緩衝和有緩衝通道

"Go 之旅 插圖,由 Go Gopher 的 Renee French 創作

Go 中的通道(channel)機制十分強大,但是理解內在的概念甚至可以使它更強大。實際上,選擇緩衝通道或無緩衝通道將改變應用程序的行爲和性能。

本文是 Go 語言中文網組織的 GCTT 翻譯,發佈在 Go 語言中文網公衆號,轉載請聯繫我們授權。

無緩衝通道

無緩衝通道是在消息發送到通道時需要接收器的通道。聲明一個無緩衝通道時,你不需要聲明容量。例如:

package main

import (
    "sync"
    "time"
)

func main() {
    c := make(chan string)

    var wg sync.WaitGroup
    wg.Add(2)

    Go func() {
        defer wg.Done()
        c <- `foo`
    }()

    Go func() {
        defer wg.Done()

        time.Sleep(time.Second * 1)
        println(`Message: `+ <-c)
    }()

    wg.Wait()
}

由於沒有準備就緒的接收者,第一個 goroutine 在發送消息 foo 時將被阻塞。這個說明文檔 [1] 很好地解釋了這種行爲:

如果容量爲零或未設置,則通道將被無緩衝,只有在發送方和接收方都準備就緒時通信才能成功。

這一點,《Effective Go》[2] 中描述的也很清晰:

如果通道是無緩衝的,發送者將被阻塞,直到接收者接收到值。

通道的內部描繪可以給我們更多關於此行爲的有趣的細節

無緩衝通道內部結構

channel 結構體 在 runtime 包的 chan.go 文件中可以找到。該結構包含與通道緩衝區相關的屬性,但是爲了說明無緩存的通道,我將省略我們稍後將看到的那些屬性。下面是無緩衝通道的示意圖:

hchan 結構

通道維護了指向接收方( recvq )和發送方( sendq )列表的指針,由鏈表 waitq.sudog 表示 ,包含指向下一個元素的指針(next)和指向上一個元素的指針(previous),以及與處理 接收方 / 發送方 的 Goroutine 相關的信息。有了這些信息,Go 程序就很容易知道,如果沒有了發送方,通道就應該阻塞接收方,反之,沒有了接收方,通道就應該阻塞發送方。

下面是我們前面示例的工作流:

  1. 通道是用一個空的接收方和發送方列表創建的。

  2. 第 16 行,我們的第一個 Goroutine 將值 foo 發送到通道。

  3. 通道從(緩衝)池中獲取一個結構體 sudog,用以表示發送者。這個結構將維護對 Goroutine 和值 foo 的引用。

  4. 這個發送者現在進入隊列(enqueued ) sendq

  5. 由於 “chan send” 阻塞,goroutine 進入等待狀態。

  6. 第 23 行,我們的第二個 Goroutine 將讀取來自通道的消息。

  7. 通道將彈出 sendq 隊列,以獲取步驟 3 中的等待發送的結構體。

  8. 通道將使用 memmove 函數將發送方發送的值 (封裝裝在 sudog 結構中) 複製到讀取的通道的變量。

  9. 現在,我們的第一個 Goroutine 可以恢復在第 5 步,並將釋放在第 3 步獲得的 sudog

正如我們在工作流中再次看到的,goroutine 必須切換到等待,直到接收器可用爲止。但是,如果需要,這種阻塞行爲可以通過緩衝通道避免。

緩衝通道內部結構

稍微改動之前的例子,以添加一個緩衝區:

package main

import (
    "sync"
    "time"
)

func main() {
    c := make(chan string, 2)

    var wg sync.WaitGroup
    wg.Add(2)

    Go func() {
        defer wg.Done()

        c <- `foo`
        c <- `bar`
    }()

    Go func() {
        defer wg.Done()

        time.Sleep(time.Second * 1)
        println(`Message: `+ <-c)
        println(`Message: `+ <-c)
    }()

    wg.Wait()
}

現在讓我們根據這個例子分析結構 hchan 和與緩衝區相關的字段:

緩衝通道的 hchan 結構

buffer(緩衝)由以下五個屬性組成:

通過 sendxrecvx,這個緩衝區就像一個循環隊列:

通道結構中的循環隊列

這個循環隊列允許我們在緩衝區中維護一個順序,而不需要在其中一個元素從緩衝區彈出時不斷移動元素。

正如我們在前一節中看到的那樣,一旦達到緩衝區的上限,嘗試在緩衝區中發送元素的 Goroutine 將被移動到發送者列表中,並切換到等待狀態。然後,一旦程序讀取緩衝區,從緩衝區中返回位於 recvx 位置的元素,將釋放等待的 Goroutine ,它的值將被推入緩衝中。這種屬性使 通道有 FIFO(先進先出)[3] 的行爲。

由於緩衝區大小不足造成的延遲

我們在通道創建期間定義的緩衝區大小可能會極大地影響性能。我使用扇出模式來密集使用通道,以查看不同緩衝區大小的影響。以下是一些壓力測試:

package bench

import (
    "sync"
    "sync/atomic"
    "testing"
)

func BenchmarkWithNoBuffer(b *testing.B) {
    benchmarkWithBuffer(b, 0)
}

func BenchmarkWithBufferSizeOf1(b *testing.B) {
    benchmarkWithBuffer(b, 1)
}

func BenchmarkWithBufferSizeEqualsToNumberOfWorker(b *testing.B) {
    benchmarkWithBuffer(b, 5)
}

func BenchmarkWithBufferSizeExceedsNumberOfWorker(b *testing.B) {
    benchmarkWithBuffer(b, 25)
}

func benchmarkWithBuffer(b *testing.B, size int) {
    for i := 0; i < b.N; i++ {
        c := make(chan uint32, size)

        var wg sync.WaitGroup
        wg.Add(1)

        Go func() {
            defer wg.Done()

            for i := uint32(0); i < 1000; i++ {
                c <- i%2
            }
            close(c)
        }()

        var total uint32
        for w := 0; w < 5; w++ {
            wg.Add(1)
            Go func() {
                defer wg.Done()

                for {
                    v, ok := <-c
                    if !ok {
                        break
                    }
                    atomic.AddUint32(&total, v)
                }
            }()
        }

        wg.Wait()
    }
}

在我們的基準測試中,一個生產者將在通道中注入一個 100 萬個整數元素,而十個消費者將讀取他們,並將它們累加到一個名爲 total 的結果變量中。

我使用 benchstat 運行他們 10 次來分析結果:

name                                    time/op
WithNoBuffer-8                          306 µ s ± 3%
WithBufferSizeOf1-8                     248 µ s ± 1%
WithBufferSizeEqualsToNumberOfWorker-8  183 µ s ± 4%
WithBufferSizeExceedsNumberOfWorker-8   134 µ s ± 2%

一個適當大小的緩衝區確實可以使您的應用程序更快!讓我們跟蹤分析基準測試,以確定延遲在哪裏。

追蹤延遲

跟蹤基準測試將使您訪問同步阻塞概要文件,該概要文件顯示等待同步原語的 goroutines 阻塞位於何處。Goroutines 在同步過程中花費了 9ms 的時間來等待無緩衝通道的值,而 50 大小的緩衝區只等待 1.9ms:

同步阻塞概要

由於緩衝的存在,來自發送值的等待延遲減小了 5 倍:

同步阻塞概要

我們現在確實證實了我們以前的懷疑。緩衝區的大小對應用程序的性能有重要影響。


via: https://medium.com/a-journey-with-go/go-buffered-and-unbuffered-channels-29a107c00268

作者:Vincent Blanchon[4] 譯者:TomatoAres[5] 校對:DingdingZhou[6]

本文由 GCTT[7] 原創編譯,Go 中文網 [8] 榮譽推出,發佈在 Go 語言中文網公衆號,轉載請聯繫我們授權。

參考資料

[1]

說明文檔: https://golang.org/ref/spec#Channel_types

[2]

《Effective Go》: https://golang.org/doc/effective_go.html#channels

[3]

FIFO(先進先出): http://lsm6ds3%20fifo%20pattern/

[4]

Vincent Blanchon: https://medium.com/@blanchon.vincent

[5]

TomatoAres: https://github.com/TomatoAres

[6]

DingdingZhou: https://github.com/DingdingZhou

[7]

GCTT: https://github.com/studygolang/GCTT

[8]

Go 中文網: https://studygolang.com/

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