Day 5: 無緩衝的同步之舞 vs. 有緩衝的非同步效率

前言

在 Day 4,我們初次體驗了 Channel 的魔力。它不僅能在 Goroutine 之間安全地傳遞資料,其阻塞的特性還為我們提供了「免費」的同步。我們昨天使用的 make(chan T) 建立的,是 無緩衝 Channel (Unbuffered Channel)

然而,Channel 的世界並非只有一種形態。今天,我們要深入探討 Channel 的另一種樣貌——有緩衝 Channel (Buffered Channel),並比較這兩者在不同場景下的優劣。這就像選擇交通工具,有時你需要的是能精準同步的F1賽車(無緩衝),有時你需要的則是可以暫存大量貨物的卡車(有緩衝)。

一個比喻:遞交報告

讓我們再次回到辦公室的場景。

情境一:無緩衝 Channel (面對面遞交)

你寫完一份報告,需要親手交給你的經理。你走到經理的辦公室,但經理正在開會。你該怎麼辦?你只能站在門口一直等,直到經理開完會,伸出手來接過你的報告,你才能離開去做別的事。

這就是無緩衝 Channel。發送方 () 和接收方 (經理) 必須同時在場,才能完成一次資料交換。這個交換的瞬間,就是一個同步點

情境二:有緩衝 Channel (經理桌上的文件匣)

你的經理非常忙,所以他在桌上放了一個文件匣,並規定:「報告放在這裡就好,但最多只能放 3 份,滿了就別放了」。

現在,你寫完報告,你只需要走到經理辦公室,把報告放進文件匣,然後立刻就可以轉身離開,去做下一件事。你根本不在乎經理什麼時候會去看。只有一種情況你會等待:當文件匣已經滿了(放了 3 份報告),你才必須等待,直到經理從裡面拿走一份報告,空出位置。

這就是有緩衝 Channel。它提供了一個緩衝區(文件匣),在緩衝區未滿的情況下,發送方可以非同步地發送資料,無需等待接收方。

無緩衝 Channel:強同步的保證

我們昨天已經見識過它了。它的建立方式是:

// 容量為 0,這就是無緩衝 Channel
ch := make(chan int)

核心特性:發送操作會阻塞,直到接收方準備好接收。

這會導致一個非常常見的死鎖 (Deadlock) 場景:

package main

import "fmt"

func main() {
	ch := make(chan int)

	fmt.Println("Sending 42 to channel...")
	ch <- 42 // 程式會永遠卡在這裡!
	fmt.Println("Send operation finished.") // 這行永遠不會被執行
}

執行這段程式碼,你會得到一個致命錯誤:

Sending 42 to channel...
fatal error: all goroutines are asleep - deadlock!

為什麼死鎖? 因為 main Goroutine 嘗試向 ch 發送 42,但此刻沒有任何其他 Goroutine 準備從 ch 接收。發送操作被永久阻塞了,而程式中又沒有其他可運行的 Goroutine,Go Runtime 檢測到這種無解的等待,於是觸發了 deadlock 恐慌。

使用時機:當你需要一個強烈的信號,確保發送的資料確實被另一方接收時,無緩衝 Channel 是絕佳選擇。發送成功本身,就意味著一次成功的「交接」。

有緩衝 Channel:提升吞吐量的利器

有緩衝 Channel 在建立時,需要額外提供一個容量 (capacity) 參數。

// 建立一個容量為 3 的 int 型別 channel
ch := make(chan int, 3)

核心特性

  • 當緩衝區未滿時,發送操作不會阻塞
  • 當緩衝區已滿時,發送操作會阻塞,直到有接收方從 Channel 取走資料。
  • 當緩衝區為空時,接收操作會阻塞

讓我們用程式碼來模擬文件匣的例子:

package main

import "fmt"

func main() {
	// 經理的文件匣,容量為 3
	reportBox := make(chan string, 3)

	// 員工提交了 3 份報告,這些操作都是非阻塞的
	reportBox <- "Report 1"
	fmt.Println("Sent Report 1")
	reportBox <- "Report 2"
	fmt.Println("Sent Report 2")
	reportBox <- "Report 3"
	fmt.Println("Sent Report 3")

	// 此時文件匣已滿。如果再嘗試發送,程式就會阻塞
	// reportBox <- "Report 4" // 取消這行的註解會導致 deadlock

	// 經理現在開始讀取報告
	fmt.Println("Manager is reading:", <-reportBox)
	fmt.Println("Manager is reading:", <-reportBox)
	fmt.Println("Manager is reading:", <-reportBox)
}

執行結果:

Sent Report 1
Sent Report 2
Sent Report 3
Manager is reading: Report 1
Manager is reading: Report 2
Manager is reading: Report 3

使用時機:當你希望解耦生產者和消費者時。例如,你有一個 Goroutine 快速地產生大量任務,而另一組 Goroutine (Worker) 以它們自己的速度來處理這些任務。有緩衝 Channel 可以作為任務佇列,有效提升系統的整體吞吐量。

總結與對比

特性無緩衝 (Unbuffered) Channel有緩衝 (Buffered) Channel
建立make(chan T)make(chan T, capacity)
容量0> 0
發送行為總是阻塞,直到接收方就緒緩衝區未滿時不阻塞,滿了才阻塞
接收行為總是阻塞,直到發送方就緒緩衝區為空時阻塞
核心用途強同步、信號傳遞解耦、非同步、提升吞吐量

今日總結

今天,我們深入剖析了兩種 Channel 的內在差異:

  • 無緩衝 Channel 是一次同步的握手,保證了訊息的即時交付。
  • 有緩衝 Channel 是一個非同步的佇列,允許生產者和消費者以不同的速率工作,起到了削峰填谷的作用。

選擇哪種 Channel 並沒有絕對的對錯,完全取決於你的業務場景。你需要的是強同步保證,還是更高的系統吞吐量?理解它們的根本差異,是寫出高效、健壯的 Go 併發程式的關鍵一步。

到目前為止,我們都是手動地、一次一次地從 Channel 接收資料。如果一個 Goroutine 會持續不斷地產生資料,直到它工作完成為止,接收方該如何優雅地接收所有資料,並在發送方停止時自動停止呢?

預告 Day 6: 【優雅地接收】用for...range遍歷Channel,直到世界盡頭。我們將學習如何關閉 Channel 以及如何使用 for...range 迴圈來優雅地消費 Channel 中的所有資料。