iT邦幫忙

2025 iThome 鐵人賽

DAY 5
0
Cloud Native

Go 語言搶票煉金術:解鎖千萬級併發下的原子交易奇蹟系列 第 5

Go 語言搶票煉金術 Day 5 - Go 的併發工具箱 (三):Channel 的消息傳遞

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20250915/20124462YARMcXUyfa.png

Go 語言搶票煉金術 Day 5 - Go 的併發工具箱 (三):Channel 的消息傳遞

在前幾篇文章中,我們探討了 goroutine 如何讓我們輕鬆地建立併發任務,以及 Mutex 如何像一位紀律嚴格的守衛,保護共享資料在同一時間只被一個人訪問。

Mutex 是一種簡單有效的同步方式,稱之為「命令式」併發:我們明確地命令程式碼何時鎖定、何時解鎖。

今天,我們來探討 Go 語言的另一種併發模型:Channel

Channel 是一種思維模式的轉變,而是理解它是一個有特定適用場景和成本的工程工具。

一句諺語的兩種解讀:「透過溝通共享記憶體」

Go 的併發哲學常被這句經典諺語概括:

Don't communicate by sharing memory; instead, share memory by communicating.
(不要透過共享記憶體來溝通;應透過溝通來共享記憶體。)

這句話聽起來有點繞,但它精準地指出了 MutexChannel 的根本區別。

兩種模型:白板 vs. 傳送帶

傳統模型:透過共享記憶體溝通 (Mutex)

這就像團隊共用一塊公共白板(共享記憶體)。

所有團隊成員(goroutines)都可以讀寫白板上的內容。
為了避免大家同時寫導致內容混亂,團隊制定了一條規則:任何人想寫字之前,必須先拿到唯一的這隻筆(Mutex 鎖)。

  • 核心:資源是共享的,透過「鎖」這個中介者來協調訪問。數據是靜態的,訪問權是動態爭搶的。
  • 隱患:如果有人拿著筆忘了還,或者大家為了搶筆而大打出手,系統就會出現死鎖或效能瓶頸。

多個 goroutine 競爭同一個資源的訪問權

var sharedData int
var mu sync.Mutex

// Goroutine A - 搶到鎖,寫入
mu.Lock()
sharedData = 42
mu.Unlock()

// Goroutine B - 搶到鎖,讀取
mu.Lock()
value := sharedData
mu.Unlock()

Go 推崇模型:透過溝通共享記憶體 (Channel)

這好比一條工廠裡的自動傳送帶

上游的工人(生產者 goroutine)把加工好的零件(資料)放到傳送帶上,然後就不用管了。下游的工人(消費者 goroutine)從傳送帶上取下零件處理。

在這個模型中,我們關注的是數據的流動消息的傳遞

  1. 沒有競爭:零件在任何一個時間點,只屬於一個人或一個環節。它從不「同時」被兩個人持有。
  2. 消息傳遞 (Message Passing):當零件被放上传送帶,它作為一個「消息」從生產者傳遞到傳送帶。當消費者取走它,消息又從傳送帶傳遞到消費者。
  3. 內建同步:如果傳送帶上沒有零件,下游工人會自動停下來等待。這就是 Channel 內建的同步機制,無需手動加鎖。
ch := make(chan int)

// Goroutine A - 將資料作為消息發送到 Channel
ch <- 42

// Goroutine B - 從 Channel 接收資料消息
value := <-ch

兩種模型:Mutex vs Channel
https://ithelp.ithome.com.tw/upload/images/20250919/20124462eKO0B6DiWR.png

  • 左邊 (Mutex): 所有的 Goroutine 都圍著同一個資源 (Shared Data),並且必須透過一個唯一的瓶頸 (Mutex) 來競爭訪問權。這就是混亂的根源。

  • 右邊 (Channel): 資料有清晰的流向,從生產者到消費者,Channel 就是那個管道。所有權在傳遞過程中被轉移,沒有競爭。

Channel 的兩種現實:值傳遞 vs. 指標傳遞

Channel 的行為完全取決於你傳遞的是什麼。這是 Channel 安全性的核心,也是最大的陷阱。

情況一:傳遞值 (The Safe Path)

當你向 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 併發實踐》
}

https://ithelp.ithome.com.tw/upload/images/20250919/20124462t6Bl6AwYjs.png

情況二:傳遞指標 (The Dangerous Path)

當你向 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,必須立即放棄對該指標指向的記憶體的所有權利(包括讀和寫)。

https://ithelp.ithome.com.tw/upload/images/20250919/20124462P5tPiXhn5g.png

傳遞值 vs. 傳遞指標情況
https://ithelp.ithome.com.tw/upload/images/20250919/20124462KwYHFqHOIg.png

精通 Channel 的實用工具箱

只了解收發操作是不夠的。要構建健壯的系統,你必須掌握 Channel 的另外三個核心特性:

  1. 緩衝 Channel (make(chan T, N)):傳送帶的緩衝區
    我們的「傳送帶」可以有一個緩衝區。生產者可以在消費者來取之前,先放 N 個零件上去。

    • 作用:解耦生產者和消費者,允許它們的速度有短暫不匹配,吸收突發流量,提高整體吞-吐量。
    • 行為:發送者只在緩衝區滿的時候阻塞,接收者只在緩衝區的時候阻塞。
  2. 關閉 Channel (close(ch)):通知「工作結束」
    生產者完成所有工作後,應該 close(ch) 來通知所有消費者:「不會再有新的數據了。」

    • 作用:這是實現優雅關閉 goroutine 的關鍵信號。
    • 接收:消費者可以使用 for val := range ch 迴圈來遍歷 Channel,該迴圈會在 Channel 關閉後自動結束。或者使用 val, ok := <-ch,當 okfalse 時,表示 Channel 已關閉且無數據可讀。
  3. Select 語句:併發的瑞士軍刀
    select 就像是 Channel 的 switch 語句,它允許一個 goroutine 同時等待多個 Channel 操作。

    • 多路複用:同時從多個 Channel 接收數據。
    • 超時控制case <-time.After(duration) 可以讓你避免無限等待。
    • 非阻塞操作:配合 default 分支,可以實現非阻塞的發送或接收,避免 goroutine 被卡住。

重新評估你的工具:Mutex vs. Channel

理解了 Channel 的全部面貌後,我們就能更清晰地做出選擇。

  • 何時使用 sync.Mutex

    • 意圖:保護一個共享資源的內部狀態,使其方法可以併發安全地被調用。
    • 場景:實現一個併發安全的計數器、一個全局的配置快取 map、或保護一個長生命週期的物件狀態。
    • 心智模型:數據是核心,鎖是衛兵。多個 goroutine 圍繞著同一個資源。
  • 何時使用 Channel

    • 意圖:在不同的 goroutine 工作單元之間傳遞數據分發任務發送信號
    • 場景:實現工作池(Worker Pool),構建數據處理管道(Pipeline),或進行事件通知(例如,任務 A 完成後通知任務 B 開始)。
    • 心智模型:溝通是核心,數據是信件。數據在 goroutine 之間流動。
      https://ithelp.ithome.com.tw/upload/images/20250919/20124462J0jfNpbpvz.png

總結

  1. 核心哲學:Channel 提倡我們從「數據流動」和「消息傳遞」的角度思考併發,而非僅僅是「資源爭搶」。
  2. 安全性基石:Channel 的安全取決於你傳遞的是值(安全)還是指標(需要紀律)。指標傳遞的「共享記憶體」在 Go 中是君子協定,而非編譯器強制。
  3. 完整工具箱:除了基本收發,緩衝區、關閉機制和 select 語句是構建健壯併發系統不可或缺的工具。
  4. 正確選擇Channel 並非 Mutex 的替代品,它們解決的是不同維度的問題。Mutex 用於保護共享狀態,Channel 用於協調和傳遞。

理解了這些,你才能真正開始利用 Channel 來構建健壯的併發系統。在下一篇文章中,我們將動手實踐,用我們剛剛學到的完整 Channel 知識,來打造一個強大且實用的併發模式——工作池 (Worker Pool)

參考資源

官方文檔與核心概念


上一篇
Go 語言搶票煉金術 Day 4 - Go 的併發工具箱 (二):Mutex 與 RWMutex
系列文
Go 語言搶票煉金術:解鎖千萬級併發下的原子交易奇蹟5
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言