「譯」 Go語言的有緩衝channel和無緩衝channel

Go 中的 channel 十分強大,理解 channel 的內部機制後再去使用它可以發揮出更大威力。另外,選擇使用有緩衝 channel 還是無緩衝 channel 會影響到我們程序的行為表現,以及性能。

無緩衝 channel

無緩衝 channel 在消息發送時需要接收者就緒。聲明無緩衝 channel 的方式是不指定緩衝大小。以下是一個列子:

<code>package main

import (
\t"sync"
\t"time"
)

func main() {
\tc := make(chan string)

\tvar wg sync.WaitGroup
\twg.Add(2)

\tgo func() {
\t\tdefer wg.Done()
\t\tc \t}()

\tgo func() {
\t\tdefer wg.Done()

\t\ttime.Sleep(time.Second * 1)
\t\tprintln(`Message: `+ \t}()

\twg.Wait()
}
/<code>

第一個協程會在發送消息foo時阻塞,原因是接收者還沒有就緒:這個特性在標準文檔中描述如下:

如果緩衝大小設置為 0 或者不設置,channel 為無緩衝類型,通信成功的前提是發送者和接收者都處於就緒狀態。

effective Go文檔也有相應的描述:

無緩衝 channel,發送者會阻塞直到接收者接收了發送的值。

為了更好的理解 channel 的特性,接下來我們分析 channel 的內部結構。

內部結構

channel 的結構體hchan被定義在runtime包中的chan.go文件中。以下是無緩衝 channel 的內部結構(本小節先介紹無緩衝 channel,所以暫時忽略了hchan結構體中和緩衝相關的屬性):

hchan結構

channel 中持有兩個鏈表,接收者鏈表recvq和發送者鏈表sendq,它們的類型是waitq。鏈表中的元素為sudog結構體類型,它包含了發送者或接收者的協程相關的信息。通過這些信息,Go 可以在發送者不存在時阻塞住接收者,反之亦然。

以下是我們前一個例子的流程:

創建一個發送者列表和接收者列表都為空的 channel。第一個協程向 channel 發送foo變量的值,第 16 行。channel 從池中獲取一個sudog結構體變量,用於表示發送者。sudog 結構體會保持對發送者所在協程的引用,以及foo的引用。發送者加入sendq隊列。發送者協程進入等待狀態。第二個協程將從 channel 中讀取一個消息,第 23 行。channel 將sendq列表中等待狀態的發送者出隊列。chanel 使用memmove函數將發送者要發送的值進行拷貝,包裝入sudog結構體,再傳遞給 channel 接收者的接收變量。在第五步中被掛起的第一個協程將恢復運行並釋放第三步中獲取的sudog結構體。

如流程所描述,發送者協程阻塞直至接收者就緒。但是,必要的時候,我們可以使用有緩衝 channel 來避免這種阻塞。

有緩衝 channel

簡單修改前面的例子,為 channel 添加緩衝,如下:

<code>package main

import (
\t"sync"
\t"time"
)

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

\tvar wg sync.WaitGroup
\twg.Add(2)

\tgo func() {
\t\tdefer wg.Done()

\t\tc \t\tc \t}()

\tgo func() {
\t\tdefer wg.Done()

\t\ttime.Sleep(time.Second * 1)
\t\tprintln(`Message: `+ \t\tprintln(`Message: `+ \t}()

\twg.Wait()
}
/<code>

通過這個例子,我們來分析hchan結構體中與緩衝相關的屬性:

帶緩衝屬性的hchan結構

緩衝相關的五個屬性:

qcount 當前緩衝中元素個數dataqsize 緩衝最大數量buf 指向緩衝區內存,這塊內存空間可容納dataqsize個元素sendx 緩衝區中下一個元素寫入時的位置recvx 緩衝區中下一個被讀取的元素的位置

通過sendx和recvx,緩衝區工作機制類似於環形隊列

channel中的循環隊列

環形隊列使得我們可以保證緩衝區有序,並且不需要在每次取出元素時對緩衝區重新排序。

當緩衝區滿了時,向緩衝區添加元素的協程將被加入sender鏈表中,並且切換到等待狀態,就像我們在上一節描述的那樣。之後,當程序讀取緩衝區時,recvx位置的元素將被返回,等待狀態的協程將恢復執行,它要發送的值將被存入緩衝區。這使得 channel 能夠保證先進先出的特性。

緩存區不足引起的延時

創建 channel 時指定的緩衝區大小,可能會對性能造成巨大的影響。下面是對不同緩衝區大小的 channel 做的壓力測試代碼:

<code>package bench

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

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

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

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

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

func benchmarkWithBuffer(b *testing.B, size int) {
\tfor i := 0; i < b.N; i++ {


\t\tc := make(chan uint32, size)

\t\tvar wg sync.WaitGroup
\t\twg.Add(1)

\t\tgo func() {
\t\t\tdefer wg.Done()

\t\t\tfor i := uint32(0); i < 1000; i++ {
\t\t\t\tc \t\t\t}
\t\t\tclose(c)
\t\t}()

\t\tvar total uint32
\t\tfor w := 0; w < 5; w++ {
\t\t\twg.Add(1)
\t\t\tgo func() {
\t\t\t\tdefer wg.Done()

\t\t\t\tfor {
\t\t\t\t\tv, ok := \t\t\t\t\tif !ok {
\t\t\t\t\t\tbreak
\t\t\t\t\t}
\t\t\t\t\tatomic.AddUint32(&total, v)
\t\t\t\t}
\t\t\t}()
\t\t}

\t\twg.Wait()
\t}
}
/<code>

在這個測試程序中,包含一個生產者,向 channel 中發送整型元素;包含多個消費者,從 channel 中讀取數據,並將它們原子的加入變量total中。

運行這個測試十次,並通過benchstat分析結果:

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

說明合適的緩衝區大小確實會使得程序執行得更快!讓我們來分析測試程序以確認耗時反生在何處。

追蹤耗時

通過 Go 工具 trace 中的synchronization blocking profile來查看測試程序被同步原語阻塞所消耗的時間。接收時的耗時對比:無緩衝 channel 為 9 毫秒,緩衝大小為 50 的 channel 為 1.9 毫秒。

profile圖

發送時的耗時對比:有緩衝 channel 將耗時縮小了五倍。

profile圖

可以得出結論,緩衝區的大小確實在程序性能方面扮演了重要角色。

英文原文: Go: Buffered and Unbuffered Channels by Vincent Blanchon (https://medium.com/@blanchon.vincent/go-buffered-and-unbuffered-channels-29a107c00268)