在前幾篇文章中,我們探討了 goroutine
如何讓我們輕鬆地建立併發任務,以及 Mutex
如何像一位紀律嚴格的守衛,保護共享資料在同一時間只被一個人訪問。
Mutex
是一種簡單有效的同步方式,稱之為「命令式」併發:我們明確地命令程式碼何時鎖定、何時解鎖。
今天,我們來探討 Go 語言的另一種併發模型:Channel
。
Channel
是一種思維模式的轉變,而是理解它是一個有特定適用場景和成本的工程工具。
Go 的併發哲學常被這句經典諺語概括:
Don't communicate by sharing memory; instead, share memory by communicating.
(不要透過共享記憶體來溝通;應透過溝通來共享記憶體。)
這句話聽起來有點繞,但它精準地指出了 Mutex
和 Channel
的根本區別。
這就像團隊共用一塊公共白板(共享記憶體)。
所有團隊成員(goroutines)都可以讀寫白板上的內容。
為了避免大家同時寫導致內容混亂,團隊制定了一條規則:任何人想寫字之前,必須先拿到唯一的這隻筆(Mutex
鎖)。
var sharedData int
var mu sync.Mutex
// Goroutine A - 搶到鎖,寫入
mu.Lock()
sharedData = 42
mu.Unlock()
// Goroutine B - 搶到鎖,讀取
mu.Lock()
value := sharedData
mu.Unlock()
這好比一條工廠裡的自動傳送帶。
上游的工人(生產者 goroutine)把加工好的零件(資料)放到傳送帶上,然後就不用管了。下游的工人(消費者 goroutine)從傳送帶上取下零件處理。
在這個模型中,我們關注的是數據的流動和消息的傳遞:
Channel
內建的同步機制,無需手動加鎖。ch := make(chan int)
// Goroutine A - 將資料作為消息發送到 Channel
ch <- 42
// Goroutine B - 從 Channel 接收資料消息
value := <-ch
兩種模型:Mutex vs Channel
左邊 (Mutex): 所有的 Goroutine
都圍著同一個資源 (Shared Data
),並且必須透過一個唯一的瓶頸 (Mutex
) 來競爭訪問權。這就是混亂的根源。
右邊 (Channel): 資料有清晰的流向,從生產者到消費者,Channel
就是那個管道。所有權在傳遞過程中被轉移,沒有競爭。
Channel
的行為完全取決於你傳遞的是什麼。這是 Channel
安全性的核心,也是最大的陷阱。
當你向 Channel 發送一個值類型(int
, string
或一個普通的 struct
),Go 會完整地複製這個值,並將副本作為消息發送給接收方。發送方和接收方擁有的是兩份位於不同記憶體地址的獨立資料。
這是最安全、最符合「消息傳遞」直覺的場景。
type Book struct { Title string }
func main() {
bookChan := make(chan Book)
go func() {
myBook := Book{Title: "《Go 併發實踐》"}
bookChan <- myBook // 傳遞的是 myBook 的一個副本
// 發送後修改原稿是安全的,因為接收方拿到的是獨立副本
myBook.Title = "一本被修改過的書"
}()
receivedBook := <-bookChan
fmt.Printf("讀者收到了書:%s\n", receivedBook.Title) // 輸出:《Go 併發實踐》
}
當你向 Channel 發送一個指標(如 *Book
),Go 複製的是指標本身(一個記憶體地址),而不是它指向的數據。
這意味著,發送方和接收方現在都持有指向同一塊記憶體的指標。你們透過消息傳遞(Channel)達成了共享記憶體的事實。
警告:Go 的消息傳遞是「值複製」,但指標傳遞會導致共享記憶體!
當你將指標作為消息發送出去,你等於在公開承諾:「這塊記憶體歸你了,我保證不再碰它。」Go 編譯器相信你的承諾,但它不會在你違背承諾時阻止你。這種模式的安全性完全建立在團隊的紀律和嚴格的 Code Review 之上。
type Book struct {
Title string
Pages int
}
func main() {
book := &Book{Title: "一本關於 Data Race 的書", Pages: 10}
ch := make(chan *Book)
var wg sync.WaitGroup
wg.Add(2)
go func() { // 發送方
defer wg.Done()
ch <- book // 把書的地址傳過去
// 災難性的錯誤!發送方違背了“即發即棄”的君子協定
book.Pages = 999
}()
go func() { // 接收方
defer wg.Done()
receivedBook := <-ch
fmt.Printf("接收方:收到的書有 %d 頁。\n", receivedBook.Pages)
}()
wg.Wait()
}
用 go run -race .
(用競爭檢測模式,編譯並執行當前目錄的 Go 程式)。
運行這段程式碼,你會得到一個明確的 DATA RACE 警告。接收方讀到的頁數可能是 10,也可能是 999,結果完全不可預測。
指標傳遞的黃金準則:即發即棄 (Fire and Forget)。 將指標作為消息發送到 Channel 的 goroutine,必須立即放棄對該指標指向的記憶體的所有權利(包括讀和寫)。
傳遞值 vs. 傳遞指標情況
只了解收發操作是不夠的。要構建健壯的系統,你必須掌握 Channel 的另外三個核心特性:
緩衝 Channel (make(chan T, N)
):傳送帶的緩衝區
我們的「傳送帶」可以有一個緩衝區。生產者可以在消費者來取之前,先放 N
個零件上去。
關閉 Channel (close(ch)
):通知「工作結束」
生產者完成所有工作後,應該 close(ch)
來通知所有消費者:「不會再有新的數據了。」
for val := range ch
迴圈來遍歷 Channel,該迴圈會在 Channel 關閉後自動結束。或者使用 val, ok := <-ch
,當 ok
為 false
時,表示 Channel 已關閉且無數據可讀。Select 語句:併發的瑞士軍刀select
就像是 Channel 的 switch
語句,它允許一個 goroutine 同時等待多個 Channel 操作。
case <-time.After(duration)
可以讓你避免無限等待。default
分支,可以實現非阻塞的發送或接收,避免 goroutine 被卡住。理解了 Channel 的全部面貌後,我們就能更清晰地做出選擇。
sync.Mutex
?map
、或保護一個長生命週期的物件狀態。Channel
?select
語句是構建健壯併發系統不可或缺的工具。Channel
並非 Mutex
的替代品,它們解決的是不同維度的問題。Mutex
用於保護共享狀態,Channel
用於協調和傳遞。理解了這些,你才能真正開始利用 Channel
來構建健壯的併發系統。在下一篇文章中,我們將動手實踐,用我們剛剛學到的完整 Channel
知識,來打造一個強大且實用的併發模式——工作池 (Worker Pool)。
官方文檔與核心概念